@zerotal/orm 1.7.2 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,8 +10,17 @@ follows the Zerotal monorepo's unified versioning.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ - **A boolean column could not hold a boolean on PostgreSQL.** `table.boolean()` compiled to
14
+ `INTEGER` on every engine — correct on SQLite, which has no boolean type, and rejected outright
15
+ by PostgreSQL: `column "…" is of type integer but expression is of type boolean` (42804) on the
16
+ first insert, and again on any `where(column, true)`. `DEFAULT` clauses failed the same way, a
17
+ boolean default having been serialised to `1`. The storage type now comes from the dialect, as
18
+ the auto-increment column already did. SQLite and MySQL are unchanged; existing PostgreSQL
19
+ tables keep their integer columns until a migration alters them. Found by the new smoke suite
20
+ that runs the ORM against a real PostgreSQL in CI.
21
+
13
22
  - **A seeder that failed partway left its rows behind.** `Seeder.call()` has always wrapped
14
- *composed* seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
23
+ _composed_ seeders in a transaction, so a `DatabaseSeeder` that delegates was atomic and one that
15
24
  does its work inline — which is most of them — was not. A failure on the fourth table committed
16
25
  the first three, so the obvious next move, running it again, died on a unique constraint, and the
17
26
  only way out was `migrate:fresh`. Migrations became transactional in 1.7.0; this closes the
@@ -22,7 +31,6 @@ follows the Zerotal monorepo's unified versioning.
22
31
  no connection is bound, because a seeder is not obliged to touch the database and an app that has
23
32
  not configured one should not fail to seed over a transaction it never needed.
24
33
 
25
-
26
34
  ### Fixed
27
35
 
28
36
  - **`DatabaseProvider` now runs in `worker`, so `zt queue:work` can boot.** It did not, and the
package/api-surface.md CHANGED
@@ -182,7 +182,7 @@ class Cast = {
182
182
  }
183
183
 
