@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/dist/lib/browser/index.mjs +146 -62
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +150 -62
- package/dist/lib/node/index.cjs.map +3 -3
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +146 -62
- package/dist/lib/node-esm/index.mjs.map +3 -3
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/ast.d.ts +30 -12
- package/dist/types/src/ast.d.ts.map +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ast.test.ts +94 -22
- package/src/ast.ts +184 -56
- package/src/index.ts +1 -0
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,
|
|
5
|
+
import { AST, Schema as S } from '@effect/schema';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
7
|
|
|
8
|
-
import {
|
|
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('
|
|
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
|
-
|
|
15
|
-
|
|
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 =
|
|
72
|
+
const prop = findProperty(Contact, 'name' as JsonPath);
|
|
21
73
|
expect(prop).to.exist;
|
|
22
74
|
}
|
|
23
75
|
{
|
|
24
|
-
const prop =
|
|
25
|
-
|
|
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 =
|
|
86
|
+
const prop = findProperty(Contact, 'address.city' as JsonPath);
|
|
29
87
|
expect(prop).not.to.exist;
|
|
30
88
|
}
|
|
31
89
|
});
|
|
32
90
|
|
|
33
|
-
test('
|
|
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.
|
|
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, (
|
|
46
|
-
|
|
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,
|
|
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
|
|
17
|
+
export type SimpleType = 'object' | 'string' | 'number' | 'boolean' | 'enum' | 'literal';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (AST.
|
|
30
|
-
return
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
53
|
+
* https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html
|
|
40
54
|
*/
|
|
41
|
-
export const
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
};
|