uql-orm 0.33.0 → 0.34.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.
Files changed (34) 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 +35 -4
  6. package/dist/dialect/abstractSqlDialect.d.ts +21 -1
  7. package/dist/dialect/abstractSqlDialect.js +67 -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/generator/mongoSchemaGenerator.js +4 -4
  15. package/dist/migrate/migrator.js +2 -2
  16. package/dist/migrate/schemaGenerator.d.ts +7 -1
  17. package/dist/migrate/schemaGenerator.js +20 -6
  18. package/dist/mongo/mongoDialect.js +5 -4
  19. package/dist/mongo/mongodbQuerierPool.js +2 -1
  20. package/dist/mysql/mysql2QuerierPool.js +2 -1
  21. package/dist/neon/neonQuerierPool.js +2 -1
  22. package/dist/pglite/pgliteQuerierPool.js +2 -1
  23. package/dist/postgres/pgQuerierPool.js +2 -1
  24. package/dist/schema/schemaASTBuilder.d.ts +1 -1
  25. package/dist/schema/schemaASTBuilder.js +10 -9
  26. package/dist/sqlite/localSqliteQuerierPool.js +2 -1
  27. package/dist/sqlite/sqliteDialect.js +2 -1
  28. package/dist/turso/tursoLocalQuerierPool.js +2 -1
  29. package/dist/turso/tursoQuerierPool.js +2 -1
  30. package/dist/type/dialect.d.ts +6 -0
  31. package/dist/type/entity.d.ts +7 -0
  32. package/dist/type/migration.d.ts +1 -1
  33. package/dist/type/querier.d.ts +5 -0
  34. 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,12 @@
1
1
  import { applyFilters, buildQueryWhereAsMap } from '../util/dialect.util.js';
2
+ /**
3
+ * The dialect's share of a pool's {@link ExtraOptions}: what changes the SQL rather than the
4
+ * connection. Every pool builds its dialect through this, so a new option lands here instead of in
5
+ * each of the thirteen constructors.
6
+ */
7
+ export function dialectOptionsFrom(extra) {
8
+ return { namingStrategy: extra?.namingStrategy, schema: extra?.schema };
9
+ }
2
10
  /**
3
11
  * Base abstract class for all database dialects (SQL and NoSQL).
4
12
  */
@@ -25,15 +33,38 @@ export class AbstractDialect {
25
33
  return this.#features;
26
34
  }
27
35
  /**
28
- * Resolve the table name for an entity, applying naming strategy if necessary.
36
+ * The table's own name, unqualified, applying naming strategy if necessary. Also the alias the
37
+ * root table gets once {@link resolveTableName} qualifies it, so it has to stay a single
38
+ * identifier: columns are prefixed with it, and a dotted prefix escapes as one identifier that
39
+ * nothing declared.
29
40
  */
30
- resolveTableName(entity, meta) {
31
- const name = meta.name ?? entity.name;
32
- if (name !== entity.name || !this.namingStrategy) {
41
+ resolveTableAlias(meta) {
42
+ const className = meta.entity.name;
43
+ const name = meta.name ?? className;
44
+ if (name !== className || !this.namingStrategy) {
33
45
  return name;
34
46
  }
35
47
  return this.namingStrategy.tableName(name);
36
48
  }
49
+ /**
50
+ * Where the table lives: {@link resolveTableAlias} behind its schema, when one applies. The schema
51
+ * skips the naming strategy - it names an object the author wrote out, and snake_casing `myCrm`
52
+ * would point at one that does not exist.
53
+ */
54
+ resolveTableName(meta) {
55
+ const name = this.resolveTableAlias(meta);
56
+ const schema = this.resolveSchema(meta);
57
+ return schema ? `${schema}.${name}` : name;
58
+ }
59
+ /**
60
+ * Which schema an entity is read from: its own wins over the pool's default, the way its `name`
61
+ * wins over the naming strategy. `undefined` is an answer, not a gap - the table stays unqualified
62
+ * and resolves through the connection's `search_path`, which is what every deployment predating
63
+ * this option relies on.
64
+ */
65
+ resolveSchema(meta) {
66
+ return this.features.schemas ? (meta.schema ?? this.options.schema) : undefined;
67
+ }
37
68
  /**
38
69
  * Resolve the column/field name for a property, applying naming strategy if necessary.
39
70
  */
@@ -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;
@@ -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,29 @@ 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
+ const alias = this.escapeId(this.resolveTableAlias(meta), true);
199
+ const schema = this.resolveSchema(meta);
200
+ return schema ? `${this.escapeId(schema, true)}.${alias}` : alias;
201
+ }
202
+ /**
203
+ * A FROM or JOIN operand plus the alias to prefix its columns by, aliased only once a schema puts
204
+ * something in front of the name. See {@link resolveTableAlias} for why the prefix cannot be the
205
+ * qualified path.
206
+ */
207
+ tableRef(meta) {
208
+ const alias = this.resolveTableAlias(meta);
209
+ const name = this.escapedTableName(meta);
210
+ const schema = this.resolveSchema(meta);
211
+ return { alias, ref: schema ? `${name} ${this.escapeId(alias, true)}` : name };
185
212
  }
186
213
  /** Columns are alias-qualified once anything else is in play: a join, or a to-many being filled. */
187
214
  resolveRelationAwarePrefix(tableName, meta, opts, populate, joins) {
@@ -198,11 +225,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
198
225
  this.selectFields(ctx, join.entity, join.query.$select, { prefix: join.path, autoPrefixAlias: true }, join.query.$exclude);
199
226
  }
200
227
  }
