41

Is it possible in Firestore to define an index with a unique constraint? If not, how is it possible to enforce uniqueness on a document field (without using document ID)?

Greg Ennis
  • 14,917
  • 2
  • 69
  • 74
  • No, this is not currently possible. Check out my answer here. https://stackoverflow.com/questions/47405774/cloud-firestore-enforcing-unique-user-names – jqualls Dec 02 '17 at 23:12
  • The answers in a related question were most helpful to me: https://stackoverflow.com/questions/47405774/cloud-firestore-enforcing-unique-user-names/47613333 – David Jan 22 '22 at 05:54

7 Answers7

35

Yes, this is possible using a combination of two collections, Firestore rules and batched writes.

https://cloud.google.com/firestore/docs/manage-data/transactions#batched-writes

The simple idea is, using a batched write, you write your document to your "data" collection and at the same write to a separate "index" collection where you index the value of the field that you want to be unique.

Using the Firestore rules, you can then ensure that the "data" collection can only have a document written to it if the document field's value also exists in the index collection and, vice versa, that the index collection can only be written to if value in the index matches what's in the data collection.

Example

Let's say that we have a User collection and we want to ensure that the username field is unique.

Our User collection will contain simply the username

/User/{id}
{
  username: String 
}

Our Index collection will contain the username in the path and a value property that contains the id of the User that is indexed.

/Index/User/username/{username}
{
  value: User.id
}

To create our User we use a batch write to create both the User document and the Index document at the same time.

const firebaseApp = ...construct your firebase app

const createUser = async (username) => {
  const database = firebaseApp.firestore()
  const batch = database.batch()

  const Collection = database.collection('User')
  const ref = Collection.doc()
  batch.set(ref, {
    username
  })

  const Index = database.collection('Index')
  const indexRef = Index.doc(`User/username/${username}`)
  batch.set(indexRef, {
    value: ref.id
  })

  await batch.commit()
}

To update our User's username we use a batch write to update the User document, delete the previous Index document and create a new Index document all at the same time.

const firebaseApp = ...construct your firebase app

const updateUser = async (id, username) => {
  const database = firebaseApp.firestore()
  const batch = database.batch()

  const Collection = database.collection('User')
  const ref = Collection.doc(id)
  const refDoc = await ref.get()
  const prevData = refDoc.data()
  batch.update(ref, {
    username
  })

  const Index = database.collection('Index')
  const prevIndexRef = Index.doc(`User/username/${prevData.username}`)
  const indexRef = Index.doc(`User/username/${username}`)
  batch.delete(prevIndexRef)
  batch.set(indexRef, {
    value: ref.id
  })

  await batch.commit()
}

To delete a User we use a batch write to delete both the User document and the Index document at the same time.

const firebaseApp = ...construct your firebase app

const deleteUser = async (id) => {
  const database = firebaseApp.firestore()
  const batch = database.batch()

  const Collection = database.collection('User')
  const ref = Collection.doc(id)
  const refDoc = await ref.get()
  const prevData = refDoc.data()
  batch.delete(ref)


  const Index = database.collection('Index')
  const indexRef = Index.doc(`User/username/${prevData.username}`)
  batch.delete(indexRef)

  await batch.commit()
}

