Skip to content

Commit 32d91e1

Browse files
committed
feat(json-crdt): 🎸 add NodeApi .replace() method
1 parent 9eafd61 commit 32d91e1

File tree

4 files changed

+152
-12
lines changed

4 files changed

+152
-12
lines changed

src/json-crdt/model/Model.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,15 @@ export class Model<N extends JsonNode = JsonNode<any>> implements Printable {
272272
/**
273273
* Experimental node retrieval API using proxy objects. Returns a strictly
274274
* typed proxy wrapper around the value of the root node.
275-
*
276-
* @todo consider renaming this to `_`.
277275
*/
278276
public get s() {
279277
return this.api.r.proxy().val;
280278
}
281279

280+
/**
281+
* Experimental strictly typed node retrieval API using proxy objects.
282+
* Automatically resolves nested "val" nodes.
283+
*/
282284
public get $() {
283285
return this.api.$;
284286
}

src/json-crdt/model/api/ModelApi.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ export class ModelApi<N extends JsonNode = JsonNode> implements SyncStore<JsonNo
107107
return new ValApi(this.model.root, this);
108108
}
109109

110-
/** @ignore */
110+
/**
111+
* @ignore
112+
*
113+
* @todo Remove this getter?
114+
*/
111115
public get node() {
112116
return this.r.get();
113117
}
@@ -277,6 +281,10 @@ export class ModelApi<N extends JsonNode = JsonNode> implements SyncStore<JsonNo
277281
return this.model.view();
278282
}
279283

