Reite
Reite

Reputation: 1667

In firestore, if a user has a separate list of documents they can access, how can I query for those documents

I have a database structure like this:

companies (collection)
  userMessages (collection)
    message1 (document)
      messageId: message1 (field)
      title: ...
    ...

users (collection)
  companyMessages (collection)
    message1 (document)
      createdAt: ... (field)

I have security rules that looks like this:

match /users/{userId}/companyMessages/{messageId} {
  allow read: if request.auth.uid == userId
}
match /{path=**}/userMessages/{messageId} {
 allow read: if 
   exists(/databases/$(database)/documents/users/$(request.auth.uid)/companyMessages/$(messageId))
}

Now the user can query for their 10 most recent posts by doing

firestore.collection("users").doc(userId).collection("companyMessages").orderBy("createdAt").limit(10)

Then I want to use the result of that query to actually get the messages, so I want to do a collection group query:

firestore.collectionGroup("userMessages").where("messageId", "in", idsFromPreviousQuery)

However this will cause a "FirebaseError: Missing or insufficient permissions." error. I have checked that my security rules works because I can do a query like this:

firestore.collection("companies").doc(companyId).collection("userMessages").doc(messageIdThatIsInUserCollection)

However doing a query with where() does not work.

firestore.collection("companies").doc(companyId).collection("userMessages").where("messageId", "==", messageIdThatIsInUserCollection)

Am I doing something wrong or is this kind of structure not possible? How can i structure my data to allow queries where users should be able to get their last n messages, but they still should only have access to the messages that is listed in their collection?

Upvotes: 0

Views: 471

Answers (2)

samthecodingman
samthecodingman

Reputation: 26171

Query by Document ID

To query by a document ID, you use firebase.firestore.FieldPath.documentId(). This returns a special object that Firestore understands as "use the document's ID as the property to search on". Importantly, when a particular ID is not found, they are simply omitted from the results - no error is thrown.

For a basic CollectionReference, you can use:

const idsFromPreviousQuery = await firestore
  .collection("users").doc(userId)
  .collection("companyMessages")
  .orderBy("createdAt")
  .limit(10)
  .then(querySnapshot => querySnapshot.map(doc => doc.id)); // return array of document IDs

const messageDocs = firestore
  .collection("companies")
  .doc(companyId)
  .collection("userMessages")
  .where(firebase.firestore.FieldPath.documentId(), "in", idsFromPreviousQuery)
  .get()
  .then(querySnapshot => querySnapshot.docs) // return only the documents array

However, for a collection group query, you must specify the full path instead when using documentId() this way. For this, you will need to store the message's path and/or the relevant company in your list of messages:

"users/someUserId/companyMessages/someMessageId": {
  createdAt: /* ... */,
  company: "companyA"
}

or

"users/someUserId/companyMessages/someMessageId": {
  createdAt: /* ... */,
  path: "companies/companyA/userMessages/someMessageId"
}
const myMessagePaths = await firestore
  .collection("users").doc(userId)
  .collection("companyMessages")
  .orderBy("createdAt")
  .limit(10)
  .then(querySnapshot => querySnapshot.map(doc => doc.get("path"))); // return array of document paths

const messageDocs = firestore.collectionGroup("userMessages")
  .where(firebase.firestore.FieldPath.documentId(), "in", myMessagePaths)
  .get()
  .then(querySnapshot => querySnapshot.docs) // return only the documents array

Another option is to store the message's ID inside the message document so that it can be queried against using a collection group query.

"companies/someCompanyId/userMessages/someMessageId": {
  "_id": "someMessageId",
  /* ... other data ... */
}
const idsFromPreviousQuery = await firestore
  .collection("users").doc(userId)
  .collection("companyMessages")
  .orderBy("createdAt")
  .limit(10)
  .then(querySnapshot => querySnapshot.map(doc => doc.id)); // return array of document IDs

firestore
  .collection("companies")
  .doc(companyId)
  .collection("userMessages")
  .where("_id", "in", idsFromPreviousQuery)
  .get()
  .then(querySnapshot => querySnapshot.docs) // return only the documents array

Filter limitations for where("field", "in", someArray)

A where() filter's in operator is limited to 10 values at a time. When searching more than 10 IDs, you should split the array of IDs up into blocks of 10 items.

/** splits array `arr` into chunks of max size `n` */
const chunkArr = (arr, n) => {
  if (n <= 0) throw new Error("n must be greater than 0");
  return Array
    .from({length: Math.ceil(arr.length/n)})
    .map((_, i) => arr.slice(n*i, n*(i+1)))
}

const idsFromPreviousQuery = [ /* ... */ ];
const idsInChunks = chunkArr(idsFromPreviousQuery, 10);

const getDocPromises = idsInChunks.map((idsInThisChunk) => (
  firestore.collectionGroup("userMessages")
    .where("_id", "in", idsInThisChunk)
    .get()
    .then(querySnapshot => querySnapshot.docs) // return only the documents array
));

const allFoundDocs = await Promise.all(getDocPromises)
  .then(getDocResults => (
    getDocResults.reduce((acc, arr) => acc.push(...arr), []) // flatten the documents into one array
  );

// allFoundDocs now contains an array of QueryDocumentSnapshot objects that match the given IDs

Upvotes: 0

Reite
Reite

Reputation: 1667

As far as I can figure out, the query I want to do is not possible in firestore because the query and the security rules must match in some way. Because my query could potentially request IDs that the user is not allowed to access, any such query is disallowed. I hope someone will correct me if I am wrong and I am making a different mistake.

I ended up solving my issue by querying for each post individually. Since I am charged per document read anyway it seems to me that this way is equivalent. So I will do:

Promise.all(
  idsFromPreviousQuery.map((id) =>
    firestore.collection("companies")
      .doc(companyId).collection("userMessages")
      .doc(messageIdThatIsInUserCollection)
  )
)

Upvotes: 1

Related Questions