@dxos/effect 0.7.0 → 0.7.1-staging.7f6f91c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ast.test.ts CHANGED
@@ -12,7 +12,10 @@ import {
12
12
  findNode,
13
13
  findProperty,
14
14
  getAnnotation,
15
+ getDiscriminatingProps,
16
+ getDiscriminatedType,
15
17
  getSimpleType,
18
+ isOption,
16
19
  isSimpleType,
17
20
  visit,
18
21
  type JsonPath,
@@ -89,20 +92,39 @@ describe('AST', () => {
89
92
  });
90
93
 
91
94
  test('findAnnotation', ({ expect }) => {
92
- const Type = S.NonEmptyString.pipe(S.pattern(/^\d{5}$/)).annotations({
95
+ const TestSchema = S.NonEmptyString.pipe(S.pattern(/^\d{5}$/)).annotations({
93
96
  [AST.TitleAnnotationId]: 'original title',
94
97
  });
95
- const Contact = S.Struct({
96
- p1: Type.annotations({ [AST.TitleAnnotationId]: 'new title' }),
97
- p2: Type.annotations({ [AST.TitleAnnotationId]: 'new title' }).pipe(S.optional),
98
- p3: S.optional(Type.annotations({ [AST.TitleAnnotationId]: 'new title' })),
98
+
99
+ const ContactSchema = S.Struct({
100
+ p1: TestSchema.annotations({ [AST.TitleAnnotationId]: 'new title' }),
101
+ p2: TestSchema.annotations({ [AST.TitleAnnotationId]: 'new title' }).pipe(S.optional),
102
+ p3: S.optional(TestSchema.annotations({ [AST.TitleAnnotationId]: 'new title' })),
99
103
  });
100
104
 
101
- {
102
- const prop = findProperty(Contact, 'p3' as JsonPath);
105
+ for (const p of ['p1', 'p2', 'p3']) {
106
+ const prop = findProperty(ContactSchema, p as JsonPath);
103
107
  invariant(prop);
104
108
  const value = findAnnotation(prop, AST.TitleAnnotationId);
105
- expect(value).to.eq('new title');
109
+ expect(value, `invalid title for ${p}`).to.eq('new title');
110
+ }
111
+ });
112
+
113
+ test('findAnnotation skips defaults', ({ expect }) => {
114
+ const annotation = findAnnotation(
115
+ S.String.annotations({ [AST.TitleAnnotationId]: 'test' }).ast,
116
+ AST.TitleAnnotationId,
117
+ { noDefault: true },
118
+ );
119
+ expect(annotation).to.eq('test');
120
+
121
+ const annotationIds = [AST.TitleAnnotationId, AST.DescriptionAnnotationId];
122
+ const schemas = [S.Object, S.String, S.Number, S.Boolean];
123
+ for (const schema of schemas) {
124
+ for (const annotationId of annotationIds) {
125
+ const annotation = findAnnotation(schema.ast, annotationId, { noDefault: true });
126
+ expect(annotation, schema.ast._tag).to.eq(undefined);
127
+ }
106
128
  }
107
129
  });
108
130
 
@@ -119,6 +141,46 @@ describe('AST', () => {
119
141
 
120
142
  const props: string[] = [];
121
143
  visit(TestSchema.ast, (_, path) => props.push(path.join('.')));
122
- console.log(JSON.stringify(props, null, 2));
144
+ });
145
+
146
+ test('discriminated unions', ({ expect }) => {
147
+ const TestUnionSchema = S.Union(
148
+ S.Struct({ kind: S.Literal('a'), label: S.String }),
149
+ S.Struct({ kind: S.Literal('b'), count: S.Number, active: S.Boolean }),
150
+ );
151
+
152
+ type TestUnionType = S.Schema.Type<typeof TestUnionSchema>;
153
+
154
+ {
155
+ expect(isOption(TestUnionSchema.ast)).to.be.false;
156
+ expect(getDiscriminatingProps(TestUnionSchema.ast)).to.deep.eq(['kind']);
157
+
158
+ const node = findNode(TestUnionSchema.ast, isSimpleType);
159
+ expect(node).to.eq(TestUnionSchema.ast);
160
+ }
161
+
162
+ {
163
+ invariant(AST.isUnion(TestUnionSchema.ast));
164
+ const [a, b] = TestUnionSchema.ast.types;
165
+
166
+ const obj1: TestUnionType = {
167
+ kind: 'a',
168
+ label: 'test',
169
+ };
170
+
171
+ const obj2: TestUnionType = {
172
+ kind: 'b',
173
+ count: 100,
174
+ active: true,
175
+ };
176
+
177
+ expect(getDiscriminatedType(TestUnionSchema.ast, obj1)?.toJSON()).to.deep.eq(a.toJSON());
178
+ expect(getDiscriminatedType(TestUnionSchema.ast, obj2)?.toJSON()).to.deep.eq(b.toJSON());
179
+ expect(getDiscriminatedType(TestUnionSchema.ast)?.toJSON()).to.deep.eq(
180
+ S.Struct({
181
+ kind: S.Literal('a', 'b'),
182
+ }).ast.toJSON(),
183
+ );
184
+ }
123
185
  });
124
186
  });
package/src/ast.ts CHANGED
@@ -6,20 +6,25 @@ import { AST, Schema as S } from '@effect/schema';
6
6
  import { Option, pipe } from 'effect';
7
7
 
8
8
  import { invariant } from '@dxos/invariant';
9
+ import { nonNullable } from '@dxos/util';
9
10
 
10
11
  //
11
12
  // Refs
12
- // https://effect.website/docs/guides/schema
13
+ // https://effect.website/docs/schema/introduction
13
14
  // https://www.npmjs.com/package/@effect/schema
14
15
  // https://effect-ts.github.io/effect/schema/AST.ts.html
15
16
  //
16
17
 
17
18
  export type SimpleType = 'object' | 'string' | 'number' | 'boolean' | 'enum' | 'literal';
18
19
 
20
+ /**
21
+ * Get the base type; e.g., traverse through refinements.
22
+ */
19
23
  export const getSimpleType = (node: AST.AST): SimpleType | undefined => {
20
- if (AST.isObjectKeyword(node)) {
24
+ if (AST.isObjectKeyword(node) || AST.isTypeLiteral(node) || isDiscriminatedUnion(node)) {
21
25
  return 'object';
22
26
  }
27
+
23
28
  if (AST.isStringKeyword(node)) {
24
29
  return 'string';
25
30
  }
@@ -29,15 +34,17 @@ export const getSimpleType = (node: AST.AST): SimpleType | undefined => {
29
34
  if (AST.isBooleanKeyword(node)) {
30
35
  return 'boolean';
31
36
  }
37
+
32
38
  if (AST.isEnums(node)) {
33
39
  return 'enum';
34
40
  }
41
+
35
42
  if (AST.isLiteral(node)) {
36
43
  return 'literal';
37
44
  }
38
45
  };
39
46
 
40
- export const isSimpleType = (node: AST.AST) => !!getSimpleType(node);
47
+ export const isSimpleType = (node: AST.AST): boolean => !!getSimpleType(node);
41
48
 
42
49
  //
43
50
  // Branded types
@@ -136,7 +143,7 @@ const visitNode = (
136
143
  }
137
144
  }
138
145
 
139
- // Branching union.
146
+ // Branching union (e.g., optional, discriminated unions).
140
147
  else if (AST.isUnion(node)) {
141
148
  for (const type of node.types) {
142
149
  const result = visitNode(type, test, visitor, path, depth);
@@ -154,13 +161,13 @@ const visitNode = (
154
161
  }
155
162
  }
156
163
 
157
- // TODO(burdon): Transform?
164
+ // TODO(burdon): Transforms?
158
165
  };
159
166
 
160
167
  /**
161
168
  * Recursively descend into AST to find first node that passes the test.
162
169
  */
163
- // TODO(burdon): Reuse visitor.
170
+ // TODO(burdon): Rewrite using visitNode?
164
171
  export const findNode = (node: AST.AST, test: (node: AST.AST) => boolean): AST.AST | undefined => {
165
172
  if (test(node)) {
166
173
  return node;
@@ -176,7 +183,7 @@ export const findNode = (node: AST.AST, test: (node: AST.AST) => boolean): AST.A
176
183
  }
177
184
  }
178
185
 
179
- // Array.
186
+ // Tuple.
180
187
  else if (AST.isTupleType(node)) {
181
188
  for (const [_, element] of node.elements.entries()) {
182
189
  const child = findNode(element.type, test);
@@ -186,12 +193,14 @@ export const findNode = (node: AST.AST, test: (node: AST.AST) => boolean): AST.A
186
193
  }
187
194
  }
188
195
 
189
- // Branching union.
196
+ // Branching union (e.g., optional, discriminated unions).
190
197
  else if (AST.isUnion(node)) {
191
- for (const type of node.types) {
192
- const child = findNode(type, test);
193
- if (child) {
194
- return child;
198
+ if (isOption(node)) {
199
+ for (const type of node.types) {
200
+ const child = findNode(type, test);
201
+ if (child) {
202
+ return child;
203
+ }
195
204
  }
196
205
  }
197
206
  }
@@ -200,8 +209,6 @@ export const findNode = (node: AST.AST, test: (node: AST.AST) => boolean): AST.A
200
209
  else if (AST.isRefinement(node)) {
201
210
  return findNode(node.from, test);
202
211
  }
203
-
204
- return undefined;
205
212
  };
206
213
 
207
214
  /**
@@ -221,33 +228,143 @@ export const findProperty = (schema: S.Schema<any>, path: JsonPath | JsonProp):
221
228
  }
222
229
  }
223
230
  }
224
-
225
- return undefined;
226
231
  };
227
232
 
228
233
  return getProp(schema.ast, path.split('.') as JsonProp[]);
229
234
  };
230
235
 
236
+ //
237
+ // Annotations
238
+ //
239
+
240
+ const defaultAnnotations: Record<string, AST.Annotated> = {
241
+ ['ObjectKeyword' as const]: AST.objectKeyword,
242
+ ['StringKeyword' as const]: AST.stringKeyword,
243
+ ['NumberKeyword' as const]: AST.numberKeyword,
244
+ ['BooleanKeyword' as const]: AST.booleanKeyword,
245
+ };
246
+
231
247
  /**
232
- * Recursively descend into AST to find first matching annotations
248
+ * Recursively descend into AST to find first matching annotations.
249
+ * Optionally skips default annotations for basic types (e.g., 'a string').
233
250
  */
234
- export const findAnnotation = <T>(node: AST.AST, annotationId: symbol): T | undefined => {
251
+ export const findAnnotation = <T>(
252
+ node: AST.AST,
253
+ annotationId: symbol,
254
+ options?: { noDefault: boolean },
255
+ ): T | undefined => {
235
256
  const getAnnotationById = getAnnotation(annotationId);
257
+
236
258
  const getBaseAnnotation = (node: AST.AST): T | undefined => {
237
259
  const value = getAnnotationById(node);
238
260
  if (value !== undefined) {
261
+ if (options?.noDefault && value === defaultAnnotations[node._tag]?.annotations[annotationId]) {
262
+ return undefined;
263
+ }
264
+
239
265
  return value as T;
240
266
  }
241
267
 
242
268
  if (AST.isUnion(node)) {
243
- for (const type of node.types) {
244
- const value = getBaseAnnotation(type);
245
- if (value !== undefined) {
246
- return value as T;
247
- }
269
+ if (isOption(node)) {
270
+ return getAnnotationById(node.types[0]) as T;
248
271
  }
249
272
  }
250
273
  };
251
274
 
252
275
  return getBaseAnnotation(node);
253
276
  };
277
+
278
+ //
279
+ // Unions
280
+ //
281
+
282
+ /**
283
+ * Effect S.optional creates a union type with undefined as the second type.
284
+ */
285
+ export const isOption = (node: AST.AST): boolean => {
286
+ return AST.isUnion(node) && node.types.length === 2 && AST.isUndefinedKeyword(node.types[1]);
287
+ };
288
+
289
+ /**
290
+ * Determines if the node is a union of literal types.
291
+ */
292
+ export const isLiteralUnion = (node: AST.AST): boolean => {
293
+ return AST.isUnion(node) && node.types.every(AST.isLiteral);
294
+ };
295
+
296
+ /**
297
+ * Determines if the node is a discriminated union.
298
+ */
299
+ export const isDiscriminatedUnion = (node: AST.AST): boolean => {
300
+ return AST.isUnion(node) && !!getDiscriminatingProps(node)?.length;
301
+ };
302
+
303
+ /**
304
+ * Get the discriminating properties for the given union type.
305
+ */
306
+ export const getDiscriminatingProps = (node: AST.AST): string[] | undefined => {
307
+ invariant(AST.isUnion(node));
308
+ if (isOption(node)) {
309
+ return;
310
+ }
311
+
312
+ // Get common literals across all types.
313
+ return node.types.reduce<string[]>((shared, type) => {
314
+ const props = AST.getPropertySignatures(type)
315
+ // TODO(burdon): Should check each literal is unique.
316
+ .filter((p) => AST.isLiteral(p.type))
317
+ .map((p) => p.name.toString());
318
+
319
+ // Return common literals.
320
+ return shared.length === 0 ? props : shared.filter((prop) => props.includes(prop));
321
+ }, []);
322
+ };
323
+
324
+ /**
325
+ * Get the discriminated type for the given value.
326
+ */
327
+ export const getDiscriminatedType = (node: AST.AST, value: Record<string, any> = {}): AST.AST | undefined => {
328
+ invariant(AST.isUnion(node));
329
+ invariant(value);
330
+ const props = getDiscriminatingProps(node);
331
+ if (!props?.length) {
332
+ return;
333
+ }
334
+
335
+ // Match provided values.
336
+ for (const type of node.types) {
337
+ const match = AST.getPropertySignatures(type)
338
+ .filter((prop) => props?.includes(prop.name.toString()))
339
+ .every((prop) => {
340
+ invariant(AST.isLiteral(prop.type));
341
+ return prop.type.literal === value[prop.name.toString()];
342
+ });
343
+
344
+ if (match) {
345
+ return type;
346
+ }
347
+ }
348
+
349
+ // Create union of discriminating properties.
350
+ // NOTE: This may not work with non-overlapping variants.
351
+ // TODO(burdon): Iterate through props and knock-out variants that don't match.
352
+ const fields = Object.fromEntries(
353
+ props
354
+ .map((prop) => {
355
+ const literals = node.types
356
+ .map((type) => {
357
+ const literal = AST.getPropertySignatures(type).find((p) => p.name.toString() === prop)!;
358
+ invariant(AST.isLiteral(literal.type));
359
+ return literal.type.literal;
360
+ })
361
+ .filter(nonNullable);
362
+
363
+ return literals.length ? [prop, S.Literal(...literals)] : undefined;
364
+ })
365
+ .filter(nonNullable),
366
+ );
367
+
368
+ const schema = S.Struct(fields);
369
+ return schema.ast;
370
+ };