Home
>
Firestore Many-to-Many: Part 3 - maps

Firestore Many-to-Many: Part 3 - maps

9 min read
Jonathan Gamble

jdgamble555 on Friday, October 15, 2021 (last modified on Sunday, December 17, 2023)

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
  }
}

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();

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);

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);

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' });

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;
  }))
);

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',
    ...
  }
}

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}`)

OR

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

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();

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)

And it will auto sort the way you like it.

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

J


Related Posts

© 2024 Code.Build