Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support updating nested fields with dot notation #148

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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':
Expand All @@ -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();
}
Expand All @@ -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<string, FirestoreAPI.Value>, [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;
}, {}),
};
Expand Down
5 changes: 3 additions & 2 deletions Firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>, mask?: boolean | string[]): Document {
updateDocument(path: string, fields: Record<string, any>, 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_;
Expand Down
16 changes: 12 additions & 4 deletions FirestoreWrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>, request: Request, mask?: boolean | string[]): Document {
updateDocument_(path: string, fields: Record<string, any>, request: Request, mask?: boolean | string[], nestedField?: boolean): Document {
if (mask) {
const maskData = typeof mask === 'boolean' ? Object.keys(fields) : mask;

Expand All @@ -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<FirestoreAPI.Document>(path, firestoreObject);
return new Document(updatedDoc, {} as Document);
}
Expand Down
81 changes: 75 additions & 6 deletions Tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}