Skip to content
Merged
46 changes: 46 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,49 @@ export function isRule(arg: Rule | Config): arg is Rule {
export function isConfig(arg: Rule | Config): arg is Config {
return (arg as Config).config !== undefined;
}

enum Key {
Primary = '{(.*?)}',
Foreign = '([$][^/]*|$)',
}

function regexMatches(text: string, regex: Key): string[] {
return text.match(new RegExp(regex, 'g')) || [];
}

export function getPrimaryKey(
ref: string
): { hasPrimaryKey: boolean; primaryKey: string } {
const keys = regexMatches(ref, Key.Primary);
if (keys.length > 0) {
const pk = keys.pop(); // Pop the last item in the matched array
// Remove { } from the primary key
return { hasPrimaryKey: true, primaryKey: pk.replace(/\{|\}/g, '') };
}
return { hasPrimaryKey: false, primaryKey: 'masterId' };
}

export function replaceReferencesWith(
fields: FirebaseFirestore.DocumentData,
targetCollection: string
): { hasFields: boolean; targetCollection: string } {
const matches = regexMatches(targetCollection, Key.Foreign);
matches.pop(); // The foreign key regex always return '' at the end
let hasFields = false;
if (matches.length > 0 && fields) {
hasFields = true;
matches.forEach(match => {
const field = fields[match.replace('$', '')];
if (field) {
console.log(
`integrify: Detected dynamic reference, replacing [${match}] with [${field}]`
);
targetCollection = targetCollection.replace(match, field);
} else {
throw new Error(`integrify: Missing dynamic reference: [${match}]`);
}
});
}

return { hasFields, targetCollection };
}
39 changes: 33 additions & 6 deletions src/rules/deleteReferences.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Config, Rule } from '../common';
import { Config, Rule, replaceReferencesWith, getPrimaryKey } from '../common';

