Skip to content

Commit 8a297a9

Browse files
committed
feat: Add support for array atomic updates
1 parent c2f1588 commit 8a297a9

8 files changed

Lines changed: 292 additions & 3 deletions

File tree

packages/admin/src/lib/firestore/converter.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ describe('Firestore Converter', () => {
139139
expect(result).toStrictEqual(FieldValue.delete());
140140
});
141141

142+
it('should convert ArrayUnion to arrayUnion FieldValue', () => {
143+
const fakeFirestore = {} as unknown as Firestore;
144+
const result = toFirestoreDocumentData(
145+
fakeFirestore,
146+
FirestoreSchema.ArrayUnion.values(['a', 'b'])
147+
);
148+
expect(result).toStrictEqual(FieldValue.arrayUnion('a', 'b'));
149+
});
150+
151+
it('should convert ArrayRemove to arrayRemove FieldValue', () => {
152+
const fakeFirestore = {} as unknown as Firestore;
153+
const result = toFirestoreDocumentData(
154+
fakeFirestore,
155+
FirestoreSchema.ArrayRemove.values(['a'])
156+
);
157+
expect(result).toStrictEqual(FieldValue.arrayRemove('a'));
158+
});
159+
142160
it('should recursively convert nested objects and arrays', () => {
143161
const fakeFirestore = {
144162
doc: (path: string) => ({ path, __tag: 'fake-doc-ref' }),

packages/admin/src/lib/firestore/converter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export const toFirestoreDocumentData = (
3838
if (data instanceof FirestoreSchema.Delete) {
3939
return FieldValue.delete();
4040
}
41+
if (data instanceof FirestoreSchema.ArrayUnion) {
42+
return FieldValue.arrayUnion(...data.values);
43+
}
44+
if (data instanceof FirestoreSchema.ArrayRemove) {
45+
return FieldValue.arrayRemove(...data.values);
46+
}
4147
if (Array.isArray(data)) {
4248
return data.map((item) => toFirestoreDocumentData(db, item));
4349
}

packages/client/src/lib/firestore/converter.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import {
3+
arrayRemove,
4+
arrayUnion,
35
deleteField,
46
GeoPoint as FirebaseGeoPoint,
57
serverTimestamp,
@@ -118,6 +120,22 @@ describe('Firestore Converter', () => {
118120
expect(result).toStrictEqual(deleteField());
119121
});
120122

123+
it('should convert ArrayUnion to arrayUnion FieldValue', () => {
124+
const result = toFirestoreDocumentData(
125+
fakeFirestore,
126+
FirestoreSchema.ArrayUnion.values(['a', 'b'])
127+
);
128+
expect(result).toStrictEqual(arrayUnion('a', 'b'));
129+
});
130+
131+
it('should convert ArrayRemove to arrayRemove FieldValue', () => {
132+
const result = toFirestoreDocumentData(
133+
fakeFirestore,
134+
FirestoreSchema.ArrayRemove.values(['a'])
135+
);
136+
expect(result).toStrictEqual(arrayRemove('a'));
137+
});
138+
121139
it('should recursively convert nested objects and arrays', () => {
122140
const result = toFirestoreDocumentData(fakeFirestore, {
123141
createdAt: FirestoreSchema.Timestamp.fromMillis(1705315800000),

packages/client/src/lib/firestore/converter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
arrayRemove,
3+
arrayUnion,
24
deleteField,
35
doc,
46
DocumentData,
@@ -41,6 +43,12 @@ export const toFirestoreDocumentData = (
4143
if (data instanceof FirestoreSchema.Delete) {
4244
return deleteField();
4345
}
46+
if (data instanceof FirestoreSchema.ArrayUnion) {
47+
return arrayUnion(...data.values);
48+
}
49+
if (data instanceof FirestoreSchema.ArrayRemove) {
50+
return arrayRemove(...data.values);
51+
}
4452
if (Array.isArray(data)) {
4553
return data.map((item) => toFirestoreDocumentData(db, item));
4654
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { Schema } from 'effect';
2+
import { describe, expect, it } from 'vitest';
3+
import { Class } from './core.js';
4+
import { WithArrayFields, Array } from './array.js';
5+
import { ArrayUnion, ArrayRemove } from '../schema/fields.js';
6+
7+
describe('WithArrayFields', () => {
8+
class TestModel extends Class<TestModel>('TestModel')({
9+
name: Schema.String,
10+
tags: WithArrayFields(Schema.Array(Schema.String)),
11+
}) {}
12+
13+
describe('get variant', () => {
14+
it('should decode an array', () => {
15+
const result = Schema.decodeUnknownSync(TestModel)({
16+
name: 'Post',
17+
tags: ['a', 'b'],
18+
});
19+
expect(result.tags).toEqual(['a', 'b']);
20+
});
21+
22+
it('should encode an array', () => {
23+
const result = Schema.encodeSync(TestModel)(
24+
TestModel.make({ name: 'Post', tags: ['a', 'b'] })
25+
);
26+
expect(result.tags).toEqual(['a', 'b']);
27+
});
28+
});
29+
30+
describe('update variant', () => {
31+
it('should accept a plain array', () => {
32+
const result = Schema.decodeUnknownSync(TestModel.update)({
33+
name: 'Post',
34+
tags: ['a', 'b'],
35+
});
36+
expect(result.tags).toEqual(['a', 'b']);
37+
});
38+
39+
it('should accept an ArrayUnion sentinel', () => {
40+
const result = Schema.decodeUnknownSync(TestModel.update)({
41+
name: 'Post',
42+
tags: ArrayUnion.values(['c', 'd']),
43+
});
44+
expect(result.tags).toBeInstanceOf(ArrayUnion);
45+
expect(result.tags.values).toEqual(['c', 'd']);
46+
});
47+
48+
it('should accept an ArrayRemove sentinel', () => {
49+
const result = Schema.decodeUnknownSync(TestModel.update)({
50+
name: 'Post',
51+
tags: ArrayRemove.values(['a']),
52+
});
53+
expect(result.tags).toBeInstanceOf(ArrayRemove);
54+
expect(result.tags.values).toEqual(['a']);
55+
});
56+
57+
it('should encode ArrayUnion sentinel as-is (for converter to handle)', () => {
58+
const result = Schema.encodeSync(TestModel.update)({
59+
name: 'Post',
60+
tags: ArrayUnion.values(['c']),
61+
});
62+
expect(result.tags).toBeInstanceOf(ArrayUnion);
63+
expect(result.tags.values).toEqual(['c']);
64+
});
65+
66+
it('should encode ArrayRemove sentinel as-is (for converter to handle)', () => {
67+
const result = Schema.encodeSync(TestModel.update)({
68+
name: 'Post',
69+
tags: ArrayRemove.values(['a']),
70+
});
71+
expect(result.tags).toBeInstanceOf(ArrayRemove);
72+
expect(result.tags.values).toEqual(['a']);
73+
});
74+
});
75+
76+
describe('json variant', () => {
77+
it('should decode an array', () => {
78+
const result = Schema.decodeUnknownSync(TestModel.json)({
79+
name: 'Post',
80+
tags: ['a', 'b'],
81+
});
82+
expect(result.tags).toEqual(['a', 'b']);
83+
});
84+
85+
it('should reject sentinels (not part of json variant)', () => {
86+
expect(() =>
87+
Schema.decodeUnknownSync(TestModel.json)({
88+
name: 'Post',
89+
tags: ArrayUnion.make({ values: ['c'] }),
90+
})
91+
).toThrow();
92+
});
93+
});
94+
});
95+
96+
describe('Array', () => {
97+
class TestModel extends Class<TestModel>('TestModel')({
98+
name: Schema.String,
99+
tags: Array(Schema.String),
100+
}) {}
101+
102+
it('get variant decodes array', () => {
103+
const result = Schema.decodeUnknownSync(TestModel)({
104+
name: 'Post',
105+
tags: ['x'],
106+
});
107+
expect(result.tags).toEqual(['x']);
108+
});
109+
110+
it('update variant accepts ArrayUnion', () => {
111+
const result = Schema.decodeUnknownSync(TestModel.update)({
112+
name: 'Post',
113+
tags: ArrayUnion.values(['y']),
114+
});
115+
expect(result.tags).toBeInstanceOf(ArrayUnion);
116+
});
117+
118+
it('update variant accepts ArrayRemove', () => {
119+
const result = Schema.decodeUnknownSync(TestModel.update)({
120+
name: 'Post',
121+
tags: ArrayRemove.values(['x']),
122+
});
123+
expect(result.tags).toBeInstanceOf(ArrayRemove);
124+
});
125+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Schema } from 'effect';
2+
import { VariantSchema } from '@effect/experimental';
3+
import { fieldEvolve } from './core.js';
4+
import {
5+
ArrayUnion,
6+
ArrayUnionInstance,
7+
ArrayRemove,
8+
ArrayRemoveInstance,
9+
} from '../schema/fields.js';
10+
11+
/**
12+
* Adds `ArrayUnion` and `ArrayRemove` sentinel support to an array field's `update` variant.
13+
*
14+
* The `get`, `add`, and JSON variants keep the original array type unchanged.
15+
* The `update` variant additionally accepts `ArrayUnion` and
16+
* `ArrayRemove` values which are converted to Firestore
17+
* `FieldValue`s by the client/admin converters.
18+
*
19+
* @example
20+
* ```ts
21+
* class PostModel extends Class<PostModel>('PostModel')({
22+
* id: Schema.String,
23+
* tags: Model.WithArrayFields(Schema.Array(Schema.String)),
24+
* }) {}
25+
*
26+
* // update variant accepts:
27+
* postRepo.update('id', { tags: ['a', 'b'] }); // replace
28+
* postRepo.update('id', { tags: ArrayUnion.withValues(['c']) }); // arrayUnion
29+
* postRepo.update('id', { tags: ArrayRemove.withValues(['a']) }); // arrayRemove
30+
* ```
31+
*/
32+
export type WithArrayFields<S extends Schema.Schema.Any> = VariantSchema.Field<{
33+
readonly get: S;
34+
readonly add: S;
35+
readonly update: Schema.Union<
36+
[
37+
S,
38+
Schema.instanceOf<typeof ArrayUnion>,
39+
Schema.instanceOf<typeof ArrayRemove>
40+
]
41+
>;
42+
readonly json: S;
43+
readonly jsonAdd: S;
44+
readonly jsonUpdate: S;
45+
}>;
46+
47+
const identity = (s: Schema.Schema.Any) => s;
48+
49+
export const WithArrayFields: <
50+
Field extends VariantSchema.Field<any> | Schema.Schema.Any
51+
>(
52+
self: Field
53+
) => Field extends Schema.Schema.Any
54+
? WithArrayFields<Field>
55+
: Field extends VariantSchema.Field<infer S>
56+
? VariantSchema.Field<{
57+
readonly [K in keyof S]: S[K] extends Schema.Schema.Any
58+
? K extends 'update'
59+
? Schema.Union<
60+
[
61+
S[K],
62+
Schema.instanceOf<typeof ArrayUnion>,
63+
Schema.instanceOf<typeof ArrayRemove>
64+
]
65+
>
66+
: S[K]
67+
: never;
68+
}>
69+
: never = fieldEvolve({
70+
get: identity,
71+
add: identity,
72+
update: (s: Schema.Schema.Any) =>
73+
Schema.Union(s, ArrayUnionInstance, ArrayRemoveInstance),
74+
json: identity,
75+
jsonAdd: identity,
76+
jsonUpdate: identity,
77+
}) as any;
78+
79+
/**
80+
* Convenience constructor that creates an array field with sentinel support.
81+
* Equivalent to `WithArraySentinels(Schema.Array(element))`.
82+
*
83+
* @example
84+
* ```ts
85+
* class PostModel extends Class<PostModel>('PostModel')({
86+
* tags: Model.Array(Schema.String),
87+
* }) {}
88+
* ```
89+
*/
90+
export const Array = <A, I, R>(
91+
element: Schema.Schema<A, I, R>
92+
): WithArrayFields<Schema.Array$<Schema.Schema<A, I, R>>> =>
93+
WithArrayFields(Schema.Array(element)) as any;

packages/effect-firebase/src/lib/firestore/model/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './array.js';
12
export * from './core.js';
23
export * from './datetime.js';
34
export * from './optional.js';

packages/effect-firebase/src/lib/firestore/schema/fields.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,28 @@ import { Schema } from 'effect';
66
export class Delete extends Schema.Class<Delete>('Delete')({}) {}
77
export const DeleteInstance = Schema.instanceOf(Delete);
88

9-
export class ArrayAdd extends Schema.Class<ArrayAdd>('ArrayAdd')({}) {}
10-
export const ArrayAddInstance = Schema.instanceOf(ArrayAdd);
9+
/**
10+
* Represents an arrayUnion operation. This will add elements to an array field.
11+
* Only valid in the `update` variant — use `WithArraySentinels` to add support to a field.
12+
*/
13+
export class ArrayUnion extends Schema.Class<ArrayUnion>('ArrayUnion')({
14+
values: Schema.Array(Schema.Unknown),
15+
}) {
16+
static values(values: readonly unknown[]): ArrayUnion {
17+
return ArrayUnion.make({ values });
18+
}
19+
}
20+
export const ArrayUnionInstance = Schema.instanceOf(ArrayUnion);
1121

12-
export class ArrayRemove extends Schema.Class<ArrayRemove>('ArrayRemove')({}) {}
22+
/**
23+
* Represents an arrayRemove operation. This will remove elements from an array field.
24+
* Only valid in the `update` variant — use `WithArraySentinels` to add support to a field.
25+
*/
26+
export class ArrayRemove extends Schema.Class<ArrayRemove>('ArrayRemove')({
27+
values: Schema.Array(Schema.Unknown),
28+
}) {
29+
static values(values: readonly unknown[]): ArrayRemove {
30+
return ArrayRemove.make({ values });
31+
}
32+
}
1333
export const ArrayRemoveInstance = Schema.instanceOf(ArrayRemove);

0 commit comments

Comments
 (0)