json-rest-api 1.0.21 → 1.0.23

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.
Files changed (88) hide show
  1. package/CLAUDE.md +1 -2
  2. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +180 -0
  3. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +172 -0
  4. package/docs/API.md +51 -14
  5. package/docs/COMPARISON.md +2 -2
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +28 -1
  7. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +5 -1
  8. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +3 -1
  9. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +12 -60
  10. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +2 -2
  11. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -1
  12. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +2 -0
  13. package/docs/GUIDE/GUIDE_X_Autofiltering.md +355 -0
  14. package/docs/GUIDE/GUIDE_X_Fastify.md +78 -0
  15. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +250 -0
  16. package/docs/GUIDE/GUIDE_X_Query_Projections.md +159 -0
  17. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +199 -0
  18. package/docs/GUIDE/index.md +18 -9
  19. package/docs/QUICKSTART.md +5 -1
  20. package/docs/index.md +1 -1
  21. package/index.js +3 -2
  22. package/package.json +4 -4
  23. package/plugins/core/connectors/express-plugin.js +97 -184
  24. package/plugins/core/connectors/fastify-plugin.js +206 -0
  25. package/plugins/core/connectors/lib/connector-core.js +195 -0
  26. package/plugins/core/connectors/lib/transport-http-helpers.js +122 -0
  27. package/plugins/core/connectors/lib/transport-route-schemas.js +92 -0
  28. package/plugins/core/lib/dbIntrospection.js +1078 -0
  29. package/plugins/core/lib/dbTablesOperations.js +1104 -160
  30. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +5 -2
  31. package/plugins/core/lib/querying/knex-process-includes.js +3 -4
  32. package/plugins/core/lib/querying/knex-query-helpers.js +17 -71
  33. package/plugins/core/lib/querying/knex-relationship-includes.js +466 -206
  34. package/plugins/core/lib/querying/query-builder-utils.js +19 -0
  35. package/plugins/core/lib/querying/query-field-sort-helpers.js +135 -0
  36. package/plugins/core/lib/querying/sort-helpers.js +33 -0
  37. package/plugins/core/lib/querying/storage-adapter-utils.js +51 -0
  38. package/plugins/core/lib/querying/url-helpers.js +8 -2
  39. package/plugins/core/lib/querying-writing/compile-schemas.js +12 -1
  40. package/plugins/core/lib/querying-writing/knex-field-helpers.js +83 -13
  41. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +13 -12
  42. package/plugins/core/lib/querying-writing/query-field-helpers.js +88 -0
  43. package/plugins/core/lib/querying-writing/request-contracts.js +590 -0
  44. package/plugins/core/lib/storage/canonical-storage-mapping.js +113 -0
  45. package/plugins/core/lib/storage/storage-adapter.js +79 -2
  46. package/plugins/core/lib/storage/storage-mapping.js +204 -0
  47. package/plugins/core/rest-api-anyapi-knex-plugin.js +355 -228
  48. package/plugins/core/rest-api-autofilter-plugin.js +414 -0
  49. package/plugins/core/rest-api-knex-plugin.js +249 -211
  50. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +10 -5
  51. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +12 -6
  52. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +41 -0
  53. package/plugins/core/rest-api-plugin-methods/common.js +121 -83
  54. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +6 -1
  55. package/plugins/core/rest-api-plugin-methods/get.js +23 -14
  56. package/plugins/core/rest-api-plugin-methods/patch.js +28 -12
  57. package/plugins/core/rest-api-plugin-methods/post.js +13 -8
  58. package/plugins/core/rest-api-plugin-methods/put.js +45 -21
  59. package/plugins/core/rest-api-plugin-methods/query.js +15 -53
  60. package/plugins/core/rest-api-plugin.js +2 -0
  61. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  62. package/plugins/core/rest-api-query-projections-plugin.js +130 -0
  63. package/plugins/core/socketio-plugin.js +1 -3
  64. package/tests/autofilter.test.js +463 -0
  65. package/tests/custom-id-simplified-post.test.js +75 -0
  66. package/tests/custom-idproperty-relationships.test.js +33 -0
  67. package/tests/db-introspection.test.js +518 -0
  68. package/tests/db-table-operations.test.js +781 -0
  69. package/tests/fastify-plugin.test.js +141 -0
  70. package/tests/field-getters.test.js +1 -2
  71. package/tests/field-setters.test.js +0 -4
  72. package/tests/fixtures/api-configs.js +188 -219
  73. package/tests/helpers/fake-fastify.js +99 -0
  74. package/tests/http-connectors-parity.test.js +235 -0
  75. package/tests/projected-fields.test.js +319 -0
  76. package/tests/relationships.test.js +3 -3
  77. package/tests/searchschema-knex-behavior.test.js +187 -0
  78. package/tests/searchschema-merge.test.js +21 -69
  79. package/tests/storage-mapping.test.js +210 -0
  80. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +0 -707
  81. package/docs/GUIDE/GUIDE_X_Multihome.md +0 -666
  82. package/plugins/core/lib/querying-writing/payload-validators.js +0 -1314
  83. package/plugins/core/rest-api-access.js +0 -459
  84. package/plugins/core/rest-api-multihome-plugin.js +0 -314
  85. package/tests/multihome.test.js +0 -553
  86. package/tests/multihome.test.js.bak +0 -550
  87. package/tests/ownership-optout.test.js +0 -76
  88. package/tests/rest-api-access.test.js +0 -206
@@ -1,19 +1,473 @@
1
1
  /**
2
- * @file Creates Knex table definitions from json-rest-schema definitions
2
+ * @file Creates Knex table definitions and migration plans from json-rest-schema definitions
3
3
  */
4
4
 