We then setup our Firestore rules so that they only allow a User to be created if the username is not already indexed for a different User. A User's username can only be updated if an Index does not already exist for the username and a User can only be deleted if the Index is deleted as well. Create and update will fail with a "Missing or insufficient permissions" error if a User with the same username already exists.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {


    // Index collection helper methods

    function getIndexAfter(path) {
      return getAfter(/databases/$(database)/documents/Index/$(path))
    }

    function getIndexBefore(path) {
      return get(/databases/$(database)/documents/Index/$(path))
    }

    function indexExistsAfter(path) {
      return existsAfter(/databases/$(database)/documents/Index/$(path))
    }

    function indexExistsBefore(path) {
      return exists(/databases/$(database)/documents/Index/$(path))
    }


    // User collection helper methods

    function getUserAfter(id) {
      return getAfter(/databases/$(database)/documents/User/$(id))
    }

    function getUserBefore(id) {
      return get(/databases/$(database)/documents/User/$(id))
    }

    function userExistsAfter(id) {
      return existsAfter(/databases/$(database)/documents/User/$(id))
    }


    match /User/{id} {
      allow read: true;

      allow create: if
        getIndexAfter(/User/username/$(getUserAfter(id).data.username)).data.value == id;

      allow update: if
        getIndexAfter(/User/username/$(getUserAfter(id).data.username)).data.value == id &&
        !indexExistsBefore(/User/username/$(getUserAfter(id).data.username));

      allow delete: if
        !indexExistsAfter(/User/username/$(getUserBefore(id).data.username));
    }

    match /Index/User/username/{username} {
      allow read: if true;

      allow create: if
        getUserAfter(getIndexAfter(/User/username/$(username)).data.value).data.username == username;

      allow delete: if 
        !userExistsAfter(getIndexBefore(/User/username/$(username)).data.value) || 
        getUserAfter(getIndexBefore(/User/username/$(username)).data.value).data.username != username;
    }
  }
}
Brian Neisler
  • 843
  • 9
  • 12
  • 53
    This is a very clever solution, however, it sure is a lot of work to deal with a very basic missing feature. – Greg Ennis Jan 26 '20 at 18:38
  • For some reason, with an empty firestore, I get the "Missing or insufficient permissions" error on the every create attempt. If I temporarily remove the rule set, I'm able to create the Index & User records. If I add the rule set again, every create request fails. Any thoughts/suggestions? – Z Jones Mar 18 '20 at 01:46
  • Clear solution but rather than using batch, you can set an identifier for a document. See my solution below – Xcode Aug 09 '20 at 04:30
  • @brian Will the `getAfter()` and `existsAfter()` functions work if I'm using a [BulkWriter](https://googleapis.dev/nodejs/firestore/latest/BulkWriter.html) instead of a batch write? – Paul McDowell Aug 09 '21 at 14:36
  • 1
    @PaulMcDowell `getAfter` and `existsAfter` should work if you're using a BulkWriter, however, bulk writing is different from batch writing in that it's used to make multiple writes in parallel but not atomically like batch write. If one write in a batch fails due to a rules check, the entire batch is rejected. If one write in a BulkWriter fails, the individual write will fail but the rest will succeed. The suggestion in this answer relies on the batch concept for rules enforcement. Both changes have to be made at the same time, one can't be made without the other. – Brian Neisler Aug 10 '21 at 12:29
  • @BrianNeisler thank you! for my use case, the BulkWriter was desireable because I want the rest of the writes to happen even if one fails. Ended up not needing either getAfter or existAfter anyway, because I found another way to track what had already been entered in the database! – Paul McDowell Aug 11 '21 at 19:00
  • Wouldn't it be easier to use Transactions to first read if a document with such a field already exists and if not, make the write? – giorgiline Nov 18 '21 at 08:55
  • @BrianNeisler I don't know if I understood well. You said the batch writes are done atomically but watching your solution doesn't seem that. I get that if one write fails no one is done, but if it was atomically you actually wouldn't need rules on both sides, because no one should be able to write between your operations. I think I have read that there is no such thing as transactions or atomically read/writes on firestore, is that correct? – Gonzalo Aug 25 '22 at 23:23
3

[Its not a perfect solution but working]

I have done this unique key using key... I want my table to be having unique date value. so i made it key of my document. Any way i am able to get all documents

db.collection('sensors').doc(sensorId).collection("data").doc(date).set(dataObj).then(() => {
    response.send(dataObj);
});
HarshitG
  • 2,677
  • 3
  • 16
  • 13
1

This is possible using a transaction, where a reading must be made to find out if another document uses the unique value.

