diff --git a/Document.ts b/Document.ts index 3fb7469..026f150 100644 --- a/Document.ts +++ b/Document.ts @@ -13,14 +13,15 @@ class Document implements FirestoreAPI.Document, FirestoreAPI.MapValue { * * @param obj * @param name + * @param nestedField */ - constructor(obj: Value | FirestoreAPI.Document, name?: string | Document | FirestoreAPI.ReadOnly) { + constructor(obj: Value | FirestoreAPI.Document, name?: string | Document | FirestoreAPI.ReadOnly, nestedField?:boolean) { //Treat parameters as existing Document with extra parameters to merge in if (typeof name === 'object') { Object.assign(this, obj); Object.assign(this, name); } else { - this.fields = Document.wrapMap(obj as ValueObject).fields; + this.fields = Document.wrapMap(obj as ValueObject, nestedField).fields; if (name) { this.name = name; } @@ -102,13 +103,13 @@ class Document implements FirestoreAPI.Document, FirestoreAPI.MapValue { return new Date(wrappedDate.replace(Util_.regexDatePrecision, '$1')); } - static wrapValue(val: Value): FirestoreAPI.Value { + static wrapValue(val: Value, nestedfield?: boolean): FirestoreAPI.Value { const type = typeof val; switch (type) { case 'string': return this.wrapString(val as string); case 'object': - return this.wrapObject(val as ValueObject); + return this.wrapObject(val as ValueObject, nestedfield); case 'number': return this.wrapNumber(val as number); case 'boolean': @@ -132,7 +133,7 @@ class Document implements FirestoreAPI.Document, FirestoreAPI.MapValue { return { stringValue: string }; } - static wrapObject(obj: ValueObject): FirestoreAPI.Value { + static wrapObject(obj: ValueObject, nestedfield?: boolean): FirestoreAPI.Value { if (!obj) { return this.wrapNull(); } @@ -151,13 +152,27 @@ class Document implements FirestoreAPI.Document, FirestoreAPI.MapValue { return this.wrapLatLong(obj as FirestoreAPI.LatLng); } - return { mapValue: this.wrapMap(obj) }; + return { mapValue: this.wrapMap(obj, nestedfield) }; } - static wrapMap(obj: ValueObject): FirestoreAPI.MapValue { + static wrapMap(obj: ValueObject, nestedfield?:boolean): FirestoreAPI.MapValue { return { fields: Object.entries(obj).reduce((o: Record, [key, val]: [string, Value]) => { - o[key] = Document.wrapValue(val); + // Support dot notation in fields + if (typeof nestedfield === 'boolean' && nestedfield == true) { + const s = key.split('.', 2); + if (s.length > 1) { + let t: ValueObject = {}; + t[s[1]] = val; + let m: ValueObject = {}; + m[s[0]] = Document.wrapValue(t, nestedfield) as Value; + Util_.mergeDeep(o, m); + } else { + o[key] = Document.wrapValue(val); + } + } else { + o[key] = Document.wrapValue(val); + } return o; }, {}), }; diff --git a/Firestore.ts b/Firestore.ts index 7fcae10..b8923be 100644 --- a/Firestore.ts +++ b/Firestore.ts @@ -96,11 +96,12 @@ class Firestore implements FirestoreRead, FirestoreWrite, FirestoreDelete { * @param {boolean|string[]} mask if true, the update will mask the given fields, * if is an array (of field names), that array would be used as the mask. * (that way you can, for example, include a field in `mask`, but not in `fields`, and by doing so, delete that field) + * @param {boolean} nestedField support nested field name * @return {object} the Document object written to Firestore */ - updateDocument(path: string, fields: Record, mask?: boolean | string[]): Document { + updateDocument(path: string, fields: Record, mask?: boolean | string[], nestedField?: boolean): Document { const request = new Request(this.baseUrl, this.authToken); - return this.updateDocument_(path, fields, request, mask); + return this.updateDocument_(path, fields, request, mask, nestedField); } updateDocument_ = FirestoreWrite.prototype.updateDocument_; diff --git a/FirestoreWrite.ts b/FirestoreWrite.ts index 654b0e5..d0d1bd4 100644 --- a/FirestoreWrite.ts +++ b/FirestoreWrite.ts @@ -33,9 +33,10 @@ class FirestoreWrite { * @param {boolean|string[]} mask the update will mask the given fields, * if is an array (of field names), that array would be used as the mask. i.e. true: updates only specific fields, false: overwrites document with specified fields * see jsdoc of the `updateDocument` method in Firestore.ts for more details + * @param {boolean} nestedField support nested field name * @return {object} the Document object written to Firestore */ - updateDocument_(path: string, fields: Record, request: Request, mask?: boolean | string[]): Document { + updateDocument_(path: string, fields: Record, request: Request, mask?: boolean | string[], nestedField?: boolean): Document { if (mask) { const maskData = typeof mask === 'boolean' ? Object.keys(fields) : mask; @@ -48,12 +49,19 @@ class FirestoreWrite { if (!maskData.length) { throw new Error('Missing fields in Mask!'); } - for (const field of maskData) { - request.addParam('updateMask.fieldPaths', `\`${field.replace(/`/g, '\\`')}\``); + + if (nestedField == true) { + for (const field of maskData) { + request.addParam('updateMask.fieldPaths', `${field}`); + } + } else { + for (const field of maskData) { + request.addParam('updateMask.fieldPaths', `\`${field.replace(/`/g, '\\`')}\``); + } } } - const firestoreObject = new Document(fields); + const firestoreObject = new Document(fields, undefined, nestedField); const updatedDoc = request.patch(path, firestoreObject); return new Document(updatedDoc, {} as Document); } diff --git a/Tests.ts b/Tests.ts index d30aa58..38f8fd4 100644 --- a/Tests.ts +++ b/Tests.ts @@ -206,6 +206,69 @@ class Tests implements TestManager { GSUnit.assertObjectEquals(expected, updatedDoc.obj); } + Test_Update_Document_Nested_Field() { + const path = 'Test Collection/Update Document Nested Field'; + const original = { + 'org number value': -100, + 'org string value 이': 'The fox jumps over the lazy dog 름', + }; + this.db.createDocument(path, original); + const updater = {'field.subField': 'value'}; + const expected = {field: {subField: 'value'}}; + const updatedDoc = this.db.updateDocument(path, updater, undefined, true); + GSUnit.assertEquals(path, updatedDoc.path); + GSUnit.assertObjectEquals(expected, updatedDoc.obj); + } + + Test_Update_Document_Mask_Nested_Field() { + const path = 'Test Collection/Update Document Mask Nested Field'; + const original = { + 'org number value': -100, + 'org string value 이': 'The fox jumps over the lazy dog 름', + }; + this.db.createDocument(path, original); + const updater = { + 'field.subField1': 'value1', + 'field.subField2': 'value2', + }; + const mask = true; + const expected = { + 'org number value': -100, + 'org string value 이': 'The fox jumps over the lazy dog 름', + field: { + subField1: 'value1', + subField2: 'value2', + } + }; + const updatedDoc = this.db.updateDocument(path, updater, mask, true); + GSUnit.assertEquals(path, updatedDoc.path); + GSUnit.assertObjectEquals(expected, updatedDoc.obj); + } + + Test_Update_Document_Mask_Array_Nested_Field() { + const path = 'Test Collection/Update Document Mask Array Nested Field'; + const original = { + 'org number value': -100, + 'org string value 이': 'The fox jumps over the lazy dog 름', + }; + this.db.createDocument(path, original); + const updater = { + 'field.subField1': 'value1', + 'field.subField2': 'value2', + }; + const mask = ['field.subField2']; + const expected = { + 'org number value': -100, + 'org string value 이': 'The fox jumps over the lazy dog 름', + field: { + subField2: 'value2', + } + }; + const updatedDoc = this.db.updateDocument(path, updater, mask, true); + GSUnit.assertEquals(path, updatedDoc.path); + GSUnit.assertObjectEquals(expected, updatedDoc.obj); + } + Test_Get_Document(): void { const path = 'Test Collection/New Document !@#$%^&*(),.<>?;\':"[]{}|-=_+áéíóúæÆÑ'; const doc = this.db.getDocument(path); @@ -228,7 +291,7 @@ class Tests implements TestManager { Test_Get_Documents(): void { const path = 'Test Collection'; const docs = this.db.getDocuments(path); - GSUnit.assertEquals(8, docs.length); + GSUnit.assertEquals(12, docs.length); const doc = docs.find((doc) => doc.name!.endsWith('/New Document !@#$%^&*(),.<>?;\':"[]{}|-=_+áéíóúæÆÑ')); GSUnit.assertNotUndefined(doc); GSUnit.assertObjectEquals(this.expected_, doc!.obj); @@ -242,6 +305,9 @@ class Tests implements TestManager { 'Updatable Document Overwrite', 'Updatable Document Mask', 'Missing Document', + 'Update Document Nested Field', + 'Update Document Mask Nested Field', + 'Update Document Mask Array Nested Field', ]; const docs = this.db.getDocuments(path, ids); GSUnit.assertEquals(ids.length - 1, docs.length); @@ -255,6 +321,9 @@ class Tests implements TestManager { 'Updatable Document Overwrite', 'Updatable Document Mask', 'Missing Document', + 'Update Document Nested Field', + 'Update Document Mask Nested Field', + 'Update Document Mask Array Nested Field', ]; const docs = this.db.getDocuments(path, ids); GSUnit.assertEquals(0, docs.length); @@ -270,7 +339,7 @@ class Tests implements TestManager { Test_Get_Document_IDs(): void { const path = 'Test Collection'; const docs = this.db.getDocumentIds(path); - GSUnit.assertEquals(8, docs.length); + GSUnit.assertEquals(12, docs.length); } Test_Get_Document_IDs_Missing(): void { @@ -295,19 +364,19 @@ class Tests implements TestManager { Test_Query_Select_Name(): void { const path = 'Test Collection'; const docs = this.db.query(path).Select().Execute(); - GSUnit.assertEquals(8, docs.length); + GSUnit.assertEquals(12, docs.length); } Test_Query_Select_Name_Number(): void { const path = 'Test Collection'; const docs = this.db.query(path).Select().Select('number value').Execute(); - GSUnit.assertEquals(8, docs.length); + GSUnit.assertEquals(12, docs.length); } Test_Query_Select_String(): void { const path = 'Test Collection'; const docs = this.db.query(path).Select('string value 이').Execute(); - GSUnit.assertEquals(8, docs.length); + GSUnit.assertEquals(12, docs.length); } Test_Query_Where_EqEq_String(): void { @@ -475,7 +544,7 @@ class Tests implements TestManager { Test_Query_Offset(): void { const path = 'Test Collection'; const docs = this.db.query(path).Offset(2).Execute(); - GSUnit.assertEquals(6, docs.length); + GSUnit.assertEquals(10, docs.length); } Test_Query_Limit(): void { diff --git a/Util.ts b/Util.ts index 0db3d58..bea16dd 100644 --- a/Util.ts +++ b/Util.ts @@ -148,4 +148,36 @@ class Util_ { .map(([k, v]) => `${process(k)}=${process(v)}`) .join('&'); } + + /** + * Simple object check. + * @param item + * @returns {boolean} + */ + static isObject(item: any) { + return (item && typeof item === 'object' && !Array.isArray(item)); + } + + /** + * Deep merge two objects. + * @param target + * @param ...sources + */ + static mergeDeep(target: any, ...sources: any): any { + if (!sources.length) return target; + const source = sources.shift(); + + if (this.isObject(target) && this.isObject(source)) { + for (const key in source) { + if (this.isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + this.mergeDeep(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return this.mergeDeep(target, ...sources); + } }