uql-orm 0.33.0 → 0.34.1

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 (52) hide show
  1. package/dist/bunSql/bunSqlQuerierPool.js +2 -1
  2. package/dist/cockroachdb/crdbQuerierPool.js +2 -1
  3. package/dist/d1/d1QuerierPool.js +2 -1
  4. package/dist/dialect/abstractDialect.d.ts +27 -3
  5. package/dist/dialect/abstractDialect.js +34 -4
  6. package/dist/dialect/abstractSqlDialect.d.ts +27 -1
  7. package/dist/dialect/abstractSqlDialect.js +74 -36
  8. package/dist/dialect/mysqlLikeSqlDialect.js +1 -0
  9. package/dist/dialect/pgLikeSqlDialect.js +1 -0
  10. package/dist/entity/metadata/definition.js +9 -0
  11. package/dist/http/handler.js +16 -1
  12. package/dist/libsql/libsqlQuerierPool.js +2 -1
  13. package/dist/maria/mariadbQuerierPool.js +2 -1
  14. package/dist/migrate/builder/migrationBuilder.js +3 -1
  15. package/dist/migrate/builder/tableBuilder.js +2 -1
  16. package/dist/migrate/generator/definitionToNode.js +3 -3
  17. package/dist/migrate/generator/mongoSchemaGenerator.js +7 -6
  18. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +12 -1
  19. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +16 -2
  20. package/dist/migrate/introspection/baseSqlIntrospector.d.ts +7 -1
  21. package/dist/migrate/introspection/baseSqlIntrospector.js +11 -12
  22. package/dist/migrate/introspection/mongoIntrospector.js +0 -1
  23. package/dist/migrate/introspection/mysqlIntrospector.d.ts +1 -0
  24. package/dist/migrate/introspection/mysqlIntrospector.js +8 -6
  25. package/dist/migrate/introspection/postgresIntrospector.d.ts +1 -0
  26. package/dist/migrate/introspection/postgresIntrospector.js +6 -5
  27. package/dist/migrate/migrator.d.ts +10 -1
  28. package/dist/migrate/migrator.js +30 -8
  29. package/dist/migrate/schemaGenerator.d.ts +16 -6
  30. package/dist/migrate/schemaGenerator.js +53 -24
  31. package/dist/mongo/mongoDialect.js +5 -4
  32. package/dist/mongo/mongodbQuerierPool.js +2 -1
  33. package/dist/mysql/mysql2QuerierPool.js +2 -1
  34. package/dist/neon/neonQuerierPool.js +2 -1
  35. package/dist/pglite/pgliteQuerierPool.js +2 -1
  36. package/dist/postgres/pgQuerierPool.js +2 -1
  37. package/dist/schema/schemaAST.d.ts +8 -1
  38. package/dist/schema/schemaAST.js +22 -13
  39. package/dist/schema/schemaASTBuilder.d.ts +5 -3
  40. package/dist/schema/schemaASTBuilder.js +24 -31
  41. package/dist/schema/types.d.ts +10 -3
  42. package/dist/sqlite/localSqliteQuerierPool.js +2 -1
  43. package/dist/sqlite/sqliteDialect.js +2 -1
  44. package/dist/turso/tursoLocalQuerierPool.js +2 -1
  45. package/dist/turso/tursoQuerierPool.js +2 -1
  46. package/dist/type/dialect.d.ts +6 -0
  47. package/dist/type/entity.d.ts +7 -0
  48. package/dist/type/migration.d.ts +16 -2
  49. package/dist/type/querier.d.ts +5 -0
  50. package/dist/util/sql.util.d.ts +17 -0
  51. package/dist/util/sql.util.js +23 -0
  52. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { SQL } from 'bun';
2
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
3
  import { MariaDialect } from '../maria/mariaDialect.js';
3
4
  import { MySqlDialect } from '../mysql/mysqlDialect.js';
4
5
  import { AbstractSqlQuerierPool } from '../querier/index.js';
