@contember/schema-utils 1.2.0-beta.1 → 1.2.0-beta.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/src/definition-generator/TsDefinitionGenerator.d.ts +26 -0
- package/dist/src/definition-generator/TsDefinitionGenerator.d.ts.map +1 -0
- package/dist/src/definition-generator/TsDefinitionGenerator.js +246 -0
- package/dist/src/definition-generator/TsDefinitionGenerator.js.map +1 -0
- package/dist/src/definition-generator/index.d.ts +2 -0
- package/dist/src/definition-generator/index.d.ts.map +1 -0
- package/dist/src/definition-generator/index.js +18 -0
- package/dist/src/definition-generator/index.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lax/LaxSchemaConverter.d.ts +10 -0
- package/dist/src/lax/LaxSchemaConverter.d.ts.map +1 -0
- package/dist/src/lax/LaxSchemaConverter.js +204 -0
- package/dist/src/lax/LaxSchemaConverter.js.map +1 -0
- package/dist/src/lax/index.d.ts +3 -0
- package/dist/src/lax/index.d.ts.map +1 -0
- package/dist/src/lax/index.js +21 -0
- package/dist/src/lax/index.js.map +1 -0
- package/dist/src/lax/schema.d.ts +285 -0
- package/dist/src/lax/schema.d.ts.map +1 -0
- package/dist/src/lax/schema.js +67 -0
- package/dist/src/lax/schema.js.map +1 -0
- package/dist/src/model/NamingConventions.d.ts +17 -0
- package/dist/src/model/NamingConventions.d.ts.map +1 -0
- package/dist/src/model/NamingConventions.js +29 -0
- package/dist/src/model/NamingConventions.js.map +1 -0
- package/dist/src/model/index.d.ts +2 -0
- package/dist/src/model/index.d.ts.map +1 -1
- package/dist/src/model/index.js +2 -0
- package/dist/src/model/index.js.map +1 -1
- package/dist/src/model/resolveDefaultColumnType.d.ts +3 -0
- package/dist/src/model/resolveDefaultColumnType.d.ts.map +1 -0
- package/dist/src/model/resolveDefaultColumnType.js +29 -0
- package/dist/src/model/resolveDefaultColumnType.js.map +1 -0
- package/dist/src/tsconfig.tsbuildinfo +1 -1
- package/dist/src/type-schema/model.js +1 -1
- package/dist/src/type-schema/model.js.map +1 -1
- package/dist/tests/cases/unit/laxSchemaConverter.test.d.ts +2 -0
- package/dist/tests/cases/unit/laxSchemaConverter.test.d.ts.map +1 -0
- package/dist/tests/cases/unit/laxSchemaConverter.test.js +118 -0
- package/dist/tests/cases/unit/laxSchemaConverter.test.js.map +1 -0
- package/dist/tests/cases/unit/schemas/basic.d.ts +5 -0
- package/dist/tests/cases/unit/schemas/basic.d.ts.map +1 -0
- package/dist/tests/cases/unit/schemas/basic.js +11 -0
- package/dist/tests/cases/unit/schemas/basic.js.map +1 -0
- package/dist/tests/cases/unit/schemas/enum.d.ts +7 -0
- package/dist/tests/cases/unit/schemas/enum.d.ts.map +1 -0
- package/dist/tests/cases/unit/schemas/enum.js +13 -0
- package/dist/tests/cases/unit/schemas/enum.js.map +1 -0
- package/dist/tests/cases/unit/schemas/relations.d.ts +15 -0
- package/dist/tests/cases/unit/schemas/relations.d.ts.map +1 -0
- package/dist/tests/cases/unit/schemas/relations.js +27 -0
- package/dist/tests/cases/unit/schemas/relations.js.map +1 -0
- package/dist/tests/cases/unit/schemas/unique.d.ts +5 -0
- package/dist/tests/cases/unit/schemas/unique.d.ts.map +1 -0
- package/dist/tests/cases/unit/schemas/unique.js +20 -0
- package/dist/tests/cases/unit/schemas/unique.js.map +1 -0
- package/dist/tests/cases/unit/tsDefinitionGenerator.test.d.ts +2 -0
- package/dist/tests/cases/unit/tsDefinitionGenerator.test.d.ts.map +1 -0
- package/dist/tests/cases/unit/tsDefinitionGenerator.test.js +56 -0
- package/dist/tests/cases/unit/tsDefinitionGenerator.test.js.map +1 -0
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/definition-generator/TsDefinitionGenerator.ts +275 -0
- package/src/definition-generator/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/lax/LaxSchemaConverter.ts +213 -0
- package/src/lax/index.ts +2 -0
- package/src/lax/schema.ts +62 -0
- package/src/model/NamingConventions.ts +53 -0
- package/src/model/index.ts +2 -0
- package/src/model/resolveDefaultColumnType.ts +25 -0
- package/src/type-schema/model.ts +1 -1
- package/tests/cases/unit/__snapshots__/laxSchemaConverter.test.ts.snap +310 -0
- package/tests/cases/unit/laxSchemaConverter.test.ts +137 -0
- package/tests/cases/unit/schemas/.eslintignore +1 -0
- package/tests/cases/unit/schemas/basic.ts +5 -0
- package/tests/cases/unit/schemas/enum.ts +8 -0
- package/tests/cases/unit/schemas/relations.ts +17 -0
- package/tests/cases/unit/schemas/unique.ts +6 -0
- package/tests/cases/unit/tsDefinitionGenerator.test.ts +31 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { Model, Schema, Writable } from '@contember/schema'
|
|
2
|
+
import {
|
|
3
|
+
acceptFieldVisitor,
|
|
4
|
+
DefaultNamingConventions,
|
|
5
|
+
isInverseRelation,
|
|
6
|
+
NamingConventions,
|
|
7
|
+
NamingHelper,
|
|
8
|
+
resolveDefaultColumnType,
|
|
9
|
+
} from '../model'
|
|
10
|
+
|
|
11
|
+
export class TsDefinitionGenerator {
|
|
12
|
+
private static reservedWords = new Set(['do', 'if', 'in', 'for', 'let', 'new', 'try', 'var', 'case', 'else', 'enum', 'eval', 'null', 'this', 'true', 'void', 'with', 'await', 'break', 'catch', 'class', 'const', 'false', 'super', 'throw', 'while', 'yield', 'delete', 'export', 'import', 'public', 'return', 'static', 'switch', 'typeof', 'default', 'extends', 'finally', 'package', 'private', 'continue', 'debugger', 'function', 'arguments', 'interface', 'protected', 'implements', 'instanceof'])
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly schema: Schema,
|
|
16
|
+
private readonly conventions: NamingConventions = new DefaultNamingConventions(),
|
|
17
|
+
) {
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public generate() {
|
|
21
|
+
const enums = Object.entries(this.schema.model.enums).map(([name, values]) => this.generateEnum({
|
|
22
|
+
name,
|
|
23
|
+
values,
|
|
24
|
+
})).join('')
|
|
25
|
+
const entities = Object.values(this.schema.model.entities).map(entity => this.generateEntity({ entity })).join('')
|
|
26
|
+
return `import { SchemaDefinition as def } from '@contember/schema-definition'
|
|
27
|
+
${enums}${entities}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private generateEnum({ name, values }: { name: string; values: readonly string[] }): string {
|
|
31
|
+
return `\nexport const ${this.formatIdentifier(name)} = def.createEnum(${values.map(it => this.formatLiteral(it)).join(', ')})\n`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
public generateEntity({ entity }: { entity: Model.Entity }): string {
|
|
36
|
+
const decorators = [
|
|
37
|
+
...Object.values(entity.unique).map(constraint => this.generateUniqueConstraint({ entity, constraint })),
|
|
38
|
+
...Object.values(entity.indexes).map(index => this.generateIndex({ entity, index })),
|
|
39
|
+
this.generateView({ entity }),
|
|
40
|
+
].filter(it => !!it).map(it => `${it}\n`)
|
|
41
|
+
|
|
42
|
+
return `\n${decorators}export class ${this.formatIdentifier(entity.name)} {
|
|
43
|
+
${Object.values(entity.fields).map(field => this.generateField({ field, entity })).filter(it => !!it).join('\n')}
|
|
44
|
+
}\n`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private generateUniqueConstraint({ entity, constraint }: { entity: Model.Entity; constraint: Model.UniqueConstraint }): string {
|
|
48
|
+
const defaultName = NamingHelper.createUniqueConstraintName(entity.name, constraint.fields)
|
|
49
|
+
if (defaultName === constraint.name) {
|
|
50
|
+
const fieldsList = `${constraint.fields.map(it => this.formatLiteral(it)).join(', ')}`
|
|
51
|
+
return `@def.Unique(${fieldsList})`
|
|
52
|
+
}
|
|
53
|
+
return `@def.Unique(${this.formatLiteral(constraint)})`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private generateIndex({ entity, index }: { entity: Model.Entity; index: Model.Index }): string {
|
|
57
|
+
const defaultName = NamingHelper.createIndexName(entity.name, index.fields)
|
|
58
|
+
if (defaultName === index.name) {
|
|
59
|
+
const fieldsList = `${index.fields.map(it => this.formatLiteral(it)).join(', ')}`
|
|
60
|
+
return `@def.Index(${fieldsList})`
|
|
61
|
+
}
|
|
62
|
+
return `@def.Index(${this.formatLiteral(index)})`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private generateView({ entity }: { entity: Model.Entity }): string | undefined {
|
|
66
|
+
if (!entity.view) {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dependenciesExpr = (entity.view.dependencies?.length ?? 0) > 0
|
|
71
|
+
? `, {\n\tdependencies: () => [${entity.view.dependencies?.map(it => this.formatIdentifier(it))}]\n}`
|
|
72
|
+
: ''
|
|
73
|
+
|
|
74
|
+
return `@def.View(\`${entity.view.sql}\`${dependenciesExpr})`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public generateField({ entity, field }: { entity: Model.Entity; field: Model.AnyField }): string | undefined {
|
|
78
|
+
const formatRelationFactory = (method: string, relation: Model.AnyRelation) => {
|
|
79
|
+
const otherSide = isInverseRelation(relation) ? relation.ownedBy : relation.inversedBy
|
|
80
|
+
const otherSideFormatted = otherSide ? `, ${this.formatLiteral(otherSide)}` : ''
|
|
81
|
+
return `${method}(${this.formatIdentifier(relation.target)}${otherSideFormatted})`
|
|
82
|
+
}
|
|
83
|
+
const formatEnumRef = (enumName: string, enumValues: Record<string, string>, providedValue?: string, defaultValue?: string): string | undefined => {
|
|
84
|
+
if (!providedValue || providedValue === defaultValue) {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
const enumValueKey = Object.entries(Model.OrderDirection).find(dir => dir[1] === providedValue)?.[0]
|
|
88
|
+
if (!enumValueKey) {
|
|
89
|
+
throw new Error(`Value ${providedValue} is not defined in enum ${enumName}`)
|
|
90
|
+
}
|
|
91
|
+
return `${enumName}.${enumValueKey}`
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
const formatOrderBy = (orderBy?: readonly Model.OrderBy[]) => {
|
|
95
|
+
return orderBy?.map(it => {
|
|
96
|
+
const enumExpr = formatEnumRef(`Model.OrderDirection`, Model.OrderDirection, it.direction, Model.OrderDirection.asc)
|
|
97
|
+
return `orderBy(${this.formatLiteral(it.path)}${enumExpr ? `, ${enumExpr}` : ''})`
|
|
98
|
+
}) ?? []
|
|
99
|
+
}
|
|
100
|
+
const formatOnDelete = (onDelete?: Model.OnDelete): string | undefined => {
|
|
101
|
+
if (onDelete === Model.OnDelete.cascade) {
|
|
102
|
+
return 'cascadeOnDelete()'
|
|
103
|
+
}
|
|
104
|
+
if (onDelete === Model.OnDelete.setNull) {
|
|
105
|
+
return 'setNullOnDelete()'
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
const formatJoiningColumn = (joiningColumnName: string, fieldName: string): string | undefined => {
|
|
110
|
+
const defaultJoiningColumn = this.conventions.getJoiningColumnName(fieldName)
|
|
111
|
+
if (defaultJoiningColumn === joiningColumnName) {
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
return `joiningColumn(${this.formatLiteral(joiningColumnName)})`
|
|
115
|
+
}
|
|
116
|
+
const definition = acceptFieldVisitor<(string | undefined)[]>(this.schema.model, entity, field, {
|
|
117
|
+
visitColumn: ctx => {
|
|
118
|
+
return this.generateColumn(ctx)
|
|
119
|
+
},
|
|
120
|
+
visitOneHasMany: ({ relation }) => {
|
|
121
|
+
return [
|
|
122
|
+
formatRelationFactory('oneHasMany', relation),
|
|
123
|
+
...formatOrderBy(relation.orderBy),
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
visitManyHasOne: ({ relation }) => {
|
|
127
|
+
return [
|
|
128
|
+
formatRelationFactory('manyHasOne', relation),
|
|
129
|
+
!relation.nullable ? 'notNull()' : undefined,
|
|
130
|
+
formatOnDelete(relation.joiningColumn.onDelete),
|
|
131
|
+
formatJoiningColumn(relation.joiningColumn.columnName, relation.name),
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
visitOneHasOneInverse: ({ relation }) => {
|
|
135
|
+
return [
|
|
136
|
+
formatRelationFactory('oneHasOneInverse', relation),
|
|
137
|
+
!relation.nullable ? 'notNull()' : undefined,
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
visitOneHasOneOwning: ({ relation }) => {
|
|
141
|
+
return [
|
|
142
|
+
formatRelationFactory('oneHasOne', relation),
|
|
143
|
+
!relation.nullable ? 'notNull()' : undefined,
|
|
144
|
+
formatOnDelete(relation.joiningColumn.onDelete),
|
|
145
|
+
formatJoiningColumn(relation.joiningColumn.columnName, relation.name),
|
|
146
|
+
relation.orphanRemoval ? 'removeOrphan()' : undefined,
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
visitManyHasManyOwning: ({ entity, relation }) => {
|
|
150
|
+
const columnNames = this.conventions.getJoiningTableColumnNames(
|
|
151
|
+
entity.name,
|
|
152
|
+
relation.name,
|
|
153
|
+
relation.target,
|
|
154
|
+
relation.inversedBy,
|
|
155
|
+
)
|
|
156
|
+
const defaultJoiningTable = this.conventions.getJoiningTableName(entity.name, relation.name)
|
|
157
|
+
const joiningTable: Writable<Partial<Model.JoiningTable>> = {}
|
|
158
|
+
if (relation.joiningTable.tableName !== defaultJoiningTable) {
|
|
159
|
+
joiningTable.tableName = relation.joiningTable.tableName
|
|
160
|
+
}
|
|
161
|
+
if (!relation.joiningTable.eventLog.enabled) {
|
|
162
|
+
joiningTable.eventLog = { enabled: false }
|
|
163
|
+
}
|
|
164
|
+
if (columnNames[0] !== relation.joiningTable.joiningColumn.columnName || relation.joiningTable.joiningColumn.onDelete !== Model.OnDelete.cascade) {
|
|
165
|
+
joiningTable.joiningColumn = {
|
|
166
|
+
columnName: relation.joiningTable.joiningColumn.columnName,
|
|
167
|
+
onDelete: relation.joiningTable.joiningColumn.onDelete,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (columnNames[1] !== relation.joiningTable.inverseJoiningColumn.columnName || relation.joiningTable.inverseJoiningColumn.onDelete !== Model.OnDelete.cascade) {
|
|
171
|
+
joiningTable.inverseJoiningColumn = {
|
|
172
|
+
columnName: relation.joiningTable.inverseJoiningColumn.columnName,
|
|
173
|
+
onDelete: relation.joiningTable.inverseJoiningColumn.onDelete,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return [
|
|
178
|
+
formatRelationFactory('manyHasMany', relation),
|
|
179
|
+
...formatOrderBy(relation.orderBy),
|
|
180
|
+
Object.keys(joiningTable).length > 0 ? `joiningTable(${this.formatLiteral(joiningTable)})` : undefined,
|
|
181
|
+
]
|
|
182
|
+
},
|
|
183
|
+
visitManyHasManyInverse: ({ relation }) => {
|
|
184
|
+
return [
|
|
185
|
+
formatRelationFactory('manyHasManyInverse', relation),
|
|
186
|
+
...formatOrderBy(relation.orderBy),
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
const definitionCode = definition.filter(it => !!it).join('.')
|
|
191
|
+
if (field.name === 'id' && definitionCode === 'uuidColumn().notNull()') {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
return `\t${this.formatIdentifier(field.name)} = def.${definitionCode}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private generateColumn({ entity, column }: { entity: Model.Entity; column: Model.AnyColumn }): string[] {
|
|
198
|
+
let parts: string[] = []
|
|
199
|
+
if (column.type === Model.ColumnType.Enum) {
|
|
200
|
+
parts.push(`enumColumn(${column.columnType})`)
|
|
201
|
+
} else {
|
|
202
|
+
parts.push(`${ColumnToMethodMapping[column.type]}()`)
|
|
203
|
+
const defaultColumnType = resolveDefaultColumnType(column.type)
|
|
204
|
+
if (defaultColumnType !== column.columnType) {
|
|
205
|
+
parts.push(`columnType(${this.formatLiteral(column.columnType)})`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const defaultColumnName = this.conventions.getColumnName(column.name)
|
|
209
|
+
if (defaultColumnName !== column.columnName) {
|
|
210
|
+
parts.push(`columnName(${this.formatLiteral(column.columnName)})`)
|
|
211
|
+
}
|
|
212
|
+
if (!column.nullable) {
|
|
213
|
+
parts.push('notNull()')
|
|
214
|
+
}
|
|
215
|
+
if (column.default !== undefined) {
|
|
216
|
+
parts.push(`default(${this.formatLiteral(column.default)})`)
|
|
217
|
+
}
|
|
218
|
+
if (column.typeAlias) {
|
|
219
|
+
parts.push(`typeAlias(${this.formatLiteral(column.typeAlias)})`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// todo: sequence
|
|
224
|
+
// todo: maybe single column unique()
|
|
225
|
+
|
|
226
|
+
return parts
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private formatIdentifier(id: string): string {
|
|
230
|
+
// todo: validate
|
|
231
|
+
return id
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private formatLiteral(value: any): string {
|
|
235
|
+
if (value === undefined || value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'function') {
|
|
236
|
+
return String(value)
|
|
237
|
+
}
|
|
238
|
+
if (typeof value === 'bigint') {
|
|
239
|
+
return value.toString(10) + 'n'
|
|
240
|
+
}
|
|
241
|
+
if (typeof value === 'string') {
|
|
242
|
+
return `'${value.replaceAll(/'/g, '\\\'')}'`
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(value)) {
|
|
245
|
+
return `[${value.map(it => this.formatLiteral(it)).join(', ')}]`
|
|
246
|
+
}
|
|
247
|
+
return `{${Object.entries(value).map(([key, value]) => {
|
|
248
|
+
const formattedKey = this.isSimpleIdentifier(key) ? key : `[${this.formatLiteral(key)}]`
|
|
249
|
+
return `${formattedKey}: ${this.formatLiteral(value)}`
|
|
250
|
+
}).join(', ')}`
|
|
251
|
+
}
|
|
252
|
+
private isValidIdentifier(identifier: string): boolean {
|
|
253
|
+
if (!this.isSimpleIdentifier(identifier)) {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
return !TsDefinitionGenerator.reservedWords.has(identifier)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private isSimpleIdentifier(identifier: string): boolean {
|
|
260
|
+
return !!identifier.match(/^[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*$/)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const ColumnToMethodMapping: {
|
|
265
|
+
[K in Exclude<Model.ColumnType, Model.ColumnType.Enum>]: string
|
|
266
|
+
} = {
|
|
267
|
+
[Model.ColumnType.Bool]: 'boolColumn',
|
|
268
|
+
[Model.ColumnType.Date]: 'dateColumn',
|
|
269
|
+
[Model.ColumnType.DateTime]: 'dateTimeColumn',
|
|
270
|
+
[Model.ColumnType.Json]: 'jsonColumn',
|
|
271
|
+
[Model.ColumnType.Double]: 'doubleColumn',
|
|
272
|
+
[Model.ColumnType.Uuid]: 'uuidColumn',
|
|
273
|
+
[Model.ColumnType.Int]: 'intColumn',
|
|
274
|
+
[Model.ColumnType.String]: 'stringColumn',
|
|
275
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './TsDefinitionGenerator'
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { emptyModelSchema } from './model'
|
|
|
3
3
|
import * as Typesafe from '@contember/typesafe'
|
|
4
4
|
import { aclSchema, modelSchema, validationSchema, settingsSchema } from './type-schema'
|
|
5
5
|
|
|
6
|
+
export * from './definition-generator'
|
|
7
|
+
export * from './lax'
|
|
6
8
|
export * from './model'
|
|
7
9
|
export * from './acl'
|
|
8
10
|
export * from './dataUtils'
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { Model, Schema, Writable } from '@contember/schema'
|
|
2
|
+
import { LaxSchema, LaxSchemaColumn, LaxSchemaEnum, LaxSchemaField, LaxSchemaRelation } from './schema'
|
|
3
|
+
import { DefaultNamingConventions, NamingConventions, resolveDefaultColumnType } from '../model'
|
|
4
|
+
|
|
5
|
+
const simpleColumnTypes: { [K in LaxSchemaColumn['type']]: { type: Model.ColumnType; columnType: string } } = {
|
|
6
|
+
boolean: { type: Model.ColumnType.Bool, columnType: 'boolean' },
|
|
7
|
+
int: { type: Model.ColumnType.Int, columnType: 'integer' },
|
|
8
|
+
date: { type: Model.ColumnType.Date, columnType: 'date' },
|
|
9
|
+
datetime: { type: Model.ColumnType.DateTime, columnType: 'timestamptz' },
|
|
10
|
+
float: { type: Model.ColumnType.Double, columnType: 'double precision' },
|
|
11
|
+
json: { type: Model.ColumnType.Json, columnType: 'jsonb' },
|
|
12
|
+
string: { type: Model.ColumnType.String, columnType: 'text' },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isColumnDefinition = (field: LaxSchemaField): field is LaxSchemaColumn => field.type in simpleColumnTypes
|
|
16
|
+
|
|
17
|
+
export class LaxSchemaConverter {
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly schema: LaxSchema,
|
|
20
|
+
private readonly conventions: NamingConventions = new DefaultNamingConventions(),
|
|
21
|
+
) {
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public convert(): Schema {
|
|
25
|
+
const builder = new LaxSchemaBuilder(this.schema, this.conventions)
|
|
26
|
+
return builder.build()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class LaxSchemaBuilder {
|
|
32
|
+
private readonly entities: Record<string, Model.Entity> = {}
|
|
33
|
+
private readonly enums: Record<string, string[]> = {}
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly schema: LaxSchema,
|
|
37
|
+
private readonly conventions: NamingConventions = new DefaultNamingConventions(),
|
|
38
|
+
) {
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public build(): Schema {
|
|
42
|
+
for (const [entityName, entityDefinition] of Object.entries(this.schema.entities)) {
|
|
43
|
+
for (const [fieldName, fieldDefinition] of Object.entries(entityDefinition.fields)) {
|
|
44
|
+
if (isColumnDefinition(fieldDefinition)) {
|
|
45
|
+
this.processColumn(entityName, fieldName, fieldDefinition)
|
|
46
|
+
|
|
47
|
+
} else if (fieldDefinition.type === 'enum') {
|
|
48
|
+
this.processEnum(entityName, fieldName, fieldDefinition)
|
|
49
|
+
|
|
50
|
+
} else {
|
|
51
|
+
this.processRelation(entityName, fieldName, fieldDefinition)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
acl: { roles: {} },
|
|
57
|
+
settings: {},
|
|
58
|
+
validation: {},
|
|
59
|
+
model: {
|
|
60
|
+
enums: this.enums,
|
|
61
|
+
entities: this.entities,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private processColumn(entityName: string, fieldName: string, columnDef: LaxSchemaColumn) {
|
|
67
|
+
const columnName = this.conventions.getColumnName(fieldName)
|
|
68
|
+
this.registerField(entityName, {
|
|
69
|
+
name: fieldName,
|
|
70
|
+
columnName: columnName,
|
|
71
|
+
type: simpleColumnTypes[columnDef.type].type,
|
|
72
|
+
nullable: !columnDef.notNull,
|
|
73
|
+
columnType: simpleColumnTypes[columnDef.type].columnType,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private processEnum(entityName: string, fieldName: string, enumDef: LaxSchemaEnum) {
|
|
78
|
+
this.enums[enumDef.enumName] = Array.from(new Set([...this.enums[enumDef.enumName] ?? [], ...enumDef.values]))
|
|
79
|
+
const columnName = this.conventions.getColumnName(fieldName)
|
|
80
|
+
this.registerField(entityName, {
|
|
81
|
+
name: fieldName,
|
|
82
|
+
columnName: columnName,
|
|
83
|
+
type: Model.ColumnType.Enum,
|
|
84
|
+
nullable: !enumDef.notNull,
|
|
85
|
+
columnType: enumDef.enumName,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private processRelation(entityName: string, fieldName: string, relDef: LaxSchemaRelation) {
|
|
90
|
+
switch (relDef.type) {
|
|
91
|
+
case 'manyHasMany':
|
|
92
|
+
const joiningColumns = this.conventions.getJoiningTableColumnNames(entityName, fieldName, relDef.targetEntity, relDef.targetField)
|
|
93
|
+
this.registerField(entityName, {
|
|
94
|
+
type: Model.RelationType.ManyHasMany,
|
|
95
|
+
name: fieldName,
|
|
96
|
+
target: relDef.targetEntity,
|
|
97
|
+
inversedBy: relDef.targetField,
|
|
98
|
+
joiningTable: {
|
|
99
|
+
tableName: this.conventions.getJoiningTableName(entityName, fieldName),
|
|
100
|
+
joiningColumn: {
|
|
101
|
+
columnName: joiningColumns[0],
|
|
102
|
+
onDelete: Model.OnDelete.cascade,
|
|
103
|
+
},
|
|
104
|
+
inverseJoiningColumn: {
|
|
105
|
+
columnName: joiningColumns[1],
|
|
106
|
+
onDelete: Model.OnDelete.cascade,
|
|
107
|
+
},
|
|
108
|
+
eventLog: { enabled: true },
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
if (!this.schema.entities[relDef.targetEntity].fields[relDef.targetField]) {
|
|
112
|
+
this.processRelation(relDef.targetEntity, relDef.targetField, {
|
|
113
|
+
type: 'manyHasManyInverse',
|
|
114
|
+
targetEntity: entityName,
|
|
115
|
+
targetField: fieldName,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
break
|
|
119
|
+
case 'manyHasManyInverse':
|
|
120
|
+
this.registerField(entityName, {
|
|
121
|
+
type: Model.RelationType.ManyHasMany,
|
|
122
|
+
name: fieldName,
|
|
123
|
+
target: relDef.targetEntity,
|
|
124
|
+
ownedBy: relDef.targetField,
|
|
125
|
+
})
|
|
126
|
+
break
|
|
127
|
+
case 'manyHasOne':
|
|
128
|
+
this.registerField(entityName, {
|
|
129
|
+
type: Model.RelationType.ManyHasOne,
|
|
130
|
+
name: fieldName,
|
|
131
|
+
target: relDef.targetEntity,
|
|
132
|
+
inversedBy: relDef.targetField,
|
|
133
|
+
joiningColumn: {
|
|
134
|
+
columnName: this.conventions.getJoiningColumnName(fieldName),
|
|
135
|
+
onDelete: Model.OnDelete.restrict,
|
|
136
|
+
},
|
|
137
|
+
nullable: !relDef.notNull,
|
|
138
|
+
})
|
|
139
|
+
if (!this.schema.entities[relDef.targetEntity].fields[relDef.targetField]) {
|
|
140
|
+
this.processRelation(relDef.targetEntity, relDef.targetField, {
|
|
141
|
+
type: 'oneHasMany',
|
|
142
|
+
targetEntity: entityName,
|
|
143
|
+
targetField: fieldName,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
break
|
|
147
|
+
case 'oneHasMany':
|
|
148
|
+
this.registerField(entityName, {
|
|
149
|
+
type: Model.RelationType.OneHasMany,
|
|
150
|
+
name: fieldName,
|
|
151
|
+
target: relDef.targetEntity,
|
|
152
|
+
ownedBy: relDef.targetField,
|
|
153
|
+
})
|
|
154
|
+
break
|
|
155
|
+
case 'oneHasOne':
|
|
156
|
+
this.registerField(entityName, {
|
|
157
|
+
type: Model.RelationType.OneHasOne,
|
|
158
|
+
name: fieldName,
|
|
159
|
+
target: relDef.targetEntity,
|
|
160
|
+
inversedBy: relDef.targetField,
|
|
161
|
+
joiningColumn: {
|
|
162
|
+
columnName: this.conventions.getJoiningColumnName(fieldName),
|
|
163
|
+
onDelete: Model.OnDelete.restrict,
|
|
164
|
+
},
|
|
165
|
+
nullable: !relDef.notNull,
|
|
166
|
+
})
|
|
167
|
+
if (!this.schema.entities[relDef.targetEntity].fields[relDef.targetField]) {
|
|
168
|
+
this.processRelation(relDef.targetEntity, relDef.targetField, {
|
|
169
|
+
type: 'oneHasOneInverse',
|
|
170
|
+
targetEntity: entityName,
|
|
171
|
+
targetField: fieldName,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
break
|
|
175
|
+
case 'oneHasOneInverse':
|
|
176
|
+
this.registerField(entityName, {
|
|
177
|
+
type: Model.RelationType.OneHasOne,
|
|
178
|
+
name: fieldName,
|
|
179
|
+
target: relDef.targetEntity,
|
|
180
|
+
ownedBy: relDef.targetField,
|
|
181
|
+
nullable: !relDef.notNull,
|
|
182
|
+
})
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private registerField(entityName: string, field: Model.AnyField) {
|
|
188
|
+
const entity = this.getEntity(entityName)
|
|
189
|
+
;(entity.fields as Writable<Model.Entity['fields']>)[field.name] = field
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
private getEntity(entityName: string): Model.Entity {
|
|
194
|
+
return this.entities[entityName] ??= {
|
|
195
|
+
name: entityName,
|
|
196
|
+
unique: {},
|
|
197
|
+
indexes: {},
|
|
198
|
+
primary: 'id',
|
|
199
|
+
primaryColumn: 'id',
|
|
200
|
+
tableName: this.conventions.getTableName(entityName),
|
|
201
|
+
eventLog: { enabled: true },
|
|
202
|
+
fields: {
|
|
203
|
+
id: {
|
|
204
|
+
name: 'id',
|
|
205
|
+
columnName: 'id',
|
|
206
|
+
type: Model.ColumnType.Uuid,
|
|
207
|
+
columnType: resolveDefaultColumnType(Model.ColumnType.Uuid),
|
|
208
|
+
nullable: false,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/lax/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as Typesafe from '@contember/typesafe'
|
|
2
|
+
|
|
3
|
+
const LaxSchemaEnumInner = Typesafe.intersection(
|
|
4
|
+
Typesafe.object({
|
|
5
|
+
values: Typesafe.array(Typesafe.string),
|
|
6
|
+
enumName: Typesafe.string,
|
|
7
|
+
}),
|
|
8
|
+
Typesafe.partial({
|
|
9
|
+
notNull: Typesafe.boolean,
|
|
10
|
+
unique: Typesafe.boolean,
|
|
11
|
+
}),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const LaxSchemaColumnInner = Typesafe.partial({
|
|
15
|
+
notNull: Typesafe.boolean,
|
|
16
|
+
unique: Typesafe.boolean,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const LaxSchemaRelationInner = Typesafe.intersection(
|
|
20
|
+
Typesafe.object({
|
|
21
|
+
targetEntity: Typesafe.string,
|
|
22
|
+
targetField: Typesafe.string,
|
|
23
|
+
}),
|
|
24
|
+
Typesafe.partial({
|
|
25
|
+
notNull: Typesafe.boolean,
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const LaxSchemaField = Typesafe.discriminatedUnion('type', {
|
|
30
|
+
oneHasOne: LaxSchemaRelationInner,
|
|
31
|
+
oneHasOneInverse: LaxSchemaRelationInner,
|
|
32
|
+
manyHasMany: LaxSchemaRelationInner,
|
|
33
|
+
manyHasManyInverse: LaxSchemaRelationInner,
|
|
34
|
+
oneHasMany: LaxSchemaRelationInner,
|
|
35
|
+
manyHasOne: LaxSchemaRelationInner,
|
|
36
|
+
int: LaxSchemaColumnInner,
|
|
37
|
+
float: LaxSchemaColumnInner,
|
|
38
|
+
boolean: LaxSchemaColumnInner,
|
|
39
|
+
string: LaxSchemaColumnInner,
|
|
40
|
+
json: LaxSchemaColumnInner,
|
|
41
|
+
date: LaxSchemaColumnInner,
|
|
42
|
+
datetime: LaxSchemaColumnInner,
|
|
43
|
+
enum: LaxSchemaEnumInner,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export type LaxSchemaField = ReturnType<typeof LaxSchemaField>
|
|
47
|
+
export type LaxSchemaEnum = Extract<LaxSchemaField, { type: 'enum' }>
|
|
48
|
+
export type LaxSchemaColumn = Extract<LaxSchemaField, { type: 'int' | 'float' | 'boolean' | 'string' | 'json' | 'datetime' | 'date' | 'uuid' }>
|
|
49
|
+
export type LaxSchemaRelation = Extract<LaxSchemaField, {type: 'oneHasOne' | 'oneHasOneInverse' | 'manyHasMany' | 'manyHasManyInverse' | 'oneHasMany' | 'manyHasOne' }>
|
|
50
|
+
|
|
51
|
+
const LaxSchemaEntity = Typesafe.object({
|
|
52
|
+
fields: Typesafe.record(Typesafe.string, LaxSchemaField),
|
|
53
|
+
})
|
|
54
|
+
export type LaxSchemaEntity = ReturnType<typeof LaxSchemaEntity>
|
|
55
|
+
|
|
56
|
+
export const LaxSchema = Typesafe.object({
|
|
57
|
+
entities: Typesafe.record(
|
|
58
|
+
Typesafe.string,
|
|
59
|
+
LaxSchemaEntity,
|
|
60
|
+
),
|
|
61
|
+
})
|
|
62
|
+
export type LaxSchema = ReturnType<typeof LaxSchema>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface NamingConventions {
|
|
2
|
+
getPrimaryField(): string
|
|
3
|
+
|
|
4
|
+
getColumnName(fieldName: string): string
|
|
5
|
+
|
|
6
|
+
getTableName(entityName: string): string
|
|
7
|
+
|
|
8
|
+
getJoiningColumnName(relationName: string): string
|
|
9
|
+
|
|
10
|
+
getJoiningTableName(entity: string, relation: string): string
|
|
11
|
+
|
|
12
|
+
getJoiningTableColumnNames(
|
|
13
|
+
owningEntity: string,
|
|
14
|
+
owningRelation: string,
|
|
15
|
+
inverseEntity: string,
|
|
16
|
+
inverseRelation?: string,
|
|
17
|
+
): [string, string]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const toUnderscore = (s: string) => s.replace(/([A-Z]+)/g, (x, y) => '_' + y.toLowerCase()).replace(/^_/, '')
|
|
21
|
+
export class DefaultNamingConventions implements NamingConventions {
|
|
22
|
+
getPrimaryField(): string {
|
|
23
|
+
return 'id'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getColumnName(fieldName: string): string {
|
|
27
|
+
return toUnderscore(fieldName)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getTableName(entityName: string): string {
|
|
31
|
+
return toUnderscore(entityName)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getJoiningColumnName(relationName: string): string {
|
|
35
|
+
return toUnderscore(relationName) + '_id'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getJoiningTableName(entity: string, relation: string): string {
|
|
39
|
+
return toUnderscore(entity) + '_' + toUnderscore(relation)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getJoiningTableColumnNames(
|
|
43
|
+
owningEntity: string,
|
|
44
|
+
owningRelation: string,
|
|
45
|
+
inverseEntity: string,
|
|
46
|
+
inverseRelation?: string,
|
|
47
|
+
): [string, string] {
|
|
48
|
+
return [
|
|
49
|
+
toUnderscore(owningEntity) + '_id',
|
|
50
|
+
toUnderscore(inverseEntity === owningEntity ? owningRelation : inverseEntity) + '_id',
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/model/index.ts
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Model } from '@contember/schema'
|
|
2
|
+
import { assertNever } from '../utils'
|
|
3
|
+
|
|
4
|
+
export const resolveDefaultColumnType = (type: Exclude<Model.ColumnType, Model.ColumnType.Enum>): string => {
|
|
5
|
+
switch (type) {
|
|
6
|
+
case Model.ColumnType.Int:
|
|
7
|
+
return 'integer'
|
|
8
|
+
case Model.ColumnType.Double:
|
|
9
|
+
return 'double precision'
|
|
10
|
+
case Model.ColumnType.String:
|
|
11
|
+
return 'text'
|
|
12
|
+
case Model.ColumnType.Uuid:
|
|
13
|
+
return 'uuid'
|
|
14
|
+
case Model.ColumnType.Bool:
|
|
15
|
+
return 'boolean'
|
|
16
|
+
case Model.ColumnType.DateTime:
|
|
17
|
+
return 'timestamptz'
|
|
18
|
+
case Model.ColumnType.Date:
|
|
19
|
+
return 'date'
|
|
20
|
+
case Model.ColumnType.Json:
|
|
21
|
+
return 'jsonb'
|
|
22
|
+
default:
|
|
23
|
+
return assertNever(type)
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/type-schema/model.ts
CHANGED
|
@@ -125,7 +125,7 @@ const columnSchema = Typesafe.intersection(
|
|
|
125
125
|
)
|
|
126
126
|
const columnSchemaCheck: Typesafe.Equals<Model.AnyColumn, ReturnType<typeof columnSchema>> = true
|
|
127
127
|
|
|
128
|
-
const fieldSchema: Typesafe.Type<Model.AnyField> = Typesafe.
|
|
128
|
+
const fieldSchema: Typesafe.Type<Model.AnyField> = Typesafe.partiallyDiscriminatedUnion(
|
|
129
129
|
'type',
|
|
130
130
|
oneHasOneInverseRelationSchema,
|
|
131
131
|
oneHasOneOwningRelationSchema,
|