I wanted to implement usernames the other day. I searched for a bit and found this*. But I also needed to allow modification of the username so I came up with 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;
}
So we need to maintain 2 collections which will be used only for keeping the relationship user vs username and not anything else.
I haven’t written tests but did a few manual tests in the app I’m building and it worked. ️I guess we can prove correctness by using some sort of constructive proof, we can prove that the frontend will only move (change the database) from a valid state to another valid state, starting from the empty set which is both collections being empty. We define “valid state” as a database state in which there is an “exact 1 to 1 relationship” between user and username, I mean: each user in the username_by_user collection points to an existing username document (with its username field) and each username in the “username” collection points to an existing user document (with its uid field) and no 2 users point to the same username neither 2 usernames point to the same user.
This is how I imagine a valid state:
For example the arrow from user01 to pepito10 is represented by having a document inside the username_by_user collection whose document id is “user01” and its username field is “pepito10”. And the arrow from pepito10 to user01 is a document inside the username collection whose document id is “pepito10” and its uid field is “user01”.
Oh, I almost forget, the frontend would need 2 functions. Pseudocode for them:
setUsername(username, userId):
collection(“username”).doc(username).set({uid: userId});
collection(“username_by_user”).doc(userId).set({username: username});
deleteUsername(username, userId):
collection(“username”).doc(username).delete();
collection(“username_by_user”).doc(userId).delete();
Both functions need to use a batched write to write simultaneously to both collections (you can take a look at the Fireship link in the beginning of this post to see an example).
And if you need to update a username you would call deleteUsername followed by setUsername.
Feel free to use it and to suggest improvements or provide feedback ✌️
*The Fireship code has an issue, I submitted a PR fixing it.