DEV Community

Cover image for Putting Angular Fire Firestore library to use - II
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Putting Angular Fire Firestore library to use - II

Previously we created our own Firestore getters to return proper observables, using from to change a Promise into a cold observable. Today let's go on with other commands to map our data properly.

Mapping data

Now that we don't rely on rxfire to return mapped document id, we're going to create our own converters.

"Firestore has a built in converter withConverter to map to and from FireStore. No thanks. We'll take it from here."

The getDoc() returns the DocumentSnapshot with id, and data() as above. getDocs() returns a QuerySnapshot with docs array, among other things, which is an array of DocumentSnapshot objects returned. Our category model now looks like this:

// category.model

// mapping new instance
export interface ICategory {
  name: string | null;
  id: string | null;
  key?: string;
}

// static class or export functions, it's a designers choice
export class Category {
  public static NewInstance(data: QueryDocumentSnapshot): ICategory {
    if (data === null) {
      return null;
    }
    const d = data.data();
    return {
      id: data.id,
      name: d['name'],
      key: d['key']
    };
  }

  // receives QuerySnapshot and maps out the `data() ` and id
  public static NewInstances(data: QuerySnapshot): ICategory[] {
    // read docs array 
    return data.docs.map(n => Category.NewInstance(n));
  }

}
Enter fullscreen mode Exit fullscreen mode

So the service call now looks like this:

// category.service

 GetCategories(params: IWhere[] = []): Observable<ICategory[]> {
    // ...
    return from(getDocs(_query)).pipe(
      map((res: QuerySnapshot) => {
        // here map
        return Category.NewInstances(res);
      })
    );
}
GetCategory(id: string): Observable<ICategory> {
    return from(getDoc(doc(this.db, 'categories', id))).pipe(
      map((res: DocumentSnapshot) => {
        // map
        return Category.NewInstance(res);
      }),
    );
}
Enter fullscreen mode Exit fullscreen mode

Let's see what addDoc() returns.

Create document

I created a quick form with name and key, to add category, and a service call to create category:

// components/categories.list

addNew() {
    // read form:
    const newCat: Partial<ICategory> = this.fg.value;

    this.categoryService.CreateCategory(newCat).subscribe({
      next: (res) => {
        // the category list should be updated here
       console.log(res);
      }
    });
}
Enter fullscreen mode Exit fullscreen mode

The service is expected to return an observable, the following may not cut it, the returned object is a DocumentReference which only is a reference to the document created with id.

// category.service
CreateCategory(category: Partial<ICategory>): Observable<any> {
    return from(addDoc(collection(this.db, 'categories'), category)).pipe(
      map(res => {
        // I have nothing but id!
        return {...category, id: res.id};

      })
    );
}
Enter fullscreen mode Exit fullscreen mode

That may look fine, but I do not wish to use id directly without going through our mappers, the solution can be as simple as a separate mapper, or we can slightly adapt the NewInstance

// category.model

// adjust NewInstance to accept partial category
public static NewInstance(data: DocumentSnapshot | Partial<ICategory>, id?: string): ICategory {
    if (data === null) {
      return null;
    }

    const d = data instanceof DocumentSnapshot ? data.data() : data;
    return {
      id: data.id || id, // if data.id doesnt exist (as in addDoc)
      name: d['name'],
      key: d['key']
    };
}
Enter fullscreen mode Exit fullscreen mode

Then in the service call we pass the original category with the returned id

// category.service

CreateCategory(category: Partial<ICategory>): Observable<ICategory> {

    return from(addDoc(collection(this.db, 'categories'), category)).pipe(
      map(res => {
        // update to pass category and res.id
        return Category.NewInstance(category, res.id);
      }),
    );

}
Enter fullscreen mode Exit fullscreen mode

The curious case of Firestore syntax

The following methods are so identical, if you do not make distinction between them you might lose your hair before 50. So I decided to list them, then forget them.

  • doc(this.db, 'categories', 'SOMEID') returns DocumentReference, of new or existing document
  • doc(collection(this.db, 'categories')) returns DocumentReference of newly generated ID
  • setDoc(_DOCUMENT_REFERENCE, {...newCategory}, options) sets the document data, saves with the provided ID in the Document Reference
  • addDoc(collection(this.db, 'categories'), {...newCategory}); adds a document with an auto generated ID (this is shortcut for doc(collection...) then setDoc())
  • updateDoc(doc(this.db, 'categories', 'EXISTING_ID'), PARTIAL_CATEGORY) this updates an existing document (a shortcut for doc(db...) then setDoc() with an existing ID)

The confusion stirred up from the first two, they look so similar, but they are different

