DEV Community

Cover image for Firestore Many-to-Many: Part 3 - maps
Jonathan Gamble
Jonathan Gamble

Posted on • Updated on • Originally published at code.build

Firestore Many-to-Many: Part 3 - maps

Update 3/24/24

This post is out-of-date. Please see my latest post on Many-to-Many Maps.
https://code.build/p/firestore-many-to-many-maps-gm3B5X


Original Post


Both arrays and maps allow you to store probably somewhere around 10,000 items. This could vary, as your document size is really the only constraint storage-wise (1MB). Generally speaking, you can have up to 40,000 indexes per document, so you can have a lot of where clauses. See here for constraints.

Searching

If you only need to search for 1 to 10 items at a time, arrays are always the way to go. They don't mess up your in-time indexing, and they seem self-explanatory. In fact, I would suggest arrays for most cases, as Firestore has made arrays easier.

However, if you need to search for 11 to 40,000 items at a time, maps are the way to go. The only real problem is that sorting can be tricky.

If you were to use the same example from the previous post, you would have something like this:

Classes / ClassID: {
  data...
  students: {
    studentID1: true,
    studentID2: true,
    ...
  }
}
Students / StudentID: {
  data...
  classes: {
    classID1: true,
    classID2: true
  }
}
Enter fullscreen mode Exit fullscreen mode

There are pros and cons of arrays vs maps, but you can generally accomplish the same goals.

Add / Update

I am not going to spend too much time here, as you should already understand the basics of writing to Firestore. Here is a quick rundown.

const batch = this.afs.firestore.batch();

const studentID = this.afs.createId();
const classID = this.afs.createId();

const studentRef = this.afs.doc(`students/${studentID}`).ref;
batch.set(studentRef, {
  name: 'tom',
  classes: {
    [classID]: true
  }
}, { merge: true });

const classRef = this.afs.doc(`classes/${classID}`).ref;
batch.set(classRef, {
  name: 'calculus',
  students: {
    [studentID]: true
  }
}, { merge: true });

await batch.commit();
Enter fullscreen mode Exit fullscreen mode

See my previous post for why you use batch here.

Query

And to query without sorting, you don't need an index, and it is this simple.

Get all classes which studentID is taking.

db.collection('classes')
.where(`students.${studentID}`, '==', true);
Enter fullscreen mode Exit fullscreen mode

OR VS AND Queries

Get all classes which StudentID_1 AND StudentID_2 are taking.

db.collection('classes')
.where(`students.${studentID_1}`, '==', true)
.where(`students.${studentID_2}`, '==', true);
Enter fullscreen mode Exit fullscreen mode

And for chaining...

const students = [studentID, studentID2,... ];

const results = this.afs.collection('classes',
  (ref: any) => students.reduce(
    (r: any, student: any) 
      => r.where(`students.${student}`, '==', true)
    , ref)
).valueChanges({ idField: 'id' });
Enter fullscreen mode Exit fullscreen mode

Get all classes which StudentID_1 OR StudentID_2 is taking.

There is unfortunately no way to get OR queries like there is with array-contains-any. However, you can combine results and filter them:

const students = [studentID, studentID2, studentID3,... ];

const results = combineLatest(students.map(
  (student: string) => this.afs.collection('classes',
    (ref: any) => ref.where(`students.${student}`, '==', true)
  ).valueChanges({ idField: 'id' })
)).pipe(
  // combine results
  map((a: any[]) => a.reduce(
    (acc: any[], cur: any) => acc.concat(cur)
    // filter duplicates
  ).filter(
    (b: any, n: number, a: any[]) => a.findIndex(
      (v: any) => v.id === b.id) === n
    // sort by id
  ).sort((a: any, b: any) => {
    const f = 'id';
    if (a[f] < b[f]) { return -1; }
    if (b[f] < a[f]) { return 1; }
    return 0;
  }))
);
Enter fullscreen mode Exit fullscreen mode

Note: Sorting this query (the only OR example I know is possible) would just require you to sort on the front end. See add a map to sort on part 1.

You can use rxjs operators or array operators here, but I suspect array operators will be quicker.

Sorting

Get all classes StudentID is taking sorted by startDate

You can't use orderBy() when you are using maps like this, unless you limit the name of your items (students, or tags i.e.), and you index them before hand. So, we have work-arounds.

Method 1

Put the field you want to sort by as the value in every single map. If you add a value, you need to copy the name value. If you update the name value, you need to update all map values. This is a pain, but it works...

{
  name: 'jon',
  students: {
    523k1s: 'jon',
    392922: 'jon',
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Ok, I already lied to you. You can use orderBy(), but without a where clause to get the desired result like so:

db.collection('classes')
.orderBy(`students.${studentID}`)
Enter fullscreen mode Exit fullscreen mode

OR

db.collection('classes')
.where(`students.${studentID}`, '>', ' ');
Enter fullscreen mode Exit fullscreen mode

Note: A space is the equivalent to 0 in ascii.

Method 2

This only works on one field at a time, so for multiple fields you have to think about the doc ID. This is the default sort order.

Basically you need to create a compound index on the document ID:

const docID = new Date() + '__' + this.afs.createId();
Enter fullscreen mode Exit fullscreen mode

Note: Notice you can't use firebase.firestore.FieldValue.serverTimestamp() here, but theoretically you could fix this in a firebase function by copying the document, and creating a new one with the correct date.

It also can be any field, not necessarily the date.

Get all classes StudentID and StudentID2 are taking sorted by startDate

db.collection('classes')
.where(`students.${studentID}`, '==', true)
.where(`students.${studentID2}`, '==', true)
Enter fullscreen mode Exit fullscreen mode

And it will auto sort the way you like it.

Next up... a non-scalable follower-feed... but it is kind-of scalable...

J

Top comments (0)