284+
public select(path?: ApiPath, leaf?: boolean) {
285+
return this.r.select(path, leaf);
286+
}
287+
280288
/**
281289
* Reads the value at the given path in the model. If no path is provided,
282290
* returns the root node's view.
@@ -293,6 +301,10 @@ export class ModelApi<N extends JsonNode = JsonNode> implements SyncStore<JsonNo
293301
return this.r.add(path, value);
294302
}
295303

304+
public replace(path: ApiPath, value: unknown) {
305+
return this.r.replace(path, value);
306+
}
307+
296308
private inTx = false;
297309
public transaction(callback: () => void) {
298310
if (this.inTx) callback();

src/json-crdt/model/api/__tests__/NodeApi.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ describe('.add()', () => {
186186
expect(doc.api.read('/arr/1')).toBe('asdf');
187187
expect(success).toBe(true);
188188
});
189+
190+
test('can add element to the beginning of array (on "arr" node)', () => {
191+
const doc = createTypedModel();
192+
expect(doc.api.read('/arr/0')).toBe('asdf');
193+
const success = doc.api.arr('/arr').add('/0', 0);
194+
expect(doc.api.read('/arr/0')).toBe(0);
195+
expect(doc.api.read('/arr/1')).toBe('asdf');
196+
expect(success).toBe(true);
197+
});
189198

190199
test('can add element to the middle of array', () => {
191200
const doc = createTypedModel();
@@ -322,5 +331,61 @@ describe('.add()', () => {
322331
});
323332
});
324333

334+
describe('.replace()', () => {
335+
describe('"obj" node', () => {
336+
test('can replace value in "obj" node', () => {
337+
const doc = createTypedModel();
338+
expect(doc.api.read('/obj/str')).toBe('asdf');
339+
const success = doc.api.replace('/obj/str', 'newValue');
340+
expect(doc.api.read('/obj/str')).toBe('newValue');
341+
expect(success).toBe(true);
342+
});
343+
344+
test('can replace value in "obj" node - 2', () => {
345+
const doc = createTypedModel();
346+
expect(doc.api.read('/obj/str')).toBe('asdf');
347+
const success = doc.api.obj(['obj']).replace('/str', s.arr([s.con(true)]));
348+
expect(doc.api.read('/obj/str')).toEqual([true]);
349+
expect(success).toBe(true);
350+
});
351+
352+
test('cannot replace non-existing key', () => {
353+
const doc = createTypedModel();
354+
const success = doc.api.replace('/obj/asdfasdf', 'newValue');
355+
expect(doc.api.read('/obj/asdfasdf')).toBe(undefined);
356+
expect(success).toBe(false);
357+
});
358+
359+
test('cannot replace non-existing key - 2', () => {
360+
const doc = createTypedModel();
361+
const success = doc.api.obj('obj').replace('/asdfasdf', 'newValue');
362+
expect(doc.api.read('/obj/asdfasdf')).toBe(undefined);
363+
expect(success).toBe(false);
364+
});
365+
});
366+
367+
describe('"arr" node', () => {
368+
test('can replace "val" value in "arr" node', () => {
369+
const doc = createTypedModel();
370+
expect(doc.api.read('/arr/1')).toBe(0);
371+
expect(doc.api.select('/arr')?.asArr().length()).toBe(2);
372+
const success = doc.api.replace('/arr/1', 'newValue');
373+
expect(doc.api.read('/arr/1')).toBe('newValue');
374+
expect(doc.api.select('/arr')?.asArr().length()).toBe(2);
375+
expect(success).toBe(true);
376+
});
377+
378+
test('can replace non-"val" value in "arr" node', () => {
379+
const doc = createTypedModel();
380+
expect(doc.api.read('/arr/0')).toBe('asdf');
381+
expect(doc.api.select('/arr')?.asArr().length()).toBe(2);
382+
const success = doc.api.replace('/arr/0', 'newValue');
383+
expect(doc.api.read('/arr/0')).toBe('newValue');
384+
expect(doc.api.select('/arr')?.asArr().length()).toBe(2);
385+
expect(success).toBe(true);
386+
});
387+
});
388+
});
389+
325390
test.todo('.merge()');
326391
test.todo('.shallowMerge()');

src/json-crdt/model/api/nodes.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ export class NodeApi<N extends JsonNode = JsonNode> implements Printable {
192192
return this.node.view() as unknown as JsonNodeView<N>;
193193
}
194194

195+
public select(path?: ApiPath, leaf?: boolean) {
196+
try {
197+
let node = path ? this.find(path) : this.node;
198+
if (leaf) while (node instanceof ValNode) node = node.child();
199+
return this.api.wrap(node);
200+
} catch (e) {
201+
return;
202+
}
203+
}
204+
195205
public read(path?: ApiPath): unknown {
196206
const view = this.view();
197207
if (Array.isArray(path)) return get(view, path);
@@ -203,38 +213,79 @@ export class NodeApi<N extends JsonNode = JsonNode> implements Printable {
203213

204214
public add(path: ApiPath, value: unknown): boolean {
205215
const [parent, key] = breakPath(path);
216+
let node: NodeApi<any> = this;
206217
try {
207-
let node: unknown = parent ? this.in(parent) : this;
218+
node = parent ? this.in(parent) : this;
208219
while (node instanceof ValApi) node = node.in();
220+
} catch {
221+
return false;
222+
}
223+
ADD: {
209224
if (node instanceof ObjApi) {
210225
node.set({[key]: value});
211-
return true;
212226
} else if (node instanceof ArrApi || node instanceof StrApi || node instanceof BinApi) {
213227
const length = node.length();
214228
let index: number = 0;
215229
if (typeof key === 'number') index = key;
216230
else if (key === '-') index = length;
217231
else {
218232
index = ~~key;
219-
if (index + '' !== key) return false;
233+
if (index + '' !== key) break ADD;
220234
}
221-
if (index !== index) return false;
235+
if (index !== index) break ADD;
222236
if (index < 0) index = 0;
223237
if (index > length) index = length;
224238
if (node instanceof ArrApi) {
225239
node.ins(index, Array.isArray(value) ? value : [value]);
226240
} else if (node instanceof StrApi) {
227241
node.ins(index, value + '');
228242
} else if (node instanceof BinApi) {
229-
if (!(value instanceof Uint8Array)) return false;
243+
if (!(value instanceof Uint8Array)) break ADD;
230244
node.ins(index, value);
231245
}
232-
return true;
233246
} else if (node instanceof VecApi) {
234247
node.set([[~~key, value]]);
235-
return true;
236-
}
237-
} catch {}
248+
} else break ADD;
249+
return true;
250+
}
251+
return false;
252+
}
253+
254+
public replace(path: ApiPath, value: unknown): boolean {
255+
const [parent, key] = breakPath(path);
256+
let node: NodeApi<any> = this;
257+
try {
258+
node = parent ? this.in(parent) : this;
259+
while (node instanceof ValApi) node = node.in();
260+
} catch {
261+
return false;
262+
}
263+
REPLACE: {
264+
if (node instanceof ObjApi) {
265+
const keyStr = key + '';
266+
if (!node.has(keyStr)) break REPLACE;
267+
node.set({[key]: value});
268+
} else if (node instanceof ArrApi) {
269+
const length = node.length();
270+
let index: number = 0;
271+
if (typeof key === 'number') index = key;
272+
else {
273+
index = ~~key;
274+
if (index + '' !== key) break REPLACE;
275+
}
276+
if (index !== index || index < 0 || index > length - 1) break REPLACE;
277+
const element = node.node.getNode(index);
278+
if (element instanceof ValNode) {
279+
this.api.wrap(element).set(value);
280+
} else {
281+
node.ins(index, [value]);
282+
node.del(index + 1, 1);
283+
}
284+
} else if (node instanceof VecApi) {
285+
node.set([[~~key, value]]);
286+
} else break REPLACE;
287+
return true;
288+
}
238289
return false;
239290
}
240291

@@ -435,6 +486,16 @@ export class ObjApi<N extends ObjNode<any> = ObjNode<any>> extends NodeApi<N> {
435486
api.apply();
436487
}
437488

489+
/**
490+
* Checks if a key exists in the object.
491+
*
492+
* @param key Key to check.
493+
* @returns True if the key exists, false otherwise.
494+
*/
495+
public has(key: string): boolean {
496+
return this.node.keys.has(key);
497+
}
498+
438499
/**
439500
* Returns a proxy object for this node. Allows to access object properties
440501
* by key.

0 commit comments

Comments
 (0)