@@ -20,7 +21,7 @@ export class BunSqlQuerierPool extends AbstractSqlQuerierPool {
20
21
  sqlDialectName;
21
22
  constructor(config, extra) {
22
23
  const dialectName = inferDialectName(config);
23
- super(new DialectMap[dialectName]({ namingStrategy: extra?.namingStrategy }), extra);
24
+ super(new DialectMap[dialectName](dialectOptionsFrom(extra)), extra);
24
25
  this.config = config;
25
26
  this.sqlDialectName = dialectName;
26
27
  const opts = normalizeBunOpts(config, dialectName);
@@ -1,4 +1,5 @@
1
1
  import { Pool, types } from 'pg';
2
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
3
  import { AbstractPgQuerierPool } from '../postgres/abstractPgQuerierPool.js';
3
4
  import { numericTypes } from '../postgres/pgNumericTypes.js';
4
5
  import { CockroachDialect } from './cockroachDialect.js';
@@ -8,7 +9,7 @@ import { CrdbQuerier } from './crdbQuerier.js';
8
9
  */
9
10
  export class CrdbQuerierPool extends AbstractPgQuerierPool {
10
11
  constructor(opts, extra) {
11
- super(new CockroachDialect({ namingStrategy: extra?.namingStrategy }), new Pool({ keepAlive: true, types: numericTypes(types), ...opts }), extra);
12
+ super(new CockroachDialect(dialectOptionsFrom(extra)), new Pool({ keepAlive: true, types: numericTypes(types), ...opts }), extra);
12
13
  }
13
14
  buildQuerier(connect) {
14
15
  return new CrdbQuerier(connect, this.dialect, this.extra);
@@ -1,10 +1,11 @@
1
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
1
2
  import { AbstractSqlQuerierPool } from '../querier/index.js';
2
3
  import { D1Querier } from './d1Querier.js';
3
4
  import { D1SqliteDialect } from './d1SqliteDialect.js';
4
5
  export class D1QuerierPool extends AbstractSqlQuerierPool {
5
6
  db;
6
7
  constructor(db, extra) {
7
- super(new D1SqliteDialect({ namingStrategy: extra?.namingStrategy }), extra);
8
+ super(new D1SqliteDialect(dialectOptionsFrom(extra)), extra);
8
9
  this.db = db;
9
10
  }
10
11
  async getQuerier() {
@@ -1,11 +1,19 @@
1
- import type { DialectFeatures, DialectName, EntityMeta, FieldOptions, InsertIdSource, NamingStrategy, QueryOptions, QueryWhere, QueryWhereMap, Type } from '../type/index.js';
1
+ import type { DialectFeatures, DialectName, EntityMeta, ExtraOptions, FieldOptions, InsertIdSource, NamingStrategy, QueryOptions, QueryWhere, QueryWhereMap } from '../type/index.js';
2
2
  /**
3
3
  * Options for initializing a dialect.
4
4
  */
5
5
  export interface DialectOptions {
6
6
  readonly namingStrategy?: NamingStrategy;
7
+ /** Default schema for entities naming none; unset leaves them unqualified. See {@link AbstractDialect.resolveSchema}. */
8
+ readonly schema?: string;
7
9
  readonly driverCapabilities?: Partial<DialectFeatures>;
8
10
  }
11
+ /**
12
+ * The dialect's share of a pool's {@link ExtraOptions}: what changes the SQL rather than the
13
+ * connection. Every pool builds its dialect through this, so a new option lands here instead of in
14
+ * each of the thirteen constructors.
15
+ */
16
+ export declare function dialectOptionsFrom(extra: ExtraOptions | undefined): DialectOptions;
9
17
  /**
10
18
  * Base abstract class for all database dialects (SQL and NoSQL).
11
19
  */
@@ -34,9 +42,25 @@ export declare abstract class AbstractDialect {
34
42
  */
35
43
  get features(): DialectFeatures;
36
44
  /**
37
- * Resolve the table name for an entity, applying naming strategy if necessary.
45
+ * The table's own name, unqualified, applying naming strategy if necessary. Also the alias the
46
+ * root table gets once {@link resolveTableName} qualifies it, so it has to stay a single
47
+ * identifier: columns are prefixed with it, and a dotted prefix escapes as one identifier that
48
+ * nothing declared.
49
+ */
50
+ resolveTableAlias<E>(meta: EntityMeta<E>): string;
51
+ /**
52
+ * Where the table lives: {@link resolveTableAlias} behind its schema, when one applies. The schema
53
+ * skips the naming strategy - it names an object the author wrote out, and snake_casing `myCrm`
54
+ * would point at one that does not exist.
55
+ */
56
+ resolveTableName<E>(meta: EntityMeta<E>): string;
57
+ /**
58
+ * Which schema an entity is read from: its own wins over the pool's default, the way its `name`
59
+ * wins over the naming strategy. `undefined` is an answer, not a gap - the table stays unqualified
60
+ * and resolves through the connection's `search_path`, which is what every deployment predating
61
+ * this option relies on.
38
62
  */
39
- resolveTableName<E>(entity: Type<E>, meta: EntityMeta<E>): string;
63
+ resolveSchema<E>(meta: EntityMeta<E>): string | undefined;
40
64
  /**
41
65
  * Resolve the column/field name for a property, applying naming strategy if necessary.
42
66
  */
@@ -1,4 +1,13 @@
1
1
  import { applyFilters, buildQueryWhereAsMap } from '../util/dialect.util.js';
2
+ import { qualifyName } from '../util/sql.util.js';
3
+ /**
4
+ * The dialect's share of a pool's {@link ExtraOptions}: what changes the SQL rather than the
5
+ * connection. Every pool builds its dialect through this, so a new option lands here instead of in
6
+ * each of the thirteen constructors.
7
+ */
8
+ export function dialectOptionsFrom(extra) {
9
+ return { namingStrategy: extra?.namingStrategy, schema: extra?.schema };
10
+ }
2
11
  /**
3
12
  * Base abstract class for all database dialects (SQL and NoSQL).
4
13
  */
@@ -25,15 +34,36 @@ export class AbstractDialect {
25
34
  return this.#features;
26
35
  }
27
36
  /**
28
- * Resolve the table name for an entity, applying naming strategy if necessary.
37
+ * The table's own name, unqualified, applying naming strategy if necessary. Also the alias the
38
+ * root table gets once {@link resolveTableName} qualifies it, so it has to stay a single
39
+ * identifier: columns are prefixed with it, and a dotted prefix escapes as one identifier that
40
+ * nothing declared.
29
41
  */
30
- resolveTableName(entity, meta) {
31
- const name = meta.name ?? entity.name;
32
- if (name !== entity.name || !this.namingStrategy) {
42
+ resolveTableAlias(meta) {
43
+ const className = meta.entity.name;
44
+ const name = meta.name ?? className;
45
+ if (name !== className || !this.namingStrategy) {
33
46
  return name;
34
47
  }
35
48
  return this.namingStrategy.tableName(name);
36
49
  }
50
+ /**
51
+ * Where the table lives: {@link resolveTableAlias} behind its schema, when one applies. The schema
52
+ * skips the naming strategy - it names an object the author wrote out, and snake_casing `myCrm`
53
+ * would point at one that does not exist.
54
+ */
55
+ resolveTableName(meta) {
56
+ return qualifyName(this.resolveTableAlias(meta), this.resolveSchema(meta));
57
+ }
58
+ /**
59
+ * Which schema an entity is read from: its own wins over the pool's default, the way its `name`
60
+ * wins over the naming strategy. `undefined` is an answer, not a gap - the table stays unqualified
61
+ * and resolves through the connection's `search_path`, which is what every deployment predating
62
+ * this option relies on.
63
+ */
64
+ resolveSchema(meta) {
65
+ return this.features.schemas ? (meta.schema ?? this.options.schema) : undefined;
66
+ }
37
67
  /**
38
68
  * Resolve the column/field name for a property, applying naming strategy if necessary.
39
69
  */
@@ -15,6 +15,12 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
15
15
  abstract readonly beginTransactionCommand: string;
16
16
  abstract readonly commitTransactionCommand: string;
17
17
  abstract readonly rollbackTransactionCommand: string;
18
+ /**
19
+ * How this engine declares a namespace, so a generated migration creates the schemas its tables
20
+ * need before creating them. Only reached where {@link DialectFeatures.schemas} is on. MySQL and
21
+ * MariaDB accept the same statement, where it means a database.
22
+ */
23
+ createSchemaSql(schema: string): string;
18
24
  readonly isolationLevelStrategy: 'inline' | 'set-before' | 'none';
19
25
  readonly alterColumnStrategy: 'separate-clauses' | 'single-statement';
20
26
  readonly alterColumnSyntax: 'ALTER COLUMN' | 'MODIFY COLUMN' | 'none';
@@ -68,10 +74,24 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
68
74
  */
69
75
  protected appendTextSearch<E>(_ctx: QueryContext, _entity: Type<E>, _meta: EntityMeta<E>, _search: QueryTextSearchOptions<E>): void;
70
76
  select<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, opts?: QueryOptions, joins?: QueryJoins): void;
77
+ /**
78
+ * The table as a statement writes it, each part escaped on its own rather than as one dotted
79
+ * string taken apart again by {@link escapeId}.
80
+ */
81
+ protected escapedTableName<E>(meta: EntityMeta<E>): string;
82
+ /**
83
+ * A FROM or JOIN operand plus the alias to prefix its columns by, aliased only once a schema puts
84
+ * something in front of the name. See {@link resolveTableAlias} for why the prefix cannot be the
85
+ * qualified path.
86
+ */
87
+ protected tableRef<E>(meta: EntityMeta<E>): {
88
+ alias: string;
89
+ ref: string;
90
+ };
71
91
  /** Columns are alias-qualified once anything else is in play: a join, or a to-many being filled. */
72
92
  private resolveRelationAwarePrefix;
73
93
  protected selectRelationFields(ctx: QueryContext, joins: QueryJoins): void;
74
- protected selectRelationJoins<E>(ctx: QueryContext, meta: EntityMeta<E>, tableName: string, joins: QueryJoins): void;
94
+ protected selectRelationJoins<E>(ctx: QueryContext, meta: EntityMeta<E>, rootAlias: string, joins: QueryJoins): void;
75
95
  where<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
76
96
  /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
77
97
  protected renderWhere<E>(ctx: QueryContext, entity: Type<E>, where?: QueryWhere<E>, opts?: QueryWhereOptions): void;
@@ -281,6 +301,12 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
281
301
  protected getUpsertConflictPathsStr<E>(meta: EntityMeta<E>, conflictPaths: QueryConflictPaths<E>): string;
282
302
  delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void;
283
303
  escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
304
+ /**
305
+ * A name behind its schema, each part escaped on its own rather than as one dotted string taken
306
+ * apart again by {@link escapeId}. Tables and their indexes both use it, since an index lives in
307
+ * the schema of the table it is on.
308
+ */
309
+ escapeQualifiedId(name: string, schema: string | undefined): string;
284
310
  /**
285
311
  * Bind one persisted value, classifying its column on the spot. Dialects override
286
312
  * {@link appendJsonValue} and {@link appendVectorValue} rather than this, so the chain runs once per
@@ -9,6 +9,14 @@ import { SqlQueryContext } from './queryContext.js';
9
9
  import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
10
10
  import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
11
11
  export class AbstractSqlDialect extends IndexSqlDialect {
12
+ /**
13
+ * How this engine declares a namespace, so a generated migration creates the schemas its tables
14
+ * need before creating them. Only reached where {@link DialectFeatures.schemas} is on. MySQL and
15
+ * MariaDB accept the same statement, where it means a database.
16
+ */
17
+ createSchemaSql(schema) {
18
+ return `CREATE SCHEMA IF NOT EXISTS ${this.escapeId(schema, true)}`;
19
+ }
12
20
  isolationLevelStrategy = 'inline';
13
21
  alterColumnStrategy = 'single-statement';
14
22
  alterColumnSyntax = 'ALTER COLUMN';
@@ -89,8 +97,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
89
97
  }
90
98
  search(ctx, entity, q = {}, opts = {}, joins = NO_JOINS) {
91
99
  const meta = getMeta(entity);
92
- const tableName = this.resolveTableName(entity, meta);
93
- const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, q.$populate, joins);
100
+ const prefix = this.resolveRelationAwarePrefix(this.resolveTableAlias(meta), meta, opts, q.$populate, joins);
94
101
  if (opts.prefix !== prefix) {
95
102
  opts = { ...opts, prefix };
96
103
  }
@@ -166,8 +173,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
166
173
  }
167
174
  select(ctx, entity, q, opts = {}, joins = NO_JOINS) {
168
175
  const meta = getMeta(entity);
169
- const tableName = this.resolveTableName(entity, meta);
170
- const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, q.$populate, joins);
176
+ const { alias, ref } = this.tableRef(meta);
177
+ const prefix = this.resolveRelationAwarePrefix(alias, meta, opts, q.$populate, joins);
171
178
  ctx.append(q.$distinct ? 'SELECT DISTINCT ' : 'SELECT ');
172
179
  this.selectFields(ctx, entity, q.$select, { prefix }, q.$exclude);
173
180
  // Add related fields BEFORE FROM clause
@@ -179,9 +186,27 @@ export class AbstractSqlDialect extends IndexSqlDialect {
179
186
  this.appendVectorProjection(ctx, meta, key, val);
180
187
  }
181
188
  }
182
- ctx.append(` FROM ${this.escapeId(tableName)}`);
189
+ ctx.append(` FROM ${ref}`);
183
190
  // Add JOINs AFTER FROM clause
184
- this.selectRelationJoins(ctx, meta, tableName, joins);
191
+ this.selectRelationJoins(ctx, meta, alias, joins);
192
+ }
193
+ /**
194
+ * The table as a statement writes it, each part escaped on its own rather than as one dotted
195
+ * string taken apart again by {@link escapeId}.
196
+ */
197
+ escapedTableName(meta) {
198
+ return this.escapeQualifiedId(this.resolveTableAlias(meta), this.resolveSchema(meta));
199
+ }
200
+ /**
201
+ * A FROM or JOIN operand plus the alias to prefix its columns by, aliased only once a schema puts
202
+ * something in front of the name. See {@link resolveTableAlias} for why the prefix cannot be the
203
+ * qualified path.
204
+ */
205
+ tableRef(meta) {
206
+ const alias = this.resolveTableAlias(meta);
207
+ const name = this.escapedTableName(meta);
208
+ const schema = this.resolveSchema(meta);
209
+ return { alias, ref: schema ? `${name} ${this.escapeId(alias, true)}` : name };
185
210
  }
186
211
  /** Columns are alias-qualified once anything else is in play: a join, or a to-many being filled. */
187
212
  resolveRelationAwarePrefix(tableName, meta, opts, populate, joins) {
@@ -198,11 +223,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
198
223
  this.selectFields(ctx, join.entity, join.query.$select, { prefix: join.path, autoPrefixAlias: true }, join.query.$exclude);
199
224
  }
200
225
  }
201
- selectRelationJoins(ctx, meta, tableName, joins) {
226
+ selectRelationJoins(ctx, meta, rootAlias, joins) {
202
227
  for (const join of joins.values()) {
203
228
  const joinAlias = this.escapeId(join.path, true);
204
- const parentAlias = join.parent ? this.escapeId(join.parent.path, true) : this.escapeId(tableName);
205
- ctx.append(` ${join.required ? 'INNER' : 'LEFT'} JOIN ${this.escapeId(this.resolveTableName(join.entity, join.meta))} ${joinAlias} ON `);
229
+ const parentAlias = join.parent ? this.escapeId(join.parent.path, true) : this.escapeId(rootAlias, true);
230
+ ctx.append(` ${join.required ? 'INNER' : 'LEFT'} JOIN ${this.escapedTableName(join.meta)} ${joinAlias} ON `);
206
231
  join.relation.references.forEach((reference, index) => {
207
232
  if (index > 0)
208
233
  ctx.append(' AND ');
@@ -264,11 +289,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
264
289
  if (val instanceof QueryRaw) {
265
290
  if (key === '$exists' || key === '$nexists') {
266
291
  ctx.append(key === '$exists' ? 'EXISTS (' : 'NOT EXISTS (');
267
- const tableName = this.resolveTableName(entity, meta);
292
+ // The alias: the enclosing statement declares one, and Postgres forbids reaching past it
293
+ // to the qualified name it aliased.
294
+ const alias = this.resolveTableAlias(meta);
268
295
  this.getRawValue(ctx, {
269
296
  value: val,
270
- prefix: tableName,
271
- escapedPrefix: this.escapeId(tableName, false, true),
297
+ prefix: alias,
298
+ escapedPrefix: this.escapeId(alias, true, true),
272
299
  });
273
300
  ctx.append(')');
274
301
  return;
@@ -735,7 +762,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
735
762
  }
736
763
  this.assertLockSupported(entity, q, joins);
737
764
  const meta = getMeta(entity);
738
- const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
765
+ // `OF` names the alias in the FROM, never the schema-qualified path it was aliased from.
766
+ const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableAlias(meta), true)}` : '';
739
767
  const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
740
768
  ctx.append(` FOR UPDATE${target}${suffix}`);
741
769
  }
@@ -757,7 +785,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
757
785
  ]);
758
786
  aggregate(ctx, entity, q, opts = {}) {
759
787
  const meta = getMeta(entity);
760
- const tableName = this.resolveTableName(entity, meta);
788
+ const tableName = this.escapedTableName(meta);
761
789
  const groupKeys = [];
762
790
  const selectParts = [];
763
791
  // Every column the statement emits, mapped to the SQL that references it. `$having` and `$sort`
@@ -787,7 +815,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
787
815
  if (!selectParts.length) {
788
816
  throw new TypeError('aggregate requires at least one $group column or $agg function');
789
817
  }
790
- ctx.append(`SELECT ${selectParts.join(', ')} FROM ${this.escapeId(tableName)}`);
818
+ ctx.append(`SELECT ${selectParts.join(', ')} FROM ${tableName}`);
791
819
  this.where(ctx, entity, q.$where, opts);
792
820
  if (groupKeys.length) {
793
821
  ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
@@ -893,8 +921,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
893
921
  columns[i] = this.escapedColumnName(meta, key);
894
922
  kinds[i] = this.persistKind(field);
895
923
  }
896
- const tableName = this.resolveTableName(entity, meta);
897
- ctx.append(`INSERT INTO ${this.escapeId(tableName)} (${columns.join(', ')}) VALUES (`);
924
+ const tableName = this.escapedTableName(meta);
925
+ ctx.append(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (`);
898
926
  for (let r = 0; r < payloads.length; r++) {
899
927
  if (r > 0) {
900
928
  ctx.append('), (');
@@ -931,8 +959,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
931
959
  const meta = getMeta(entity);
932
960
  const [filledPayload] = fillOnFields(meta, payload, 'onUpdate');
933
961
  const keys = filterFieldKeys(meta, filledPayload, 'onUpdate');
934
- const tableName = this.resolveTableName(entity, meta);
935
- ctx.append(`UPDATE ${this.escapeId(tableName)} SET `);
962
+ const tableName = this.escapedTableName(meta);
963
+ ctx.append(`UPDATE ${tableName} SET `);
936
964
  for (let i = 0; i < keys.length; i++) {
937
965
  if (i > 0) {
938
966
  ctx.append(', ');
@@ -1014,14 +1042,14 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1014
1042
  }
1015
1043
  delete(ctx, entity, q, opts = {}) {
1016
1044
  const meta = getMeta(entity);
1017
- const tableName = this.resolveTableName(entity, meta);
1045
+ const tableName = this.escapedTableName(meta);
1018
1046
  // Soft-delete (stamp only live rows) unless `hardDelete` is requested or the entity has no
1019
1047
  // soft-delete field (e.g. a cascade onto a non-soft-deletable child).
1020
1048
  if (!opts.hardDelete && meta.softDelete) {
1021
1049
  const field = meta.fields[meta.softDelete];
1022
1050
  if (field) {
1023
1051
  const columnName = this.resolveColumnName(meta.softDelete, field);
1024
- ctx.append(`UPDATE ${this.escapeId(tableName)} SET ${this.escapeId(columnName)} = `);
1052
+ ctx.append(`UPDATE ${tableName} SET ${this.escapeId(columnName)} = `);
1025
1053
  this.formatPersistableValue(ctx, field, getSoftDeleteValue(field));
1026
1054
  this.search(ctx, entity, q, opts);
1027
1055
  return;
@@ -1029,12 +1057,21 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1029
1057
  }
1030
1058
  // Hard delete removes matching rows regardless of soft-delete state (keeps other filters, e.g. tenant).
1031
1059
  // Only rewrite the filters when there is a soft-delete filter to disable.
1032
- ctx.append(`DELETE FROM ${this.escapeId(tableName)}`);
1060
+ ctx.append(`DELETE FROM ${tableName}`);
1033
1061
  this.search(ctx, entity, q, meta.softDelete ? { ...opts, filters: withoutSoftDeleteFilter(opts.filters) } : opts);
1034
1062
  }
1035
1063
  escapeId(val, forbidQualified, addDot) {
1036
1064
  return escapeSqlId(val, this.escapeIdChar, forbidQualified, addDot);
1037
1065
  }
1066
+ /**
1067
+ * A name behind its schema, each part escaped on its own rather than as one dotted string taken
1068
+ * apart again by {@link escapeId}. Tables and their indexes both use it, since an index lives in
1069
+ * the schema of the table it is on.
1070
+ */
1071
+ escapeQualifiedId(name, schema) {
1072
+ const escaped = this.escapeId(name, true);
1073
+ return schema ? `${this.escapeId(schema, true)}.${escaped}` : escaped;
1074
+ }
1038
1075
  /**
1039
1076
  * Bind one persisted value, classifying its column on the spot. Dialects override
1040
1077
  * {@link appendJsonValue} and {@link appendVectorValue} rather than this, so the chain runs once per
@@ -1353,38 +1390,39 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1353
1390
  */
1354
1391
  appendRelationSubquery(ctx, entity, rel, opts, projection, val) {
1355
1392
  const meta = getMeta(entity);
1356
- const parentTable = this.resolveTableName(entity, meta);
1393
+ // Aliases, not paths, everywhere a column is prefixed; `tableRef` declares them in the FROM.
1394
+ const parentAlias = this.resolveTableAlias(meta);
1357
1395
  const references = rel.references;
1358
- const escapedParentId = this.escapedParentColumn(parentTable, meta, opts, meta.id);
1396
+ const escapedParentId = this.escapedParentColumn(parentAlias, meta, opts, meta.id);
1359
1397
  const relatedEntity = rel.entity();
1360
1398
  const relatedMeta = getMeta(relatedEntity);
1361
- const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
1399
+ const { alias: relatedAlias, ref: relatedRef } = this.tableRef(relatedMeta);
1362
1400
  // Resolved before any SQL is emitted: it also decides whether the mm form reaches the target.
1363
1401
  const targetWhere = this.scopedWhereMap(relatedMeta, val);
1364
1402
  ctx.append(`(SELECT ${projection} FROM `);
1365
1403
  if (rel.cardinality === 'mm' && rel.through) {
1366
1404
  const throughEntity = rel.through();
1367
1405
  const throughMeta = getMeta(throughEntity);
1368
- const throughTable = this.resolveTableName(throughEntity, throughMeta);
1369
- ctx.append(this.escapeId(throughTable));
1370
- ctx.append(` WHERE ${this.escapedColumn(throughTable, throughMeta, references[0].local)} = ${escapedParentId}`);
1406
+ const { alias: throughAlias, ref: throughRef } = this.tableRef(throughMeta);
1407
+ ctx.append(throughRef);
1408
+ ctx.append(` WHERE ${this.escapedColumn(throughAlias, throughMeta, references[0].local)} = ${escapedParentId}`);
1371
1409
  // The junction is a row being read too: a soft-deleted link is not a link.
1372
- this.where(ctx, throughEntity, {}, { prefix: throughTable, clause: 'AND' });
1410
+ this.where(ctx, throughEntity, {}, { prefix: throughAlias, clause: 'AND' });
1373
1411
  if (hasKeys(targetWhere)) {
1374
- ctx.append(` AND ${this.escapedColumn(throughTable, throughMeta, references[1].local)} IN (`);
1375
- ctx.append(`SELECT ${this.escapedColumn(relatedTable, relatedMeta, relatedMeta.id)} FROM ${this.escapeId(relatedTable)}`);
1376
- this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedTable, clause: 'WHERE' });
1412
+ ctx.append(` AND ${this.escapedColumn(throughAlias, throughMeta, references[1].local)} IN (`);
1413
+ ctx.append(`SELECT ${this.escapedColumn(relatedAlias, relatedMeta, relatedMeta.id)} FROM ${relatedRef}`);
1414
+ this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedAlias, clause: 'WHERE' });
1377
1415
  ctx.append(')');
1378
1416
  }
1379
1417
  }
1380
1418
  else {
1381
- const joinLeft = this.escapedColumn(relatedTable, relatedMeta, references[0].foreign);
1419
+ const joinLeft = this.escapedColumn(relatedAlias, relatedMeta, references[0].foreign);
1382
1420
  const joinRight = rel.cardinality === '1m'
1383
1421
  ? escapedParentId
1384
- : this.escapedParentColumn(parentTable, meta, opts, references[0].local);
1385
- ctx.append(this.escapeId(relatedTable));
1422
+ : this.escapedParentColumn(parentAlias, meta, opts, references[0].local);
1423
+ ctx.append(relatedRef);
1386
1424
  ctx.append(` WHERE ${joinLeft} = ${joinRight}`);
1387
- this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedTable, clause: 'AND' });
1425
+ this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedAlias, clause: 'AND' });
1388
1426
  }
1389
1427
  ctx.append(')');
1390
1428
  }
@@ -23,6 +23,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
23
23
  supportsJsonb: false,
24
24
  ifNotExists: true,
25
25
  indexIfNotExists: false,
26
+ schemas: true,
26
27
  dropTableCascade: false,
27
28
  renameColumn: true,
28
29
  foreignKeyAlter: true,
@@ -20,6 +20,7 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
20
20
  supportsJsonb: true,
21
21
  ifNotExists: true,
22
22
  indexIfNotExists: true,
23
+ schemas: true,
23
24
  dropTableCascade: true,
24
25
  renameColumn: true,
25
26
  foreignKeyAlter: true,
@@ -104,6 +104,14 @@ export function applyMembers(entity, specs) {
104
104
  }
105
105
  }
106
106
  export function defineEntity(entity, opts = {}) {
107
+ // Ahead of any registration, so a rejected definition leaves nothing half-written in the registry.
108
+ // A dotted name reads like a schema and is not one: it escapes as a single identifier, so the
109
+ // statement builds and then fails at the database. `schema` is the way to say it.
110
+ if (opts.name?.includes('.')) {
111
+ const [schema, ...rest] = opts.name.split('.');
112
+ throw new TypeError(`'${entity.name}' has a dotted name '${opts.name}'. Name the schema separately as ` +
113
+ `{ schema: '${schema}', name: '${rest.join('.')}' }.`);
114
+ }
107
115
  const meta = ensureMeta(entity);
108
116
  // Covers `defineEntity(Decorated)` called on a class whose members carry decorators. `@Entity()`
109
117
  // drains `context.metadata` itself, because TypeScript only attaches `Symbol.metadata` to the class
@@ -121,6 +129,7 @@ export function defineEntity(entity, opts = {}) {
121
129
  throw TypeError(`'${entity.name}' must have fields`);
122
130
  }
123
131
  meta.name = opts.name ?? entity.name;
132
+ meta.schema = opts.schema;
124
133
  let proto = Object.getPrototypeOf(entity.prototype);
125
134
  while (proto.constructor !== Object) {
126
135
  const parent = proto.constructor;
@@ -2,6 +2,11 @@ import { withContext } from '../context/context.js';
2
2
  import { getEntities, getMeta } from '../entity/index.js';
3
3
  import { entityPath, matchRoute, } from './contract.js';
4
4
  import { parseQueryParams } from './query.js';
5
+ /** `Company (crm.Company)`: the class, and the table it maps, which is what tells two apart. */
6
+ function tableOf(entity) {
7
+ const meta = getMeta(entity);
8
+ return `${entity.name} (${meta.schema ? `${meta.schema}.${meta.name}` : meta.name})`;
9
+ }
5
10
  export function createRequestHandler(opts) {
6
11
  const { include, exclude, pre, preSave, preFilter, post, getContext, pool } = opts;
7
12
  let entities = include ?? getEntities();
@@ -11,8 +16,18 @@ export function createRequestHandler(opts) {
11
16
  if (!entities.length) {
12
17
  throw new TypeError('no entities for the uql middleware');
13
18
  }
19
+ // The route is the class name, so two entities mapping one table in different schemas collide here
20
+ // even though nothing else about them does. All of them at once, so fixing the first collision
21
+ // does not just reveal the next.
22
+ const byPath = Map.groupBy(entities, entityPath);
23
+ const collisions = [...byPath].filter(([, clashing]) => clashing.length > 1);
24
+ if (collisions.length) {
25
+ const lines = collisions.map(([path, clashing]) => ` /${path} <- ${clashing.map(tableOf).join(', ')}`);
26
+ throw new TypeError(`every entity below shares a route with another, so all but the first are unreachable:\n${lines.join('\n')}\n` +
27
+ "A route is the kebab-cased class name. Rename a class, or pass only one of them in 'include'.");
28
+ }
14
29
  // biome-ignore lint/suspicious/noExplicitAny: heterogeneous entity map
15
- const entityByPath = new Map(entities.map((entity) => [entityPath(entity), entity]));
30
+ const entityByPath = new Map([...byPath].map(([path, [entity]]) => [path, entity]));
16
31
  return (req) => {
17
32
  const entity = entityByPath.get(req.entityPath);
18
33
  if (!entity) {
@@ -1,3 +1,4 @@
1
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
1
2
  import { AbstractHranaQuerierPool } from '../sqlite/hranaQuerierPool.js';
2
3
  import { LibsqlDialect } from './libsqlDialect.js';
3
4
  import { LibsqlQuerier } from './libsqlQuerier.js';
@@ -13,7 +14,7 @@ function remoteMigrationClientConfig(config) {
13
14
  export class LibsqlQuerierPool extends AbstractHranaQuerierPool {
14
15
  conf;
15
16
  constructor(conf, extra) {
16
- super(new LibsqlDialect({ namingStrategy: extra?.namingStrategy }), extra);
17
+ super(new LibsqlDialect(dialectOptionsFrom(extra)), extra);
17
18
  this.conf = conf;
18
19
  }
19
20
  openClient() {
@@ -1,4 +1,5 @@
1
1
  import { createPool } from 'mariadb';
2
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
3
  import { AbstractSqlQuerierPool } from '../querier/index.js';
3
4
  import { attachPoolErrorHandler } from '../util/index.js';
4
5
  import { MariaDialect } from './mariaDialect.js';
@@ -6,7 +7,7 @@ import { MariadbQuerier } from './mariadbQuerier.js';
6
7
  export class MariadbQuerierPool extends AbstractSqlQuerierPool {
7
8
  pool;
8
9
  constructor(opts, extra) {
9
- super(new MariaDialect({ namingStrategy: extra?.namingStrategy }), extra);
10
+ super(new MariaDialect(dialectOptionsFrom(extra)), extra);
10
11
  // `mariadb` defaults to handing BIGINT back as a BigInt, and uql maps `type: Number` to BIGINT
11
12
  // (see `schema/canonicalType.ts`), so every auto-increment id reached a field declared `number`
12
13
  // as `9n` without this. Same trade as the pg pools: exact to 2^53, and `...opts` wins for a
@@ -6,6 +6,7 @@
6
6
  * - MigrationBuilder: Execute DDL operations (for integration tests/runtime)
7
7
  */
8
8
  import { normalizeIndexColumn } from '../../util/index.js';
9
+ import { derivedIndexName } from '../../util/sql.util.js';
9
10
  import { createSchemaGenerator } from '../schemaGenerator.js';
10
11
  import { splitSqlStatements } from './splitSqlStatements.js';
11
12
  import { TableBuilder } from './tableBuilder.js';
@@ -26,7 +27,8 @@ function createIndexOperation(tableName, columns, options = {}) {
26
27
  tableName,
27
28
  index: {
28
29
  ...index,
29
- name: name ?? `idx_${tableName}_${entries.map((entry) => entry.column).join('_')}`,
30
+ name: name ??
31
+ derivedIndexName(tableName, entries.map((entry) => entry.column)),
30
32
  entries,
31
33
  unique: unique ?? false,
32
34
  },
@@ -4,6 +4,7 @@
4
4
  * Fluent API for defining tables in migrations.
5
5
  */
6
6
  import { normalizeIndexColumn } from '../../util/index.js';
7
+ import { derivedIndexName } from '../../util/sql.util.js';
7
8
  import { ColumnBuilder } from './columnBuilder.js';
8
9
  import { t } from './expressions.js';
9
10
  /**
@@ -190,7 +191,7 @@ export class TableBuilder {
190
191
  // Collect column-level indexes
191
192
  for (const col of columns) {
192
193
  if (col.index) {
193
- const indexName = typeof col.index === 'string' ? col.index : `idx_${this._name}_${col.name}`;
194
+ const indexName = typeof col.index === 'string' ? col.index : derivedIndexName(this._name, [col.name]);
194
195
  // Only add if not already in table-level indexes
195
196
  if (!this._indexes.some((idx) => idx.name === indexName)) {
196
197
  this._indexes.push({
@@ -1,3 +1,4 @@
1
+ import { derivedForeignKeyName } from '../../util/sql.util.js';
1
2
  /**
2
3
  * A migration builder's table definition as the AST nodes the generators render from, so a hand-written
3
4
  * `createTable` and an entity reach `generateCreateTableFromNode` in the same shape. Free functions and
@@ -11,7 +12,6 @@ export function tableDefinitionToNode(def) {
11
12
  columns,
12
13
  primaryKey: [], // placeholder
13
14
  indexes: [],
14
- schema: { tables: new Map(), relationships: [], indexes: [] },
15
15
  incomingRelations: [],
16
16
  outgoingRelations: [],
17
17
  comment: def.comment,
@@ -33,7 +33,7 @@ export function tableDefinitionToNode(def) {
33
33
  }
34
34
  for (const fkDef of def.foreignKeys) {
35
35
  const relNode = {
36
- name: fkDef.name ?? `fk_${def.name}_${fkDef.columns.join('_')}`,
36
+ name: fkDef.name ?? derivedForeignKeyName(def.name, fkDef.columns),
37
37
  type: 'ManyToOne', // Builder default
38
38
  from: {
39
39
  table,
@@ -64,7 +64,7 @@ export function fullColumnDefinitionToNode(col, tableName) {
64
64
  referencedBy: [],
65
65
  references: col.foreignKey
66
66
  ? {
67
- name: `fk_${tableName}_${col.name}`,
67
+ name: derivedForeignKeyName(tableName, [col.name]),
68
68
  type: 'ManyToOne',
69
69
  from: { table: { name: tableName }, columns: [] },
70
70
  to: {