// Find document reference, or create it with new ID
const categoryRef = doc(this.db, 'categories', '_SomeDocumentReferenceId');

// Create a document reference with auto generated id
const categoryRef = doc(collection(this.db, 'categories'));
Enter fullscreen mode Exit fullscreen mode

Now the returned reference document can be used to create an actual document, or update it. So it is expected to be succeeded with setDoc

// then use the returned reference to setDoc, to add a new document
setDoc(categoryRef, {name: 'Bubbles', key: 'bubbles'});

// pass partial document with options: merge to partially update an existing document
setDoc(existingCategoryRef, {name: 'Bubble'}, {merge: true});
Enter fullscreen mode Exit fullscreen mode

If the document does not exist wit merge set to true, it will create a new document with partial data. Not good. Be careful.

The safe options are the last two:


// add a new documnet with a new generated ID
addDoc(collection(this.db, 'categories'), {name: 'Bubbles', key: 'bubbles'});

// update and existing document, this will throw an error if non existent
updateDoc(existingCategoryRef, {name: 'Bubbles'})
Enter fullscreen mode Exit fullscreen mode

For illustration purposes, here is the other way to create a new category

// an alternative way to create
CreateCategory(category: Partial<ICategory>): Observable<ICategory> {

    // new auto generated id
    const ref = doc(collection(this.db, 'categories'));

    return from(setDoc(ref, category)).pipe(
       map(_ => {
         // response is void, id is in original ref
         return Category.NewInstance(category, ref.id);
       })
    );
}
Enter fullscreen mode Exit fullscreen mode

Update document

Building on the above, I created a quick form for the category details to allow update. I will still pass all fields, but we can always pass partial fields.

// category.sevice

// id must be passed in category
UpdateCategory(category: Partial<ICategory>): Observable<ICategory> {
    return from(updateDoc(doc(this.db, 'categories', category.id), category)).pipe(
      map(_ => {
        // response is void
        // why do we do this? because you never know in the future what other changes you might need to make before returning
        return Category.NewInstance(category);
      })
    );
}
Enter fullscreen mode Exit fullscreen mode

This is a proof of concept, there are ways to tighten the loose ends, like forcing the id to be passed, checking non existent document, returning different shape of data, etc.

To use it, the form is collected, and the id is captured:

