Cómo implementar un campo único en Firestore

08 de julio, 2022

Imagina que tienes que guardar un campo de RUT de pacientes en Firestore y que además tienes que preocuparte de no duplicarlos. ¿Cómo lo haces?

Firestore no tiene una forma nativa para guardar campos únicos, así que tenemos que usar técnicas para lograrlo.

En este post, veremos una técnica para guardar campos únicos, que podría ser usada para no duplicar registros en base al RUT.

Nota: El RUT es un número único implantado en Chile, que se usa como identificación tributaria. En registros clínicos, se suele usar para identificar al paciente de forma inequívoca.

Meta

La meta de este artículo es mostrarte un paso a paso de cómo lograr guardar valores únicos en un documento de firestore.

Para el resto del artículo, consideraremos que existe una colección de pacientes (patients) y que queremos hacer que el campo rut sea único.

¿Qué es un campo único?

Un campo único es un dato particular que debe ser único entre todos tus registros.

Estos van a depender de la aplicación que estés construyendo, pero algunos ejemplos que suelen ser únicos: identificadores, RUT de pacientes y correo electrónicos.

¿Por qué guardar un campo único en Firestore no es fácil?

Firestore no tiene la funcionalidad para guardar un campo único. No es como otros motores de base de datos donde puedes decir “mira, quiero que la propiedad rut sea única para todos estos registros”.

Para lograrlo, hay que usar distintas técnicas, y va a depender de tu caso particular.

¿Cómo podemos guardar un campo único?

En este artículo veremos dos formas, las cuales van a depender de tu situación particular.

La primera es la más sencilla: haz que el campo único sea el identificador de tu documento en Firestore. Te conviene esta solución cuando no puede existir un registro sin el dato.

La segunda es más complicada: crea una colección para tu campo único y controla la escritura mediante reglas de seguridad. Te conviene esta solución cuando no puedes usar la primera.

Alternativa #1: Haz que el campo único sea el identificador de tu documento en Firestore

Si tienes registros de pacientes y por requerimientos siempre existirán pacientes con RUT, puedes optar por esta alternativa.

Cuando crees pacientes, en vez de usar la función add():

firestore().collection('patients').add(patientData);

Tendrás que hacerlo con doc() y set():

firestore().collection('patients').doc(patientData.rut).set(patientData);

Luego te recomiendo agregar en Reglas de Seguridad que el ID del documento sea igual al RUT:

match /patients/{patientId} {
  allow create: if patientId == request.resource.data.rut;
}

Puedes validar también que patientId parezca un rut válido (mediante una expresión regular).

Nota: para validar el dígito verificador, tendrías que hacerlo en una Cloud Function, ya que en Firestore Security Rules no puedes usar loops (aunque quizás es posible hacerlo sin loops 🙃).

Alternativa #2: Crea una colección para tu campo único y controla la escritura mediante reglas de seguridad

Si no puedes optar por la solución anterior, tendrás que hacer algo más complicado.

La idea es tener una colección aparte, que sea como un mapa entre tu campo único y el ID del documento que lo tiene. Luego agregar validaciones mediante reglas de seguridad. A esta colección la llamaré identifiers.

El resultado final será que para cada registro de paciente, también existirá un registro en la colección de identificadores. Por ejemplo:

Paciente con ID = 5dedf…

Paciente con ID = 5dedf…

Identificador con ID = 1111111-1, y patientId = 5dedf…

Identificador con ID = 1111111-1, y patientId = 5dedf…

Para lograrlo, son dos pasos:

1) Agregar colección de identificadores

Al crear pacientes, ahora tenemos que también crear identificadores. Por ejemplo:

const patientRef = firestore().collection('patients').doc() // ID auto generado
const identifierRef = firestore().collection('identifiers').doc(patientData.rut) // ID de RUT

await identifierRef.set({ patientId: patientRef.id })
await patientRef.set(patientData)

Aquí estamos creando ambos documentos. El problema es que esta no es una operación atómica, es decir, si falla la creación de uno, el otro documento no se ve afectado.

Para solucionar este problema, tenemos que usar escrituras en lotes (Batch writes):

const batch = firestore().batch();
const patientRef = firestore().collection('patients').doc();
const identifierRef = firestore().collection('identifiers').doc(patientData.rut);

batch.set(patientRef, newPatient);
batch.set(identifierRef, { patientId: patientRef.id });

try {
  await batch.commit();
} catch {
  // Ocurrió un error. Quizás el RUT ya estaba en uso :)
}

Con esto tenemos listo la primera parte. Ahora debemos hacer que esta operación falle si el RUT está en uso.

2) Agregar reglas de seguridad para evitar duplicados

Si ya existe un paciente con el RUT, la creación de paciente debe fallar. Como estamos usando una transacción, basta con la siguiente regla de seguridad:

match /identifiers/{identifierId} {
  allow create: if 'patientId' in request.resource.data;
}

Encapsulando la lógica anterior en una función llamada createPatient:

async function createPatient(patientData) {
	const batch = firestore().batch();
	const patientRef = firestore().collection('patients').doc();
	const identifierRef = firestore().collection('identifiers').doc(patientData.rut);
	
	batch.set(patientRef, newPatient);
	batch.set(identifierRef, { patientId: patientRef.id });
	
	try {
	  await batch.commit();
	} catch {
	  // Ocurrió un error. Quizás el RUT ya estaba en uso :)
	}
}