184
184
  class ColumnBuilder = {
185
- new <Locked extends string = never>(name: string, _sqlType: string, isPrimary?: boolean, isAutoIncrement?: boolean): ColumnBuilder<Locked>
185
+ new <Locked extends string = never>(name: string, _sqlType: string, isPrimary?: boolean, isAutoIncrement?: boolean, _isBoolean?: boolean): ColumnBuilder<Locked>
186
186
  after: (_column: string) => ColumnBuilder<Locked>
187
187
  alter: () => ColumnBuilder<Locked>
188
188
  before: (_column: string) => ColumnBuilder<Locked>
@@ -566,9 +566,11 @@ class MysqlDialect = {
566
566
  advisoryLockSql: (key: number) => DialectQuery
567
567
  advisoryUnlockSql: (key: number) => DialectQuery
568
568
  autoIncrementColumn: (column: string) => string
569
+ booleanLiteral: (value: boolean) => string
569
570
  dateExpr: (part: DatePart, column: string) => string
570
571
  hasColumnSql: (table: string, column: string) => DialectQuery
571
572
  hasTableSql: (table: string) => DialectQuery
573
+ readonly booleanType: 'INTEGER'
572
574
  readonly name: 'mysql'
573
575
  readonly supportsAdvisoryLocks: true
574
576
  readonly supportsTransactionalDdl: false
@@ -605,9 +607,11 @@ class PostgresDialect = {
605
607
  advisoryLockSql: (key: number) => DialectQuery
606
608
  advisoryUnlockSql: (key: number) => DialectQuery
607
609
  autoIncrementColumn: (column: string) => string
610
+ booleanLiteral: (value: boolean) => string
608
611
  dateExpr: (part: DatePart, column: string) => string
609
612
  hasColumnSql: (table: string, column: string) => DialectQuery
610
613
  hasTableSql: (table: string) => DialectQuery
614
+ readonly booleanType: 'BOOLEAN'
611
615
  readonly name: 'postgres'
612
616
  readonly supportsAdvisoryLocks: true
613
617
  readonly supportsTransactionalDdl: true
@@ -742,9 +746,11 @@ class SqliteDialect = {
742
746
  advisoryLockSql: () => DialectQuery | null
743
747
  advisoryUnlockSql: () => DialectQuery | null
744
748
  autoIncrementColumn: (column: string) => string
749
+ booleanLiteral: (value: boolean) => string
745
750
  dateExpr: (part: DatePart, column: string) => string
746
751
  hasColumnSql: (table: string, column: string) => DialectQuery
747
752
  hasTableSql: (table: string) => DialectQuery
753
+ readonly booleanType: 'INTEGER'
748
754
  readonly name: 'sqlite'
749
755
  readonly supportsAdvisoryLocks: false
750
756
  readonly supportsTransactionalDdl: true
@@ -1260,9 +1266,11 @@ interface SqlDialect = {
1260
1266
  advisoryLockSql: (key: number) => DialectQuery | null
1261
1267
  advisoryUnlockSql: (key: number) => DialectQuery | null
1262
1268
  autoIncrementColumn: (column: string) => string
1269
+ booleanLiteral: (value: boolean) => string
1263
1270
  dateExpr: (part: DatePart, column: string) => string
1264
1271
  hasColumnSql: (table: string, column: string) => DialectQuery
1265
1272
  hasTableSql: (table: string) => DialectQuery
1273
+ readonly booleanType: string
1266
1274
  readonly name: DialectName
1267
1275
  readonly supportsAdvisoryLocks: boolean
1268
1276
  readonly supportsTransactionalDdl: boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,8 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.7.2",
35
- "@zerotal/validator": "1.7.2"
34
+ "@zerotal/core": "1.7.3",
35
+ "@zerotal/validator": "1.7.3"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -51,6 +51,13 @@ export class MysqlDialect implements SqlDialect {
51
51
  return `${column} INT AUTO_INCREMENT PRIMARY KEY`;
52
52
  }
53
53
 
54
+ // MySQL BOOLEAN is a synonym for TINYINT(1) and INTEGER accepts 0/1 all the same.
55
+ readonly booleanType = "INTEGER";
56
+
57
+ booleanLiteral(value: boolean): string {
58
+ return value ? "1" : "0";
59
+ }
60
+
54
61
  advisoryLockSql(key: number): DialectQuery {
55
62
  return { sql: `SELECT GET_LOCK(?, -1)`, params: [`zerotal_lock_${key}`] };
56
63
  }
@@ -48,6 +48,12 @@ export class PostgresDialect implements SqlDialect {
48
48
  return `${column} INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY`;
49
49
  }
50
50
 
51
+ readonly booleanType = "BOOLEAN";
52
+
53
+ booleanLiteral(value: boolean): string {
54
+ return value ? "TRUE" : "FALSE";
55
+ }
56
+
51
57
  advisoryLockSql(key: number): DialectQuery {
52
58
  return { sql: `SELECT pg_advisory_lock(?)`, params: [key] };
53
59
  }
@@ -48,6 +48,13 @@ export class SqliteDialect implements SqlDialect {
48
48
  return `${column} INTEGER PRIMARY KEY AUTOINCREMENT`;
49
49
  }
50
50
 
51
+ // SQLite has no boolean type — 0/1 in an INTEGER is the storage class it uses.
52
+ readonly booleanType = "INTEGER";
53
+
54
+ booleanLiteral(value: boolean): string {
55
+ return value ? "1" : "0";
56
+ }
57
+
51
58
  advisoryLockSql(): DialectQuery | null {
52
59
  return null;
53
60
  }
@@ -56,6 +56,26 @@ export interface SqlDialect {
56
56
  */
57
57
  autoIncrementColumn(column: string): string;
58
58
 
59
+ /**
60
+ * The column type a portable `table.boolean()` compiles to.
61
+ *
62
+ * SQLite has no boolean type and stores 0/1 in an `INTEGER`, which is why the
63
+ * Blueprint emitted `INTEGER` for every engine. PostgreSQL has a real `boolean`
64
+ * and refuses to compare or assign one against an integer column, so a table
65
+ * built that way rejected its own booleans — `column "active" is of type integer
66
+ * but expression is of type boolean` (SQLSTATE 42804) on the first insert, and
67
+ * again on any `where("active", true)`. Nothing caught it because no test
68
+ * executed the DDL against a server; `postgres.smoke.test.ts` now does.
69
+ *
70
+ * MySQL keeps `INTEGER`: its `BOOLEAN` is a synonym for `TINYINT(1)` and it
71
+ * accepts 0/1 either way, so there is no defect there to fix and no reason to
72
+ * churn the DDL of an engine no CI job covers.
73
+ */
74
+ readonly booleanType: string;
75
+
76
+ /** A boolean as this engine spells it in a `DEFAULT` clause. */
77
+ booleanLiteral(value: boolean): string;
78
+
59
79
  /** Whether the engine supports application-level advisory locks. */
60
80
  readonly supportsAdvisoryLocks: boolean;
61
81
 
@@ -314,12 +314,15 @@ export class Blueprint {
314
314
  // ── Boolean ───────────────────────────────────────────────────────────────
315
315
 
316
316
  /**
317
- * Boolean column. Stored as `INTEGER` (0 / 1); JS booleans passed to
318
- * {@link ColumnBuilder.default} serialise to `1` / `0`.
317
+ * Boolean column. The storage type is the engine's: `INTEGER` holding 0 / 1 on
318
+ * SQLite and MySQL, a real `BOOLEAN` on PostgreSQL — which rejects the integer
319
+ * form for both assignment and comparison, so emitting `INTEGER` everywhere
320
+ * built a column that would not take its own booleans. JS booleans passed to
321
+ * {@link ColumnBuilder.default} follow the same engine's spelling.
319
322
  * @category Column types
320
323
  */
321
324
  boolean(name: string): ColumnBuilder {
322
- return this._add(new ColumnBuilder(name, "INTEGER"));
325
+ return this._add(new ColumnBuilder(name, "INTEGER", false, false, true));
323
326
  }
324
327
 
325
328
  // ── Date / time columns ───────────────────────────────────────────────────
@@ -64,6 +64,11 @@ export class ColumnBuilder<Locked extends string = never> {
64
64
  private _sqlType: string,
65
65
  isPrimary = false,
66
66
  isAutoIncrement = false,
67
+ /**
68
+ * Logically a boolean, whatever `_sqlType` says. The engine decides the
69
+ * storage type at compile time — see {@link SqlDialect.booleanType}.
70
+ */
71
+ private _isBoolean = false,
67
72
  ) {
68
73
  this._isPrimary = isPrimary;
69
74
  this._isAutoIncr = isAutoIncrement;
@@ -324,13 +329,16 @@ export class ColumnBuilder<Locked extends string = never> {
324
329
  // PostgreSQL a syntax error and against MySQL a 1064.
325
330
  if (this._isAutoIncr) return getDialect(dialect).autoIncrementColumn(this.name);
326
331
 
327
- const parts: string[] = [`${this.name} ${this._sqlType}`];
332
+ // A boolean's storage type is the engine's to choose: SQLite stores 0/1 in an
333
+ // INTEGER, PostgreSQL has a real one and rejects the integer form outright.
334
+ const sqlType = this._isBoolean ? getDialect(dialect).booleanType : this._sqlType;
335
+ const parts: string[] = [`${this.name} ${sqlType}`];
328
336
 
329
337
  if (this._isPrimary) parts.push("PRIMARY KEY");
330
338
  if (!this._isNullable && !this._isPrimary) parts.push("NOT NULL");
331
339
 
332
340
  if (this._useCurrent) parts.push("DEFAULT CURRENT_TIMESTAMP");
333
- else if (this._hasDefault) parts.push(`DEFAULT ${this._serializeDefault()}`);
341
+ else if (this._hasDefault) parts.push(`DEFAULT ${this._serializeDefault(dialect)}`);
334
342
 
335
343
  if (this._isUnique) parts.push("UNIQUE");
336
344
  if (this._check) parts.push(`CHECK (${this._check})`);
@@ -340,10 +348,11 @@ export class ColumnBuilder<Locked extends string = never> {
340
348
  return parts.join(" ");
341
349
  }
342
350
 
343
- private _serializeDefault(): string {
351
+ private _serializeDefault(dialect: DialectName = "sqlite"): string {
344
352
  const v = this._default;
345
353
  if (v === null) return "NULL";
346
- if (typeof v === "boolean") return v ? "1" : "0";
354
+ // `DEFAULT 1` on a PostgreSQL boolean column is the same 42804 the value itself hit.
355
+ if (typeof v === "boolean") return getDialect(dialect).booleanLiteral(v);
347
356
  if (typeof v === "string") return `'${v.replace(/'/g, "''")}'`;
348
357
  return String(v);
349
358
  }