export interface DeleteReferencesRule extends Rule {
source: {
Expand Down Expand Up @@ -30,12 +30,24 @@ export function integrifyDeleteReferences(
)
);

const { hasPrimaryKey, primaryKey } = getPrimaryKey(rule.source.collection);
if (!hasPrimaryKey) {
rule.source.collection = `${rule.source.collection}/{${primaryKey}}`;
}

return functions.firestore
.document(`${rule.source.collection}/{masterId}`)
.document(rule.source.collection)
.onDelete((snap, context) => {
const masterId = context.params.masterId;
// Get the last {...} in the source collection
const primaryKeyValue = context.params[primaryKey];
if (!primaryKeyValue) {
throw new Error(
`integrify: Missing a primary key [${primaryKey}] in the source params`
);
}

console.log(
`integrify: Detected delete in [${rule.source.collection}], id [${masterId}]`
`integrify: Detected delete in [${rule.source.collection}], id [${primaryKeyValue}]`
);

// Call "pre" hook if defined
Expand All @@ -53,18 +65,33 @@ export function integrifyDeleteReferences(
target.isCollectionGroup ? 'group ' : ''
}[${target.collection}] where foreign key [${
target.foreignKey
}] matches [${masterId}]`
}] matches [${primaryKeyValue}]`
);

// Replace the context.params in the target collection
const paramSwap = replaceReferencesWith(
context.params,
target.collection
);

// Replace the snapshot fields in the target collection
const fieldSwap = replaceReferencesWith(
snap.data(),
paramSwap.targetCollection
);
target.collection = fieldSwap.targetCollection;

// Delete all docs in this target corresponding to deleted master doc
let whereable = null;
if (target.isCollectionGroup) {
whereable = db.collectionGroup(target.collection);
} else {
whereable = db.collection(target.collection);
}

promises.push(
whereable
.where(target.foreignKey, '==', masterId)
.where(target.foreignKey, '==', primaryKeyValue)
.get()
.then(querySnap => {
querySnap.forEach(doc => {
Expand Down
98 changes: 97 additions & 1 deletion test/functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ module.exports.replicateMasterToDetail = integrify({
module.exports.deleteReferencesToMaster = integrify({
rule: 'DELETE_REFERENCES',
source: {
collection: 'master',
collection: 'master/{masterId}',
},
targets: [
{
Expand All @@ -62,6 +62,102 @@ module.exports.deleteReferencesToMaster = integrify({
},
});

module.exports.deleteReferencesWithMasterParam = integrify({
rule: 'DELETE_REFERENCES',
source: {
collection: 'master/{primaryKey}',
},
targets: [
{
collection: 'detail1',
foreignKey: 'primaryKey',
},
{
collection: 'somecoll/$primaryKey/detail2',
foreignKey: 'primaryKey',
},
],
hooks: {
pre: (snap, context) => {
setState({
snap,
context,
});
},
},
});

module.exports.deleteReferencesWithSnapshotFields = integrify({
rule: 'DELETE_REFERENCES',
source: {
collection: 'master/{anotherId}',
},
targets: [
{
collection: 'detail1',
foreignKey: 'anotherId',
},
{
collection: 'somecoll/$testId/detail2',
foreignKey: 'anotherId',
},
],
hooks: {
pre: (snap, context) => {
setState({
snap,
context,
});
},
},
});

module.exports.deleteReferencesWithMissingKey = integrify({
rule: 'DELETE_REFERENCES',
source: {
collection: 'master',
},
targets: [
{
collection: 'detail1',
foreignKey: 'randomId',
},
],
hooks: {
pre: (snap, context) => {
setState({
snap,
context,
});
},
},
});

module.exports.deleteReferencesWithMissingFields = integrify({
rule: 'DELETE_REFERENCES',
source: {
collection: 'master/{randomId}',
},
targets: [
{
collection: 'detail1',
foreignKey: 'randomId',
},
{
collection: 'somecoll/$testId/detail2',
foreignKey: 'randomId',
},
],
hooks: {
pre: (snap, context) => {
setState({
snap,
context,
});
},
},
});

module.exports.maintainFavoritesCount = integrify({
rule: 'MAINTAIN_COUNT',
source: {
Expand Down
66 changes: 65 additions & 1 deletion test/functions/integrify.rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module.exports = [
rule: 'DELETE_REFERENCES',
name: 'deleteReferencesToMaster',
source: {
collection: 'master',
collection: 'master/{masterId}',
},
targets: [
{
Expand All @@ -45,6 +45,70 @@ module.exports = [
},
],
},
{
rule: 'DELETE_REFERENCES',
name: 'deleteReferencesWithMasterParam',
source: {
collection: 'master/{primaryKey}',
},
targets: [
{
collection: 'detail1',
foreignKey: 'primaryKey',
},
{
collection: 'somecoll/$primaryKey/detail2',
foreignKey: 'primaryKey',
},
],
},
{
rule: 'DELETE_REFERENCES',
name: 'deleteReferencesWithSnapshotFields',
source: {
collection: 'master/{anotherId}',
},
targets: [
{
collection: 'detail1',
foreignKey: 'anotherId',
},
{
collection: 'somecoll/$testId/detail2',
foreignKey: 'anotherId',
},
],
},
{
rule: 'DELETE_REFERENCES',
name: 'deleteReferencesWithMissingKey',
source: {
collection: 'master',
},
targets: [
{
collection: 'detail1',
foreignKey: 'randomId',
},
],
},
{
rule: 'DELETE_REFERENCES',
name: 'deleteReferencesWithMissingFields',
source: {
collection: 'master/{randomId}',
},
targets: [
{
collection: 'detail1',
foreignKey: 'randomId',
},
{
collection: 'somecoll/$testId/detail2',
foreignKey: 'randomId',
},
],
},
{
rule: 'MAINTAIN_COUNT',
name: 'maintainFavoritesCount',
Expand Down
Loading