@subsquid/openreader 0.3.2 → 0.3.3
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/db.d.ts +1 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/gql/opencrud.d.ts +1 -0
- package/dist/gql/opencrud.d.ts.map +1 -0
- package/dist/gql/schema.d.ts +1 -0
- package/dist/gql/schema.d.ts.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/model.d.ts +1 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.tools.d.ts +1 -0
- package/dist/model.tools.d.ts.map +1 -0
- package/dist/orderBy.d.ts +1 -0
- package/dist/orderBy.d.ts.map +1 -0
- package/dist/queryBuilder.d.ts +1 -0
- package/dist/queryBuilder.d.ts.map +1 -0
- package/dist/relayConnection.d.ts +1 -0
- package/dist/relayConnection.d.ts.map +1 -0
- package/dist/requestedFields.d.ts +1 -0
- package/dist/requestedFields.d.ts.map +1 -0
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/scalars.d.ts +1 -0
- package/dist/scalars.d.ts.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/test/basic.test.d.ts +2 -0
- package/dist/test/basic.test.d.ts.map +1 -0
- package/dist/test/basic.test.js +286 -0
- package/dist/test/basic.test.js.map +1 -0
- package/dist/test/connection.test.d.ts +2 -0
- package/dist/test/connection.test.d.ts.map +1 -0
- package/dist/test/connection.test.js +193 -0
- package/dist/test/connection.test.js.map +1 -0
- package/dist/test/fts.test.d.ts +2 -0
- package/dist/test/fts.test.d.ts.map +1 -0
- package/dist/test/fts.test.js +110 -0
- package/dist/test/fts.test.js.map +1 -0
- package/dist/test/lists.test.d.ts +2 -0
- package/dist/test/lists.test.d.ts.map +1 -0
- package/dist/test/lists.test.js +266 -0
- package/dist/test/lists.test.js.map +1 -0
- package/dist/test/lookup.test.d.ts +2 -0
- package/dist/test/lookup.test.d.ts.map +1 -0
- package/dist/test/lookup.test.js +109 -0
- package/dist/test/lookup.test.js.map +1 -0
- package/dist/test/scalars.test.d.ts +2 -0
- package/dist/test/scalars.test.d.ts.map +1 -0
- package/dist/test/scalars.test.js +303 -0
- package/dist/test/scalars.test.js.map +1 -0
- package/dist/test/tools.test.d.ts +2 -0
- package/dist/test/tools.test.d.ts.map +1 -0
- package/dist/test/tools.test.js +49 -0
- package/dist/test/tools.test.js.map +1 -0
- package/dist/test/typed-json.test.d.ts +2 -0
- package/dist/test/typed-json.test.d.ts.map +1 -0
- package/dist/test/typed-json.test.js +75 -0
- package/dist/test/typed-json.test.js.map +1 -0
- package/dist/test/unions.test.d.ts +2 -0
- package/dist/test/unions.test.d.ts.map +1 -0
- package/dist/test/unions.test.js +84 -0
- package/dist/test/unions.test.js.map +1 -0
- package/dist/test/util/setup.d.ts +7 -0
- package/dist/test/util/setup.d.ts.map +1 -0
- package/dist/test/util/setup.js +60 -0
- package/dist/test/util/setup.js.map +1 -0
- package/dist/test/where.test.d.ts +2 -0
- package/dist/test/where.test.d.ts.map +1 -0
- package/dist/test/where.test.js +127 -0
- package/dist/test/where.test.js.map +1 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/util.d.ts +1 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/where.d.ts +1 -0
- package/dist/where.d.ts.map +1 -0
- package/package.json +4 -3
- package/src/db.ts +83 -0
- package/src/gql/opencrud.ts +328 -0
- package/src/gql/schema.ts +337 -0
- package/src/main.ts +51 -0
- package/src/model.tools.ts +173 -0
- package/src/model.ts +125 -0
- package/src/orderBy.ts +105 -0
- package/src/queryBuilder.ts +785 -0
- package/src/relayConnection.ts +80 -0
- package/src/requestedFields.ts +246 -0
- package/src/resolver.ts +199 -0
- package/src/scalars.ts +247 -0
- package/src/server.ts +115 -0
- package/src/test/basic.test.ts +339 -0
- package/src/test/connection.test.ts +195 -0
- package/src/test/fts.test.ts +114 -0
- package/src/test/lists.test.ts +278 -0
- package/src/test/lookup.test.ts +111 -0
- package/src/test/scalars.test.ts +316 -0
- package/src/test/tools.test.ts +27 -0
- package/src/test/typed-json.test.ts +76 -0
- package/src/test/unions.test.ts +85 -0
- package/src/test/util/setup.ts +63 -0
- package/src/test/where.test.ts +135 -0
- package/src/tools.ts +33 -0
- package/src/util.ts +39 -0
- package/src/where.ts +110 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import assert from "assert"
|
|
2
|
+
import {
|
|
3
|
+
buildASTSchema,
|
|
4
|
+
DocumentNode,
|
|
5
|
+
extendSchema,
|
|
6
|
+
GraphQLEnumType,
|
|
7
|
+
GraphQLField,
|
|
8
|
+
GraphQLInterfaceType,
|
|
9
|
+
GraphQLList,
|
|
10
|
+
GraphQLNamedType,
|
|
11
|
+
GraphQLNonNull,
|
|
12
|
+
GraphQLObjectType,
|
|
13
|
+
GraphQLOutputType,
|
|
14
|
+
GraphQLScalarType,
|
|
15
|
+
GraphQLSchema,
|
|
16
|
+
GraphQLUnionType,
|
|
17
|
+
parse,
|
|
18
|
+
validateSchema
|
|
19
|
+
} from "graphql"
|
|
20
|
+
import {Model, Prop, PropType} from "../model"
|
|
21
|
+
import {validateModel} from "../model.tools"
|
|
22
|
+
import {scalars_list} from "../scalars"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const baseSchema = buildASTSchema(parse(`
|
|
26
|
+
directive @entity on OBJECT
|
|
27
|
+
directive @derivedFrom(field: String!) on FIELD_DEFINITION
|
|
28
|
+
directive @unique on FIELD_DEFINITION
|
|
29
|
+
directive @fulltext(query: String!) on FIELD_DEFINITION
|
|
30
|
+
directive @variant on OBJECT # legacy
|
|
31
|
+
directive @jsonField on OBJECT # legacy
|
|
32
|
+
${scalars_list.map(name => 'scalar ' + name).join('\n')}
|
|
33
|
+
`))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export function buildSchema(doc: DocumentNode): GraphQLSchema {
|
|
37
|
+
let schema = extendSchema(baseSchema, doc)
|
|
38
|
+
let errors = validateSchema(schema).filter(err => !/query root/i.test(err.message))
|
|
39
|
+
if (errors.length > 0) {
|
|
40
|
+
throw errors[0]
|
|
41
|
+
}
|
|
42
|
+
return schema
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export function buildModel(schema: GraphQLSchema): Model {
|
|
47
|
+
let types = schema.getTypeMap()
|
|
48
|
+
let model: Model = {}
|
|
49
|
+
for (let key in types) {
|
|
50
|
+
let type = types[key]
|
|
51
|
+
if (isEntityType(type)) {
|
|
52
|
+
addEntityOrJsonObjectOrInterface(model, type as GraphQLObjectType)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
validateModel(model)
|
|
56
|
+
return model
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
function isEntityType(type: GraphQLNamedType): boolean {
|
|
61
|
+
return type instanceof GraphQLObjectType && !!type.astNode?.directives?.some(d => d.name.value == 'entity')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
function addEntityOrJsonObjectOrInterface(model: Model, type: GraphQLObjectType | GraphQLInterfaceType): void {
|
|
66
|
+
if (model[type.name]) return
|
|
67
|
+
|
|
68
|
+
let kind: 'entity' | 'object' | 'interface' = isEntityType(type)
|
|
69
|
+
? 'entity'
|
|
70
|
+
: type instanceof GraphQLInterfaceType ? 'interface' : 'object'
|
|
71
|
+
|
|
72
|
+
let properties: Record<string, Prop> = {}
|
|
73
|
+
let interfaces: string[] = []
|
|
74
|
+
let description = type.description || undefined
|
|
75
|
+
|
|
76
|
+
if (kind != 'interface') {
|
|
77
|
+
model[type.name] = {kind, properties, interfaces, description}
|
|
78
|
+
} else {
|
|
79
|
+
model[type.name] = {kind, properties, description}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let fields = type.getFields()
|
|
83
|
+
if (kind == 'entity') {
|
|
84
|
+
if (fields.id == null) {
|
|
85
|
+
properties.id = {
|
|
86
|
+
type: {kind: 'scalar', name: 'ID'},
|
|
87
|
+
nullable: false
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
let correctIdType = fields.id.type instanceof GraphQLNonNull
|
|
91
|
+
&& fields.id.type.ofType instanceof GraphQLScalarType
|
|
92
|
+
&& fields.id.type.ofType.name === 'ID'
|
|
93
|
+
if (!correctIdType) {
|
|
94
|
+
throw unsupportedFieldTypeError(type.name + '.id')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (let key in fields) {
|
|
100
|
+
let f: GraphQLField<any, any> = fields[key]
|
|
101
|
+
let fieldType = f.type
|
|
102
|
+
let nullable = true
|
|
103
|
+
let description = f.description || undefined
|
|
104
|
+
let propName = `${type.name}.${f.name}`
|
|
105
|
+
let unique = f.astNode?.directives?.some(d => d.name.value == 'unique')
|
|
106
|
+
handleFulltextDirective(model, type, f)
|
|
107
|
+
if (fieldType instanceof GraphQLNonNull) {
|
|
108
|
+
nullable = false
|
|
109
|
+
fieldType = fieldType.ofType
|
|
110
|
+
}
|
|
111
|
+
let list = unwrapList(fieldType)
|
|
112
|
+
fieldType = list.item
|
|
113
|
+
if (fieldType instanceof GraphQLScalarType) {
|
|
114
|
+
properties[key] = {
|
|
115
|
+
type: wrapWithList(list.nulls, {
|
|
116
|
+
kind: 'scalar',
|
|
117
|
+
name: fieldType.name
|
|
118
|
+
}),
|
|
119
|
+
nullable,
|
|
120
|
+
description
|
|
121
|
+
}
|
|
122
|
+
} else if (fieldType instanceof GraphQLEnumType) {
|
|
123
|
+
addEnum(model, fieldType)
|
|
124
|
+
properties[key] = {
|
|
125
|
+
type: wrapWithList(list.nulls, {
|
|
126
|
+
kind: 'enum',
|
|
127
|
+
name: fieldType.name
|
|
128
|
+
}),
|
|
129
|
+
nullable,
|
|
130
|
+
description
|
|
131
|
+
}
|
|
132
|
+
} else if (fieldType instanceof GraphQLUnionType) {
|
|
133
|
+
addUnion(model, fieldType)
|
|
134
|
+
properties[key] = {
|
|
135
|
+
type: wrapWithList(list.nulls, {
|
|
136
|
+
kind: 'union',
|
|
137
|
+
name: fieldType.name
|
|
138
|
+
}),
|
|
139
|
+
nullable,
|
|
140
|
+
description
|
|
141
|
+
}
|
|
142
|
+
} else if (fieldType instanceof GraphQLObjectType) {
|
|
143
|
+
if (isEntityType(fieldType)) {
|
|
144
|
+
let derivedFrom = f.astNode?.directives?.filter(d => d.name.value == 'derivedFrom').map(d => {
|
|
145
|
+
let valueNode = d.arguments?.[0].value
|
|
146
|
+
assert(valueNode != null)
|
|
147
|
+
assert(valueNode.kind == 'StringValue')
|
|
148
|
+
return valueNode.value
|
|
149
|
+
})[0]
|
|
150
|
+
|
|
151
|
+
switch(list.nulls.length) {
|
|
152
|
+
case 0:
|
|
153
|
+
if (derivedFrom) {
|
|
154
|
+
if (!nullable) {
|
|
155
|
+
throw new SchemaError(`Property ${propName} must be nullable`)
|
|
156
|
+
}
|
|
157
|
+
properties[key] = {
|
|
158
|
+
type: {
|
|
159
|
+
kind: 'lookup',
|
|
160
|
+
entity: fieldType.name,
|
|
161
|
+
field: derivedFrom
|
|
162
|
+
},
|
|
163
|
+
nullable,
|
|
164
|
+
description
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
if (unique && nullable) {
|
|
168
|
+
throw new SchemaError(`Unique property ${propName} must be non-nullable`)
|
|
169
|
+
}
|
|
170
|
+
properties[key] = {
|
|
171
|
+
type: {
|
|
172
|
+
kind: 'fk',
|
|
173
|
+
foreignEntity: fieldType.name
|
|
174
|
+
},
|
|
175
|
+
nullable,
|
|
176
|
+
unique,
|
|
177
|
+
description
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
break
|
|
181
|
+
case 1:
|
|
182
|
+
if (derivedFrom == null) {
|
|
183
|
+
throw new SchemaError(`@derivedFrom directive is required on ${propName} declaration`)
|
|
184
|
+
}
|
|
185
|
+
properties[key] = {
|
|
186
|
+
type: {
|
|
187
|
+
kind: 'list-lookup',
|
|
188
|
+
entity: fieldType.name,
|
|
189
|
+
field: derivedFrom
|
|
190
|
+
},
|
|
191
|
+
nullable: false,
|
|
192
|
+
description
|
|
193
|
+
}
|
|
194
|
+
break
|
|
195
|
+
default:
|
|
196
|
+
throw unsupportedFieldTypeError(propName)
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
addEntityOrJsonObjectOrInterface(model, fieldType)
|
|
200
|
+
properties[key] = {
|
|
201
|
+
type: wrapWithList(list.nulls, {
|
|
202
|
+
kind: 'object',
|
|
203
|
+
name: fieldType.name
|
|
204
|
+
}),
|
|
205
|
+
nullable,
|
|
206
|
+
description
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
throw unsupportedFieldTypeError(propName)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (kind != 'interface') {
|
|
215
|
+
type.getInterfaces().forEach(i => {
|
|
216
|
+
addEntityOrJsonObjectOrInterface(model, i)
|
|
217
|
+
interfaces.push(i.name)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
function addUnion(model: Model, type: GraphQLUnionType): void {
|
|
224
|
+
if (model[type.name]) return
|
|
225
|
+
let variants: string[] = []
|
|
226
|
+
|
|
227
|
+
model[type.name] = {
|
|
228
|
+
kind: 'union',
|
|
229
|
+
variants,
|
|
230
|
+
description: type.description || undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type.getTypes().forEach(obj => {
|
|
234
|
+
if (isEntityType(obj)) {
|
|
235
|
+
throw new Error(`union ${type.name} has entity ${obj.name} as a variant. Entities in union types are not supported`)
|
|
236
|
+
}
|
|
237
|
+
addEntityOrJsonObjectOrInterface(model, obj)
|
|
238
|
+
variants.push(obj.name)
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
function addEnum(model: Model, type: GraphQLEnumType): void {
|
|
244
|
+
if (model[type.name]) return
|
|
245
|
+
let values: Record<string, {}> = {}
|
|
246
|
+
|
|
247
|
+
model[type.name] = {
|
|
248
|
+
kind: 'enum',
|
|
249
|
+
values,
|
|
250
|
+
description: type.description || undefined
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
type.getValues().forEach(item => {
|
|
254
|
+
values[item.name] = {}
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
function handleFulltextDirective(model: Model, object: GraphQLNamedType, f: GraphQLField<any, any>): void {
|
|
260
|
+
f.astNode?.directives?.forEach(d => {
|
|
261
|
+
if (d.name.value != 'fulltext') return
|
|
262
|
+
if (!isEntityType(object) || !isStringField(f)) {
|
|
263
|
+
throw new Error(`@fulltext directive can be only applied to String entity fields, but was applied to ${object.name}.${f.name}`)
|
|
264
|
+
}
|
|
265
|
+
let queryArgument = d.arguments?.find(arg => arg.name.value == 'query')
|
|
266
|
+
assert(queryArgument != null)
|
|
267
|
+
assert(queryArgument.value.kind == 'StringValue')
|
|
268
|
+
let queryName = queryArgument.value.value
|
|
269
|
+
let query = model[queryName]
|
|
270
|
+
if (query == null) {
|
|
271
|
+
query = model[queryName] = {
|
|
272
|
+
kind: 'fts',
|
|
273
|
+
sources: []
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
assert(query.kind == 'fts')
|
|
277
|
+
let src = query.sources.find(s => s.entity == object.name)
|
|
278
|
+
if (src == null) {
|
|
279
|
+
query.sources.push({
|
|
280
|
+
entity: object.name,
|
|
281
|
+
fields: [f.name]
|
|
282
|
+
})
|
|
283
|
+
} else {
|
|
284
|
+
src.fields.push(f.name)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
function isStringField(f: GraphQLField<any, any>): boolean {
|
|
291
|
+
let type = f.type
|
|
292
|
+
if (type instanceof GraphQLNonNull) {
|
|
293
|
+
type = type.ofType
|
|
294
|
+
}
|
|
295
|
+
return type instanceof GraphQLScalarType && type.name == 'String'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
function unwrapList(type: GraphQLOutputType): DeepList {
|
|
300
|
+
let nulls: boolean[] = []
|
|
301
|
+
while (type instanceof GraphQLList) {
|
|
302
|
+
type = type.ofType
|
|
303
|
+
if (type instanceof GraphQLNonNull) {
|
|
304
|
+
nulls.push(false)
|
|
305
|
+
type = type.ofType
|
|
306
|
+
} else {
|
|
307
|
+
nulls.push(true)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {item: type, nulls}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
interface DeepList {
|
|
315
|
+
item: GraphQLOutputType
|
|
316
|
+
nulls: boolean[]
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
function wrapWithList(nulls: boolean[], dataType: PropType): PropType {
|
|
321
|
+
if (nulls.length == 0) return dataType
|
|
322
|
+
return {
|
|
323
|
+
kind: 'list',
|
|
324
|
+
item: {
|
|
325
|
+
type: wrapWithList(nulls.slice(1), dataType),
|
|
326
|
+
nullable: nulls[0]
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
function unsupportedFieldTypeError(propName: string): Error {
|
|
333
|
+
return new SchemaError(`Property ${propName} has unsupported type`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
export class SchemaError extends Error {}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {Pool} from "pg"
|
|
2
|
+
import {createPoolConfig} from "./db"
|
|
3
|
+
import {serve} from "./server"
|
|
4
|
+
import {loadModel} from "./tools"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
module.exports = function main() {
|
|
8
|
+
let args = process.argv.slice(2)
|
|
9
|
+
|
|
10
|
+
if (args.indexOf('--help') >= 0) {
|
|
11
|
+
help()
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (args.length != 1) {
|
|
16
|
+
help()
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let model = loadModel(args[0])
|
|
21
|
+
let db = new Pool(createPoolConfig())
|
|
22
|
+
let port = process.env.GRAPHQL_SERVER_PORT || 3000
|
|
23
|
+
|
|
24
|
+
serve({model, db, port}).then(
|
|
25
|
+
() => {
|
|
26
|
+
console.log('OpenReader is listening on port ' + port)
|
|
27
|
+
},
|
|
28
|
+
err => {
|
|
29
|
+
console.error(err)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
function help() {
|
|
37
|
+
console.error(`
|
|
38
|
+
Usage: openreader SCHEMA
|
|
39
|
+
|
|
40
|
+
OpenCRUD GraphQL server.
|
|
41
|
+
|
|
42
|
+
Can be configured using the following environment variables:
|
|
43
|
+
|
|
44
|
+
DB_NAME
|
|
45
|
+
DB_USER
|
|
46
|
+
DB_PASS
|
|
47
|
+
DB_HOST
|
|
48
|
+
DB_PORT
|
|
49
|
+
GRAPHQL_SERVER_PORT
|
|
50
|
+
`)
|
|
51
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "assert"
|
|
2
|
+
import {Entity, FTS_Query, JsonObject, Model, Prop, PropType} from "./model"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const UNION_MAPS = new WeakMap<Model, Record<string, JsonObject>>()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export function getUnionProps(model: Model, unionName: string): JsonObject {
|
|
9
|
+
let map = UNION_MAPS.get(model)
|
|
10
|
+
if (map == null) {
|
|
11
|
+
map = {}
|
|
12
|
+
UNION_MAPS.set(model, map)
|
|
13
|
+
}
|
|
14
|
+
if (map[unionName]) return map[unionName]
|
|
15
|
+
return map[unionName] = buildUnionProps(model, unionName)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export function buildUnionProps(model: Model, unionName: string): JsonObject {
|
|
20
|
+
let union = model[unionName]
|
|
21
|
+
assert(union.kind == 'union')
|
|
22
|
+
let properties: Record<string, Prop> = {}
|
|
23
|
+
for (let i = 0; i < union.variants.length; i++) {
|
|
24
|
+
let objectName = union.variants[i]
|
|
25
|
+
let object = model[objectName]
|
|
26
|
+
assert(object.kind == 'object')
|
|
27
|
+
Object.assign(properties, object.properties)
|
|
28
|
+
}
|
|
29
|
+
properties.isTypeOf = {
|
|
30
|
+
type: {kind: 'scalar', name: 'String'},
|
|
31
|
+
nullable: false
|
|
32
|
+
}
|
|
33
|
+
return {kind: 'object', properties}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export function validateModel(model: Model) {
|
|
38
|
+
// TODO: check all invariants we assume
|
|
39
|
+
validateNames(model)
|
|
40
|
+
validateUnionTypes(model)
|
|
41
|
+
validateLookups(model)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
const TYPE_NAME_REGEX = /^[A-Z][a-zA-Z0-9]*$/
|
|
46
|
+
const PROP_NAME_REGEX = /^[a-z][a-zA-Z0-9]*$/
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
export function validateNames(model: Model) {
|
|
50
|
+
for (let name in model) {
|
|
51
|
+
let item = model[name]
|
|
52
|
+
if (item.kind == 'fts') {
|
|
53
|
+
if (!PROP_NAME_REGEX.test(name)) {
|
|
54
|
+
throw new Error(`Invalid fulltext search name: ${name}. It must match ${PROP_NAME_REGEX}.`)
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
if (!TYPE_NAME_REGEX.test(name)) {
|
|
58
|
+
throw new Error(`Invalid ${item.kind} name: ${name}. It must match ${TYPE_NAME_REGEX}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
switch(item.kind) {
|
|
62
|
+
case 'entity':
|
|
63
|
+
case 'object':
|
|
64
|
+
case 'interface':
|
|
65
|
+
for (let prop in item.properties) {
|
|
66
|
+
if (!PROP_NAME_REGEX.test(prop)) {
|
|
67
|
+
throw new Error(`Type ${name} has a property with invalid name: ${prop}. It must match ${PROP_NAME_REGEX}.`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
export function validateUnionTypes(model: Model): void {
|
|
77
|
+
for (let key in model) {
|
|
78
|
+
let item = model[key]
|
|
79
|
+
if (item.kind != 'union') continue
|
|
80
|
+
let properties: Record<string, { objectName: string, type: PropType }> = {}
|
|
81
|
+
item.variants.forEach(objectName => {
|
|
82
|
+
let object = model[objectName]
|
|
83
|
+
assert(object.kind == 'object')
|
|
84
|
+
for (let propName in object.properties) {
|
|
85
|
+
let rec = properties[propName]
|
|
86
|
+
if (rec && !propTypeEquals(rec.type, object.properties[propName].type)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${rec.objectName} and ${objectName} variants of union ${key} both have property '${propName}', but types of ${rec.objectName}.${propName} and ${objectName}.${propName} are different.`
|
|
89
|
+
)
|
|
90
|
+
} else {
|
|
91
|
+
properties[propName] = {objectName, type: object.properties[propName].type}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
export function validateLookups(model: Model): void {
|
|
100
|
+
for (let name in model) {
|
|
101
|
+
let item = model[name]
|
|
102
|
+
switch(item.kind) {
|
|
103
|
+
case 'object':
|
|
104
|
+
case 'interface':
|
|
105
|
+
for (let key in item.properties) {
|
|
106
|
+
let prop = item.properties[key]
|
|
107
|
+
if (prop.type.kind == 'lookup' || prop.type.kind == 'list-lookup') {
|
|
108
|
+
throw invalidProperty(name, key, `lookups are only supported on entity types`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
break
|
|
112
|
+
case 'entity':
|
|
113
|
+
for (let key in item.properties) {
|
|
114
|
+
let prop = item.properties[key]
|
|
115
|
+
if (prop.type.kind == 'lookup' && !prop.nullable) {
|
|
116
|
+
throw invalidProperty(name, key, 'one-to-one lookups must be nullable')
|
|
117
|
+
}
|
|
118
|
+
if (prop.type.kind == 'lookup' || prop.type.kind == 'list-lookup') {
|
|
119
|
+
let lookupEntity = getEntity(model, prop.type.entity)
|
|
120
|
+
let lookupProperty = lookupEntity.properties[prop.type.field]
|
|
121
|
+
if (lookupProperty?.type.kind != 'fk' || lookupProperty.type.foreignEntity != name) {
|
|
122
|
+
throw invalidProperty(name, key, `${prop.type.entity}.${prop.type.field} is not a foreign key pointing to ${name}`)
|
|
123
|
+
}
|
|
124
|
+
if (prop.type.kind == 'lookup' && !lookupProperty.unique) {
|
|
125
|
+
throw invalidProperty(name, key, `${prop.type.entity}.${prop.type.field} is not @unique`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
function invalidProperty(item: string, key: string, msg: string): Error {
|
|
136
|
+
return new Error(`Invalid property ${item}.${key}: ${msg}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
export function propTypeEquals(a: PropType, b: PropType): boolean {
|
|
141
|
+
if (a.kind != b.kind) return false
|
|
142
|
+
if (a.kind == 'list') return propTypeEquals(a.item.type, (b as typeof a).item.type)
|
|
143
|
+
switch(a.kind) {
|
|
144
|
+
case 'fk':
|
|
145
|
+
return a.foreignEntity == (b as typeof a).foreignEntity
|
|
146
|
+
case 'lookup':
|
|
147
|
+
case 'list-lookup':
|
|
148
|
+
return a.entity == (b as typeof a).entity && a.field == (b as typeof a).field
|
|
149
|
+
default:
|
|
150
|
+
return a.name == (b as typeof a).name
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
export function getEntity(model: Model, name: string): Entity {
|
|
156
|
+
let entity = model[name]
|
|
157
|
+
assert(entity.kind == 'entity', `${name} expected to be an entity`)
|
|
158
|
+
return entity
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
export function getObject(model: Model, name: string): JsonObject {
|
|
163
|
+
let object = model[name]
|
|
164
|
+
assert(object.kind == 'object', `${name} expected to be an object`)
|
|
165
|
+
return object
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
export function getFtsQuery(model: Model, name: string): FTS_Query {
|
|
170
|
+
let query = model[name]
|
|
171
|
+
assert(query.kind == 'fts', `${name} expected to be FTS query`)
|
|
172
|
+
return query
|
|
173
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export type Name = string
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export type Model = Record<Name, Entity | JsonObject | Interface | Union | Enum | FTS_Query>
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export interface Entity extends TypeMeta {
|
|
8
|
+
kind: 'entity'
|
|
9
|
+
properties: Record<Name, Prop>
|
|
10
|
+
interfaces?: Name[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export interface JsonObject extends TypeMeta {
|
|
15
|
+
kind: 'object'
|
|
16
|
+
properties: Record<Name, Prop>
|
|
17
|
+
interfaces?: Name[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export interface Interface extends TypeMeta {
|
|
22
|
+
kind: 'interface'
|
|
23
|
+
properties: Record<Name, Prop>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export interface Union extends TypeMeta {
|
|
28
|
+
kind: 'union'
|
|
29
|
+
variants: Name[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
export interface Enum extends TypeMeta {
|
|
34
|
+
kind: 'enum'
|
|
35
|
+
values: Record<string, {}>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
export interface TypeMeta {
|
|
40
|
+
description?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
export interface Prop {
|
|
45
|
+
type: PropType
|
|
46
|
+
nullable: boolean
|
|
47
|
+
description?: string
|
|
48
|
+
/**
|
|
49
|
+
* Whether the values in the column must be unique. Applicable only to entities.
|
|
50
|
+
*/
|
|
51
|
+
unique?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export type PropType =
|
|
56
|
+
ScalarPropType |
|
|
57
|
+
EnumPropType |
|
|
58
|
+
ListPropType |
|
|
59
|
+
ObjectPropType |
|
|
60
|
+
UnionPropType |
|
|
61
|
+
FkPropType |
|
|
62
|
+
LookupPropType |
|
|
63
|
+
ListLookupPropType
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export interface ScalarPropType {
|
|
67
|
+
kind: 'scalar'
|
|
68
|
+
name: Name
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
export interface EnumPropType {
|
|
73
|
+
kind: 'enum'
|
|
74
|
+
name: Name
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
export interface ObjectPropType {
|
|
79
|
+
kind: 'object'
|
|
80
|
+
name: Name
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
export interface UnionPropType {
|
|
85
|
+
kind: 'union'
|
|
86
|
+
name: Name
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export interface ListPropType {
|
|
91
|
+
kind: 'list'
|
|
92
|
+
item: Prop
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
export interface FkPropType {
|
|
97
|
+
kind: 'fk'
|
|
98
|
+
foreignEntity: Name
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
export interface LookupPropType {
|
|
103
|
+
kind: 'lookup'
|
|
104
|
+
entity: Name
|
|
105
|
+
field: Name
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
export interface ListLookupPropType {
|
|
110
|
+
kind: 'list-lookup'
|
|
111
|
+
entity: Name
|
|
112
|
+
field: Name
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
export interface FTS_Query {
|
|
117
|
+
kind: 'fts'
|
|
118
|
+
sources: FTS_Source[]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
export interface FTS_Source {
|
|
123
|
+
entity: Name
|
|
124
|
+
fields: Name[]
|
|
125
|
+
}
|