24

I have a Cloud Firestore DB with the following structure:

  • users
    • [uid]
      • name: "Test User"
  • posts
    • [id]
      • content: "Just some test post."
      • timestamp: (Dec. 22, 2017)
      • uid: [uid]

There is more data present in the actual DB, the above just illustrates the collection/document/field structure.

I have a view in my web app where I'm displaying posts and would like to display the name of the user who posted. I'm using the below query to fetch the posts:

let loadedPosts = {};
posts = db.collection('posts')
          .orderBy('timestamp', 'desc')
          .limit(3);
posts.get()
.then((docSnaps) => {
  const postDocs = docSnaps.docs;
  for (let i in postDocs) {
    loadedPosts[postDocs[i].id] = postDocs[i].data();
  }
});

// Render loadedPosts later

What I want to do is query the user object by the uid stored in the post's uid field, and add the user's name field into the corresponding loadedPosts object. If I was only loading one post at a time this would be no problem, just wait for the query to come back with an object and in the .then() function make another query to the user document, and so on.

However because I'm getting multiple post documents at once, I'm having a hard time figuring out how to map the correct user to the correct post after calling .get() on each post's user/[uid] document due to the asynchronous way they return.

Can anyone think of an elegant solution to this issue?

saricden
  • 2,084
  • 4
  • 29
  • 40
  • https://medium.com/google-developer-experts/performing-or-queries-in-firebase-cloud-firestore-for-javascript-with-rxjs-c361671b201e – Jek Apr 11 '18 at 02:44
  • Link to my Answer in another post here https://stackoverflow.com/a/51671175/7212651 – Lagistos Aug 03 '18 at 11:05

5 Answers5

23

It seems fairly simple to me:

let loadedPosts = {};
posts = db.collection('posts')
          .orderBy('timestamp', 'desc')
          .limit(3);
posts.get()
.then((docSnaps) => {
  docSnaps.forEach((doc) => {
    loadedPosts[doc.id] = doc.data();
    db.collection('users').child(doc.data().uid).get().then((userDoc) => {
      loadedPosts[doc.id].userName = userDoc.data().name;
    });
  })
});

If you want to prevent loading a user multiple times, you can cache the user data client side. In that case I'd recommend factoring the user-loading code into a helper function. But it'll be a variation of the above.

Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807
  • Oh great I just tested and it works perfectly! I was thinking if I approached it like this the doc.id would end up being the id of the last iteration's doc for every call to the users get().then(), misunderstanding of defining callbacks in a loop on my part! – saricden Dec 22 '17 at 23:09
  • It seams that when there is n posts on page, this results into 1+n database calls. Is there a way to download all usernames for the received posts in one database call? – Marek Feb 14 '19 at 11:50
  • No there isn't an option for that in the client-side SDKs. See https://stackoverflow.com/questions/46721517/google-firestore-how-to-get-document-by-multiple-ids-in-one-round-trip – Frank van Puffelen Feb 14 '19 at 14:40
  • re: "you can cache the user data client side" - I believe that if you attach a listener instead of using a .get(), the Firebase SDK will handle caching the data and you can ask for it as often as you want, and always get the cached data. The cache itself will update if the data you're subscribed to changes... – Methodician Aug 26 '19 at 19:29
12

I would do 1 user doc call and the needed posts call.

let users = {} ;
let loadedPosts = {};
db.collection('users').get().then((results) => {
  results.forEach((doc) => {
    users[doc.id] = doc.data();
  });
  posts = db.collection('posts').orderBy('timestamp', 'desc').limit(3);
  posts.get().then((docSnaps) => {
    docSnaps.forEach((doc) => {
    loadedPosts[doc.id] = doc.data();
    loadedPosts[doc.id].userName = users[doc.data().uid].name;
  });
}); 
saricden
  • 2,084
  • 4
  • 29
  • 40
parmigiana
  • 191
  • 7
3

After trying multiple solution I get it done with RXJS combineLatest, take operator. Using map function we can combine result.

Might not be an optimum solution but here its solve your problem.

combineLatest(
    this.firestore.collection('Collection1').snapshotChanges(),
    this.firestore.collection('Collection2').snapshotChanges(),
    //In collection 2 we have document with reference id of collection 1
)
.pipe(
    take(1),
).subscribe(
    ([dataFromCollection1, dataFromCollection2]) => {

        this.dataofCollection1 = dataFromCollection1.map((data) => {
            return {
                id: data.payload.doc.id,
                ...data.payload.doc.data() as {},
            }
            as IdataFromCollection1;
        });

        this.dataofCollection2 = dataFromCollection2.map((data2) => {
            return {
                id: data2.payload.doc.id,
                ...data2.payload.doc.data() as {},
            }
            as IdataFromCollection2;
        });

        console.log(this.dataofCollection2, 'all feeess');

        const mergeDataFromCollection =
            this.dataofCollection1.map(itm => ({
                payment: [this.dataofCollection2.find((item) => (item.RefId === itm.id))],
                ...itm
            }))

        console.log(mergeDataFromCollection, 'all data');
    },
1

my solution as below.

Concept: You know user id you want to get information, in your posts list, you can request user document and save it as promise in your post item. after promise resolve then you get user information.

Note: i do not test below code, but it is simplify version of my code.

let posts: Observable<{}[]>;  // you can display in HTML directly with  | async tag
this.posts = this.listenPosts()
         .map( posts => {
           posts.forEach( post => {
                post.promise = this.getUserDoc( post.uid )
                               .then( (doc: DocumentSnapshot) => {
                                  post.userName = doc.data().name;
                               });
           }); // end forEach
           return posts;
         });

// normally, i keep in provider
listenPosts(): Observable<any> {
  let fsPath = 'posts';
  return this.afDb.collection( fsPath ).valueChanges();
}

// to get the document according the user uid
getUserDoc( uid: string ): Promise<any> {
   let fsPath = 'users/' + uid;
   return this.afDb.doc( fsPath ).ref.get();
}

Note: afDb: AngularFirestore it is initialize in constructor (by angularFire lib)

0

If you want to join observables instead of promises, use combineLatest. Here is an example joining a user document to a post document:

  getPosts(): Observable<Post[]> {
    let data: any;
    return this.afs.collection<Post>('posts').valueChanges().pipe(
      switchMap((r: any[]) => {
        data = r;
        const docs = r.map(
          (d: any) => this.afs.doc<any>(`users/${d.user}`).valueChanges()
        );
        return combineLatest(docs).pipe(
          map((arr: any) => arr.reduce((acc: any, cur: any) => [acc].concat(cur)))
        );
      }),
      map((d: any) => {
        let i = 0;
        return d.map(
          (doc: any) => {
            const t = { ...data[i], user: doc };
            ++i;
            return t;
          }
        );
      })
    );
  }

This example joins each document in a collection, but you could simplify this if you wanted to just join one single document to another.

This assumes your post document has a user variable with the userId of the document.

J

Jonathan
  • 3,893
  • 5
  • 46
  • 77