@dxos/effect 0.6.14-staging.e15392e → 0.7.1-staging.599df14

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
@@ -2,38 +2,136 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Schema as S } from '@effect/schema';
6
- import { describe, expect, test } from 'vitest';
5
+ import { AST, Schema as S } from '@effect/schema';
6
+ import { describe, test } from 'vitest';
7
7
 
8
- import { getProperty, isLeafType, visit } from './ast';
8
+ import { invariant } from '@dxos/invariant';
9
+
10
+ import {
11
+ findAnnotation,
12
+ findNode,
13
+ findProperty,
14
+ getAnnotation,
15
+ getDiscriminatingProps,
16
+ getDiscriminatedType,
17
+ getSimpleType,
18
+ isOption,
19
+ isSimpleType,
20
+ visit,
21
+ type JsonPath,
22
+ type JsonProp,
23
+ } from './ast';
24
+
25
+ const ZipCode = S.String.pipe(
26
+ S.pattern(/^\d{5}$/, {
27
+ typeId: Symbol.for('@example/schema/ZipCode'),
28
+ identifier: 'ZipCode',
29
+ title: 'ZIP code',
30
+ description: 'Simple 5 digit zip code',
31
+ }),
32
+ );
33
+
34
+ const LatLng = S.Struct({
35
+ lat: S.Number,
36
+ lng: S.Number,
37
+ });
38
+
39
+ const Contact = S.Struct({
40
+ name: S.String,
41
+ address: S.Struct({
42
+ zip: ZipCode,
43
+ location: S.optional(LatLng),
44
+ }),
45
+ });
46
+
47
+ const getTitle = getAnnotation(AST.TitleAnnotationId);
9
48
 
10
49
  describe('AST', () => {
11
- test('getProperty', () => {
50
+ test('validation', ({ expect }) => {
51
+ const validate = S.validateSync(ZipCode);
52
+ validate('11205');
53
+
54
+ expect(() => validate(null)).to.throw();
55
+ expect(() => validate(12345)).to.throw();
56
+ expect(() => validate('')).to.throw();
57
+ expect(() => validate('1234')).to.throw();
58
+ });
59
+
60
+ test('findNode', ({ expect }) => {
12
61
  const TestSchema = S.Struct({
13
- name: S.String,
14
- address: S.Struct({
15
- zip: S.String,
16
- }),
17
- });
62
+ name: S.optional(S.String),
63
+ }).pipe(S.mutable);
18
64
 
65
+ const prop = findProperty(TestSchema, 'name' as JsonProp);
66
+ invariant(prop);
67
+ const node = findNode(prop, isSimpleType);
68
+ invariant(node);
69
+ const type = getSimpleType(node);
70
+ expect(type).to.eq('string');
71
+ });
72
+
73
+ test('findProperty', ({ expect }) => {
19
74
  {
20
- const prop = getProperty(TestSchema, 'name');
75
+ const prop = findProperty(Contact, 'name' as JsonPath);
21
76
  expect(prop).to.exist;
22
77
  }
23
78
  {
24
- const prop = getProperty(TestSchema, 'address.zip');
25
- expect(prop).to.exist;
79
+ const prop = findProperty(Contact, 'address.zip' as JsonPath);
80
+ invariant(prop);
81
+ expect(getTitle(prop)).to.eq('ZIP code');
26
82
  }
27
83
  {
28
- const prop = getProperty(TestSchema, 'address.city');
84
+ const prop = findProperty(Contact, 'address.location.lat' as JsonPath);
85
+ invariant(prop);
86
+ expect(AST.isNumberKeyword(prop)).to.be.true;
87
+ }
88
+ {
89
+ const prop = findProperty(Contact, 'address.city' as JsonPath);
29
90
  expect(prop).not.to.exist;
30
91
  }
31
92
  });
32
93
 
33
- test('visit', () => {
94
+ test('findAnnotation', ({ expect }) => {
95
+ const TestSchema = S.NonEmptyString.pipe(S.pattern(/^\d{5}$/)).annotations({
96
+ [AST.TitleAnnotationId]: 'original title',
97
+ });
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' })),
103
+ });
104
+
105
+ for (const p of ['p1', 'p2', 'p3']) {
106
+ const prop = findProperty(ContactSchema, p as JsonPath);
107
+ invariant(prop);
108
+ const value = findAnnotation(prop, AST.TitleAnnotationId);
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
+ }
128
+ }
129
+ });
130
+
131
+ test('visit', ({ expect }) => {
34
132
  const TestSchema = S.Struct({
35
- name: S.optional(S.String),
36
- emails: S.mutable(S.Array(S.String)),
133
+ name: S.NonEmptyString,
134
+ emails: S.optional(S.mutable(S.Array(S.String))),
37
135
  address: S.optional(
38
136
  S.Struct({
39
137
  zip: S.String,
@@ -42,11 +140,47 @@ describe('AST', () => {
42
140
  });
43
141
 
44
142
  const props: string[] = [];
45
- visit(TestSchema.ast, (node, path) => {
46
- if (isLeafType(node)) {
47
- props.push(path.join('.'));
48
- }
49
- });
50
- expect(props).to.deep.eq(['name', 'address.zip']);
143
+ visit(TestSchema.ast, (_, path) => props.push(path.join('.')));
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
+ }
51
185
  });
52
186
  });
package/src/ast.ts CHANGED
@@ -2,59 +2,73 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { AST, type Schema as S } from '@effect/schema';
5
+ 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
- export const isLeafType = (node: AST.AST) => !AST.isTupleType(node) && !AST.isTypeLiteral(node);
18
+ export type SimpleType = 'object' | 'string' | 'number' | 'boolean' | 'enum' | 'literal';
18
19
 
19
20
  /**
20
- * Get annotation or return undefined.
21
+ * Get the base type; e.g., traverse through refinements.
21
22
  */
22
- export const getAnnotation = <T>(annotationId: symbol, node: AST.Annotated): T | undefined =>
23
- pipe(AST.getAnnotation<T>(annotationId)(node), Option.getOrUndefined);
23
+ export const getSimpleType = (node: AST.AST): SimpleType | undefined => {
24
+ if (AST.isObjectKeyword(node) || AST.isTypeLiteral(node) || isDiscriminatedUnion(node)) {
25
+ return 'object';
26
+ }
24
27
 
25
- /**
26
- * Get type node.
27
- */
28
- export const getType = (node: AST.AST): AST.AST | undefined => {
29
- if (AST.isUnion(node)) {
30
- return node.types.find((type) => getType(type));
31
- } else if (AST.isRefinement(node)) {
32
- return getType(node.from);
33
- } else {
34
- return node;
28
+ if (AST.isStringKeyword(node)) {
29
+ return 'string';
30
+ }
31
+ if (AST.isNumberKeyword(node)) {
32
+ return 'number';
33
+ }
34
+ if (AST.isBooleanKeyword(node)) {
35
+ return 'boolean';
36
+ }
37
+
38
+ if (AST.isEnums(node)) {
39
+ return 'enum';
40
+ }
41
+
42
+ if (AST.isLiteral(node)) {
43
+ return 'literal';
35
44
  }
36
45
  };
37
46
 
47
+ export const isSimpleType = (node: AST.AST): boolean => !!getSimpleType(node);
48
+
49
+ //
50
+ // Branded types
51
+ //
52
+
53
+ export type JsonProp = string & { __JsonPath: true; __JsonProp: true };
54
+ export type JsonPath = string & { __JsonPath: true };
55
+
56
+ const PATH_REGEX = /[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*/;
57
+ const PROP_REGEX = /\w+/;
58
+
38
59
  /**
39
- * Get the AST node for the given property (dot-path).
60
+ * https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html
40
61
  */
41
- export const getProperty = (schema: S.Schema<any>, path: string): AST.AST | undefined => {
42
- let node: AST.AST = schema.ast;
43
- for (const part of path.split('.')) {
44
- const props = AST.getPropertySignatures(node);
45
- const prop = props.find((prop) => prop.name === part);
46
- if (!prop) {
47
- return undefined;
48
- }
62
+ export const JsonPath = S.NonEmptyString.pipe(S.pattern(PATH_REGEX)) as any as S.Schema<JsonPath>;
63
+ export const JsonProp = S.NonEmptyString.pipe(S.pattern(PROP_REGEX)) as any as S.Schema<JsonProp>;
49
64
 
50
- // TODO(burdon): Check if leaf.
51
- const type = getType(prop.type);
52
- invariant(type, `invalid type: ${path}`);
53
- node = type;
54
- }
55
-
56
- return node;
57
- };
65
+ /**
66
+ * Get annotation or return undefined.
67
+ */
68
+ export const getAnnotation =
69
+ <T>(annotationId: symbol) =>
70
+ (node: AST.Annotated): T | undefined =>
71
+ pipe(AST.getAnnotation<T>(annotationId)(node), Option.getOrUndefined);
58
72
 
59
73
  export enum VisitResult {
60
74
  CONTINUE = 0,
@@ -63,16 +77,18 @@ export enum VisitResult {
63
77
  */
64
78
  SKIP = 1,
65
79
  /**
66
- * Stop traversing immeditaely.
80
+ * Stop traversing immediately.
67
81
  */
68
82
  EXIT = 2,
69
83
  }
70
84
 
71
85
  export type Path = (string | number)[];
72
86
 
73
- export type Tester = (node: AST.AST, path: Path, depth: number) => VisitResult;
87
+ export type Tester = (node: AST.AST, path: Path, depth: number) => VisitResult | undefined;
74
88
  export type Visitor = (node: AST.AST, path: Path, depth: number) => void;
75
89
 
90
+ const defaultTest: Tester = (node) => (isSimpleType(node) ? VisitResult.CONTINUE : VisitResult.SKIP);
91
+
76
92
  /**
77
93
  * Visit leaf nodes.
78
94
  * Refs:
@@ -84,7 +100,7 @@ export const visit: {
84
100
  (node: AST.AST, test: Tester, visitor: Visitor): void;
85
101
  } = (node: AST.AST, testOrVisitor: Tester | Visitor, visitor?: Visitor): void => {
86
102
  if (!visitor) {
87
- visitNode(node, undefined, testOrVisitor);
103
+ visitNode(node, defaultTest, testOrVisitor);
88
104
  } else {
89
105
  visitNode(node, testOrVisitor as Tester, visitor);
90
106
  }
@@ -97,29 +113,258 @@ const visitNode = (
97
113
  path: Path = [],
98
114
  depth = 0,
99
115
  ): VisitResult | undefined => {
100
- for (const prop of AST.getPropertySignatures(node)) {
101
- const currentPath = [...path, prop.name.toString()];
102
- const type = getType(prop.type);
103
- if (type) {
104
- const result = test?.(node, path, depth) ?? VisitResult.CONTINUE;
116
+ const result = test?.(node, path, depth) ?? VisitResult.CONTINUE;
117
+ if (result === VisitResult.EXIT) {
118
+ return result;
119
+ }
120
+ if (result !== VisitResult.SKIP) {
121
+ visitor(node, path, depth);
122
+ }
123
+
124
+ // Object.
125
+ if (AST.isTypeLiteral(node)) {
126
+ for (const prop of AST.getPropertySignatures(node)) {
127
+ const currentPath = [...path, prop.name.toString()];
128
+ const result = visitNode(prop.type, test, visitor, currentPath, depth + 1);
105
129
  if (result === VisitResult.EXIT) {
106
130
  return result;
107
131
  }
132
+ }
133
+ }
108
134
 
109
- visitor(type, currentPath, depth);
110
-
111
- if (result !== VisitResult.SKIP) {
112
- if (AST.isTypeLiteral(type)) {
113
- visitNode(type, test, visitor, currentPath, depth + 1);
114
- } else if (AST.isTupleType(type)) {
115
- for (const [i, elementType] of type.elements.entries()) {
116
- const type = getType(elementType.type);
117
- if (type) {
118
- visitNode(type, test, visitor, [i, ...currentPath], depth);
119
- }
120
- }
135
+ // Array.
136
+ else if (AST.isTupleType(node)) {
137
+ for (const [i, element] of node.elements.entries()) {
138
+ const currentPath = [...path, i];
139
+ const result = visitNode(element.type, test, visitor, currentPath, depth);
140
+ if (result === VisitResult.EXIT) {
141
+ return result;
142
+ }
143
+ }
144
+ }
145
+
146
+ // Branching union (e.g., optional, discriminated unions).
147
+ else if (AST.isUnion(node)) {
148
+ for (const type of node.types) {
149
+ const result = visitNode(type, test, visitor, path, depth);
150
+ if (result === VisitResult.EXIT) {
151
+ return result;
152
+ }
153
+ }
154
+ }
155
+
156
+ // Refinement.
157
+ else if (AST.isRefinement(node)) {
158
+ const result = visitNode(node.from, test, visitor, path, depth);
159
+ if (result === VisitResult.EXIT) {
160
+ return result;
161
+ }
162
+ }
163
+
164
+ // TODO(burdon): Transforms?
165
+ };
166
+
167
+ /**
168
+ * Recursively descend into AST to find first node that passes the test.
169
+ */
170
+ // TODO(burdon): Rewrite using visitNode?
171
+ export const findNode = (node: AST.AST, test: (node: AST.AST) => boolean): AST.AST | undefined => {
172
+ if (test(node)) {
173
+ return node;
174
+ }
175
+
176
+ // Object.
177
+ else if (AST.isTypeLiteral(node)) {
178
+ for (const prop of AST.getPropertySignatures(node)) {
179
+ const child = findNode(prop.type, test);
180
+ if (child) {
181
+ return child;
182
+ }
183
+ }
184
+ }
185
+
186
+ // Tuple.
187
+ else if (AST.isTupleType(node)) {
188
+ for (const [_, element] of node.elements.entries()) {
189
+ const child = findNode(element.type, test);
190
+ if (child) {
191
+ return child;
192
+ }
193
+ }
194
+ }
195
+
196
+ // Branching union (e.g., optional, discriminated unions).
197
+ else if (AST.isUnion(node)) {
198
+ if (isOption(node)) {
199
+ for (const type of node.types) {
200
+ const child = findNode(type, test);
201
+ if (child) {
202
+ return child;
121
203
  }
122
204
  }
123
205
  }
124
206
  }
207
+
208
+ // Refinement.
209
+ else if (AST.isRefinement(node)) {
210
+ return findNode(node.from, test);
211
+ }
212
+ };
213
+
214
+ /**
215
+ * Get the AST node for the given property (dot-path).
216
+ */
217
+ export const findProperty = (schema: S.Schema<any>, path: JsonPath | JsonProp): AST.AST | undefined => {
218
+ const getProp = (node: AST.AST, path: JsonProp[]): AST.AST | undefined => {
219
+ const [name, ...rest] = path;
220
+ const typeNode = findNode(node, AST.isTypeLiteral);
221
+ invariant(typeNode);
222
+ for (const prop of AST.getPropertySignatures(typeNode)) {
223
+ if (prop.name === name) {
224
+ if (rest.length) {
225
+ return getProp(prop.type, rest);
226
+ } else {
227
+ return prop.type;
228
+ }
229
+ }
230
+ }
231
+ };
232
+
233
+ return getProp(schema.ast, path.split('.') as JsonProp[]);
234
+ };
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
+
247
+ /**
248
+ * Recursively descend into AST to find first matching annotations.
249
+ * Optionally skips default annotations for basic types (e.g., 'a string').
250
+ */
251
+ export const findAnnotation = <T>(
252
+ node: AST.AST,
253
+ annotationId: symbol,
254
+ options?: { noDefault: boolean },
255
+ ): T | undefined => {
256
+ const getAnnotationById = getAnnotation(annotationId);
257
+
258
+ const getBaseAnnotation = (node: AST.AST): T | undefined => {
259
+ const value = getAnnotationById(node);
260
+ if (value !== undefined) {
261
+ if (options?.noDefault && value === defaultAnnotations[node._tag]?.annotations[annotationId]) {
262
+ return undefined;
263
+ }
264
+
265
+ return value as T;
266
+ }
267
+
268
+ if (AST.isUnion(node)) {
269
+ if (isOption(node)) {
270
+ return getAnnotationById(node.types[0]) as T;
271
+ }
272
+ }
273
+ };
274
+
275
+ return getBaseAnnotation(node);
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;
125
370
  };
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { AST, JSONSchema, Schema as S } from '@effect/schema';
6
6
  import type * as Types from 'effect/Types';
7
7
 
8
+ // TODO(dmaretskyi): Remove re-exports.
8
9
  export { AST, JSONSchema, S, Types };
9
10
 
10
11
  export * from './ast';