201
- selectRelationJoins(ctx, meta, tableName, joins) {
228
+ selectRelationJoins(ctx, meta, rootAlias, joins) {
202
229
  for (const join of joins.values()) {
203
230
  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 `);
231
+ const parentAlias = join.parent ? this.escapeId(join.parent.path, true) : this.escapeId(rootAlias, true);
232
+ ctx.append(` ${join.required ? 'INNER' : 'LEFT'} JOIN ${this.escapedTableName(join.meta)} ${joinAlias} ON `);
206
233
  join.relation.references.forEach((reference, index) => {
207
234
  if (index > 0)
208
235
  ctx.append(' AND ');
@@ -264,11 +291,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
264
291
  if (val instanceof QueryRaw) {
265
292
  if (key === '$exists' || key === '$nexists') {
266
293
  ctx.append(key === '$exists' ? 'EXISTS (' : 'NOT EXISTS (');
267
- const tableName = this.resolveTableName(entity, meta);
294
+ // The alias: the enclosing statement declares one, and Postgres forbids reaching past it
295
+ // to the qualified name it aliased.
296
+ const alias = this.resolveTableAlias(meta);
268
297
  this.getRawValue(ctx, {
269
298
  value: val,
270
- prefix: tableName,
271
- escapedPrefix: this.escapeId(tableName, false, true),
299
+ prefix: alias,
300
+ escapedPrefix: this.escapeId(alias, true, true),
272
301
  });
273
302
  ctx.append(')');
274
303
  return;
@@ -735,7 +764,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
735
764
  }
736
765
  this.assertLockSupported(entity, q, joins);
737
766
  const meta = getMeta(entity);
738
- const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
767
+ // `OF` names the alias in the FROM, never the schema-qualified path it was aliased from.
768
+ const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableAlias(meta), true)}` : '';
739
769
  const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
740
770
  ctx.append(` FOR UPDATE${target}${suffix}`);
741
771
  }
@@ -757,7 +787,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
757
787
  ]);
