@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/dist/lib/browser/index.mjs +246 -61
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +258 -64
- package/dist/lib/node/index.cjs.map +3 -3
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +246 -61
- 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 +54 -10
- package/dist/types/src/ast.d.ts.map +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/ast.test.ts +156 -22
- package/src/ast.ts +298 -53
- package/src/index.ts +1 -0
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,
|
|
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
|
+
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('
|
|
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
|
-
|
|
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 =
|
|
75
|
+
const prop = findProperty(Contact, 'name' as JsonPath);
|
|
21
76
|
expect(prop).to.exist;
|
|
22
77
|
}
|
|
23
78
|
{
|
|
24
|
-
const prop =
|
|
25
|
-
|
|
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 =
|
|
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('
|
|
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.
|
|
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, (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
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/
|
|
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
|
|
18
|
+
export type SimpleType = 'object' | 'string' | 'number' | 'boolean' | 'enum' | 'literal';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* Get
|
|
21
|
+
* Get the base type; e.g., traverse through refinements.
|
|
21
22
|
*/
|
|
22
|
-
export const
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
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
|
-
*
|
|
60
|
+
* https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html
|
|
40
61
|
*/
|
|
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
|
-
}
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
};
|