5
- /**
6
- * Maps json-rest-schema types to Knex column types
7
- * @param {object} table - The Knex table builder instance
8
- * @param {string} columnName - The name of the column
9
- * @param {object} definition - The schema definition for the field
10
- * @returns {object} The Knex column builder instance
11
- */
12
- function mapTypeToKnex (table, columnName, definition) {
13
- const { type, precision, scale, maxLength, unsigned } = definition
5
+ import { buildStorageInfo } from './storage/storage-mapping.js'
6
+
7
+ const MYSQL_DIALECT_PATTERN = /mysql/i
8
+
9
+ function normalizeText (value) {
10
+ if (value == null) return ''
11
+ return String(value).trim()
12
+ }
13
+
14
+ function quoteJsString (value) {
15
+ const escaped = String(value)
16
+ .replace(/\\/g, '\\\\')
17
+ .replace(/'/g, "\\'")
18
+ .replace(/\r/g, '\\r')
19
+ .replace(/\n/g, '\\n')
20
+
21
+ return `'${escaped}'`
22
+ }
23
+
24
+ function formatArrayLiteral (values = []) {
25
+ return `[${values.map((value) => quoteJsString(value)).join(', ')}]`
26
+ }
27
+
28
+ function formatPlainObjectLiteral (value) {
29
+ const entries = Object.entries(value).map(([key, entryValue]) => {
30
+ return `${JSON.stringify(key)}: ${formatCodeLiteral(entryValue)}`
31
+ })
32
+
33
+ return `{ ${entries.join(', ')} }`
34
+ }
35
+
36
+ function formatCodeLiteral (value) {
37
+ if (value === undefined) {
38
+ throw new Error('Cannot generate a migration literal from undefined.')
39
+ }
40
+
41
+ if (typeof value === 'function') {
42
+ throw new Error('Cannot generate deterministic migrations for function defaults.')
43
+ }
44
+
45
+ if (typeof value === 'string') {
46
+ return quoteJsString(value)
47
+ }
48
+
49
+ if (typeof value === 'number' || typeof value === 'bigint') {
50
+ return String(value)
51
+ }
52
+
53
+ if (typeof value === 'boolean') {
54
+ return value ? 'true' : 'false'
55
+ }
56
+
57
+ if (value === null) {
58
+ return 'null'
59
+ }
60
+
61
+ if (Array.isArray(value)) {
62
+ return `[${value.map((entry) => formatCodeLiteral(entry)).join(', ')}]`
63
+ }
64
+
65
+ if (typeof value === 'object') {
66
+ return formatPlainObjectLiteral(value)
67
+ }
68
+
69
+ return JSON.stringify(value)
70
+ }
71
+
72
+ function detectKnexDialect (knex) {
73
+ return normalizeText(
74
+ knex?.client?.config?.client ||
75
+ knex?.client?.dialect ||
76
+ knex?.client?.driverName
77
+ ).toLowerCase()
78
+ }
79
+
80
+ function isMysqlDialect (dialect = '') {
81
+ return MYSQL_DIALECT_PATTERN.test(String(dialect || ''))
82
+ }
83
+
84
+ function ensureStringArray (value, label) {
85
+ const values = Array.isArray(value) ? value : [value]
86
+ const normalized = values
87
+ .map((entry) => normalizeText(entry))
88
+ .filter(Boolean)
89
+
90
+ if (normalized.length < 1) {
91
+ throw new Error(`${label} must define at least one column.`)
92
+ }
93
+
94
+ return normalized
95
+ }
96
+
97
+ function buildSetColumnType (values = []) {
98
+ const normalized = values.map((value) => normalizeText(value)).filter(Boolean)
99
+ if (normalized.length < 1) {
100
+ throw new Error('setValues must contain at least one value.')
101
+ }
102
+
103
+ return `set(${normalized.map((value) => quoteJsString(value)).join(', ')})`
104
+ }
105
+
106
+ function createTableBuilderContext (schemaStructure = {}, idColumn = 'id') {
107
+ return {
108
+ schemaStructure,
109
+ idProperty: idColumn,
110
+ storageInfo: buildStorageInfo({
111
+ schemaStructure,
112
+ idProperty: idColumn
113
+ })
114
+ }
115
+ }
116
+
117
+ function resolveFieldOrColumnName (tableContext, fieldOrColumn) {
118
+ const normalized = normalizeText(fieldOrColumn)
119
+ if (!normalized) return ''
120
+
121
+ if (normalized === 'id') {
122
+ return tableContext.storageInfo.idColumn
123
+ }
124
+
125
+ return tableContext.storageInfo.fields[normalized]?.column || normalized
126
+ }
127
+
128
+ function getColumnNameForDefinition (tableContext, fieldName) {
129
+ return resolveFieldOrColumnName(tableContext, fieldName)
130
+ }
131
+
132
+ function normalizeTopLevelIndexes (schemaLike, tableName, tableContext) {
133
+ const indexes = Array.isArray(schemaLike?.indexes) ? schemaLike.indexes : []
14
134
 
15
- switch (type) {
135
+ return indexes.map((indexDef, indexPosition) => {
136
+ if (!indexDef || typeof indexDef !== 'object' || Array.isArray(indexDef)) {
137
+ throw new Error(`Index metadata for table '${tableName}' must be an object.`)
138
+ }
139
+
140
+ const columns = ensureStringArray(indexDef.columns, `Index ${indexPosition + 1}`)
141
+ .map((column) => resolveFieldOrColumnName(tableContext, column))
142
+ const unique = indexDef.unique === true
143
+ const indexType = normalizeText(indexDef.indexType).toUpperCase()
144
+ const name = normalizeText(indexDef.name) || `${unique ? 'uq' : 'idx'}_${tableName}_${columns.join('_')}`
145
+
146
+ if (unique && indexType) {
147
+ throw new Error(`Unique index '${name}' on table '${tableName}' cannot define indexType.`)
148
+ }
149
+
150
+ return {
151
+ name,
152
+ unique,
153
+ columns,
154
+ indexType
155
+ }
156
+ })
157
+ }
158
+
159
+ function normalizeFieldIndexes (schemaStructure, tableName, tableContext) {
160
+ const indexes = []
161
+
162
+ for (const [fieldName, definition] of Object.entries(schemaStructure)) {
163
+ const columnName = getColumnNameForDefinition(tableContext, fieldName)
164
+
165
+ if (definition.unique === true) {
166
+ indexes.push({
167
+ name: `uq_${tableName}_${columnName}`,
168
+ unique: true,
169
+ columns: [columnName],
170
+ indexType: ''
171
+ })
172
+ }
173
+
174
+ if (definition.index === true) {
175
+ indexes.push({
176
+ name: `idx_${tableName}_${columnName}`,
177
+ unique: false,
178
+ columns: [columnName],
179
+ indexType: ''
180
+ })
181
+ }
182
+ }
183
+
184
+ return indexes
185
+ }
186
+
187
+ function normalizeTopLevelForeignKeys (schemaLike, tableName, tableContext) {
188
+ const foreignKeys = Array.isArray(schemaLike?.foreignKeys) ? schemaLike.foreignKeys : []
189
+
190
+ return foreignKeys.map((foreignKeyDef, foreignKeyPosition) => {
191
+ if (!foreignKeyDef || typeof foreignKeyDef !== 'object' || Array.isArray(foreignKeyDef)) {
192
+ throw new Error(`Foreign key metadata for table '${tableName}' must be an object.`)
193
+ }
194
+
195
+ const columns = ensureStringArray(foreignKeyDef.columns, `Foreign key ${foreignKeyPosition + 1}`)
196
+ .map((column) => resolveFieldOrColumnName(tableContext, column))
197
+ const referencedTableName = normalizeText(
198
+ foreignKeyDef.referencedTableName ||
199
+ foreignKeyDef.table ||
200
+ foreignKeyDef.references?.table
201
+ )
202
+
203
+ if (!referencedTableName) {
204
+ throw new Error(`Foreign key ${foreignKeyPosition + 1} on table '${tableName}' requires referencedTableName.`)
205
+ }
206
+
207
+ const referencedColumns = ensureStringArray(
208
+ foreignKeyDef.referencedColumns ||
209
+ foreignKeyDef.references?.columns ||
210
+ foreignKeyDef.references?.column ||
211
+ 'id',
212
+ `Foreign key ${foreignKeyPosition + 1}`
213
+ )
214
+ const name = normalizeText(foreignKeyDef.name) || `${tableName}_${columns.join('_')}_foreign`
215
+
216
+ return {
217
+ name,
218
+ columns,
219
+ referencedTableName,
220
+ referencedColumns,
221
+ deleteRule: normalizeText(foreignKeyDef.deleteRule || foreignKeyDef.onDelete).toUpperCase(),
222
+ updateRule: normalizeText(foreignKeyDef.updateRule || foreignKeyDef.onUpdate).toUpperCase()
223
+ }
224
+ })
225
+ }
226
+
227
+ function normalizeFieldForeignKeys (schemaStructure, tableName, tableContext) {
228
+ const foreignKeys = []
229
+
230
+ for (const [fieldName, definition] of Object.entries(schemaStructure)) {
231
+ if (!definition.references) continue
232
+
233
+ const columnName = getColumnNameForDefinition(tableContext, fieldName)
234
+ foreignKeys.push({
235
+ name: normalizeText(definition.references.name) || `${tableName}_${columnName}_foreign`,
236
+ columns: [columnName],
237
+ referencedTableName: normalizeText(definition.references.table),
238
+ referencedColumns: [normalizeText(definition.references.column || 'id')],
239
+ deleteRule: normalizeText(definition.references.onDelete).toUpperCase(),
240
+ updateRule: normalizeText(definition.references.onUpdate).toUpperCase()
241
+ })
242
+ }
243
+
244
+ return foreignKeys
245
+ }
246
+
247
+ function normalizeCheckConstraints (schemaLike, tableName) {
248
+ const constraints = Array.isArray(schemaLike?.checkConstraints) ? schemaLike.checkConstraints : []
249
+
250
+ return constraints.map((constraintDef, constraintIndex) => {
251
+ if (!constraintDef || typeof constraintDef !== 'object' || Array.isArray(constraintDef)) {
252
+ throw new Error(`Check constraint metadata for table '${tableName}' must be an object.`)
253
+ }
254
+
255
+ const clause = normalizeText(constraintDef.clause)
256
+ if (!clause) {
257
+ throw new Error(`Check constraint ${constraintIndex + 1} on table '${tableName}' requires a clause.`)
258
+ }
259
+
260
+ return {
261
+ name: normalizeText(constraintDef.name) || `${tableName}_check_${constraintIndex + 1}`,
262
+ clause
263
+ }
264
+ })
265
+ }
266
+
267
+ function dedupeBySignature (entries, signatureFn, sortFn) {
268
+ const deduped = new Map()
269
+
270
+ for (const entry of entries) {
271
+ deduped.set(signatureFn(entry), entry)
272
+ }
273
+
274
+ return [...deduped.values()].sort(sortFn)
275
+ }
276
+
277
+ function compareByName (left, right) {
278
+ return left.name.localeCompare(right.name)
279
+ }
280
+
281
+ function normalizeColumnShape (definition = {}) {
282
+ if (Array.isArray(definition.enum) && definition.enum.length > 0) {
283
+ return 'enum'
284
+ }
285
+ if (Array.isArray(definition.setValues) && definition.setValues.length > 0) {
286
+ return 'set'
287
+ }
288
+
289
+ switch (definition.type) {
290
+ case 'id':
291
+ return 'integer'
292
+ case 'number':
293
+ return definition.precision !== undefined && definition.scale !== undefined ? 'decimal' : 'float'
294
+ case 'boolean':
295
+ return 'boolean'
296
+ case 'date':
297
+ return 'date'
298
+ case 'dateTime':
299
+ return 'datetime'
300
+ case 'time':
301
+ return 'time'
302
+ case 'timestamp':
303
+ return 'integer'
304
+ case 'array':
305
+ case 'object':
306
+ case 'serialize':
307
+ return 'json'
308
+ case 'blob':
309
+ case 'file':
310
+ return 'binary'
311
+ case 'string':
312
+ case 'none':
313
+ default:
314
+ return 'string'
315
+ }
316
+ }
317
+
318
+ function normalizeDesiredTypeKind (definition = {}) {
319
+ if (Array.isArray(definition.enum) && definition.enum.length > 0) {
320
+ return 'string'
321
+ }
322
+ if (Array.isArray(definition.setValues) && definition.setValues.length > 0) {
323
+ return 'string'
324
+ }
325
+
326
+ switch (definition.type) {
327
+ case 'id':
328
+ return 'integer'
329
+ case 'number':
330
+ return 'number'
331
+ case 'boolean':
332
+ return 'boolean'
333
+ case 'date':
334
+ return 'date'
335
+ case 'dateTime':
336
+ return 'datetime'
337
+ case 'time':
338
+ return 'time'
339
+ case 'array':
340
+ case 'object':
341
+ case 'serialize':
342
+ return 'json'
343
+ case 'blob':
344
+ case 'file':
345
+ return 'binary'
346
+ case 'timestamp':
347
+ return 'integer'
16
348
  case 'string':
349
+ case 'none':
350
+ default:
351
+ return 'string'
352
+ }
353
+ }
354
+
355
+ function normalizeComparableDefault (value) {
356
+ if (value === undefined) return undefined
357
+ if (typeof value === 'boolean') return value ? '1' : '0'
358
+ if (value === null) return null
359
+ if (typeof value === 'object') return JSON.stringify(value)
360
+ return String(value)
361
+ }
362
+
363
+ function normalizeDesiredColumn (tableContext, fieldName, definition, options = {}) {
364
+ const nullable = definition.nullable === true
365
+ ? true
366
+ : !(definition.required === true || definition.nullable === false)
367
+ const shape = normalizeColumnShape(definition)
368
+
369
+ return {
370
+ fieldName,
371
+ name: getColumnNameForDefinition(tableContext, fieldName),
372
+ definition,
373
+ shape,
374
+ typeKind: normalizeDesiredTypeKind(definition),
375
+ nullable,
376
+ required: definition.required === true,
377
+ hasDefault: definition.defaultTo !== undefined,
378
+ defaultValue: normalizeComparableDefault(definition.defaultTo),
379
+ unsigned: definition.type === 'id' ? definition.unsigned !== false : definition.unsigned === true,
380
+ maxLength: definition.maxLength ?? null,
381
+ numericPrecision: definition.precision ?? null,
382
+ numericScale: definition.scale ?? null,
383
+ datetimePrecision: definition.temporalPrecision ?? null,
384
+ enumValues: Array.isArray(definition.enum) ? definition.enum.map((value) => String(value)) : [],
385
+ setValues: Array.isArray(definition.setValues) ? definition.setValues.map((value) => String(value)) : [],
386
+ autoIncrement: options.autoIncrement === true
387
+ }
388
+ }
389
+
390
+ function buildImplicitIdDefinition (idColumn) {
391
+ return {
392
+ type: 'id',
393
+ primary: true,
394
+ storage: {
395
+ column: idColumn
396
+ }
397
+ }
398
+ }
399
+
400
+ function resolveTableSchemaContext (schemaLike, options = {}) {
401
+ const schemaStructure = schemaLike?.structure || schemaLike || {}
402
+ const configuredIdColumn = options.idProperty || schemaStructure.id?.storage?.column || 'id'
403
+ const tableContext = createTableBuilderContext(schemaStructure, configuredIdColumn)
404
+
405
+ let resourceIdField = null
406
+ for (const [fieldName, definition] of Object.entries(schemaStructure)) {
407
+ if (definition?.type !== 'id') continue
408
+ const columnName = getColumnNameForDefinition(tableContext, fieldName)
409
+ if (columnName === configuredIdColumn) {
410
+ resourceIdField = fieldName
411
+ break
412
+ }
413
+ }
414
+
415
+ const implicitIndexes = normalizeFieldIndexes(schemaStructure, options.tableName || 'table', tableContext)
416
+ const explicitIndexes = normalizeTopLevelIndexes(schemaLike, options.tableName || 'table', tableContext)
417
+ const implicitForeignKeys = normalizeFieldForeignKeys(schemaStructure, options.tableName || 'table', tableContext)
418
+ const explicitForeignKeys = normalizeTopLevelForeignKeys(schemaLike, options.tableName || 'table', tableContext)
419
+ const checkConstraints = normalizeCheckConstraints(schemaLike, options.tableName || 'table')
420
+
421
+ return {
422
+ schemaStructure,
423
+ idColumn: configuredIdColumn,
424
+ resourceIdField,
425
+ hasPrimaryIdField: resourceIdField
426
+ ? schemaStructure[resourceIdField]?.primary === true
427
+ : false,
428
+ tableContext,
429
+ indexes: dedupeBySignature(
430
+ [...implicitIndexes, ...explicitIndexes],
431
+ (entry) => `${entry.name}|${entry.unique ? 'unique' : 'index'}|${entry.columns.join(',')}`,
432
+ compareByName
433
+ ),
434
+ foreignKeys: dedupeBySignature(
435
+ [...implicitForeignKeys, ...explicitForeignKeys],
436
+ (entry) => `${entry.name}|${entry.columns.join(',')}|${entry.referencedTableName}|${entry.referencedColumns.join(',')}`,
437
+ compareByName
438
+ ),
439
+ checkConstraints: dedupeBySignature(
440
+ checkConstraints,
441
+ (entry) => `${entry.name}|${entry.clause}`,
442
+ compareByName
443
+ )
444
+ }
445
+ }
446
+
447
+ function mapTypeToKnex (table, columnName, definition, options = {}) {
448
+ const {
449
+ precision,
450
+ scale,
451
+ maxLength,
452
+ unsigned,
453
+ temporalPrecision
454
+ } = definition
455
+ const enumValues = Array.isArray(definition.enum) ? definition.enum : []
456
+ const setValues = Array.isArray(definition.setValues) ? definition.setValues : []
457
+ const dialect = normalizeText(options.dialect).toLowerCase()
458
+
459
+ if (setValues.length > 0) {
460
+ if (!isMysqlDialect(dialect)) {
461
+ throw new Error(`Field '${columnName}' uses setValues, which requires a MySQL-compatible knex client.`)
462
+ }
463
+ return table.specificType(columnName, buildSetColumnType(setValues))
464
+ }
465
+
466
+ switch (definition.type) {
467
+ case 'string':
468
+ if (enumValues.length > 0) {
469
+ return table.enu(columnName, enumValues)
470
+ }
17
471
  return maxLength ? table.string(columnName, maxLength) : table.string(columnName)
18
472
 
19
473
  case 'number':
@@ -22,9 +476,10 @@ function mapTypeToKnex (table, columnName, definition) {
22
476
  }
23
477
  return table.float(columnName)
24
478
 
25
- case 'id':
479
+ case 'id': {
26
480
  const col = table.integer(columnName)
27
481
  return unsigned !== false ? col.unsigned() : col
482
+ }
28
483
 
29
484
  case 'boolean':
30
485
  return table.boolean(columnName)
@@ -33,10 +488,14 @@ function mapTypeToKnex (table, columnName, definition) {
33
488
  return table.date(columnName)
34
489
 
35
490
  case 'dateTime':
36
- return table.datetime(columnName)
491
+ return temporalPrecision != null
492
+ ? table.datetime(columnName, { precision: temporalPrecision })
493
+ : table.datetime(columnName)
37
494
 
38
495
  case 'time':
39
- return table.time(columnName)
496
+ return temporalPrecision != null
497
+ ? table.time(columnName, { precision: temporalPrecision })
498
+ : table.time(columnName)
40
499
 
41
500
  case 'timestamp':
42
501
  return table.integer(columnName)
@@ -56,61 +515,368 @@ function mapTypeToKnex (table, columnName, definition) {
56
515
  }
57
516
  }
58
517
 
59
- /**
60
- * Applies schema constraints to a Knex column
61
- * @param {object} column - The Knex column builder instance
62
- * @param {object} definition - The schema definition for the field
63
- */
64
- function applyConstraints (column, definition) {
65
- // Nullability
518
+ function applyColumnConstraints (column, definition) {
66
519
  if (definition.nullable === true) {
67
520
  column.nullable()
68
521
  } else if (definition.required === true || definition.nullable === false) {
69
522
  column.notNullable()
70
523
  }
71
524
 
72
- // Default value
73
525
  if (definition.defaultTo !== undefined) {
74
526
  column.defaultTo(definition.defaultTo)
75
527
  }
76
528
 
77
- // Database-specific constraints
78
- if (definition.unique === true) {
79
- column.unique()
80
- }
81
-
82
529
  if (definition.primary === true) {
83
530
  column.primary()
84
531
  }
85
532
 
86
- if (definition.index === true) {
87
- column.index()
533
+ if (definition.comment) {
534
+ column.comment(definition.comment)
535
+ }
536
+ }
537
+
538
+ function applyTableMetadata (table, tableSchemaContext) {
539
+ for (const index of tableSchemaContext.indexes) {
540
+ if (index.unique) {
541
+ table.unique(index.columns, index.name)
542
+ continue
543
+ }
544
+
545
+ const indexOptions = index.indexType
546
+ ? { indexType: index.indexType }
547
+ : undefined
548
+ table.index(index.columns, index.name, indexOptions)
88
549
  }
89
550
 
90
- if (definition.references) {
91
- column.references(definition.references.column || 'id')
92
- .inTable(definition.references.table)
551
+ for (const foreignKey of tableSchemaContext.foreignKeys) {
552
+ const reference = table.foreign(foreignKey.columns, foreignKey.name)
553
+ .references(foreignKey.referencedColumns)
554
+ .inTable(foreignKey.referencedTableName)
93
555
 
94
- if (definition.references.onDelete) {
95
- column.onDelete(definition.references.onDelete)
556
+ if (foreignKey.deleteRule) {
557
+ reference.onDelete(foreignKey.deleteRule)
96
558
  }
97
559
 
98
- if (definition.references.onUpdate) {
99
- column.onUpdate(definition.references.onUpdate)
560
+ if (foreignKey.updateRule) {
561
+ reference.onUpdate(foreignKey.updateRule)
100
562
  }
101
563
  }
102
564
 
565
+ for (const constraint of tableSchemaContext.checkConstraints) {
566
+ table.check(constraint.clause, [], constraint.name)
567
+ }
568
+ }
569
+
570
+ function buildColumnBuilderCode (columnName, definition, options = {}) {
571
+ const dialect = normalizeText(options.dialect).toLowerCase()
572
+ const enumValues = Array.isArray(definition.enum) ? definition.enum : []
573
+ const setValues = Array.isArray(definition.setValues) ? definition.setValues : []
574
+ let line = ''
575
+
576
+ if (setValues.length > 0) {
577
+ if (dialect && !isMysqlDialect(dialect)) {
578
+ throw new Error(`Field '${columnName}' uses setValues, which requires a MySQL-compatible migration target.`)
579
+ }
580
+ line = `table.specificType(${quoteJsString(columnName)}, ${quoteJsString(buildSetColumnType(setValues))})`
581
+ } else {
582
+ switch (definition.type) {
583
+ case 'string':
584
+ if (enumValues.length > 0) {
585
+ line = `table.enu(${quoteJsString(columnName)}, ${formatArrayLiteral(enumValues)})`
586
+ } else {
587
+ line = definition.maxLength
588
+ ? `table.string(${quoteJsString(columnName)}, ${definition.maxLength})`
589
+ : `table.string(${quoteJsString(columnName)})`
590
+ }
591
+ break
592
+
593
+ case 'number':
594
+ if (definition.precision !== undefined && definition.scale !== undefined) {
595
+ line = `table.decimal(${quoteJsString(columnName)}, ${definition.precision}, ${definition.scale})`
596
+ } else {
597
+ line = `table.float(${quoteJsString(columnName)})`
598
+ }
599
+ break
600
+
601
+ case 'id':
602
+ line = `table.integer(${quoteJsString(columnName)})`
603
+ if (definition.unsigned !== false) {
604
+ line += '.unsigned()'
605
+ }
606
+ break
607
+
608
+ case 'boolean':
609
+ line = `table.boolean(${quoteJsString(columnName)})`
610
+ break
611
+
612
+ case 'date':
613
+ line = `table.date(${quoteJsString(columnName)})`
614
+ break
615
+
616
+ case 'dateTime':
617
+ if (definition.temporalPrecision != null) {
618
+ line = `table.datetime(${quoteJsString(columnName)}, { precision: ${definition.temporalPrecision} })`
619
+ } else {
620
+ line = `table.datetime(${quoteJsString(columnName)})`
621
+ }
622
+ break
623
+
624
+ case 'time':
625
+ if (definition.temporalPrecision != null) {
626
+ line = `table.time(${quoteJsString(columnName)}, { precision: ${definition.temporalPrecision} })`
627
+ } else {
628
+ line = `table.time(${quoteJsString(columnName)})`
629
+ }
630
+ break
631
+
632
+ case 'timestamp':
633
+ line = `table.integer(${quoteJsString(columnName)})`
634
+ break
635
+
636
+ case 'array':
637
+ case 'object':
638
+ case 'serialize':
639
+ line = `table.json(${quoteJsString(columnName)})`
640
+ break
641
+
642
+ case 'blob':
643
+ case 'file':
644
+ line = `table.binary(${quoteJsString(columnName)})`
645
+ break
646
+
647
+ case 'none':
648
+ default:
649
+ line = `table.string(${quoteJsString(columnName)})`
650
+ }
651
+ }
652
+
653
+ if (definition.nullable === true) {
654
+ line += '.nullable()'
655
+ } else if (definition.required === true || definition.nullable === false) {
656
+ line += '.notNullable()'
657
+ }
658
+
659
+ if (definition.defaultTo !== undefined) {
660
+ line += `.defaultTo(${formatCodeLiteral(definition.defaultTo)})`
661
+ }
662
+
663
+ if (definition.primary === true && options.includePrimary !== false) {
664
+ line += '.primary()'
665
+ }
666
+
103
667
  if (definition.comment) {
104
- column.comment(definition.comment)
668
+ line += `.comment(${quoteJsString(definition.comment)})`
669
+ }
670
+
671
+ if (options.alter === true) {
672
+ line += '.alter()'
105
673
  }
674
+
675
+ return line
676
+ }
677
+
678
+ function buildIndexLine (index) {
679
+ if (index.unique) {
680
+ return `table.unique(${formatCodeLiteral(index.columns)}, ${quoteJsString(index.name)})`
681
+ }
682
+
683
+ if (index.indexType) {
684
+ return `table.index(${formatCodeLiteral(index.columns)}, ${quoteJsString(index.name)}, ${formatCodeLiteral({ indexType: index.indexType })})`
685
+ }
686
+
687
+ return `table.index(${formatCodeLiteral(index.columns)}, ${quoteJsString(index.name)})`
688
+ }
689
+
690
+ function buildForeignKeyLine (foreignKey) {
691
+ let line = `table.foreign(${formatCodeLiteral(foreignKey.columns)}, ${quoteJsString(foreignKey.name)})`
692
+ line += `.references(${formatCodeLiteral(foreignKey.referencedColumns)})`
693
+ line += `.inTable(${quoteJsString(foreignKey.referencedTableName)})`
694
+
695
+ if (foreignKey.deleteRule) {
696
+ line += `.onDelete(${quoteJsString(foreignKey.deleteRule)})`
697
+ }
698
+
699
+ if (foreignKey.updateRule) {
700
+ line += `.onUpdate(${quoteJsString(foreignKey.updateRule)})`
701
+ }
702
+
703
+ return line
704
+ }
705
+
706
+ function buildCheckConstraintLine (constraint) {
707
+ return `table.check(${quoteJsString(constraint.clause)}, [], ${quoteJsString(constraint.name)})`
708
+ }
709
+
710
+ function buildDesiredColumnsMap (tableSchemaContext, options = {}) {
711
+ const desiredColumns = new Map()
712
+
713
+ if (!tableSchemaContext.hasPrimaryIdField && options.autoIncrement === true) {
714
+ const implicitIdDefinition = buildImplicitIdDefinition(tableSchemaContext.idColumn)
715
+ desiredColumns.set(
716
+ tableSchemaContext.idColumn,
717
+ normalizeDesiredColumn(
718
+ createTableBuilderContext({ id: implicitIdDefinition }, tableSchemaContext.idColumn),
719
+ 'id',
720
+ implicitIdDefinition,
721
+ { autoIncrement: true }
722
+ )
723
+ )
724
+ }
725
+
726
+ for (const [fieldName, definition] of Object.entries(tableSchemaContext.schemaStructure)) {
727
+ if (fieldName === tableSchemaContext.resourceIdField && !tableSchemaContext.hasPrimaryIdField && options.autoIncrement === true) {
728
+ continue
729
+ }
730
+
731
+ desiredColumns.set(
732
+ getColumnNameForDefinition(tableSchemaContext.tableContext, fieldName),
733
+ normalizeDesiredColumn(tableSchemaContext.tableContext, fieldName, definition)
734
+ )
735
+ }
736
+
737
+ return desiredColumns
738
+ }
739
+
740
+ function valuesEqual (left, right) {
741
+ return JSON.stringify(left) === JSON.stringify(right)
742
+ }
743
+
744
+ function normalizeSnapshotDefaultValue (column = {}) {
745
+ if (!column.hasDefault) return undefined
746
+ return normalizeComparableDefault(column.defaultValue)
747
+ }
748
+
749
+ function collectDestructiveColumnWarnings (currentColumn, desiredColumn) {
750
+ const warnings = []
751
+ const columnName = desiredColumn.name
752
+
753
+ if (currentColumn.nullable === true && desiredColumn.nullable === false) {
754
+ warnings.push(`Column '${columnName}' changes from nullable to not-null and may fail on existing rows.`)
755
+ }
756
+
757
+ if (
758
+ currentColumn.maxLength != null &&
759
+ desiredColumn.maxLength != null &&
760
+ desiredColumn.maxLength < currentColumn.maxLength
761
+ ) {
762
+ warnings.push(`Column '${columnName}' reduces maxLength from ${currentColumn.maxLength} to ${desiredColumn.maxLength}.`)
763
+ }
764
+
765
+ if (
766
+ currentColumn.numericPrecision != null &&
767
+ desiredColumn.numericPrecision != null &&
768
+ desiredColumn.numericPrecision < currentColumn.numericPrecision
769
+ ) {
770
+ warnings.push(`Column '${columnName}' reduces numeric precision from ${currentColumn.numericPrecision} to ${desiredColumn.numericPrecision}.`)
771
+ }
772
+
773
+ if (
774
+ currentColumn.numericScale != null &&
775
+ desiredColumn.numericScale != null &&
776
+ desiredColumn.numericScale < currentColumn.numericScale
777
+ ) {
778
+ warnings.push(`Column '${columnName}' reduces numeric scale from ${currentColumn.numericScale} to ${desiredColumn.numericScale}.`)
779
+ }
780
+
781
+ if (!valuesEqual(currentColumn.enumValues || [], desiredColumn.enumValues || [])) {
782
+ warnings.push(`Column '${columnName}' changes enum values and may invalidate existing data.`)
783
+ }
784
+
785
+ if (!valuesEqual(currentColumn.setValues || [], desiredColumn.setValues || [])) {
786
+ warnings.push(`Column '${columnName}' changes setValues and may invalidate existing data.`)
787
+ }
788
+
789
+ if (normalizeText(currentColumn.typeKind) !== normalizeText(desiredColumn.typeKind)) {
790
+ warnings.push(`Column '${columnName}' changes type semantics from '${currentColumn.typeKind}' to '${desiredColumn.typeKind}'.`)
791
+ }
792
+
793
+ return warnings
794
+ }
795
+
796
+ function columnNeedsAlter (currentColumn, desiredColumn, options = {}) {
797
+ const currentNumericShape = (() => {
798
+ const dataType = normalizeText(currentColumn.dataType)
799
+ if (dataType === 'decimal' || dataType === 'numeric') return 'decimal'
800
+ if (dataType === 'float' || dataType === 'double' || dataType === 'real') return 'float'
801
+ return ''
802
+ })()
803
+ const compareUnsigned = isMysqlDialect(normalizeText(options.dialect).toLowerCase())
804
+
805
+ return (
806
+ normalizeText(currentColumn.typeKind) !== normalizeText(desiredColumn.typeKind) ||
807
+ (desiredColumn.typeKind === 'number' && currentNumericShape && currentNumericShape !== normalizeText(desiredColumn.shape)) ||
808
+ Boolean(currentColumn.nullable) !== Boolean(desiredColumn.nullable) ||
809
+ (compareUnsigned && Boolean(currentColumn.unsigned) !== Boolean(desiredColumn.unsigned)) ||
810
+ (currentColumn.maxLength ?? null) !== (desiredColumn.maxLength ?? null) ||
811
+ (currentColumn.numericPrecision ?? null) !== (desiredColumn.numericPrecision ?? null) ||
812
+ (currentColumn.numericScale ?? null) !== (desiredColumn.numericScale ?? null) ||
813
+ normalizeSnapshotDefaultValue(currentColumn) !== desiredColumn.defaultValue ||
814
+ Boolean(currentColumn.hasDefault) !== Boolean(desiredColumn.hasDefault) ||
815
+ !valuesEqual(currentColumn.enumValues || [], desiredColumn.enumValues || []) ||
816
+ !valuesEqual(currentColumn.setValues || [], desiredColumn.setValues || [])
817
+ )
818
+ }
819
+
820
+ function mapByName (entries = []) {
821
+ return new Map((entries || []).map((entry) => [entry.name, entry]))
822
+ }
823
+
824
+ function createEmptyDiffPlan () {
825
+ return {
826
+ addColumns: [],
827
+ alterColumns: [],
828
+ dropColumns: [],
829
+ addIndexes: [],
830
+ dropIndexes: [],
831
+ addForeignKeys: [],
832
+ dropForeignKeys: [],
833
+ addCheckConstraints: [],
834
+ warnings: []
835
+ }
836
+ }
837
+
838
+ function assertNoTopLevelTableMetadata (schemaLike, helperName) {
839
+ const metadataKeys = ['indexes', 'foreignKeys', 'checkConstraints']
840
+
841
+ for (const key of metadataKeys) {
842
+ if (Array.isArray(schemaLike?.[key]) && schemaLike[key].length > 0) {
843
+ throw new Error(`${helperName} does not accept top-level ${key}. Use createKnexTable(), generateKnexMigration(), or generateKnexMigrationDiff() for table metadata.`)
844
+ }
845
+ }
846
+ }
847
+
848
+ function buildUnsupportedConstraintWarning (constraintType, name, dialect) {
849
+ return `Skipping ${constraintType} '${name}' because automatic ALTER support is not available for dialect '${dialect || 'unknown'}'.`
850
+ }
851
+
852
+ function isSqliteDialectName (dialect = '') {
853
+ return String(dialect || '').toLowerCase().includes('sqlite')
854
+ }
855
+
856
+ function normalizeConstraintClause (clause = '') {
857
+ return normalizeText(clause)
858
+ .replace(/[`"]/g, '')
859
+ .replace(/\s*\(\s*/g, '(')
860
+ .replace(/\s*\)\s*/g, ')')
861
+ .replace(/\s*,\s*/g, ',')
862
+ .replace(/\s+/g, ' ')
863
+ .toLowerCase()
864
+ }
865
+
866
+ function isInlineEnumCheckConstraint (currentCheck = {}, desiredColumn = {}) {
867
+ if (!desiredColumn.enumValues || desiredColumn.enumValues.length < 1) {
868
+ return false
869
+ }
870
+
871
+ const expectedClause = `${desiredColumn.name} in (${desiredColumn.enumValues.map((value) => quoteJsString(value)).join(', ')})`
872
+ return normalizeConstraintClause(currentCheck.clause) === normalizeConstraintClause(expectedClause)
106
873
  }
107
874
 
108
875
  /**
109
876
  * Creates a Knex table from a json-rest-schema definition
110
877
  * @param {object} knex - The Knex instance
111
- * @param {string} tableName - The name of the table to create
112
- * @param {object} schema - The json-rest-schema instance
113
- * @param {string} [idProperty='id'] - The name of the ID column
878
+ * @param {object} schemaInfo - Resource schema metadata
879
+ * @param {object} tableSchemaInstance - The json-rest-schema instance or schema-like table metadata object
114
880
  * @param {object} [options={}] - Additional options
115
881
  * @param {boolean} [options.autoIncrement=true] - Whether to use auto-incrementing IDs
116
882
  * @param {boolean} [options.timestamps=false] - Whether to add created_at/updated_at columns
@@ -118,62 +884,68 @@ function applyConstraints (column, definition) {
118
884
  */
119
885
  export async function createKnexTable (knex, schemaInfo, tableSchemaInstance, options = {}) {
120
886
  const { autoIncrement = true, timestamps = false } = options
121
-
122
887
  const tableName = schemaInfo.tableName
123
- const idProperty = schemaInfo.idProperty
124
- const tableSchemaStructure = tableSchemaInstance.structure
888
+ const dialect = detectKnexDialect(knex)
889
+ const tableSchemaContext = resolveTableSchemaContext(tableSchemaInstance, {
890
+ idProperty: schemaInfo.idProperty,
891
+ tableName
892
+ })
125
893
 
126
894
  return knex.schema.createTable(tableName, (table) => {
127
- // Check if schema has the idProperty field with primary key
128
- const hasIdField = tableSchemaStructure[idProperty] && tableSchemaStructure[idProperty].primary === true
129
-
130
- // Add auto-incrementing ID if no primary key is defined and autoIncrement is true
131
- if (!hasIdField && autoIncrement) {
132
- table.increments(idProperty).primary()
895
+ if (!tableSchemaContext.hasPrimaryIdField && autoIncrement) {
896
+ table.increments(tableSchemaContext.idColumn).primary()
133
897
  }
134
898
 
135
- // Process each field in the schema
136
- for (const [fieldName, definition] of Object.entries(tableSchemaStructure)) {
137
- // Skip if this is the ID field and we already handled it
138
- if (fieldName === idProperty && !hasIdField && autoIncrement) {
899
+ for (const [fieldName, definition] of Object.entries(tableSchemaContext.schemaStructure)) {
900
+ if (fieldName === tableSchemaContext.resourceIdField && !tableSchemaContext.hasPrimaryIdField && autoIncrement) {
139
901
  continue
140
902
  }
141
903
 
142
- // Create the column with the appropriate type
143
- const column = mapTypeToKnex(table, fieldName, definition)
144
-
145
- // Apply constraints
146
- applyConstraints(column, definition)
904
+ const columnName = getColumnNameForDefinition(tableSchemaContext.tableContext, fieldName)
905
+ const column = mapTypeToKnex(table, columnName, definition, { dialect })
906
+ applyColumnConstraints(column, definition)
147
907
  }
148
908
 
149
- // Add timestamps if requested
909
+ applyTableMetadata(table, tableSchemaContext)
910
+
150
911
  if (timestamps) {
151
912
  table.timestamps(true, true)
152
913
  }
153
914
  })
154
915
  }
155
916
 
156
- export async function addKnexFields (knex, tableName, schema) {
917
+ export async function addKnexFields (knex, tableName, schema, options = {}) {
918
+ assertNoTopLevelTableMetadata(schema, 'addKnexFields')
919
+ const dialect = detectKnexDialect(knex)
920
+ const tableSchemaContext = resolveTableSchemaContext(schema, {
921
+ ...options,
922
+ tableName
923
+ })
924
+
157
925
  return knex.schema.alterTable(tableName, (table) => {
158
- for (const [fieldName, definition] of Object.entries(schema.structure)) {
159
- const column = mapTypeToKnex(table, fieldName, definition)
160
- applyConstraints(column, definition)
926
+ for (const [fieldName, definition] of Object.entries(tableSchemaContext.schemaStructure)) {
927
+ const columnName = getColumnNameForDefinition(tableSchemaContext.tableContext, fieldName)
928
+ const column = mapTypeToKnex(table, columnName, definition, { dialect })
929
+ applyColumnConstraints(column, definition)
161
930
  }
162
931
  })
163
932
  }
164
933
 
165
934
  // Helper function to alter multiple fields in an existing table
166
935
  export async function alterKnexFields (knex, tableName, fields, options = {}) {
167
- return knex.schema.alterTable(tableName, (table) => {
168
- for (const [fieldName, definition] of Object.entries(fields)) {
169
- // Create the column with alter flag
170
- const column = mapTypeToKnex(table, fieldName, definition)
936
+ assertNoTopLevelTableMetadata(fields, 'alterKnexFields')
937
+ const dialect = detectKnexDialect(knex)
938
+ const tableSchemaContext = resolveTableSchemaContext(fields, {
939
+ ...options,
940
+ tableName
941
+ })
171
942
 
172
- // Mark this as an alteration
943
+ return knex.schema.alterTable(tableName, (table) => {
944
+ for (const [fieldName, definition] of Object.entries(tableSchemaContext.schemaStructure)) {
945
+ const columnName = getColumnNameForDefinition(tableSchemaContext.tableContext, fieldName)
946
+ const column = mapTypeToKnex(table, columnName, definition, { dialect })
173
947
  column.alter()
174
-
175
- // Apply constraints (these will be reapplied with the alter)
176
- applyConstraints(column, definition)
948
+ applyColumnConstraints(column, definition)
177
949
  }
178
950
  })
179
951
  }
@@ -181,124 +953,296 @@ export async function alterKnexFields (knex, tableName, fields, options = {}) {
181
953
  /**
182
954
  * Generates a Knex migration string from a json-rest-schema definition
183
955
  * @param {string} tableName - The name of the table
184
- * @param {object} schema - The json-rest-schema instance
956
+ * @param {object} schema - The json-rest-schema instance or schema-like table metadata object
185
957
  * @param {object} [options={}] - Additional options
186
958
  * @returns {string} The migration code as a string
187
959
  */
188
960
  export function generateKnexMigration (tableName, schema, options = {}) {
189
- const { autoIncrement = true, timestamps = false } = options
961
+ const { autoIncrement = true, timestamps = false, dialect = '' } = options
962
+ const tableSchemaContext = resolveTableSchemaContext(schema, {
963
+ ...options,
964
+ tableName
965
+ })
190
966
 
191
- let migration = `exports.up = function(knex) {
192
- return knex.schema.createTable('${tableName}', (table) => {\n`
967
+ const lines = []
193
968
 
194
- // Check if schema has an 'id' field with primary key
195
- const hasIdField = schema.structure.id && schema.structure.id.primary === true
969
+ if (!tableSchemaContext.hasPrimaryIdField && autoIncrement) {
970
+ lines.push(`table.increments(${quoteJsString(tableSchemaContext.idColumn)}).primary()`)
971
+ }
972
+
973
+ for (const [fieldName, definition] of Object.entries(tableSchemaContext.schemaStructure)) {
974
+ if (fieldName === tableSchemaContext.resourceIdField && !tableSchemaContext.hasPrimaryIdField && autoIncrement) {
975
+ continue
976
+ }
977
+
978
+ const columnName = getColumnNameForDefinition(tableSchemaContext.tableContext, fieldName)
979
+ lines.push(buildColumnBuilderCode(columnName, definition, {
980
+ dialect
981
+ }))
982
+ }
983
+
984
+ for (const index of tableSchemaContext.indexes) {
985
+ lines.push(buildIndexLine(index))
986
+ }
987
+
988
+ for (const foreignKey of tableSchemaContext.foreignKeys) {
989
+ lines.push(buildForeignKeyLine(foreignKey))
990
+ }
196
991
 
197
- // Add auto-incrementing ID if needed
198
- if (!hasIdField && autoIncrement) {
199
- migration += ' table.increments(\'id\').primary();\n'
992
+ for (const checkConstraint of tableSchemaContext.checkConstraints) {
993
+ lines.push(buildCheckConstraintLine(checkConstraint))
200
994
  }
201
995
 
202
- // Process each field
203
- for (const [fieldName, definition] of Object.entries(schema.structure)) {
204
- if (fieldName === 'id' && !hasIdField && autoIncrement) {
996
+ if (timestamps) {
997
+ lines.push('table.timestamps(true, true)')
998
+ }
999
+
1000
+ return `exports.up = function(knex) {
1001
+ return knex.schema.createTable(${quoteJsString(tableName)}, (table) => {
1002
+ ${lines.map((line) => ` ${line};`).join('\n')}
1003
+ });
1004
+ };
1005
+
1006
+ exports.down = function(knex) {
1007
+ return knex.schema.dropTable(${quoteJsString(tableName)});
1008
+ };`
1009
+ }
1010
+
1011
+ /**
1012
+ * Generates a deterministic Knex alter migration by diffing a live table snapshot against a desired schema.
1013
+ * @param {string} tableName - The table name being diffed
1014
+ * @param {object} currentSnapshot - The normalized table snapshot returned by introspection
1015
+ * @param {object} schema - The desired schema-like table metadata object
1016
+ * @param {object} [options={}] - Diff options
1017
+ * @param {boolean} [options.autoIncrement=true] - Whether the resource expects an implicit auto-increment id column
1018
+ * @param {boolean} [options.allowDropColumns=false] - Whether removed columns should be dropped automatically
1019
+ * @param {string} [options.dialect=''] - Optional target dialect override
1020
+ * @returns {{ migration: string, warnings: string[], plan: object }} Diff result
1021
+ */
1022
+ export function generateKnexMigrationDiff (tableName, currentSnapshot, schema, options = {}) {
1023
+ const {
1024
+ autoIncrement = true,
1025
+ allowDropColumns = false,
1026
+ dialect = ''
1027
+ } = options
1028
+ const resolvedDialect = normalizeText(dialect || currentSnapshot?.dialect).toLowerCase()
1029
+ const tableSchemaContext = resolveTableSchemaContext(schema, {
1030
+ ...options,
1031
+ tableName
1032
+ })
1033
+ const desiredColumns = buildDesiredColumnsMap(tableSchemaContext, { autoIncrement })
1034
+ const currentColumns = mapByName(currentSnapshot?.columns || [])
1035
+ const desiredIndexes = mapByName(tableSchemaContext.indexes)
1036
+ const currentIndexes = mapByName(currentSnapshot?.indexes || [])
1037
+ const desiredForeignKeys = mapByName(tableSchemaContext.foreignKeys)
1038
+ const currentForeignKeys = mapByName(currentSnapshot?.foreignKeys || [])
1039
+ const desiredChecks = mapByName(tableSchemaContext.checkConstraints)
1040
+ const currentChecks = mapByName(currentSnapshot?.checkConstraints || [])
1041
+ const plan = createEmptyDiffPlan()
1042
+
1043
+ for (const [columnName, desiredColumn] of desiredColumns.entries()) {
1044
+ const currentColumn = currentColumns.get(columnName)
1045
+
1046
+ if (!currentColumn) {
1047
+ if (desiredColumn.autoIncrement) {
1048
+ plan.warnings.push(`Skipping implicit auto-increment id column '${columnName}' because it cannot be added safely to an existing table.`)
1049
+ continue
1050
+ }
1051
+
1052
+ if (desiredColumn.setValues.length > 0 && !isMysqlDialect(resolvedDialect)) {
1053
+ plan.warnings.push(`Skipping setValues column '${columnName}' because it requires a MySQL-compatible ALTER target.`)
1054
+ continue
1055
+ }
1056
+
1057
+ plan.addColumns.push(desiredColumn)
205
1058
  continue
206
1059
  }
207
1060
 
208
- let line = ' '
1061
+ if (desiredColumn.autoIncrement) {
1062
+ continue
1063
+ }
209
1064
 
210
- // Map type
211
- switch (definition.type) {
212
- case 'string':
213
- line += definition.maxLength
214
- ? `table.string('${fieldName}', ${definition.maxLength})`
215
- : `table.string('${fieldName}')`
216
- break
217
- case 'number':
218
- if (definition.precision !== undefined && definition.scale !== undefined) {
219
- line += `table.decimal('${fieldName}', ${definition.precision}, ${definition.scale})`
220
- } else {
221
- line += `table.float('${fieldName}')`
222
- }
223
- break
224
- case 'id':
225
- line += `table.integer('${fieldName}')`
226
- if (definition.unsigned !== false) line += '.unsigned()'
227
- break
228
- case 'boolean':
229
- line += `table.boolean('${fieldName}')`
230
- break
231
- case 'date':
232
- line += `table.date('${fieldName}')`
233
- break
234
- case 'dateTime':
235
- line += `table.datetime('${fieldName}')`
236
- break
237
- case 'time':
238
- line += `table.time('${fieldName}')`
239
- break
240
- case 'timestamp':
241
- line += `table.integer('${fieldName}')`
242
- break
243
- case 'array':
244
- case 'object':
245
- case 'serialize':
246
- line += `table.json('${fieldName}')`
247
- break
248
- case 'blob':
249
- case 'file':
250
- line += `table.binary('${fieldName}')`
251
- break
252
- default:
253
- line += `table.string('${fieldName}')`
1065
+ if (!columnNeedsAlter(currentColumn, desiredColumn, { dialect: resolvedDialect })) {
1066
+ continue
1067
+ }
1068
+
1069
+ if (desiredColumn.setValues.length > 0 && !isMysqlDialect(resolvedDialect)) {
1070
+ plan.warnings.push(`Skipping setValues alteration for column '${columnName}' because it requires a MySQL-compatible ALTER target.`)
1071
+ continue
254
1072
  }
255
1073
 
256
- // Add constraints
257
- if (definition.nullable === true) {
258
- line += '.nullable()'
259
- } else if (definition.required === true || definition.nullable === false) {
260
- line += '.notNullable()'
1074
+ plan.alterColumns.push(desiredColumn)
1075
+ plan.warnings.push(...collectDestructiveColumnWarnings(currentColumn, desiredColumn))
1076
+ }
1077
+
1078
+ for (const currentColumn of currentColumns.values()) {
1079
+ if (desiredColumns.has(currentColumn.name)) {
1080
+ continue
261
1081
  }
262
1082
 
263
- if (definition.defaultTo !== undefined) {
264
- const defaultValue = typeof definition.defaultTo === 'string'
265
- ? `'${definition.defaultTo}'`
266
- : definition.defaultTo
267
- line += `.defaultTo(${defaultValue})`
1083
+ const warning = `Column '${currentColumn.name}' exists in the live table but not in the desired schema.`
1084
+ if (allowDropColumns) {
1085
+ plan.dropColumns.push(currentColumn)
1086
+ plan.warnings.push(`${warning} It will be dropped because allowDropColumns=true.`)
1087
+ } else {
1088
+ plan.warnings.push(`${warning} Skipping automatic drop.`)
268
1089
  }
1090
+ }
269
1091
 
270
- if (definition.unique === true) line += '.unique()'
271
- if (definition.primary === true) line += '.primary()'
272
- if (definition.index === true) line += '.index()'
1092
+ for (const desiredIndex of desiredIndexes.values()) {
1093
+ const currentIndex = currentIndexes.get(desiredIndex.name)
1094
+ const sameIndexType = normalizeText(currentIndex?.indexType).toUpperCase() === desiredIndex.indexType
1095
+ const indexChanged = Boolean(currentIndex) && (
1096
+ !valuesEqual(currentIndex.columns, desiredIndex.columns) ||
1097
+ Boolean(currentIndex.unique) !== Boolean(desiredIndex.unique) ||
1098
+ !sameIndexType
1099
+ )
273
1100
 
274
- if (definition.references) {
275
- const refCol = definition.references.column || 'id'
276
- line += `.references('${refCol}').inTable('${definition.references.table}')`
277
- if (definition.references.onDelete) {
278
- line += `.onDelete('${definition.references.onDelete}')`
279
- }
280
- if (definition.references.onUpdate) {
281
- line += `.onUpdate('${definition.references.onUpdate}')`
1101
+ if (!currentIndex || indexChanged) {
1102
+ plan.addIndexes.push(desiredIndex)
1103
+ if (indexChanged) {
1104
+ plan.dropIndexes.push(currentIndex)
282
1105
  }
283
1106
  }
1107
+ }
284
1108
 
285
- if (definition.comment) {
286
- line += `.comment('${definition.comment}')`
1109
+ for (const currentIndex of currentIndexes.values()) {
1110
+ if (!desiredIndexes.has(currentIndex.name)) {
1111
+ plan.dropIndexes.push(currentIndex)
287
1112
  }
1113
+ }
1114
+
1115
+ for (const desiredForeignKey of desiredForeignKeys.values()) {
1116
+ const currentForeignKey = currentForeignKeys.get(desiredForeignKey.name)
1117
+ const sameColumns = valuesEqual(
1118
+ (currentForeignKey?.columns || []).map((entry) => entry.name),
1119
+ desiredForeignKey.columns
1120
+ )
1121
+ const sameReferencedColumns = valuesEqual(
1122
+ (currentForeignKey?.columns || []).map((entry) => entry.referencedName),
1123
+ desiredForeignKey.referencedColumns
1124
+ )
1125
+ const sameTable = normalizeText(currentForeignKey?.referencedTableName) === desiredForeignKey.referencedTableName
1126
+ const sameDeleteRule = normalizeText(currentForeignKey?.deleteRule).toUpperCase() === desiredForeignKey.deleteRule
1127
+ const sameUpdateRule = normalizeText(currentForeignKey?.updateRule).toUpperCase() === desiredForeignKey.updateRule
288
1128
 
289
- migration += line + ';\n'
1129
+ if (!currentForeignKey || !sameColumns || !sameReferencedColumns || !sameTable || !sameDeleteRule || !sameUpdateRule) {
1130
+ plan.addForeignKeys.push(desiredForeignKey)
1131
+ }
290
1132
  }
291
1133
 
292
- if (timestamps) {
293
- migration += ' table.timestamps(true, true);\n'
1134
+ for (const currentForeignKey of currentForeignKeys.values()) {
1135
+ if (!desiredForeignKeys.has(currentForeignKey.name)) {
1136
+ plan.dropForeignKeys.push(currentForeignKey)
1137
+ }
294
1138
  }
295
1139
 
296
- migration += ` });
297
- };
1140
+ for (const desiredCheck of desiredChecks.values()) {
1141
+ const currentCheck = currentChecks.get(desiredCheck.name)
1142
+ if (currentCheck?.clause === desiredCheck.clause) {
1143
+ continue
1144
+ }
298
1145
 
299
- exports.down = function(knex) {
300
- return knex.schema.dropTable('${tableName}');
301
- };`
1146
+ if (isSqliteDialectName(resolvedDialect)) {
1147
+ plan.warnings.push(buildUnsupportedConstraintWarning('check constraint', desiredCheck.name, resolvedDialect))
1148
+ continue
1149
+ }
1150
+
1151
+ plan.addCheckConstraints.push(desiredCheck)
1152
+ }
302
1153
 
303
- return migration
1154
+ for (const currentCheck of currentChecks.values()) {
1155
+ const matchesDesiredEnum = [...desiredColumns.values()].some((desiredColumn) => {
1156
+ return isInlineEnumCheckConstraint(currentCheck, desiredColumn)
1157
+ })
1158
+ if (matchesDesiredEnum) {
1159
+ continue
1160
+ }
1161
+
1162
+ if (!desiredChecks.has(currentCheck.name)) {
1163
+ plan.warnings.push(buildUnsupportedConstraintWarning('check constraint removal', currentCheck.name, resolvedDialect))
1164
+ continue
1165
+ }
1166
+
1167
+ if (desiredChecks.get(currentCheck.name)?.clause !== currentCheck.clause) {
1168
+ plan.warnings.push(buildUnsupportedConstraintWarning('check constraint alteration', currentCheck.name, resolvedDialect))
1169
+ }
1170
+ }
1171
+
1172
+ plan.addColumns.sort(compareByName)
1173
+ plan.alterColumns.sort(compareByName)
1174
+ plan.dropColumns.sort(compareByName)
1175
+ plan.addIndexes.sort(compareByName)
1176
+ plan.dropIndexes.sort(compareByName)
1177
+ plan.addForeignKeys.sort(compareByName)
1178
+ plan.dropForeignKeys.sort(compareByName)
1179
+ plan.addCheckConstraints.sort(compareByName)
1180
+ plan.warnings = [...new Set(plan.warnings)]
1181
+
1182
+ const blocks = []
1183
+
1184
+ const addOrAlterColumnLines = [
1185
+ ...plan.addColumns.map((column) => buildColumnBuilderCode(column.name, column.definition, { dialect: resolvedDialect })),
1186
+ ...plan.alterColumns.map((column) => buildColumnBuilderCode(column.name, column.definition, {
1187
+ dialect: resolvedDialect,
1188
+ alter: true,
1189
+ includePrimary: false
1190
+ }))
1191
+ ]
1192
+
1193
+ if (plan.dropForeignKeys.length > 0) {
1194
+ blocks.push(` await knex.schema.alterTable(${quoteJsString(tableName)}, (table) => {
1195
+ ${plan.dropForeignKeys.map((foreignKey) => ` table.dropForeign(${formatCodeLiteral(foreignKey.columns.map((entry) => entry.name))}, ${quoteJsString(foreignKey.name)});`).join('\n')}
1196
+ });`)
1197
+ }
1198
+
1199
+ if (plan.dropIndexes.length > 0) {
1200
+ blocks.push(` await knex.schema.alterTable(${quoteJsString(tableName)}, (table) => {
1201
+ ${plan.dropIndexes.map((index) => {
1202
+ if (index.unique) {
1203
+ return ` table.dropUnique(${formatCodeLiteral(index.columns)}, ${quoteJsString(index.name)});`
1204
+ }
1205
+ return ` table.dropIndex(${formatCodeLiteral(index.columns)}, ${quoteJsString(index.name)});`
1206
+ }).join('\n')}
1207
+ });`)
1208
+ }
1209
+
1210
+ if (plan.dropColumns.length > 0) {
1211
+ blocks.push(` await knex.schema.alterTable(${quoteJsString(tableName)}, (table) => {
1212
+ ${plan.dropColumns.map((column) => ` table.dropColumn(${quoteJsString(column.name)});`).join('\n')}
1213
+ });`)
1214
+ }
1215
+
1216
+ if (addOrAlterColumnLines.length > 0) {
1217
+ blocks.push(` await knex.schema.alterTable(${quoteJsString(tableName)}, (table) => {
1218
+ ${addOrAlterColumnLines.map((line) => ` ${line};`).join('\n')}
1219
+ });`)
1220
+ }
1221
+
1222
+ const addConstraintLines = [
1223
+ ...plan.addIndexes.map((index) => buildIndexLine(index)),
1224
+ ...plan.addForeignKeys.map((foreignKey) => buildForeignKeyLine(foreignKey)),
1225
+ ...plan.addCheckConstraints.map((constraint) => buildCheckConstraintLine(constraint))
1226
+ ]
1227
+
1228
+ if (addConstraintLines.length > 0) {
1229
+ blocks.push(` await knex.schema.alterTable(${quoteJsString(tableName)}, (table) => {
1230
+ ${addConstraintLines.map((line) => ` ${line};`).join('\n')}
1231
+ });`)
1232
+ }
1233
+
1234
+ const warningComment = plan.warnings.length > 0
1235
+ ? ` // Warnings:\n${plan.warnings.map((warning) => ` // - ${warning}`).join('\n')}\n`
1236
+ : ''
1237
+
1238
+ return {
1239
+ migration: `exports.up = async function(knex) {
1240
+ ${warningComment}${blocks.length > 0 ? `${blocks.join('\n\n')}\n` : ' return Promise.resolve();\n'}};
1241
+
1242
+ exports.down = async function() {
1243
+ throw new Error(${quoteJsString(`Down migration was not generated automatically for table '${tableName}'.`)});
1244
+ };`,
1245
+ warnings: plan.warnings,
1246
+ plan
1247
+ }
304
1248
  }