Al intentar ejecutar el siguiente código, obtenemos el siguiente resultado:

await createPatient({ rut: '11111111-1', name: 'Chewy 1' })
// ^funciona, y crea al paciente y al identificador (id = 11111111-1)
await createPatient({ rut: '11111111-1', name: 'Chewy 2' })
// ^falla, identificador id = 11111111-1 ya existe, la operación se considera
// como un update, por lo tanto las reglas de seguridad la rechazan

Esto ocurre porque el segundo llamado está escribiendo sobre el identifier con ID igual a 11111111-1, como ya existe, estamos hablando de una operación update la cual no es permitida.

Pero la regla no evita que agreguemos los pacientes directamente:

// Las siguientes llamadas funcionan:
await firestore().collection('patients').add({ rut: '11111111-1', name: 'Chewy 1' });
await firestore().collection('patients').add({ rut: '11111111-1', name: 'Chewy 2' });

Ni tampoco evita que agreguemos identificadores cuando los pacientes no existen:

await firestore().collection('identifiers').doc('11111111-1').set({ patientId: 'no existe' })

Es por eso que necesitamos agregar las siguientes 2 reglas:

1) Cuando creo un identificador, verificar que el paciente existe:

function patientExists(identifierId) {
  let identifier = getAfter(/databases/$(database)/documents/identifiers/$(identifierId)).data;
  let patient = getAfter(/databases/$(database)/documents/patients/$(identifider.patientId)).data;
  return identifierId == patient.rut;
}

match /identifiers/{identifierId} {
  allow create: if patientExists(identifierId);
}

La función getAfter obtiene el documento asumiendo que la solicitud actual se realiza correctamente. Si el paciente no existe, la verificación fallará, ya que el getAfter del paciente fallaría.

2) Cuando creo un paciente, verificar que el identificador existe:

function identifierExists(patientId) {
  let patient = getAfter(/databases/$(database)/documents/patients/$(patientId)).data;
  let identifier = getAfter(/databases/$(database)/documents/identifiers/$(patient.rut)).data;
  return identifier.patientId == patientId;
}

match /patients/{patientId} {
  allow create: if identifierExists(patientId);
}

Esto es lo mismo que la regla anterior, pero desde el punto de vista del paciente.

Gracias a estas dos reglas, los casos anteriores funcionan como deberían:

// Prueba 1: ambas llamadas fallan porque no existe identifier
await firestore().collection('patients').add({ rut: '11111111-1', name: 'Chewy 1' });
await firestore().collection('patients').add({ rut: '11111111-1', name: 'Chewy 2' });

// Prueba 2: falla porque no existe paciente
await firestore().collection('identifiers').doc('11111111-1').set({ patientId: 'no existe' })

La única forma de crear pacientes ahora es con una operación atómica, como la que tenemos en la función createPatient.

Conclusión

En este artículo vimos como implementar la funcionalidad de “campo único” en Firestore.

Como ejemplo, nos pusimos en la situación donde queremos guardar pacientes con un campo único “RUT”, que es un identificador único que tienen los ciudadanos en Chile 🇨🇱. De todos modos, puedes usar las mismas técnicas para otros tipos de identificadores, tales como pasaporte, nombres de usuario o incluso correos.

Las técnicas que vimos son 2:

  1. La fácil, que es utilizando tu campo único como ID del documento.
  2. La difícil, que es utilizando una colección auxiliar que funciona como un registro que guarda los valores únicos.

Me gustaría destacar que las técnicas mostradas le falta una capa adicional de privacidad, ya que el RUT estaría visible en el administrador:

Screen Shot 2022-07-06 at 13.19.54.png

Además, ¿qué pasa si el campo único tiene caracteres que a Firebase no le gusta en sus IDs?

Y solo vimos un único identificador, ¿cómo podemos hacer que existan otros tipos de identificadores? Por ejemplo, permitir que pacientes tengan RUT o Pasaporte.

A continuación te dejo contenido extra de cómo abordar estos requerimientos.

Extra: Cómo esconder el identificador

Si no quieres que tu campo único esté visible desde Firebase:

Screen Shot 2022-07-06 at 13.19.54.png

Puedes “ofuscar” el identificador, de manera que en vez de guardar el RUT, guardes su valor encriptado.

Para esto, tienes que usar alguna función que esté disponible en Cloud Firestore Rules, por ejemplo, md5 o sha256.

Usando sha256, el ID del documento ahora será el RUT encriptado:

const patientId = sha256(patientData.rut)
firestore().collection('patients').doc(patientId).set(patientData)

Y tendrías que actualizar las reglas de seguridad para verificar que el RUT del paciente sea igual al RUT encriptado:

function verifyPatientId(patientId, patientData) {
  return patientId == hashing.sha256(patientData.rut).toHexString().lower();
}

match /patients/{patientId} {
  allow create: if verifyPatientId(patientId, request.resource.data);
  allow update: if verifyPatientId(patientId, request.resource.data);
}

Así, el ID en vez de ser 11111111-1, queda en 5dedf71e6903bbb3c0b08ab5d94db62a4a5dea268bf2ef8d591089345989f82f

Screen Shot 2022-07-06 at 13.png

El código de la segunda técnica bajo ofuscación te lo dejo como ejercicio 😆



Hecho por Codeness con ❤️
© 2024 Codeness