// categories/list.component
update(category: ICategory) {
    // gather field values
    const cat: Partial<ICategory> = this.ufg.value;

    // make sure the id is passed
    this.categoryService.UpdateCategory({...cat, id: category.id}).subscribe({
      next: (res) => {
        console.log(res); // this has the same Category after update
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

Delete document

This one is straight forward, we might choose to return a Boolean for success.

// category.sevice

DeleteCategory(id: string): Observable<boolean> {
    return from(deleteDoc(doc(this.db, 'categories', id))).pipe(
      // response is void, but we might want to differentiate failures later
      map(_ => true)
    );
}
Enter fullscreen mode Exit fullscreen mode

We call it simply by passing ID

// category/list.component

delete(category: ICategory) {
    this.categoryService.DeleteCategory(category.id).subscribe({
      next: (res) => {
        console.log('success!', res);
      }
    });
}
Enter fullscreen mode Exit fullscreen mode

There is no straight forward way to bulk delete. Let's move on.

Querying responsibly

We created an IWhere model to allow any query to be passed to our category list. But we should control the fields to query in our models, and protect our components from field changes. We should also control what can be queried and how, in order to always be ahead of any hidden prices on Firebase.

In our IWhere model, we can add IFieldOptions model. Here is an example of label instead of name, to show how we protect our components from Firestore changes.

// where.model

// the param fields object (yes its plural)
export interface IFieldOptions {
    label?: string; // exmaple
    key?: string;

    // other example fields
    month?: Date;
    recent?: boolean;
    maxPrice?: number;
}


// map fields to where conditions of the proper firestore keys

export const mapListoptions = (options?: IFieldOptions): IWhere[] => {

    let c: any[] = [];
    if (!options) return [];

    if (options.label) {
      // mapping label to name
      c.push({ fieldPath: 'name', opStr: '==', value: options.label });
    }

    if(options.key) {
      c.push({ fieldPath: 'key', opStr: '==', value: options.key });
    }
    // maxPrice is a less than or equal operator
    if (options.maxPrice) {
        c.push({ fieldPath: 'price', opStr: '<=', value: options.maxPrice });
    }

    if (options.month) {
        // to implement, push only data of the same month
        c.push(...mapMonth(options.month));
    }

    if (options.recent) {
        const lastWeek = new Date();
        lastWeek.setDate(lastWeek.getDate() - 7);
        // things that happened since last week:
        c.push({ fieldPath: 'date', opStr: '>=', value: lastWeek });
    }

    return c;
}
Enter fullscreen mode Exit fullscreen mode

The change on our service is like this

// category.sevice

// accept options of specific fields
GetCategories(params?: IFieldOptions): Observable<ICategory[]> {
    // translate fields to where:
    const _where: any[] = (mapFieldOptions(fieldOptions))
        .map(n => where(n.fieldPath, n.opStr, n.value));

    // pass the where to query
    const _query = query(collection(this.db, this._url), ..._where);

    // ... getDocs
}
Enter fullscreen mode Exit fullscreen mode

Here is an example of how to get a group of documents by maximum price:

// example usage
something$ = someService.GetList({maxPrice: 344});
Enter fullscreen mode Exit fullscreen mode

This makes much more sense. I added an example of date, note that operations on JavaScript date works fine, even if the Firestore date is of type Timestamp. The idea of the example is to showcase that the component should not really know the details of the implementation, so getting "most recent documents" for example should be like this

// example
something$ = someService.GetList({recent: true});
Enter fullscreen mode Exit fullscreen mode

The other example is to get month of date, Firestore deals with Javascript date directly:

// where.model

const mapMonth = (month: Date): IWhere[] => {
  const from = new Date(month);
  const to = new Date(month);

  from.setDate(1);
  from.setHours(0, 0, 0, 0);
  to.setMonth(to.getMonth() + 1);
  to.setHours(0, 0, 0, 0); // the edge of next month, is last day this month


  let c: IWhere[] = [

    { fieldPath: 'date', opStr: '>=', value: from },
    { fieldPath: 'date', opStr: '<=', value: to }
  ];

  return c;
};
Enter fullscreen mode Exit fullscreen mode

This works as expected.

Timestamp

Firestore reference of Timestamp.

This class may not make a difference when querying, but if you have a date field, and would like to save user provided date into it, it's best to use Firestore Timestamp class to do the conversion. The difference between Firestore Timestamp and Javascript date is subtle. So subtle I don't even see it! I think it's the ranks of rounding.

// somemodel

// use Timestamp class to map from and to dates

const jsDate = new Date();

// addDoc with
const newData = {
    fbdate: Timestamp.fromDate(jsDate)
}

// DocumentSnapshot returns data() with date field, the type is Timestamp
// use toDate to convert to js Date
return data['fbdate'].toDate();
Enter fullscreen mode Exit fullscreen mode

Firestore Lite

Firebase offers a lite version of Firestore for simple CRUD, so far we have used only those available in lite version. I changed all
import { ... } from '@angular/fire/firestore';
Into
import { ... } from '@angular/fire/firestore/lite';

The build chunk was indeed smaller. The main chunk that was reduced in my personal project, came down from 502 KB to 352 KB.

Interception

With httpClient we used an interception function to add the loading effect, so how do we do that with our solution? We can funnel all calls to one http service that takes care of it. Our new service should be something around this:

// http.service

@Injectable({ providedIn: 'root' })
export class HttpService {
  public run(fn: Observable<any>): Observable<any> {

    // show loader here
    showloader();

    // return function as is
    return fn
      .pipe(
        // hide loader
        finalize(() => hideloader()),
        // debug and catchErrors as well here
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

Then in the service

// category.service

private httpService = inject(HttpService);

GetCategories(params?: IWhere[]): Observable<ICategory[]> {
    // ...
    // wrap in run
    return this.httpService.run(from(getDocs(q)).pipe(
      map((res: QuerySnapshot) => {
        return Category.NewInstances(res);
      })
    ));
}
Enter fullscreen mode Exit fullscreen mode

One design problem in the above is the loss of type checking because the inner function returns observable<any>. This can be fixed with a generic

// http.service
// ...
public run<T>(fn: Observable<any>): Observable<T> {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then call it like this:

// category.service
return this.httpService.run<ICategory[]>(from(getDocs(q)).pipe(
  map((res: QuerySnapshot) => {
    return Category.NewInstances(res);
  })
));
Enter fullscreen mode Exit fullscreen mode

Have a look at the loading effect we created previously using state management. All we need is to inject the state service, then call show and hide. This uses good ol' RxJs, I looked into Signals, and could not see how it would replace RxJs without making a big mess. May be one fine Tuesday.

That's it. Don't lose hair before 50.

Did you catch the pants on fire? Keep talking. It's not over yet.

Resources

Related posts

Putting Angular Fire Firestore library to use - II - Sekrab Garage

Angular Firebase Firestore. Previously we created our own Firestore getters to return proper observables, using from to change a Promise into a cold observable. Today let's go on with other commands to map our data properly.Ma.... Posted in Angular, Design

favicon garage.sekrab.com

Top comments (0)