Firestore Many-to-Many: Part 2 - array-contains-all

Jonathan Gamble - Aug 27 '21 - - Dev Community

Update 3/24/24

This post is out-of-date. Please see my latest post on the array-contains-all workaround.

https://code.build/p/firestore-many-to-many-array-contains-all-dyFZgf


Original Post


This is Part 2 of the Firestore Many-to-Many Series. Before I cover the map many-to-many situations, there is two more situation in arrays that you need to think about:

array-contains-any / array-contains-all

Get all students taking either Class A or Class B

this.afs.collection('students')
.where('classes', 'array-contains-any', [classA_ID, classB_ID]);
Enter fullscreen mode Exit fullscreen mode

As you can see, you can easily accomplish this with array-contains-any.

Get all classes either Student A is taking or Student B is taking

this.afs.collection('classes')
.where('students', 'array-contains-any', [studentA_ID, studentB_ID]);
Enter fullscreen mode Exit fullscreen mode

OR

Just to give you another route, you can still use pipe with in to accomplish the rxjs frontend join:

this.afs.collection('students', ref =>
  ref.where(
    firebase.firestore.FieldPath.documentId(),
    'in',
    [studentA_ID, studentB_ID]
  )
).valueChanges().pipe(
  switchMap((r: any[]) => {
    let ids = r.map((m: any) => m.classes);
    ids = Array.prototype.concat.apply([], ids);
    const diff = ids.filter(
      (v: any, i: number, a: any[]) =>
        a.indexOf(v) === i
    ).sort();
    const docs: Observable<any>[] = diff.map(
      (id: number) => this.afs.doc('classes/' + id).valueChanges()
    );
    return combineLatest(docs);
  })
);
Enter fullscreen mode Exit fullscreen mode

Basically here, you use the in where clause to look for several students. You then reduce the array and filter for only unique documents. Again, you don't want to filter after the reads, but you want to avoid reading duplicates in the first place.

Note: in, array-contains, and array-contains-any all only allow up to 10 instances.

array-contains - JOIN "="
array-contains-any - JOIN "OR"
array-contains-all - JOIN "AND"

But how do you do array-contains-all? You can't natively. I foresee Firestore adding this feature before any other real features. However, there is a hack.

Basically you create your own index using __ between every combination of items in the array. This would give you search options. You could do this on the front end, or on the backend in Firebase Functions. Here I am only covering the frontend, although I may one day add this ability to my adv-firestore-functions package.

Array-contains-all - ADD

function createArrays(arr: any[]) {
  function getSubArrays(a: any[]) {
    if (a.length === 1) return [a];
    else {
      const subarr: any[] = getSubArrays(a.slice(1));
      return subarr.concat(
        subarr.map((e: any[]) => e.concat(a[0])), [[a[0]]]
      );
    }
  }
  return getSubArrays(arr).map((a: any[]) => a.sort().join('__'));
}
Enter fullscreen mode Exit fullscreen mode

array-contains-all - UPDATE

function getArray(arr: any[]) {
  return arr.filter((f: string) => !f.includes('__')).sort();
}
Enter fullscreen mode Exit fullscreen mode

array-contains-all - Query

function createSearch(...s: any) {
  return typeof s === 'string'
    ? s
    : s.sort().join('__');
}
Enter fullscreen mode Exit fullscreen mode

You need to use these three functions in order to add, update, and query a doc in your firestore collection. So, using our example:

ADD

this.afs.collection('students').add({
  classes: createArrays([
    class1,
    class2,
    class3
  ])
});
Enter fullscreen mode Exit fullscreen mode

UPDATE

const q = this.afs.doc('students/' + studentID).valueChanges();
const x = (await q.pipe(take(1)).toPromise() as any).classes;
const classes = getArray(x);

this.afs.collection('students').set({
  classes: createArrays([
    ...classes,
    add_new_class_here
  ])
});
Enter fullscreen mode Exit fullscreen mode

You will basically be storing ids alphabetically like:

id1__id2, id1__id3, id2__id3,... etc

QUERY

Then you can query the doc like this:

Get all students taking both Class A AND Class B

this.afs.collection('students)
.where(
  'classes',
  'array-contains',
  createSearch(class1_ID, class2_ID)
);
Enter fullscreen mode Exit fullscreen mode

OR

this.afs.collection('classes',
  ref =>
    ref.where(
      firebase.firestore.FieldPath.documentId(),
      'in',
      [classID_1, classID_2])
).valueChanges().pipe(
  switchMap((r: any[]) => {
    const ids = r.map((m: any) => m.students);
    const common = ids.reduce(
      (a: number[], b: number[]) => a.filter(
        (c: number) => b.includes(c)
      )
    ).sort();
    const docs: Observable<any>[] = common.map(
      (id: number) => this.afs.doc('students/' + id).valueChanges()
    );
    return combineLatest(docs);
  })
);
Enter fullscreen mode Exit fullscreen mode

The reverse version of this would be getting both class documents using IN, filtering the students array to what is different, grabbing documents in common using combineLatest, and sorting.

And you get array-contains-all!

Next up: Using map for Many-to-Many... coming soon!

J

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .