@dxos/effect 0.6.14-main.f49f251 → 0.6.14-staging.3e2eaca

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