758
788
  aggregate(ctx, entity, q, opts = {}) {
759
789
  const meta = getMeta(entity);
760
- const tableName = this.resolveTableName(entity, meta);
790
+ const tableName = this.escapedTableName(meta);
761
791
  const groupKeys = [];
762
792
  const selectParts = [];
763
793
  // Every column the statement emits, mapped to the SQL that references it. `$having` and `$sort`
@@ -787,7 +817,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
787
817
  if (!selectParts.length) {
788
818
  throw new TypeError('aggregate requires at least one $group column or $agg function');
789
819
  }
790
- ctx.append(`SELECT ${selectParts.join(', ')} FROM ${this.escapeId(tableName)}`);
820
+ ctx.append(`SELECT ${selectParts.join(', ')} FROM ${tableName}`);
791
821
  this.where(ctx, entity, q.$where, opts);
792
822
  if (groupKeys.length) {
793
823
  ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
@@ -893,8 +923,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
893
923
  columns[i] = this.escapedColumnName(meta, key);
894
924
  kinds[i] = this.persistKind(field);
895
925
  }
896
- const tableName = this.resolveTableName(entity, meta);
897
- ctx.append(`INSERT INTO ${this.escapeId(tableName)} (${columns.join(', ')}) VALUES (`);
926
+ const tableName = this.escapedTableName(meta);
927
+ ctx.append(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (`);
898
928
  for (let r = 0; r < payloads.length; r++) {
899
929
  if (r > 0) {
900
930
  ctx.append('), (');
@@ -931,8 +961,8 @@ export class AbstractSqlDialect extends IndexSqlDialect {
931
961
  const meta = getMeta(entity);
932
962
  const [filledPayload] = fillOnFields(meta, payload, 'onUpdate');
933
963
  const keys = filterFieldKeys(meta, filledPayload, 'onUpdate');
934
- const tableName = this.resolveTableName(entity, meta);
935
- ctx.append(`UPDATE ${this.escapeId(tableName)} SET `);
964
+ const tableName = this.escapedTableName(meta);
965
+ ctx.append(`UPDATE ${tableName} SET `);
936
966
  for (let i = 0; i < keys.length; i++) {
937
967
  if (i > 0) {
938
968
  ctx.append(', ');
@@ -1014,14 +1044,14 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1014
1044
  }
1015
1045
  delete(ctx, entity, q, opts = {}) {
1016
1046
  const meta = getMeta(entity);
1017
- const tableName = this.resolveTableName(entity, meta);
1047
+ const tableName = this.escapedTableName(meta);
1018
1048
  // Soft-delete (stamp only live rows) unless `hardDelete` is requested or the entity has no
1019
1049
  // soft-delete field (e.g. a cascade onto a non-soft-deletable child).
1020
1050
  if (!opts.hardDelete && meta.softDelete) {
1021
1051
  const field = meta.fields[meta.softDelete];
1022
1052
  if (field) {
1023
1053
  const columnName = this.resolveColumnName(meta.softDelete, field);
1024
- ctx.append(`UPDATE ${this.escapeId(tableName)} SET ${this.escapeId(columnName)} = `);
1054
+ ctx.append(`UPDATE ${tableName} SET ${this.escapeId(columnName)} = `);
1025
1055
  this.formatPersistableValue(ctx, field, getSoftDeleteValue(field));
1026
1056
  this.search(ctx, entity, q, opts);
1027
1057
  return;
@@ -1029,7 +1059,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1029
1059
  }
1030
1060
  // Hard delete removes matching rows regardless of soft-delete state (keeps other filters, e.g. tenant).
1031
1061
  // Only rewrite the filters when there is a soft-delete filter to disable.
1032
- ctx.append(`DELETE FROM ${this.escapeId(tableName)}`);
1062
+ ctx.append(`DELETE FROM ${tableName}`);
1033
1063
  this.search(ctx, entity, q, meta.softDelete ? { ...opts, filters: withoutSoftDeleteFilter(opts.filters) } : opts);
1034
1064
  }
1035
1065
  escapeId(val, forbidQualified, addDot) {
@@ -1353,38 +1383,39 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1353
1383
  */
1354
1384
  appendRelationSubquery(ctx, entity, rel, opts, projection, val) {
1355
1385
  const meta = getMeta(entity);
1356
- const parentTable = this.resolveTableName(entity, meta);
1386
+ // Aliases, not paths, everywhere a column is prefixed; `tableRef` declares them in the FROM.
1387
+ const parentAlias = this.resolveTableAlias(meta);
1357
1388
  const references = rel.references;
1358
- const escapedParentId = this.escapedParentColumn(parentTable, meta, opts, meta.id);
1389
+ const escapedParentId = this.escapedParentColumn(parentAlias, meta, opts, meta.id);
1359
1390
  const relatedEntity = rel.entity();
1360
1391
  const relatedMeta = getMeta(relatedEntity);
1361
- const relatedTable = this.resolveTableName(relatedEntity, relatedMeta);
1392
+ const { alias: relatedAlias, ref: relatedRef } = this.tableRef(relatedMeta);
1362
1393
  // Resolved before any SQL is emitted: it also decides whether the mm form reaches the target.
1363
1394
  const targetWhere = this.scopedWhereMap(relatedMeta, val);
1364
1395
  ctx.append(`(SELECT ${projection} FROM `);
1365
1396
  if (rel.cardinality === 'mm' && rel.through) {
1366
1397
  const throughEntity = rel.through();
1367
1398
  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}`);
1399
+ const { alias: throughAlias, ref: throughRef } = this.tableRef(throughMeta);
1400
+ ctx.append(throughRef);
1401
+ ctx.append(` WHERE ${this.escapedColumn(throughAlias, throughMeta, references[0].local)} = ${escapedParentId}`);
1371
1402
  // 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' });
1403
+ this.where(ctx, throughEntity, {}, { prefix: throughAlias, clause: 'AND' });
1373
1404
  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' });
1405
+ ctx.append(` AND ${this.escapedColumn(throughAlias, throughMeta, references[1].local)} IN (`);
1406
+ ctx.append(`SELECT ${this.escapedColumn(relatedAlias, relatedMeta, relatedMeta.id)} FROM ${relatedRef}`);
1407
+ this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedAlias, clause: 'WHERE' });
1377
1408
  ctx.append(')');
1378
1409
  }
1379
1410
  }
1380
1411
  else {
1381
- const joinLeft = this.escapedColumn(relatedTable, relatedMeta, references[0].foreign);
1412
+ const joinLeft = this.escapedColumn(relatedAlias, relatedMeta, references[0].foreign);
1382
1413
  const joinRight = rel.cardinality === '1m'
1383
1414
  ? escapedParentId
1384
- : this.escapedParentColumn(parentTable, meta, opts, references[0].local);
1385
- ctx.append(this.escapeId(relatedTable));
1415
+ : this.escapedParentColumn(parentAlias, meta, opts, references[0].local);
1416
+ ctx.append(relatedRef);
1386
1417
  ctx.append(` WHERE ${joinLeft} = ${joinRight}`);
1387
- this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedTable, clause: 'AND' });
1418
+ this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: relatedAlias, clause: 'AND' });
1388
1419
  }
1389
1420
  ctx.append(')');
1390
1421
  }
@@ -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
@@ -22,18 +22,18 @@ export class MongoSchemaGenerator extends AbstractDialect {
22
22
  return this.selected(entities, options?.only).flatMap((entity) => this.generateCreateTable(entity, options));
23
23
  }
