uql-orm 0.42.1 → 0.43.0
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/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +2 -2
- package/dist/dialect/abstractSqlDialect.d.ts +10 -12
- package/dist/dialect/abstractSqlDialect.js +34 -34
- package/dist/dialect/jsonSql.d.ts +3 -2
- package/dist/dialect/jsonSql.js +7 -5
- package/dist/dialect/vectorCast.d.ts +0 -6
- package/dist/dialect/vectorCast.js +0 -8
- package/dist/entity/decorator/members.d.ts +5 -2
- package/dist/entity/metadata/definition.js +29 -12
- package/dist/maria/mariaDialect.js +2 -2
- package/dist/migrate/drift/driftDetector.js +5 -1
- package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
- package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
- package/dist/migrate/schemaGenerator.d.ts +1 -5
- package/dist/migrate/schemaGenerator.js +11 -15
- package/dist/schema/canonicalType.d.ts +19 -4
- package/dist/schema/canonicalType.js +114 -164
- package/dist/schema/schemaASTBuilder.js +1 -1
- package/dist/schema/schemaASTDiffer.js +6 -2
- package/dist/type/entity.d.ts +23 -6
- package/dist/type/migration.d.ts +1 -1
- package/dist/type/query.d.ts +2 -6
- package/dist/util/field.util.d.ts +19 -8
- package/dist/util/field.util.js +47 -51
- package/dist/util/fieldOption.util.d.ts +79 -0
- package/dist/util/fieldOption.util.js +84 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +1 -0
- package/dist/util/sql.util.d.ts +4 -0
- package/dist/util/sql.util.js +4 -0
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SOFT_DELETE_FILTER } from '../../type/index.js';
|
|
2
|
-
import { getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
|
|
2
|
+
import { fieldOptionConflict, getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
|
|
3
3
|
import { ownRegistrations } from '../decorator/bag.js';
|
|
4
4
|
// Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
|
|
5
5
|
// evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
|
|
@@ -10,11 +10,15 @@ const metaKey = Symbol.for('uql-orm/entity/metadata/v1');
|
|
|
10
10
|
const metas = holder[metaKey] ?? new Map();
|
|
11
11
|
holder[metaKey] = metas;
|
|
12
12
|
export function defineField(entity, key, opts = {}) {
|
|
13
|
-
const meta =
|
|
13
|
+
const meta = ensureWritableMeta(entity);
|
|
14
14
|
if (!opts.type && !opts.references && !opts.virtual) {
|
|
15
15
|
throw new TypeError(`'${entity.name}.${key}' needs a 'type'. Declare it - '@Field({ type: String })' - or point the field ` +
|
|
16
16
|
"at another entity with 'references', which resolves the column type from its primary key.");
|
|
17
17
|
}
|
|
18
|
+
const conflict = fieldOptionConflict(opts);
|
|
19
|
+
if (conflict) {
|
|
20
|
+
throw new TypeError(`'${entity.name}.${key}' ${conflict}.`);
|
|
21
|
+
}
|
|
18
22
|
const fieldKey = key;
|
|
19
23
|
// Flagged when the author gave `references` but no `type`, so schema generation knows to resolve the
|
|
20
24
|
// column from the referenced primary key (picking up its `columnType`, length and chained keys)
|
|
@@ -32,7 +36,7 @@ export function defineRelation(entity, key, opts) {
|
|
|
32
36
|
if (!opts.entity) {
|
|
33
37
|
throw new TypeError(`'${entity.name}.${key}' needs an 'entity' getter, e.g. '@ManyToOne({ entity: () => Company })'.`);
|
|
34
38
|
}
|
|
35
|
-
const meta =
|
|
39
|
+
const meta = ensureWritableMeta(entity);
|
|
36
40
|
// Registration writes the authored shape into a map declared as resolved: `getMeta` runs
|
|
37
41
|
// `fillRelations`, which settles `entity`, `references` and `mappedBy` or throws. Bridging the two
|
|
38
42
|
// shapes here is what lets every consumer read `RelationMeta` without asserting.
|
|
@@ -41,7 +45,7 @@ export function defineRelation(entity, key, opts) {
|
|
|
41
45
|
return meta;
|
|
42
46
|
}
|
|
43
47
|
export function defineHook(entity, methodName, event) {
|
|
44
|
-
const meta =
|
|
48
|
+
const meta = ensureWritableMeta(entity);
|
|
45
49
|
if (!meta.hooks)
|
|
46
50
|
meta.hooks = {};
|
|
47
51
|
if (!meta.hooks[event])
|
|
@@ -54,7 +58,7 @@ export function defineHook(entity, methodName, event) {
|
|
|
54
58
|
* lets the dialects render one shape instead of re-parsing it.
|
|
55
59
|
*/
|
|
56
60
|
export function defineIndex(entity, index) {
|
|
57
|
-
const meta =
|
|
61
|
+
const meta = ensureWritableMeta(entity);
|
|
58
62
|
if (!meta.indexes)
|
|
59
63
|
meta.indexes = [];
|
|
60
64
|
meta.indexes.push({
|
|
@@ -66,7 +70,7 @@ export function defineIndex(entity, index) {
|
|
|
66
70
|
return meta;
|
|
67
71
|
}
|
|
68
72
|
export function defineFilter(entity, name, opts) {
|
|
69
|
-
const meta =
|
|
73
|
+
const meta = ensureWritableMeta(entity);
|
|
70
74
|
if (name === SOFT_DELETE_FILTER) {
|
|
71
75
|
throw TypeError(`'${entity.name}' filter name '${SOFT_DELETE_FILTER}' is reserved; it is auto-registered from @Field({ softDelete })`);
|
|
72
76
|
}
|
|
@@ -112,7 +116,7 @@ export function defineEntity(entity, opts = {}) {
|
|
|
112
116
|
throw new TypeError(`'${entity.name}' has a dotted name '${opts.name}'. Name the schema separately as ` +
|
|
113
117
|
`{ schema: '${schema}', name: '${rest.join('.')}' }.`);
|
|
114
118
|
}
|
|
115
|
-
const meta =
|
|
119
|
+
const meta = ensureWritableMeta(entity);
|
|
116
120
|
// Covers `defineEntity(Decorated)` called on a class whose members carry decorators. `@Entity()`
|
|
117
121
|
// drains `context.metadata` itself, because TypeScript only attaches `Symbol.metadata` to the class
|
|
118
122
|
// after class decorators return; draining empties the bag, so whichever runs second is a no-op.
|
|
@@ -132,8 +136,9 @@ export function defineEntity(entity, opts = {}) {
|
|
|
132
136
|
if (!hasKeys(meta.fields)) {
|
|
133
137
|
throw TypeError(`'${entity.name}' must have fields`);
|
|
134
138
|
}
|
|
135
|
-
|
|
136
|
-
meta.
|
|
139
|
+
// A later call composes onto the entity, so saying nothing about the table retracts nothing.
|
|
140
|
+
meta.name = opts.name ?? meta.name ?? entity.name;
|
|
141
|
+
meta.schema = opts.schema ?? meta.schema;
|
|
137
142
|
let proto = Object.getPrototypeOf(entity.prototype);
|
|
138
143
|
while (proto.constructor !== Object) {
|
|
139
144
|
const parent = proto.constructor;
|
|
@@ -211,12 +216,21 @@ export function getEntities() {
|
|
|
211
216
|
return acc;
|
|
212
217
|
}, []);
|
|
213
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The metadata of `entity`, marked as changed. Every `define*` goes through this, and nothing outside
|
|
221
|
+
* this file writes to a meta, so it is the one place a derived cache can be told it has gone stale.
|
|
222
|
+
*/
|
|
223
|
+
function ensureWritableMeta(entity) {
|
|
224
|
+
const meta = ensureMeta(entity);
|
|
225
|
+
meta.revision++;
|
|
226
|
+
return meta;
|
|
227
|
+
}
|
|
214
228
|
function ensureMeta(entity) {
|
|
215
229
|
let meta = metas.get(entity);
|
|
216
230
|
if (meta) {
|
|
217
231
|
return meta;
|
|
218
232
|
}
|
|
219
|
-
meta = { entity, ids: [], fields: {}, relations: {} };
|
|
233
|
+
meta = { entity, ids: [], fields: {}, relations: {}, revision: 0 };
|
|
220
234
|
metas.set(entity, meta);
|
|
221
235
|
return meta;
|
|
222
236
|
}
|
|
@@ -225,10 +239,13 @@ export function getMeta(entity) {
|
|
|
225
239
|
if (!meta) {
|
|
226
240
|
throw TypeError(`'${entity.name}' is not an entity`);
|
|
227
241
|
}
|
|
228
|
-
if (meta.
|
|
242
|
+
if (meta.processedAt === meta.revision) {
|
|
229
243
|
return meta;
|
|
230
244
|
}
|
|
231
|
-
|
|
245
|
+
// Stamped before finalizing, not after: `fillInverseSide` reads the other side through `getMeta`,
|
|
246
|
+
// and with each side mapped by the other that recursion has to find this half-filled meta rather
|
|
247
|
+
// than run again. Finalizing twice is harmless anyway - every step of it skips what it settled.
|
|
248
|
+
meta.processedAt = meta.revision;
|
|
232
249
|
return fillRelations(meta);
|
|
233
250
|
}
|
|
234
251
|
function fillRelations(meta) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsonPath } from '../dialect/jsonSql.js';
|
|
2
2
|
import { MysqlLikeSqlDialect } from '../dialect/mysqlLikeSqlDialect.js';
|
|
3
|
-
import { isVectorFieldType } from '../dialect/vectorCast.js';
|
|
4
3
|
import { getMeta } from '../entity/index.js';
|
|
4
|
+
import { columnFamily } from '../util/field.util.js';
|
|
5
5
|
import { MARIA_VECTOR_METRICS } from './mariaVectorMetrics.js';
|
|
6
6
|
export class MariaDialect extends MysqlLikeSqlDialect {
|
|
7
7
|
dialectName = 'mariadb';
|
|
@@ -76,6 +76,6 @@ export class MariaDialect extends MysqlLikeSqlDialect {
|
|
|
76
76
|
}
|
|
77
77
|
/** The reverse: selecting a `VECTOR` column raw yields that blob, so it is read back as text. */
|
|
78
78
|
selectFieldExpr(escapedColumn, field) {
|
|
79
|
-
return
|
|
79
|
+
return columnFamily(field.type) === 'vector' ? `VEC_ToText(${escapedColumn})` : escapedColumn;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Detects schema drift between expected schema (from entities) and
|
|
5
5
|
* actual database schema.
|
|
6
6
|
*/
|
|
7
|
-
import { canonicalToSql } from '../../schema/canonicalType.js';
|
|
7
|
+
import { canonicalToSql, engineType } from '../../schema/canonicalType.js';
|
|
8
8
|
import { diffSchemas } from '../../schema/schemaASTDiffer.js';
|
|
9
9
|
function resolveOptions(options) {
|
|
10
10
|
return {
|
|
@@ -24,11 +24,15 @@ function resolveOptions(options) {
|
|
|
24
24
|
*/
|
|
25
25
|
export function detectDrift(expectedAST, actualAST, options = {}) {
|
|
26
26
|
const opts = resolveOptions(options);
|
|
27
|
+
const { dialect } = opts;
|
|
27
28
|
const diff = diffSchemas(expectedAST, actualAST, {
|
|
28
29
|
compareIndexes: opts.checkIndexes,
|
|
29
30
|
indexFacets: opts.indexFacets,
|
|
30
31
|
compareRelationships: opts.checkForeignKeys,
|
|
31
32
|
excludeTables: opts.excludeTables,
|
|
33
|
+
// Without a dialect there is no engine to compare through, and `formatType` below then reports no
|
|
34
|
+
// type drift at all.
|
|
35
|
+
...(dialect && { normalizeType: engineType(dialect) }),
|
|
32
36
|
});
|
|
33
37
|
const drifts = [
|
|
34
38
|
...detectTableDrifts(diff),
|
|
@@ -39,7 +39,7 @@ export declare class MongoSchemaGenerator extends AbstractDialect implements Sch
|
|
|
39
39
|
*/
|
|
40
40
|
generateCreateIndex(tableName: string, index: IndexSchema): string;
|
|
41
41
|
generateDropIndex(tableName: string, indexName: string): string;
|
|
42
|
-
getSqlType(fieldOptions: FieldOptions
|
|
42
|
+
getSqlType(fieldOptions: FieldOptions): string;
|
|
43
43
|
generateCreateTableFromNode(table: TableNode, _options?: {
|
|
44
44
|
ifNotExists?: boolean;
|
|
45
45
|
}): string[];
|
|
@@ -27,10 +27,6 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
|
|
|
27
27
|
* Primary key type for auto-increment integer IDs
|
|
28
28
|
*/
|
|
29
29
|
protected get serialType(): string;
|
|
30
|
-
/**
|
|
31
|
-
* Convert FieldOptions to CanonicalType using the unified type system.
|
|
32
|
-
*/
|
|
33
|
-
protected getCanonicalType(field: FieldOptions, fieldType?: unknown): CanonicalType;
|
|
34
30
|
protected canonicalTypeToSql(type: CanonicalType): string;
|
|
35
31
|
/**
|
|
36
32
|
* Every `CREATE TABLE` for `entities`, then their foreign keys.
|
|
@@ -85,7 +81,7 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
|
|
|
85
81
|
/** ` DEFAULT <sql>`, or nothing where the column declares none. Empty rather than `DEFAULT NULL`
|
|
86
82
|
* so an absent default stays absent - `defaultValue: null` is the way to ask for one. */
|
|
87
83
|
private defaultClause;
|
|
88
|
-
getSqlType(field: FieldMeta
|
|
84
|
+
getSqlType(field: FieldMeta): string;
|
|
89
85
|
/**
|
|
90
86
|
* Generate ALTER COLUMN statements (database-specific)
|
|
91
87
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AbstractSqlDialect } from '../dialect/index.js';
|
|
2
2
|
import { getMeta, soleIdOf } from '../entity/index.js';
|
|
3
|
-
import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategory,
|
|
3
|
+
import { areTypesEqual, canonicalToSql, engineType, fieldOptionsToCanonical, isVectorCategory, } from '../schema/canonicalType.js';
|
|
4
4
|
import { indexSignature } from '../schema/indexDifferences.js';
|
|
5
5
|
import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
|
|
6
6
|
import { diffTable } from '../schema/schemaASTDiffer.js';
|
|
@@ -52,12 +52,6 @@ export class SqlSchemaGenerator {
|
|
|
52
52
|
get serialType() {
|
|
53
53
|
return this.dialect.serialType;
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Convert FieldOptions to CanonicalType using the unified type system.
|
|
57
|
-
*/
|
|
58
|
-
getCanonicalType(field, fieldType) {
|
|
59
|
-
return fieldOptionsToCanonical(field, fieldType);
|
|
60
|
-
}
|
|
61
55
|
canonicalTypeToSql(type) {
|
|
62
56
|
return canonicalToSql(type, this.dialect);
|
|
63
57
|
}
|
|
@@ -275,18 +269,20 @@ export class SqlSchemaGenerator {
|
|
|
275
269
|
? ''
|
|
276
270
|
: ` DEFAULT ${formatDefaultValue(column.defaultValue, this.dialect, column.type)}`;
|
|
277
271
|
}
|
|
278
|
-
getSqlType(field
|
|
279
|
-
//
|
|
272
|
+
getSqlType(field) {
|
|
273
|
+
// A foreign key takes the type of the key it points at. A `referencedKey` the target does not
|
|
274
|
+
// have falls through to this column's own options, as the AST builder does with the same case.
|
|
280
275
|
if (field.references) {
|
|
281
|
-
const
|
|
282
|
-
const refMeta = getMeta(refEntity);
|
|
276
|
+
const refMeta = getMeta(field.references());
|
|
283
277
|
const refIdField = refMeta.fields[field.referencedKey ?? soleIdOf(refMeta, 'a foreign key')];
|
|
284
|
-
|
|
278
|
+
if (refIdField) {
|
|
279
|
+
return this.getSqlType({ ...refIdField, references: undefined, isId: undefined, autoIncrement: false });
|
|
280
|
+
}
|
|
285
281
|
}
|
|
286
282
|
// Get canonical type and convert to SQL
|
|
287
|
-
const canonical =
|
|
283
|
+
const canonical = fieldOptionsToCanonical(field);
|
|
288
284
|
// Special case for serial primary keys
|
|
289
|
-
if (isAutoIncrement(field,
|
|
285
|
+
if (isAutoIncrement(field, field.isId === true)) {
|
|
290
286
|
return this.dialect.serialType;
|
|
291
287
|
}
|
|
292
288
|
return this.canonicalTypeToSql(canonical);
|
|
@@ -408,7 +404,7 @@ export class SqlSchemaGenerator {
|
|
|
408
404
|
}
|
|
409
405
|
diffOptions() {
|
|
410
406
|
return {
|
|
411
|
-
normalizeType: (
|
|
407
|
+
normalizeType: engineType(this.dialect),
|
|
412
408
|
defaultsEqual: (expected, actual) => this.isDefaultValueEqual(actual, expected),
|
|
413
409
|
};
|
|
414
410
|
}
|
|
@@ -36,17 +36,32 @@ export declare function canonicalToSql(type: CanonicalType, dialect: AbstractDia
|
|
|
36
36
|
* Convert a canonical type to a TypeScript type string.
|
|
37
37
|
*/
|
|
38
38
|
export declare function canonicalToTypeScript(type: CanonicalType): string;
|
|
39
|
+
/**
|
|
40
|
+
* A type as `dialect` would actually store it: rendered to that engine's SQL and read back.
|
|
41
|
+
*
|
|
42
|
+
* Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL and
|
|
43
|
+
* `INTEGER` on SQLite - and only the engine settles an unstated bound, since `VARCHAR` is 255 on MySQL
|
|
44
|
+
* and `TEXT` on Postgres. Both paths that diff a schema compare through this, so a migration and a
|
|
45
|
+
* drift report cannot disagree about what changed.
|
|
46
|
+
*/
|
|
47
|
+
export declare function engineType(dialect: AbstractDialect): (type: CanonicalType) => CanonicalType;
|
|
39
48
|
/**
|
|
40
49
|
* Convert UQL FieldOptions to a canonical type.
|
|
41
50
|
*/
|
|
42
|
-
export declare function fieldOptionsToCanonical(options: FieldOptions
|
|
51
|
+
export declare function fieldOptionsToCanonical(options: FieldOptions): CanonicalType;
|
|
43
52
|
/**
|
|
44
|
-
* Compare two canonical types for equality.
|
|
45
|
-
*
|
|
53
|
+
* Compare two canonical types for equality. Used for schema diffing.
|
|
54
|
+
*
|
|
55
|
+
* Nothing here guesses an engine's own default for an unstated bound: `VARCHAR` is 255 on MySQL and
|
|
56
|
+
* `TEXT` on Postgres, and a comparison that assumed either was blind to that difference on the other.
|
|
57
|
+
* `DiffOptions.normalizeType` is what settles it, by putting both sides through the engine first.
|
|
46
58
|
*/
|
|
47
59
|
export declare function areTypesEqual(a: CanonicalType, b: CanonicalType): boolean;
|
|
48
60
|
/**
|
|
49
|
-
*
|
|
61
|
+
* Whether changing a column from one type to the other can lose what it holds.
|
|
62
|
+
*
|
|
63
|
+
* An unstated bound is the engine's widest, so stating one for the first time narrows the column just
|
|
64
|
+
* as lowering one does: `TEXT` to `VARCHAR(50)` truncates, and `NUMERIC` to `NUMERIC(5,2)` rounds.
|
|
50
65
|
*/
|
|
51
66
|
export declare function isBreakingTypeChange(from: CanonicalType, to: CanonicalType): boolean;
|
|
52
67
|
/**
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - Canonical types (dialect-agnostic)
|
|
7
7
|
* - TypeScript types (for entity generation)
|
|
8
8
|
*/
|
|
9
|
+
import { columnFamily } from '../util/field.util.js';
|
|
9
10
|
/** Whether a category is one of the vector types, narrowing it to the cast pgvector names use. */
|
|
10
11
|
export function isVectorCategory(category) {
|
|
11
12
|
return category === 'vector' || category === 'halfvec' || category === 'sparsevec';
|
|
@@ -108,66 +109,57 @@ const PG_TYPE_MAP = {
|
|
|
108
109
|
halfvec: 'HALFVEC',
|
|
109
110
|
sparsevec: 'SPARSEVEC',
|
|
110
111
|
};
|
|
112
|
+
const MYSQL_SCALAR_MAP = {
|
|
113
|
+
integer: 'INT',
|
|
114
|
+
float: 'FLOAT',
|
|
115
|
+
decimal: 'DECIMAL',
|
|
116
|
+
string: 'VARCHAR',
|
|
117
|
+
boolean: 'TINYINT(1)',
|
|
118
|
+
date: 'DATE',
|
|
119
|
+
time: 'TIME',
|
|
120
|
+
timestamp: 'DATETIME',
|
|
121
|
+
json: 'JSON',
|
|
122
|
+
uuid: 'CHAR(36)',
|
|
123
|
+
blob: 'BLOB',
|
|
124
|
+
};
|
|
125
|
+
const SQLITE_SCALAR_MAP = {
|
|
126
|
+
integer: 'INTEGER',
|
|
127
|
+
float: 'REAL',
|
|
128
|
+
decimal: 'REAL',
|
|
129
|
+
string: 'TEXT',
|
|
130
|
+
boolean: 'INTEGER',
|
|
131
|
+
date: 'TEXT',
|
|
132
|
+
time: 'TEXT',
|
|
133
|
+
timestamp: 'TEXT',
|
|
134
|
+
json: 'TEXT',
|
|
135
|
+
uuid: 'TEXT',
|
|
136
|
+
blob: 'BLOB',
|
|
137
|
+
};
|
|
138
|
+
/** MongoDB uses BSON types, not SQL types. These are placeholders for compatibility. */
|
|
139
|
+
const MONGO_SCALAR_MAP = {
|
|
140
|
+
integer: 'int',
|
|
141
|
+
float: 'double',
|
|
142
|
+
decimal: 'decimal128',
|
|
143
|
+
string: 'string',
|
|
144
|
+
boolean: 'bool',
|
|
145
|
+
date: 'date',
|
|
146
|
+
time: 'string',
|
|
147
|
+
timestamp: 'timestamp',
|
|
148
|
+
json: 'object',
|
|
149
|
+
uuid: 'binData',
|
|
150
|
+
blob: 'binData',
|
|
151
|
+
};
|
|
152
|
+
/** Every engine's scalars, and the one spelling it gives all three vector widths. */
|
|
111
153
|
const CANONICAL_TO_SQL = {
|
|
112
154
|
postgres: PG_TYPE_MAP,
|
|
113
155
|
// CockroachDB's VECTOR is native, no extension needed.
|
|
114
156
|
cockroachdb: withVectorType(PG_SCALAR_MAP, 'VECTOR'),
|
|
115
|
-
mysql: withVectorType({
|
|
116
|
-
integer: 'INT',
|
|
117
|
-
float: 'FLOAT',
|
|
118
|
-
decimal: 'DECIMAL',
|
|
119
|
-
string: 'VARCHAR',
|
|
120
|
-
boolean: 'TINYINT(1)',
|
|
121
|
-
date: 'DATE',
|
|
122
|
-
time: 'TIME',
|
|
123
|
-
timestamp: 'DATETIME',
|
|
124
|
-
json: 'JSON',
|
|
125
|
-
uuid: 'CHAR(36)',
|
|
126
|
-
blob: 'BLOB',
|
|
127
|
-
},
|
|
128
157
|
// MySQL does have a `VECTOR` type (26.7), but no distance function outside HeatWave and no vector
|
|
129
158
|
// index, so JSON keeps the column queryable with the JSON operators and needs no conversion.
|
|
130
|
-
'JSON'),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
decimal: 'REAL',
|
|
135
|
-
string: 'TEXT',
|
|
136
|
-
boolean: 'INTEGER',
|
|
137
|
-
date: 'TEXT',
|
|
138
|
-
time: 'TEXT',
|
|
139
|
-
timestamp: 'TEXT',
|
|
140
|
-
json: 'TEXT',
|
|
141
|
-
uuid: 'TEXT',
|
|
142
|
-
blob: 'BLOB',
|
|
143
|
-
}, 'TEXT'),
|
|
144
|
-
mariadb: withVectorType({
|
|
145
|
-
integer: 'INT',
|
|
146
|
-
float: 'FLOAT',
|
|
147
|
-
decimal: 'DECIMAL',
|
|
148
|
-
string: 'VARCHAR',
|
|
149
|
-
boolean: 'TINYINT(1)',
|
|
150
|
-
date: 'DATE',
|
|
151
|
-
time: 'TIME',
|
|
152
|
-
timestamp: 'DATETIME',
|
|
153
|
-
json: 'JSON',
|
|
154
|
-
uuid: 'CHAR(36)',
|
|
155
|
-
blob: 'BLOB',
|
|
156
|
-
}, 'VECTOR'),
|
|
157
|
-
// MongoDB uses BSON types, not SQL types. These are placeholders for compatibility.
|
|
158
|
-
mongodb: withVectorType({
|
|
159
|
-
integer: 'int',
|
|
160
|
-
float: 'double',
|
|
161
|
-
decimal: 'decimal128',
|
|
162
|
-
string: 'string',
|
|
163
|
-
boolean: 'bool',
|
|
164
|
-
date: 'date',
|
|
165
|
-
time: 'string',
|
|
166
|
-
timestamp: 'timestamp',
|
|
167
|
-
json: 'object',
|
|
168
|
-
uuid: 'binData',
|
|
169
|
-
blob: 'binData',
|
|
170
|
-
}, 'array'),
|
|
159
|
+
mysql: withVectorType(MYSQL_SCALAR_MAP, 'JSON'),
|
|
160
|
+
mariadb: withVectorType(MYSQL_SCALAR_MAP, 'VECTOR'),
|
|
161
|
+
sqlite: withVectorType(SQLITE_SCALAR_MAP, 'TEXT'),
|
|
162
|
+
mongodb: withVectorType(MONGO_SCALAR_MAP, 'array'),
|
|
171
163
|
};
|
|
172
164
|
/**
|
|
173
165
|
* Size variant modifiers for SQL types.
|
|
@@ -239,7 +231,7 @@ const CANONICAL_TO_TS = {
|
|
|
239
231
|
timestamp: 'Date',
|
|
240
232
|
json: 'unknown',
|
|
241
233
|
uuid: 'string',
|
|
242
|
-
blob: '
|
|
234
|
+
blob: 'Uint8Array',
|
|
243
235
|
vector: 'number[]',
|
|
244
236
|
halfvec: 'number[]',
|
|
245
237
|
sparsevec: 'number[]',
|
|
@@ -387,136 +379,94 @@ function formatDecimalSqlType(type, dialect, baseType) {
|
|
|
387
379
|
export function canonicalToTypeScript(type) {
|
|
388
380
|
return CANONICAL_TO_TS[type.category];
|
|
389
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* A type as `dialect` would actually store it: rendered to that engine's SQL and read back.
|
|
384
|
+
*
|
|
385
|
+
* Several canonical types share one storage type per engine - a `boolean` is `TINYINT(1)` on MySQL and
|
|
386
|
+
* `INTEGER` on SQLite - and only the engine settles an unstated bound, since `VARCHAR` is 255 on MySQL
|
|
387
|
+
* and `TEXT` on Postgres. Both paths that diff a schema compare through this, so a migration and a
|
|
388
|
+
* drift report cannot disagree about what changed.
|
|
389
|
+
*/
|
|
390
|
+
export function engineType(dialect) {
|
|
391
|
+
return (type) => sqlToCanonical(canonicalToSql(type, dialect));
|
|
392
|
+
}
|
|
390
393
|
/**
|
|
391
394
|
* Convert UQL FieldOptions to a canonical type.
|
|
392
395
|
*/
|
|
393
|
-
export function fieldOptionsToCanonical(options
|
|
394
|
-
//
|
|
396
|
+
export function fieldOptionsToCanonical(options) {
|
|
397
|
+
// An explicit column type is read exactly as an introspected one is: the SQL type, plus whatever
|
|
398
|
+
// bounds are stated beside it.
|
|
395
399
|
if (options.columnType) {
|
|
396
|
-
|
|
397
|
-
return {
|
|
398
|
-
...base,
|
|
399
|
-
length: options.length ?? base.length,
|
|
400
|
-
precision: options.precision ?? base.precision,
|
|
401
|
-
scale: options.scale ?? base.scale,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
// Infer from type (could be constructor or string)
|
|
405
|
-
const type = options.type || tsType;
|
|
406
|
-
if (type === String) {
|
|
407
|
-
return {
|
|
408
|
-
category: 'string',
|
|
409
|
-
length: options.length,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
if (type === Number) {
|
|
413
|
-
if (options.precision || options.scale) {
|
|
414
|
-
return {
|
|
415
|
-
category: 'decimal',
|
|
416
|
-
precision: options.precision,
|
|
417
|
-
scale: options.scale,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
// BIGINT for every `Number`, key or not: a 32-bit column is a migration waiting to happen, and
|
|
421
|
-
// the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
|
|
422
|
-
return { category: 'integer', size: 'big' };
|
|
423
|
-
}
|
|
424
|
-
if (type === Boolean) {
|
|
425
|
-
return { category: 'boolean' };
|
|
426
|
-
}
|
|
427
|
-
if (type === Date) {
|
|
428
|
-
return { category: 'timestamp' };
|
|
429
|
-
}
|
|
430
|
-
if (type === BigInt) {
|
|
431
|
-
return { category: 'integer', size: 'big' };
|
|
400
|
+
return canonicalColumnType(options.columnType, options);
|
|
432
401
|
}
|
|
402
|
+
// Infer from type, which is a SQL type string or one of the constructors.
|
|
403
|
+
const { type } = options;
|
|
433
404
|
if (typeof type === 'string') {
|
|
434
405
|
const canonical = sqlToCanonical(type);
|
|
435
406
|
// Propagate explicit dimensions into CanonicalType.length for vector types
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
407
|
+
return options.dimensions && isVectorCategory(canonical.category)
|
|
408
|
+
? { ...canonical, length: options.dimensions }
|
|
409
|
+
: canonical;
|
|
410
|
+
}
|
|
411
|
+
switch (columnFamily(type)) {
|
|
412
|
+
case 'numeric':
|
|
413
|
+
// BIGINT for every `Number` without a scale, key or not: a 32-bit column is a migration waiting
|
|
414
|
+
// to happen, and the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
|
|
415
|
+
return type === Number && (options.precision || options.scale)
|
|
416
|
+
? { category: 'decimal', precision: options.precision, scale: options.scale }
|
|
417
|
+
: { category: 'integer', size: 'big' };
|
|
418
|
+
case 'boolean':
|
|
419
|
+
return { category: 'boolean' };
|
|
420
|
+
case 'date':
|
|
421
|
+
return { category: 'timestamp' };
|
|
422
|
+
// `String`, and anything a reflected type left unrecognised.
|
|
423
|
+
default:
|
|
424
|
+
return { category: 'string', length: options.length };
|
|
440
425
|
}
|
|
441
|
-
// Default to string
|
|
442
|
-
return { category: 'string', length: options.length };
|
|
443
426
|
}
|
|
444
427
|
/**
|
|
445
|
-
* Compare two canonical types for equality.
|
|
446
|
-
*
|
|
428
|
+
* Compare two canonical types for equality. Used for schema diffing.
|
|
429
|
+
*
|
|
430
|
+
* Nothing here guesses an engine's own default for an unstated bound: `VARCHAR` is 255 on MySQL and
|
|
431
|
+
* `TEXT` on Postgres, and a comparison that assumed either was blind to that difference on the other.
|
|
432
|
+
* `DiffOptions.normalizeType` is what settles it, by putting both sides through the engine first.
|
|
447
433
|
*/
|
|
448
434
|
export function areTypesEqual(a, b) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
// For strings, compare length (allowing for default 255)
|
|
458
|
-
if (a.category === 'string') {
|
|
459
|
-
const lengthA = a.length ?? 255;
|
|
460
|
-
const lengthB = b.length ?? 255;
|
|
461
|
-
if (lengthA !== lengthB) {
|
|
462
|
-
return false;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
// For decimals, compare precision and scale (allowing for default 10, 2)
|
|
466
|
-
if (a.category === 'decimal') {
|
|
467
|
-
const pA = a.precision ?? 10;
|
|
468
|
-
const pB = b.precision ?? 10;
|
|
469
|
-
const sA = a.scale ?? 2;
|
|
470
|
-
const sB = b.scale ?? 2;
|
|
471
|
-
if (pA !== pB || sA !== sB) {
|
|
472
|
-
return false;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
// For timestamps, compare timezone
|
|
476
|
-
if (a.category === 'timestamp') {
|
|
477
|
-
if (!!a.withTimezone !== !!b.withTimezone) {
|
|
478
|
-
return false;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
// Unsigned must match
|
|
482
|
-
if (!!a.unsigned !== !!b.unsigned) {
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
return true;
|
|
435
|
+
return (a.category === b.category &&
|
|
436
|
+
a.size === b.size &&
|
|
437
|
+
a.length === b.length &&
|
|
438
|
+
a.precision === b.precision &&
|
|
439
|
+
a.scale === b.scale &&
|
|
440
|
+
!!a.withTimezone === !!b.withTimezone &&
|
|
441
|
+
!!a.unsigned === !!b.unsigned);
|
|
486
442
|
}
|
|
487
443
|
/**
|
|
488
|
-
*
|
|
444
|
+
* Whether changing a column from one type to the other can lose what it holds.
|
|
445
|
+
*
|
|
446
|
+
* An unstated bound is the engine's widest, so stating one for the first time narrows the column just
|
|
447
|
+
* as lowering one does: `TEXT` to `VARCHAR(50)` truncates, and `NUMERIC` to `NUMERIC(5,2)` rounds.
|
|
489
448
|
*/
|
|
490
449
|
export function isBreakingTypeChange(from, to) {
|
|
491
|
-
// Changing category is always potentially breaking
|
|
492
450
|
if (from.category !== to.category) {
|
|
493
451
|
return true;
|
|
494
452
|
}
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
if (from.size && to.size) {
|
|
498
|
-
|
|
499
|
-
const toIndex = sizeOrder.indexOf(to.size);
|
|
500
|
-
if (toIndex < fromIndex) {
|
|
501
|
-
return true;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
// Reducing string length is breaking
|
|
505
|
-
if (from.category === 'string' && from.length && to.length) {
|
|
506
|
-
if (to.length < from.length) {
|
|
507
|
-
return true;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
// Reducing precision/scale is breaking
|
|
511
|
-
if (from.category === 'decimal') {
|
|
512
|
-
if (from.precision && to.precision && to.precision < from.precision) {
|
|
513
|
-
return true;
|
|
514
|
-
}
|
|
515
|
-
if (from.scale && to.scale && to.scale < from.scale) {
|
|
516
|
-
return true;
|
|
517
|
-
}
|
|
453
|
+
// Only where both state a size: an unsized member of a family is the engine's base type, wider than
|
|
454
|
+
// `tiny` and narrower than `big`, and which of those it is depends on a family this does not know.
|
|
455
|
+
if (from.size && to.size && SIZE_ORDER.indexOf(to.size) < SIZE_ORDER.indexOf(from.size)) {
|
|
456
|
+
return true;
|
|
518
457
|
}
|
|
519
|
-
return
|
|
458
|
+
return (narrows(from.length, to.length) ||
|
|
459
|
+
narrows(from.precision, to.precision) ||
|
|
460
|
+
narrows(from.scale, to.scale) ||
|
|
461
|
+
// Either direction drops half the range: signed loses the top bit, unsigned the negatives.
|
|
462
|
+
!!from.unsigned !== !!to.unsigned ||
|
|
463
|
+
// An offset the column stops keeping cannot be recovered from what is left.
|
|
464
|
+
(!!from.withTimezone && !to.withTimezone));
|
|
465
|
+
}
|
|
466
|
+
const SIZE_ORDER = ['tiny', 'small', 'medium', 'big'];
|
|
467
|
+
/** A bound narrowed, where an unstated one is unbounded. */
|
|
468
|
+
function narrows(from, to) {
|
|
469
|
+
return to !== undefined && (from === undefined || to < from);
|
|
520
470
|
}
|
|
521
471
|
/**
|
|
522
472
|
* Get the UQL ColumnType that best matches a canonical type.
|
|
@@ -65,7 +65,7 @@ function resolveColumnCanonicalType(field, seen = new Set()) {
|
|
|
65
65
|
return resolveColumnCanonicalType(referencedIdField, seen);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
return fieldOptionsToCanonical(field
|
|
68
|
+
return fieldOptionsToCanonical(field);
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* Add a table from entity metadata.
|
|
@@ -175,7 +175,8 @@ function diffColumn(tableName, source, target, opts) {
|
|
|
175
175
|
// key column" rule these two replace used to hide.
|
|
176
176
|
const generatedType = source.isAutoIncrement && target.isAutoIncrement;
|
|
177
177
|
const impliedNotNull = source.isPrimaryKey && target.isPrimaryKey;
|
|
178
|
-
|
|
178
|
+
const typeChanged = !generatedType && !areTypesEqual(opts.normalizeType(source.type), opts.normalizeType(target.type));
|
|
179
|
+
if (typeChanged) {
|
|
179
180
|
differences.push(`type: ${formatType(source.type)} → ${formatType(target.type)}`);
|
|
180
181
|
}
|
|
181
182
|
if (!impliedNotNull && source.nullable !== target.nullable) {
|
|
@@ -202,7 +203,10 @@ function diffColumn(tableName, source, target, opts) {
|
|
|
202
203
|
type: 'alter',
|
|
203
204
|
expected: source,
|
|
204
205
|
actual: target,
|
|
205
|
-
|
|
206
|
+
// Only the type this diff actually reports: a column altered for its default carries no data loss,
|
|
207
|
+
// and a generated key's type - never compared above - reads as unsigned against an entity that
|
|
208
|
+
// cannot say so.
|
|
209
|
+
isBreaking: typeChanged && isBreakingTypeChange(target.type, source.type),
|
|
206
210
|
description: differences.join(', '),
|
|
207
211
|
};
|
|
208
212
|
}
|