IMPORTANT: Transaction has to be done using a Firestore server library to ensure blocking on concurrent operations (https://firebase.google.com/docs/firestore/transaction-data-contention#transactions_and_data_contention)

I did several tests simultaneously using Cloud Functions simulating delays and it worked great. See an example:

const result = await admin.firestore().runTransaction(async (t) => {
  const personsRef = admin.firestore().collection("persons").where('email', '==', data.email)
  const query = await t.get(personsRef);
  if (query.docs.length > 0) {
    throw new functions.https.HttpsError('permission-denied', `email ${data.email} already exists`);
  } 
  const newPersonRef = admin.firestore().collection("persons").doc();
  t.set(newPersonRef, {name: data.name, email: data.email});
  return "update success";
}

In this example it is guaranteed that two people cannot use the same email in the inclusion (the same should be done for email changes).

0

What about doing a Transaction to first check if there are documents with the same value in this unique field, and only create the document if the result is empty.

As an example, creating a User with username as unique field:

type User = {
    id?: string
    username: string
    firstName: string
    lastName: string
}

async function createUser(user: User) {
    try {
        const newDocRef = db.collection('Users').doc()
        await db.runTransaction(async t => {
            const checkRef = db.collection('Users')
                .where('username', '==', user.username)
            const doc = await t.get(checkRef)
            if (!doc.empty) {
                throw new FirebaseError('firestore/unique-restriction',
                    `There is already a user with the username: '${user.username}' in the database.`
                )
            }
            await t.create(newDocRef, user)
        })
        console.log('User Created')
    } catch (err) {
        if (err instanceof FirebaseError) {
            console.log('Some error in firebase')
            //Do something
        } else {
            console.log('Another error')
            //Do whatever
        }
    }
}

Is this code ok or am I missing something?.

giorgiline
  • 1,271
  • 4
  • 21
  • 35
  • 2
    This doesn't work. Since queries aren't part of the transaction, it is possible for two parallel requests to get empty query results and for both of them to create a new doc, resulting in multiple documents with same key. – Pallav Agarwal Mar 15 '22 at 20:07
0

You can achieve that by just using the following firestore rules:

    // username uniqueness

    match /username_by_user/{userId} {
      allow read;
      allow create: if isValidUser(userId, true);
      allow delete: if isValidUser(userId, false);
    }

    function isValidUser(userId, isCreate) {
      let isOwner = request.auth.uid == userId;
      let username = isCreate ? request.resource.data.username : resource.data.username;
      let createdValidUsername = isCreate ? (getAfter(/databases/$(database)/documents/username/$(username)).data.uid == userId)
      : !existsAfter(/databases/$(database)/documents/username/$(username))
      ;

      return isOwner && createdValidUsername;
    }

    match /username/{username} {
      allow read;
      allow create: if isValidUsername(username, true);
      allow delete: if isValidUsername(username, false);
    }

    function isValidUsername(username, isCreate) {
      let isOwner = request.auth.uid == (isCreate ? request.resource.data.uid : resource.data.uid);
      // only allow usernames that are at least 3 characters long and only contain lowercase letters, numbers, underscores and dots
      let isValidLength = isCreate ? username.matches("^[a-z0-9_.]{3,}$") : true;
      let isValidUserDoc = isCreate ? getAfter(/databases/$(database)/documents/username_by_user/$(request.auth.uid)).data.username == username : 
        !existsAfter(/databases/$(database)/documents/username_by_user/$(request.auth.uid));

      return isOwner && isValidLength && isValidUserDoc;     
    }

More details: https://mirror.xyz/dashboard/edit/HYXisWWgweZrILDR6C_8FqAZxcKMMX7qbg3jzRJ1JRU

josue.0
  • 775
  • 1
  • 10
  • 23
-2

It's possible, and it's quite simple actually, you don't need to add anything to your collection, just use the method create instead of set when you're trying to create an item, something like this:


  try {
    firebase.doc(`collection/${row.id}`).create(row);
  } catch (err) {
    // Potentially already exists
    console.log(err);
termohead
  • 1
  • 1
-3

Based on the documentation from this section https://cloud.google.com/firestore/docs/manage-data/add-data#set_a_document

You can simply add a custom identifier when adding document object to a collection as shown below:

const data = {
  name: 'Los Angeles',
  state: 'CA',
  country: 'USA'
};

// Add a new document in collection "cities" with ID 'LA'
const res = await db.collection('cities').doc('LA').set(data);

Using this https://cloud.google.com/firestore/docs/manage-data/add-data#node.js_4 as a reference when you use set as a method on your collection you can be able to specify an id for such document when you need to auto-generate an id you simply use the add method on your collection

Xcode
  • 129
  • 1
  • 11