24
24
  generateDropSchema(entities) {
25
- return this.selected(entities).map((entity) => this.generateDropTable(this.resolveTableName(entity, getMeta(entity))));
25
+ return this.selected(entities).map((entity) => this.generateDropTable(this.resolveTableName(getMeta(entity))));
26
26
  }
27
27
  selected(entities, only) {
28
28
  if (!only) {
29
29
  return entities;
30
30
  }
31
31
  const wanted = new Set(only);
32
- return entities.filter((entity) => wanted.has(this.resolveTableName(entity, getMeta(entity))));
32
+ return entities.filter((entity) => wanted.has(this.resolveTableName(getMeta(entity))));
33
33
  }
34
34
  generateCreateTable(entity, _options) {
35
35
  const meta = getMeta(entity);
36
- const collectionName = this.resolveTableName(entity, meta);
36
+ const collectionName = this.resolveTableName(meta);
37
37
  const indexes = [];
38
38
  for (const key of getKeys(meta.fields)) {
39
39
  const field = meta.fields[key];
@@ -132,7 +132,7 @@ export class MongoSchemaGenerator extends AbstractDialect {
132
132
  }
133
133
  diffSchema(entity, currentTable) {
134
134
  const meta = getMeta(entity);
135
- const collectionName = this.resolveTableName(entity, meta);
135
+ const collectionName = this.resolveTableName(meta);
136
136
  if (!currentTable) {
137
137
  return { tableName: collectionName, type: 'create' };
138
138
  }
@@ -283,7 +283,7 @@ export class Migrator {
283
283
  const diffs = [];
284
284
  for (const entity of this.entities) {
285
285
  const meta = getMeta(entity);
286
- const tableName = this.schemaGenerator.resolveTableName(entity, meta);
286
+ const tableName = this.schemaGenerator.resolveTableName(meta);
287
287
  const currentTable = ast.getTable(tableName);
288
288
  const diff = this.schemaGenerator.diffSchema(entity, currentTable);
289
289
  if (diff) {
@@ -299,7 +299,7 @@ export class Migrator {
299
299
  }
300
300
  for (const entity of this.entities) {
301
301
  const meta = getMeta(entity);
302
- const name = this.schemaGenerator.resolveTableName(entity, meta);
302
+ const name = this.schemaGenerator.resolveTableName(meta);
303
303
  if (name === tableName) {
304
304
  return entity;
305
305
  }
@@ -13,7 +13,7 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
13
13
  constructor(dialect: AbstractSqlDialect, defaultForeignKeyAction?: ForeignKeyAction);
14
14
  get namingStrategy(): NamingStrategy | undefined;
15
15
  get features(): DialectFeatures;
16
- resolveTableName<E>(entity: Type<E>, meta: EntityMeta<E>): string;
16
+ resolveTableName<E>(meta: EntityMeta<E>): string;
17
17
  resolveColumnName(key: string, field: FieldOptions): string;
18
18
  /**
19
19
  * Escape an identifier (table name, column name, etc.)
@@ -38,6 +38,12 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
38
38
  * foreign key in, but it resolves targets lazily, so a forward reference is fine there.
39
39
  */
40
40
  generateCreateSchema(entities: readonly Type<unknown>[], options?: CreateSchemaOptions): string[];
41
+ /**
42
+ * One statement per distinct schema the entities name, in first-seen order. `resolveSchema` is
43
+ * already `undefined` on an engine without schemas, so this is empty there, and empty in the
44
+ * ordinary case where nothing named one.
45
+ */
46
+ private generateCreateSchemas;
41
47
  generateDropSchema(entities: readonly Type<unknown>[], options?: DropSchemaOptions): string[];
42
48
  /**
43
49
  * The tables of `entities` in dependency order, optionally narrowed to `only`. The AST always spans
@@ -23,8 +23,8 @@ export class SqlSchemaGenerator {
23
23
  get features() {
24
24
  return this.dialect.features;
25
25
  }
26
- resolveTableName(entity, meta) {
27
- return this.dialect.resolveTableName(entity, meta);
26
+ resolveTableName(meta) {
27
+ return this.dialect.resolveTableName(meta);
28
28
  }
29
29
  resolveColumnName(key, field) {
30
30
  return this.dialect.resolveColumnName(key, field);
@@ -65,7 +65,10 @@ export class SqlSchemaGenerator {
65
65
  // Inline only where a constraint cannot be added afterwards, which is what makes the cyclic case
66
66
  // work everywhere else.
67
67
  const inline = withForeignKeys && !this.features.foreignKeyAlter;
68
- const statements = tables.flatMap((table) => this.generateCreateTableFromNode(inline ? table : { ...table, outgoingRelations: [] }, options));
68
+ // Namespaces first: a qualified `CREATE TABLE` fails against a schema nobody created, and the
69
+ // schema is the one part of the layout a migration cannot infer from the table it is making.
70
+ const statements = this.generateCreateSchemas(entities);
71
+ statements.push(...tables.flatMap((table) => this.generateCreateTableFromNode(inline ? table : { ...table, outgoingRelations: [] }, options)));
69
72
  if (withForeignKeys && !inline) {
70
73
  for (const table of tables) {
71
74
  for (const rel of table.outgoingRelations) {
@@ -82,6 +85,17 @@ export class SqlSchemaGenerator {
82
85
  }
83
86
  return statements;
84
87
  }
88
+ /**
89
+ * One statement per distinct schema the entities name, in first-seen order. `resolveSchema` is
90
+ * already `undefined` on an engine without schemas, so this is empty there, and empty in the
91
+ * ordinary case where nothing named one.
92
+ */
93
+ generateCreateSchemas(entities) {
94
+ const named = entities
95
+ .map((entity) => this.dialect.resolveSchema(getMeta(entity)))
96
+ .filter((it) => it !== undefined);
97
+ return [...new Set(named)].map((schema) => this.dialect.createSchemaSql(schema));
98
+ }
85
99
  generateDropSchema(entities, options = {}) {
86
100
  return this.orderedTables(entities, 'drop').map((table) => this.generateDropTable(table.name, options));
87
101
  }
@@ -302,7 +316,7 @@ export class SqlSchemaGenerator {
302
316
  const meta = getMeta(entity);
303
317
  if (!currentTable) {
304
318
  return {
305
- tableName: this.dialect.resolveTableName(entity, meta),
319
+ tableName: this.dialect.resolveTableName(meta),
306
320
  type: 'create',
307
321
  };
308
322
  }
@@ -340,7 +354,7 @@ export class SqlSchemaGenerator {
340
354
  return undefined;
341
355
  }
342
356
  return {
343
- tableName: this.dialect.resolveTableName(entity, meta),
357
+ tableName: this.dialect.resolveTableName(meta),
344
358
  type: 'alter',
345
359
  columnsToAdd: columnsToAdd.length > 0 ? columnsToAdd : undefined,
346
360
  columnsToAlter: columnsToAlter.length > 0 ? columnsToAlter : undefined,
@@ -581,7 +595,7 @@ export class SqlSchemaGenerator {
581
595
  */
582
596
  export function buildEntityAST(generator, entities, defaultForeignKeyAction) {
583
597
  return buildSchemaAST(entities, {
584
- resolveTableName: (entity, meta) => generator.resolveTableName(entity, meta),
598
+ resolveTableName: (meta) => generator.resolveTableName(meta),
585
599
  resolveColumnName: (key, field) => generator.resolveColumnName(key, field),
586
600
  defaultForeignKeyAction,
587
601
  });
@@ -11,6 +11,7 @@ export const mongoDialectFeatures = {
11
11
  supportsJsonb: false,
12
12
  ifNotExists: false,
13
13
  indexIfNotExists: false,
14
+ schemas: false, // the connection picks the database, and a collection name takes no dot
14
15
  dropTableCascade: false,
15
16
  renameColumn: false,
16
17
  foreignKeyAlter: false,
@@ -156,7 +157,7 @@ export class MongoDialect extends AbstractDialect {
156
157
  ? this.junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts)
157
158
  : {
158
159
  $lookup: {
159
- from: this.resolveTableName(relEntity, relMeta),
160
+ from: this.resolveTableName(relMeta),
160
161
  ...this.joinKeys(meta, relMeta, relOpts),
161
162
  pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), ...tail],
162
163
  as: temp,
@@ -177,14 +178,14 @@ export class MongoDialect extends AbstractDialect {
177
178
  const nested = MongoDialect.REL_NESTED_KEY;
178
179
  return {
179
180
  $lookup: {
180
- from: this.resolveTableName(throughEntity, throughMeta),
181
+ from: this.resolveTableName(throughMeta),
181
182
  localField: MongoDialect.ID_KEY,
182
183
  foreignField: this.columnOf(throughMeta, relOpts.references[0].local),
183
184
  pipeline: [
184
185
  ...(hasKeys(junctionScope) ? [{ $match: junctionScope }] : []),
185
186
  {
186
187
  $lookup: {
187
- from: this.resolveTableName(relEntity, relMeta),
188
+ from: this.resolveTableName(relMeta),
188
189
  localField: this.columnOf(throughMeta, relOpts.references[1].local),
189
190
  foreignField: MongoDialect.ID_KEY,
190
191
  pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), { $limit: 1 }],
@@ -575,7 +576,7 @@ export class MongoDialect extends AbstractDialect {
575
576
  ];
576
577
  pipeline.push({
577
578
  $lookup: {
578
- from: this.resolveTableName(join.entity, join.meta),
579
+ from: this.resolveTableName(join.meta),
579
580
  ...this.joinKeys(parentMeta, join.meta, join.relation),
580
581
  ...(lookupPipeline.length ? { pipeline: lookupPipeline } : {}),
581
582
  as: join.key,
@@ -1,11 +1,12 @@
1
1
  import { MongoClient } from 'mongodb';
2
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
3
  import { AbstractQuerierPool } from '../querier/index.js';
3
4
  import { MongodbNativeDialect } from './mongodbNativeDialect.js';
4
5
  import { MongodbQuerier } from './mongodbQuerier.js';
5
6
  export class MongodbQuerierPool extends AbstractQuerierPool {
6
7
  client;
7
8
  constructor(uri, opts, extra) {
8
- super(new MongodbNativeDialect({ namingStrategy: extra?.namingStrategy }), extra);
9
+ super(new MongodbNativeDialect(dialectOptionsFrom(extra)), extra);
9
10
  this.client = new MongoClient(uri, opts);
10
11
  }
11
12
  async getQuerier() {
@@ -1,11 +1,12 @@
1
1
  import { createPool } from 'mysql2/promise';
2
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
3
  import { AbstractSqlQuerierPool } from '../querier/index.js';
3
4
  import { MySql2Dialect } from './mysql2Dialect.js';
4
5
  import { MySql2Querier } from './mysql2Querier.js';
5
6
  export class MySql2QuerierPool extends AbstractSqlQuerierPool {
6
7
  pool;
7
8
  constructor(opts, extra) {
8
- super(new MySql2Dialect({ namingStrategy: extra?.namingStrategy }), extra);
9
+ super(new MySql2Dialect(dialectOptionsFrom(extra)), extra);
9
10
  this.pool = createPool(opts);
10
11
  }
11
12
  async getQuerier() {
@@ -1,4 +1,5 @@
1
1
  import { Pool, types } from '@neondatabase/serverless';
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 { NeonDialect } from './neonDialect.js';
@@ -6,7 +7,7 @@ import { NeonQuerier } from './neonQuerier.js';
6
7
  export class NeonQuerierPool extends AbstractPgQuerierPool {
7
8
  constructor(opts, extra) {
8
9
  // Neon's own `types`, not `pg`'s: this entry has to load on an edge runtime where `pg` is absent.
9
- super(new NeonDialect({ namingStrategy: extra?.namingStrategy }), new Pool({ types: numericTypes(types), ...opts }), extra);
10
+ super(new NeonDialect(dialectOptionsFrom(extra)), new Pool({ types: numericTypes(types), ...opts }), extra);
10
11
  }
11
12
  buildQuerier(connect) {
12
13
  return new NeonQuerier(connect, this.dialect, this.extra);
@@ -1,3 +1,4 @@
1
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
1
2
  import { AbstractSharedHandleQuerierPool } from '../querier/abstractSharedHandleQuerierPool.js';
2
3
  import { PgliteDialect } from './pgliteDialect.js';
3
4
  import { PgliteQuerier } from './pgliteQuerier.js';
@@ -20,7 +21,7 @@ export class PgliteQuerierPool extends AbstractSharedHandleQuerierPool {
20
21
  dataDir;
21
22
  opts;
22
23
  constructor(dataDir = 'memory://', opts, extra) {
23
- super(new PgliteDialect({ namingStrategy: extra?.namingStrategy }), extra);
24
+ super(new PgliteDialect(dialectOptionsFrom(extra)), extra);
24
25
  this.dataDir = dataDir;
25
26
  this.opts = opts;
26
27
  }
@@ -1,4 +1,5 @@
1
1
  import { Pool, types } from 'pg';
2
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
3
  import { AbstractPgQuerierPool } from './abstractPgQuerierPool.js';
3
4
  import { PgDialect } from './pgDialect.js';
4
5
  import { numericTypes } from './pgNumericTypes.js';
@@ -7,7 +8,7 @@ export class PgQuerierPool extends AbstractPgQuerierPool {
7
8
  constructor(opts, extra) {
8
9
  // keepAlive reduces (but can't eliminate) idle connections being silently
9
10
  // dropped by NATs/firewalls on long-lived remote connections.
10
- super(new PgDialect({ namingStrategy: extra?.namingStrategy }), new Pool({ keepAlive: true, types: numericTypes(types), ...opts }), extra);
11
+ super(new PgDialect(dialectOptionsFrom(extra)), new Pool({ keepAlive: true, types: numericTypes(types), ...opts }), extra);
11
12
  }
12
13
  buildQuerier(connect) {
13
14
  return new PgQuerier(connect, this.dialect, this.extra);
@@ -14,7 +14,7 @@ import type { ForeignKeyAction } from './types.js';
14
14
  */
15
15
  export interface BuildSchemaASTOptions {
16
16
  /** Custom table name resolver */
17
- resolveTableName?: (entity: Type<unknown>, meta: EntityMeta<unknown>) => string;
17
+ resolveTableName?: (meta: EntityMeta<unknown>) => string;
18
18
  /** Custom column name resolver */
19
19
  resolveColumnName?: (key: string, field: FieldOptions) => string;
20
20
  /** Naming strategy to use */
@@ -19,13 +19,14 @@ export function buildSchemaAST(entities, options = {}) {
19
19
  const { namingStrategy } = options;
20
20
  const ctx = {
21
21
  ast: new SchemaAST(),
22
- resolveTableName: options.resolveTableName ?? ((e, m) => namingStrategy?.tableName(m.name ?? e.name) ?? m.name ?? e.name),
22
+ resolveTableName: options.resolveTableName ??
23
+ ((m) => namingStrategy?.tableName(m.name ?? m.entity.name) ?? m.name ?? m.entity.name),
23
24
  resolveColumnName: options.resolveColumnName ?? ((k, f) => namingStrategy?.columnName(f.name ?? k) ?? f.name ?? k),
24
25
  defaultForeignKeyAction: options.defaultForeignKeyAction ?? DEFAULT_FOREIGN_KEY_ACTION,
25
26
  };
26
27
  for (const pass of [addTableFromEntity, addRelationshipsFromEntity, addIndexesFromEntity]) {
27
28
  for (const entity of entities) {
28
- pass(ctx, entity, getMeta(entity));
29
+ pass(ctx, getMeta(entity));
29
30
  }
30
31
  }
31
32
  return ctx.ast;
@@ -64,8 +65,8 @@ function resolveColumnCanonicalType(field, seen = new Set()) {
64
65
  /**
65
66
  * Add a table from entity metadata.
66
67
  */
67
- function addTableFromEntity(ctx, entity, meta) {
68
- const tableName = ctx.resolveTableName(entity, meta);
68
+ function addTableFromEntity(ctx, meta) {
69
+ const tableName = ctx.resolveTableName(meta);
69
70
  const columns = new Map();
70
71
  const primaryKey = [];
71
72
  // Create placeholder table (will be fully initialized below)
@@ -115,8 +116,8 @@ function addTableFromEntity(ctx, entity, meta) {
115
116
  /**
116
117
  * Add relationships from entity relation decorators.
117
118
  */
118
- function addRelationshipsFromEntity(ctx, entity, meta) {
119
- const tableName = ctx.resolveTableName(entity, meta);
119
+ function addRelationshipsFromEntity(ctx, meta) {
120
+ const tableName = ctx.resolveTableName(meta);
120
121
  const table = ctx.ast.getTable(tableName);
121
122
  if (!table)
122
123
  return;
@@ -127,7 +128,7 @@ function addRelationshipsFromEntity(ctx, entity, meta) {
127
128
  continue;
128
129
  const relatedEntity = relation.entity();
129
130
  const relatedMeta = getMeta(relatedEntity);
130
- const relatedTableName = ctx.resolveTableName(relatedEntity, relatedMeta);
131
+ const relatedTableName = ctx.resolveTableName(relatedMeta);
131
132
  const relatedTable = ctx.ast.getTable(relatedTableName);
132
133
  if (!relatedTable)
133
134
  continue;
@@ -171,8 +172,8 @@ function addRelationshipsFromEntity(ctx, entity, meta) {
171
172
  * Add indexes from field options (`@Field({ index })`) and from `@Index([...])`, which have nothing
172
173
  * in common beyond their target table.
173
174
  */
174
- function addIndexesFromEntity(ctx, entity, meta) {
175
- const tableName = ctx.resolveTableName(entity, meta);
175
+ function addIndexesFromEntity(ctx, meta) {
176
+ const tableName = ctx.resolveTableName(meta);
176
177
  const table = ctx.ast.getTable(tableName);
177
178
  if (!table)
178
179
  return;
@@ -1,3 +1,4 @@
1
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
1
2
  import { AbstractSharedHandleQuerierPool } from '../querier/abstractSharedHandleQuerierPool.js';
2
3
  import { SqliteDialect } from './sqliteDialect.js';
3
4
  import { SqliteQuerier } from './sqliteQuerier.js';
@@ -11,7 +12,7 @@ import { SqliteQuerier } from './sqliteQuerier.js';
11
12
  export class AbstractLocalSqliteQuerierPool extends AbstractSharedHandleQuerierPool {
12
13
  opts;
13
14
  constructor(opts, extra) {
14
- super(new SqliteDialect({ namingStrategy: extra?.namingStrategy }), extra);
15
+ super(new SqliteDialect(dialectOptionsFrom(extra)), extra);
15
16
  this.opts = opts;
16
17
  }
17
18
  async openDb() {
@@ -8,6 +8,7 @@ export class SqliteDialect extends AbstractSqlDialect {
8
8
  supportsJsonb: false,
9
9
  ifNotExists: true,
10
10
  indexIfNotExists: true,
11
+ schemas: false, // SQLite's namespaces are attached database files, not declared objects
11
12
  dropTableCascade: false,
12
13
  renameColumn: true,
13
14
  foreignKeyAlter: false, // SQLite does not support adding FKs to existing tables
@@ -70,7 +71,7 @@ export class SqliteDialect extends AbstractSqlDialect {
70
71
  */
71
72
  appendTextSearch(ctx, entity, meta, search) {
72
73
  const columns = search.$fields.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
73
- ctx.append(`${this.escapeId(this.resolveTableName(entity, meta))} MATCH {${columns.join(' ')}} : `);
74
+ ctx.append(`${this.escapedTableName(meta)} MATCH {${columns.join(' ')}} : `);
74
75
  ctx.addValue(search.$value);
75
76
  }
76
77
  /**
@@ -1,3 +1,4 @@
1
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
1
2
  import { AbstractSharedHandleQuerierPool } from '../querier/abstractSharedHandleQuerierPool.js';
2
3
  import { TursoDialect } from './tursoDialect.js';
3
4
  import { TursoLocalQuerier } from './tursoLocalQuerier.js';
@@ -12,7 +13,7 @@ export class TursoLocalQuerierPool extends AbstractSharedHandleQuerierPool {
12
13
  filename;
13
14
  opts;
14
15
  constructor(filename = ':memory:', opts, extra) {
15
- super(new TursoDialect({ namingStrategy: extra?.namingStrategy }), extra);
16
+ super(new TursoDialect(dialectOptionsFrom(extra)), extra);
16
17
  this.filename = filename;
17
18
  this.opts = opts;
18
19
  }
@@ -1,3 +1,4 @@
1
+ import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
1
2
  import { AbstractHranaQuerierPool } from '../sqlite/hranaQuerierPool.js';
2
3
  import { TursoDialect } from './tursoDialect.js';
3
4
  import { TursoQuerier } from './tursoQuerier.js';
@@ -21,7 +22,7 @@ export class TursoQuerierPool extends AbstractHranaQuerierPool {
21
22
  * with the same shape: `@libsql/client/web`, `@libsql/client-wasm`, or a test double.
22
23
  */
23
24
  constructor(conf, extra) {
24
- super(new TursoDialect({ namingStrategy: extra?.namingStrategy }), extra);
25
+ super(new TursoDialect(dialectOptionsFrom(extra)), extra);
25
26
  this.conf = conf;
26
27
  this.ownsClient = !isClient(conf);
27
28
  }
@@ -76,6 +76,12 @@ export type InsertIdSource = 'returning' | 'firstId' | 'lastId';
76
76
  export interface EngineFeatures {
77
77
  readonly ifNotExists: boolean;
78
78
  readonly indexIfNotExists: boolean;
79
+ /**
80
+ * Whether the engine has namespaces a table can sit behind. `false` leaves every table
81
+ * unqualified, whatever `schema` an entity or pool named: SQLite attaches database files and
82
+ * MongoDB takes its database from the connection, so neither has one to name.
83
+ */
84
+ readonly schemas: boolean;
79
85
  readonly dropTableCascade: boolean;
80
86
  readonly renameColumn: boolean;
81
87
  readonly foreignKeyAlter: boolean;
@@ -577,6 +577,8 @@ export type EntityIndexMeta = {
577
577
  export type EntityMeta<E> = {
578
578
  readonly entity: Type<E>;
579
579
  name?: string;
580
+ /** Set only when the entity named one; unset defers to the pool where it is used. See `AbstractDialect.resolveSchema`. */
581
+ schema?: string;
580
582
  id: IdKey<E>;
581
583
  softDelete?: FieldKey<E>;
582
584
  /** Named, default-on `$where` filters applied to every query unless bypassed. */
@@ -605,6 +607,11 @@ export type EntityMeta<E> = {
605
607
  */
606
608
  export type EntityOptions<E = unknown> = {
607
609
  readonly name?: string;
610
+ /**
611
+ * The schema (in MySQL terms, database) this table lives in, pinning it whichever pool reads it;
612
+ * unset follows the pool's own. Not in `name`: a dotted `name` is rejected.
613
+ */
614
+ readonly schema?: string;
608
615
  /** Named, default-on `$where` filters (soft-delete is auto-registered from `@Field({ softDelete })`). */
609
616
  readonly filters?: Record<string, FilterOptions<E>>;
610
617
  /** Scalar fields; use `isId: true` on exactly one field for the primary key. */
@@ -245,7 +245,7 @@ export interface SchemaGenerator {
245
245
  /**
246
246
  * Resolve table name using entity and naming strategy
247
247
  */
248
- resolveTableName<E>(entity: Type<E>, meta: EntityMeta<E>): string;
248
+ resolveTableName<E>(meta: EntityMeta<E>): string;
249
249
  /**
250
250
  * Resolve column name using field options and naming strategy
251
251
  */
@@ -164,5 +164,10 @@ export type ExtraOptions = {
164
164
  /** Threshold in milliseconds - queries exceeding this are logged as slow. */
165
165
  readonly slowQuery?: number;
166
166
  readonly namingStrategy?: NamingStrategy;
167
+ /**
168
+ * Default schema (in MySQL terms, database) for entities naming none; unset leaves them
169
+ * unqualified. `@Entity({ schema })` overrides it.
170
+ */
171
+ readonly schema?: string;
167
172
  readonly listeners?: readonly QuerierListener[];
168
173
  };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.33.0",
6
+ "version": "0.34.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"