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.
anchorMapping 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));
}
}
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);
}),
);
}
Let's see what addDoc()
returns.
anchorCreate 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);
}
});
}
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};
})
);
}
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']
};
}
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);
}),
);
}
anchorThe 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')
returnsDocumentReference
, of new or existing documentdoc(collection(this.db, 'categories'))
returnsDocumentReference
of newly generated IDsetDoc(_DOCUMENT_REFERENCE, {...newCategory}, options)
sets the document data, saves with the provided ID in the Document ReferenceaddDoc(collection(this.db, 'categories'), {...newCategory});
adds a document with an auto generated ID (this is shortcut fordoc(collection...)
thensetDoc()
)updateDoc(doc(this.db, 'categories', 'EXISTING_ID'), PARTIAL_CATEGORY)
this updates an existing document (a shortcut fordoc(db...)
thensetDoc()
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'));
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});
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'})
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);
})
);
}
anchorUpdate 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);
})
);
}
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
}
});
}
anchorDelete 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)
);
}
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);
}
});
}
There is no straight forward way to bulk delete. Let's move on.
anchorQuerying 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;
}
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
}
Here is an example of how to get a group of documents by maximum price:
// example usage
something$ = someService.GetList({maxPrice: 344});
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
something$ = someService.GetList({recent: true});
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;
};
This works as expected.
anchorTimestamp
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();
anchorFirestore Lite
Firebase offers a lite version of Firestore for simple CRUD, so far we have used only those available in lite version. I changed allimport { ... } from '@angular/fire/firestore';
Intoimport { ... } 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.
anchorInterception
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
);
}
}
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);
})
));
}
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> {
// ...
}
Then call it like this:
// category.service
return this.httpService.run<ICategory[]>(from(getDocs(q)).pipe(
map((res: QuerySnapshot) => {
return Category.NewInstances(res);
})
));
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.