Skip to content

Commit fe29649

Browse files
authored
Merge pull request #10 from GitLiveApp/delete-field-from-target-when-not-in-source
Delete field from target when not in source
2 parents a8bc2f8 + 74fb121 commit fe29649

File tree

6 files changed

+257
-18
lines changed

6 files changed

+257
-18
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,18 @@ integrify({ config: { functions, db } });
2525
module.exports.replicateMasterToDetail = integrify({
2626
rule: 'REPLICATE_ATTRIBUTES',
2727
source: {
28-
collection: 'master',
28+
source: {
29+
collection: 'master', // <-- This will append {masterId}
30+
// OR
31+
collection: 'master/{masterId}', // <-- Can be any string as in Firebase
32+
},
2933
},
3034
targets: [
3135
{
3236
collection: 'detail1',
3337
foreignKey: 'masterId',
3438
attributeMapping: {
35-
masterField1: 'detail1Field1',
39+
masterField1: 'detail1Field1', // If an field is missing after the update, the field will be deleted
3640
masterField2: 'detail1Field2',
3741
},
3842
},

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "integrify",
3-
"version": "3.0.1",
3+
"version": "4.0.0",
44
"description": "Enforce referential integrity in Firestore using Cloud Functions",
55
"keywords": [
66
"firebase",

src/rules/replicateAttributes.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { Config, Rule } from '../common';
1+
import { Config, Rule, getPrimaryKey } from '../common';
2+
import { firestore } from 'firebase-admin';
3+
const FieldValue = firestore.FieldValue;
24

35
export interface ReplicateAttributesRule extends Rule {
46
source: {
@@ -40,6 +42,11 @@ export function integrifyReplicateAttributes(
4042
});
4143
});
4244

45+
const { hasPrimaryKey, primaryKey } = getPrimaryKey(rule.source.collection);
46+
if (!hasPrimaryKey) {
47+
rule.source.collection = `${rule.source.collection}/{${primaryKey}}`;
48+
}
49+
4350
// Create map of master attributes to track for replication
4451
const trackedMasterAttributes = {};
4552
rule.targets.forEach(target => {
@@ -49,12 +56,18 @@ export function integrifyReplicateAttributes(
4956
});
5057

5158
return functions.firestore
52-
.document(`${rule.source.collection}/{masterId}`)
59+
.document(rule.source.collection)
5360
.onUpdate((change, context) => {
54-
const masterId = context.params.masterId;
61+
// Get the last {...} in the source collection
62+
const primaryKeyValue = context.params[primaryKey];
63+
if (!primaryKeyValue) {
64+
throw new Error(
65+
`integrify: Missing a primary key [${primaryKey}] in the source params`
66+
);
67+
}
5568
const newValue = change.after.data();
5669
console.log(
57-
`integrify: Detected update in [${rule.source.collection}], id [${masterId}], new value:`,
70+
`integrify: Detected update in [${rule.source.collection}], id [${primaryKeyValue}], new value:`,
5871
newValue
5972
);
6073

@@ -80,19 +93,19 @@ export function integrifyReplicateAttributes(
8093
return null;
8194
}
8295

83-
// Loop over each target specification to replicate atributes
96+
// Loop over each target specification to replicate attributes
8497
const db = config.config.db;
8598
rule.targets.forEach(target => {
8699
const targetCollection = target.collection;
87100
const update = {};
88101

89-
// Create "update" mapping each changed attribute from source => target
90-
Object.keys(newValue).forEach(changedAttribute => {
91-
if (target.attributeMapping[changedAttribute]) {
92-
update[target.attributeMapping[changedAttribute]] =
93-
newValue[changedAttribute];
94-
}
102+
// Create "update" mapping each changed attribute from source => target,
103+
// if delete is set delete field
104+
Object.keys(target.attributeMapping).forEach(changedAttribute => {
105+
update[target.attributeMapping[changedAttribute]] =
106+
newValue[changedAttribute] || FieldValue.delete();
95107
});
108+
96109
console.log(
97110
`integrify: On collection ${
98111
target.isCollectionGroup ? 'group ' : ''
@@ -110,7 +123,7 @@ export function integrifyReplicateAttributes(
110123
}
111124
promises.push(
112125
whereable
113-
.where(target.foreignKey, '==', masterId)
126+
.where(target.foreignKey, '==', primaryKeyValue)
114127
.get()
115128
.then(detailDocs => {
116129
detailDocs.forEach(detailDoc => {

test/functions/index.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,55 @@ module.exports.replicateMasterToDetail = integrify({
3939
},
4040
});
4141

42+
module.exports.replicateMasterDeleteWhenEmpty = integrify({
43+
rule: 'REPLICATE_ATTRIBUTES',
44+
source: {
45+
collection: 'master/{primaryKey}',
46+
},
47+
targets: [
48+
{
49+
collection: 'detail1',
50+
foreignKey: 'tempId',
51+
attributeMapping: {
52+
masterDetail1: 'foreignDetail1',
53+
masterDetail2: 'foreignDetail2',
54+
},
55+
},
56+
],
57+
hooks: {
58+
pre: (change, context) => {
59+
setState({
60+
change,
61+
context,
62+
});
63+
},
64+
},
65+
});
66+
67+
module.exports.replicateReferencesWithMissingKey = integrify({
68+
rule: 'REPLICATE_ATTRIBUTES',
69+
source: {
70+
collection: 'master/{masterId}',
71+
},
72+
targets: [
73+
{
74+
collection: 'detail1',
75+
foreignKey: 'randomId',
76+
attributeMapping: {
77+
masterDetail1: 'foreignDetail1',
78+
},
79+
},
80+
],
81+
hooks: {
82+
pre: (snap, context) => {
83+
setState({
84+
snap,
85+
context,
86+
});
87+
},
88+
},
89+
});
90+
4291
module.exports.deleteReferencesToMaster = integrify({
4392
rule: 'DELETE_REFERENCES',
4493
source: {

test/functions/integrify.rules.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,39 @@ module.exports = [
2727
},
2828
],
2929
},
30+
{
31+
rule: 'REPLICATE_ATTRIBUTES',
32+
name: 'replicateMasterDeleteWhenEmpty',
33+
source: {
34+
collection: 'master/{primaryKey}',
35+
},
36+
targets: [
37+
{
38+
collection: 'detail1',
39+
foreignKey: 'tempId',
40+
attributeMapping: {
41+
masterDetail1: 'foreignDetail1',
42+
masterDetail2: 'foreignDetail2',
43+
},
44+
},
45+
],
46+
},
47+
{
48+
rule: 'REPLICATE_ATTRIBUTES',
49+
name: 'replicateReferencesWithMissingKey',
50+
source: {
51+
collection: 'master/{masterId}',
52+
},
53+
targets: [
54+
{
55+
collection: 'detail1',
56+
foreignKey: 'randomId',
57+
attributeMapping: {
58+
masterDetail1: 'foreignDetail1',
59+
},
60+
},
61+
],
62+
},
3063
{
3164
rule: 'DELETE_REFERENCES',
3265
name: 'deleteReferencesToMaster',

test/unit.test.js

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ admin.initializeApp({
1717
const db = admin.firestore();
1818

1919
async function clearFirestore() {
20-
const collections = ['detail1', 'detail2', 'somecoll'];
20+
const collections = ['detail1', 'detail2', 'detail3', 'somecoll'];
2121
for (const collection of collections) {
2222
const { docs } = await admin
2323
.firestore()
@@ -55,12 +55,20 @@ testsuites.forEach(testsuite => {
5555
testPrimaryKey(sut, t, name));
5656
test(`[${name}] test target collection parameter swap`, async t =>
5757
testTargetVariableSwap(sut, t, name));
58+
59+
// Standard functionality
5860
test(`[${name}] test replicate attributes`, async t =>
5961
testReplicateAttributes(sut, t, name));
6062
test(`[${name}] test delete references`, async t =>
6163
testDeleteReferences(sut, t, name));
6264
test(`[${name}] test maintain count`, async t => testMaintainCount(sut, t));
6365

66+
// Added by GitLive
67+
test(`[${name}] test replicate attributes delete when field is not there`, async t =>
68+
testReplicateAttributesDeleteEmpty(sut, t, name));
69+
test(`[${name}] test replicate attributes with missing primary key in source reference`, async t =>
70+
testReplicateMissingSourceCollectionKey(sut, t, name));
71+
6472
test(`[${name}] test delete with masterId in target reference`, async t =>
6573
testDeleteParamReferences(sut, t, name));
6674
test(`[${name}] test delete with snapshot fields in target reference`, async t =>
@@ -72,7 +80,6 @@ testsuites.forEach(testsuite => {
7280

7381
test(`[${name}] test delete all sub-collections in target reference`, async t =>
7482
testDeleteAllSubCollections(sut, t, name));
75-
7683
test(`[${name}] test delete missing arguments error`, async t =>
7784
testDeleteMissingArgumentsError(sut, t, name));
7885
});
@@ -163,7 +170,9 @@ async function testReplicateAttributes(sut, t, name) {
163170

164171
// Call trigger to replicate attributes from master
165172
const beforeSnap = fft.firestore.makeDocumentSnapshot(
166-
{},
173+
{
174+
masterField5: 'missing',
175+
},
167176
`master/${masterId}`
168177
);
169178
const afterSnap = fft.firestore.makeDocumentSnapshot(
@@ -226,6 +235,137 @@ async function testReplicateAttributes(sut, t, name) {
226235
await t.pass();
227236
}
228237

238+
async function testReplicateAttributesDeleteEmpty(sut, t, name) {
239+
// Add a couple of detail documents to follow master
240+
const primaryKey = makeid();
241+
await db.collection('detail1').add({
242+
tempId: primaryKey,
243+
foreignDetail1: 'foreign_detail_1',
244+
foreignDetail2: 'foreign_detail_2',
245+
});
246+
247+
// Call trigger to replicate attributes from master
248+
const beforeSnap = fft.firestore.makeDocumentSnapshot(
249+
{
250+
masterDetail1: 'after1',
251+
masterDetail2: 'after2',
252+
},
253+
`master/${primaryKey}`
254+
);
255+
const afterSnap = fft.firestore.makeDocumentSnapshot(
256+
{
257+
masterDetail2: 'after3',
258+
},
259+
`master/${primaryKey}`
260+
);
261+
const change = fft.makeChange(beforeSnap, afterSnap);
262+
const wrapped = fft.wrap(sut.replicateMasterDeleteWhenEmpty);
263+
setState({
264+
change: null,
265+
context: null,
266+
});
267+
await wrapped(change, {
268+
params: {
269+
primaryKey: primaryKey,
270+
},
271+
});
272+
273+
// Assert pre-hook was called (only for rules-in-situ)
274+
if (name === 'rules-in-situ') {
275+
const state = getState();
276+
t.truthy(state.change);
277+
t.truthy(state.context);
278+
t.is(state.context.params.primaryKey, primaryKey);
279+
}
280+
281+
// Assert that attributes get replicated to detail documents
282+
await assertQuerySizeEventually(
283+
db
284+
.collection('detail1')
285+
.where('tempId', '==', primaryKey)
286+
.where('foreignDetail1', '==', 'foreign_detail_1'),
287+
0
288+
);
289+
await assertQuerySizeEventually(
290+
db
291+
.collection('detail1')
292+
.where('tempId', '==', primaryKey)
293+
.where('foreignDetail2', '==', 'after3'),
294+
1
295+
);
296+
297+
await t.pass();
298+
}
299+
300+
async function testReplicateMissingSourceCollectionKey(sut, t, name) {
301+
// Create some docs referencing master doc
302+
const randomId = makeid();
303+
await db.collection('detail1').add({
304+
tempId: randomId,
305+
foreignDetail1: 'foreign_detail_1',
306+
foreignDetail2: 'foreign_detail_2',
307+
});
308+
309+
// Trigger function to delete references
310+
const beforeSnap = fft.firestore.makeDocumentSnapshot(
311+
{
312+
masterDetail1: 'after1',
313+
masterDetail2: 'after2',
314+
},
315+
`master/${randomId}`
316+
);
317+
const afterSnap = fft.firestore.makeDocumentSnapshot(
318+
{
319+
masterDetail2: 'after3',
320+
},
321+
`master/${randomId}`
322+
);
323+
const change = fft.makeChange(beforeSnap, afterSnap);
324+
const wrapped = fft.wrap(sut.replicateReferencesWithMissingKey);
325+
setState({
326+
snap: null,
327+
context: null,
328+
});
329+
330+
const error = await t.throwsAsync(async () => {
331+
await wrapped(change, {
332+
params: {
333+
randomId: randomId,
334+
},
335+
});
336+
});
337+
338+
t.is(
339+
error.message,
340+
'integrify: Missing a primary key [masterId] in the source params'
341+
);
342+
343+
// Assert pre-hook was called (only for rules-in-situ)
344+
if (name === 'rules-in-situ') {
345+
const state = getState();
346+
t.is(state.snap, null);
347+
t.is(state.context, null);
348+
}
349+
350+
// Assert referencing docs were not deleted
351+
await assertQuerySizeEventually(
352+
db
353+
.collection('detail1')
354+
.where('tempId', '==', randomId)
355+
.where('foreignDetail1', '==', 'foreign_detail_1'),
356+
1
357+
);
358+
await assertQuerySizeEventually(
359+
db
360+
.collection('detail1')
361+
.where('tempId', '==', randomId)
362+
.where('foreignDetail2', '==', 'foreign_detail_2'),
363+
1
364+
);
365+
366+
t.pass();
367+
}
368+
229369
async function testDeleteReferences(sut, t, name) {
230370
// Create some docs referencing master doc
231371
const masterId = makeid();

0 commit comments

Comments
 (0)