@atscript/db-postgres 0.1.154 → 0.1.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -138,6 +138,12 @@ function toPgValue(value) {
138
138
  if (typeof value === "object") return JSON.stringify(value);
139
139
  return value;
140
140
  }
141
+ /**
142
+ * Text a JSON column can take as it is (`SqlDialect.jsonFromText`): an
143
+ * object / array (by its first characters), or a whole JSON string, number,
144
+ * `true`, `false` or `null` — so `42 Main St` is no number.
145
+ */
146
+ const PG_JSON_LIKE = String.raw`^\s*(\{\s*["}]|\[\s*(["{[\]tfn0-9-])|("([^"\\]|\\.)*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?|true|false|null)\s*$)`;
141
147
  const pgDialect = {
142
148
  quoteIdentifier(name) {
143
149
  return qi(name);
@@ -171,6 +177,9 @@ const pgDialect = {
171
177
  },
172
178
  calendarBucket: pgCalendarBucket,
173
179
  jsonExtract: pgJsonExtract,
180
+ jsonExtractText: (quotedCol, path) => `((${quotedCol})::jsonb #>> ${pgJsonPath(path)})`,
181
+ jsonExtractValue: (quotedCol, path) => `NULLIF((${quotedCol})::jsonb #> ${pgJsonPath(path)}, 'null'::jsonb)`,
182
+ jsonFromText: (quotedCol) => `CASE WHEN ${quotedCol} ~ '${PG_JSON_LIKE}' THEN ${quotedCol} ELSE to_jsonb(${quotedCol})::text END`,
174
183
  castDouble: (expr) => `CAST(${expr} AS DOUBLE PRECISION)`,
175
184
  anyValue: pgAnyValue,
176
185
  booleanAggregates: {
@@ -204,6 +213,10 @@ const PG_JSON_LEAF_CAST = {
204
213
  number: "::double precision",
205
214
  boolean: "::boolean"
206
215
  };
216
+ /** A `#>` / `#>>` path literal (`'{"a","b"}'`). */
217
+ function pgJsonPath(path) {
218
+ return (0, _atscript_db_sql_tools.sqlStringLiteral)(`{${(0, _atscript_db_sql_tools.quotedJsonPathSegments)(path).join(",")}}`);
219
+ }
207
220
  /**
208
221
  * Typed read of a JSON leaf (`SqlDialect.jsonExtract`): `jsonb_typeof()`
209
222
  * guards the declared type, so a missing path, JSON `null` or another JSON
@@ -211,7 +224,7 @@ const PG_JSON_LEAF_CAST = {
211
224
  * native. The column is cast `::jsonb`, so JSON and JSONB storage both work.
212
225
  */
213
226
  function pgJsonExtract(quotedCol, path, type) {
214
- const p = (0, _atscript_db_sql_tools.sqlStringLiteral)(`{${(0, _atscript_db_sql_tools.quotedJsonPathSegments)(path).join(",")}}`);
227
+ const p = pgJsonPath(path);
215
228
  const j = `(${quotedCol})::jsonb`;
216
229
  const cast = PG_JSON_LEAF_CAST[type];
217
230
  return `CASE jsonb_typeof(${j} #> ${p}) WHEN '${type}' THEN ${cast ? `(${j} #>> ${p})${cast}` : `${j} #>> ${p}`} END`;
@@ -513,6 +526,13 @@ const GEO_PROBE_SQL = `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname =
513
526
  LEFT JOIN pg_type b ON b.oid = NULLIF(t.typbasetype, 0)
514
527
  WHERE a.attrelid = to_regclass($1::text) AND a.attnum > 0 AND NOT a.attisdropped
515
528
  AND a.attname = ANY($2::text[])) AS "columns"`;
529
+ const identity = (value) => value;
530
+ /** A column type node-postgres parses on read. */
531
+ const PG_JSON_TYPE = /^\s*jsonb?\s*$/i;
532
+ /** A JSON string value as JSON text — see `formatValue`. */
533
+ function reencodeJsonString(value) {
534
+ return typeof value === "string" ? JSON.stringify(value) : value;
535
+ }
516
536
  /** A PostGIS column type — a geo value binds as EWKT. Anything else (JSONB, TEXT) takes the JSON form. */
517
537
  const POSTGIS_TYPES = new Set(["geography", "geometry"]);
518
538
  /** The SQL `$geoWithin` renders (`pgDialect.geoWithin`) — PostGIS only. */
@@ -868,6 +888,10 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
868
888
  }
869
889
  };
870
890
  }
891
+ if (field.storage === "json" && !field.encrypted && PG_JSON_TYPE.test(this.typeMapper(field))) return {
892
+ toStorage: identity,
893
+ fromStorage: reencodeJsonString
894
+ };
871
895
  }
872
896
  /**
873
897
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -1305,6 +1329,12 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1305
1329
  this._log(sql);
1306
1330
  return (await this._exec().get(sql, []))?.present ?? false;
1307
1331
  }
1332
+ async countKeyViolations(columns, tableName) {
1333
+ const sql = (0, _atscript_db_sql_tools.buildKeyViolationCount)(pgDialect, tableName ? this._qualify(tableName) : quoteTableName(this.resolveTableName()), columns);
1334
+ this._log(sql);
1335
+ const row = await this._exec().get(sql, []);
1336
+ return Number(row?.violations ?? 0);
1337
+ }
1308
1338
  /**
1309
1339
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
1310
1340
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -1350,8 +1380,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1350
1380
  }
1351
1381
  /**
1352
1382
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
1353
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
1354
- * key columns NOT NULL. A demoted identity column is handled by the
1383
+ * (atomic: rows a concurrent write made violate the new key fail it with
1384
+ * the old key in place). Called on an empty table or one whose rows satisfy
1385
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
1355
1386
  * `defaultChanged` diff (`DROP IDENTITY`).
1356
1387
  */
1357
1388
  async rebuildPrimaryKey(change) {
@@ -1445,10 +1476,10 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1445
1476
  this._log(ddl);
1446
1477
  await this._exec().exec(ddl);
1447
1478
  }
1448
- for (const { field } of diff.typeChanged ?? []) {
1479
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1449
1480
  const sqlType = this.typeMapper(field);
1450
1481
  const col = qi(field.physicalName);
1451
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType)}`;
1482
+ const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType, existingType)}`;
1452
1483
  this._log(ddl);
1453
1484
  await this._exec().exec(ddl);
1454
1485
  }
@@ -1556,8 +1587,9 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1556
1587
  const selectExprs = commonFields.map((field) => {
1557
1588
  const c = qi(field.physicalName);
1558
1589
  const sqlType = this.typeMapper(field);
1559
- const changed = (0, _atscript_db.isColumnTypeChanged)(oldTypes.get(field.physicalName), sqlType);
1560
- const value = changed ? convertColumnExpr(c, field, sqlType) : c;
1590
+ const oldType = oldTypes.get(field.physicalName);
1591
+ const changed = (0, _atscript_db.isColumnTypeChanged)(oldType, sqlType);
1592
+ const value = changed ? convertColumnExpr(c, field, sqlType, oldType) : c;
1561
1593
  if (!field.optional && !field.isPrimaryKey) return `COALESCE(${value}, ${field.defaultValue?.kind === "value" ? defaultValueToSqlLiteral(field.designType, field.defaultValue.value) : defaultValueForType(field.designType)}) AS ${c}`;
1562
1594
  return changed ? `${value} AS ${c}` : c;
1563
1595
  }).join(", ");
@@ -1674,6 +1706,20 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
1674
1706
  this._log(ddl);
1675
1707
  await this._exec().exec(ddl);
1676
1708
  }
1709
+ async copyFromJsonColumn(source, targets) {
1710
+ const sql = (0, _atscript_db_sql_tools.buildJsonColumnCopy)(pgDialect, this.resolveTableName(), source, targets, (expr, target) => {
1711
+ if (target.kind !== "text") return expr;
1712
+ const type = this.typeMapper(target.field);
1713
+ return /^(VARCHAR|CHAR)\b/i.test(type) ? expr : `(${expr})::${type}`;
1714
+ });
1715
+ this._log(sql);
1716
+ await this._exec().exec(sql);
1717
+ }
1718
+ async jsonifyTextColumn(column) {
1719
+ const sql = (0, _atscript_db_sql_tools.buildJsonifyText)(pgDialect, this.resolveTableName(), column);
1720
+ this._log(sql);
1721
+ await this._exec().exec(sql);
1722
+ }
1677
1723
  async dropColumns(columns) {
1678
1724
  const tableName = this.resolveTableName();
1679
1725
  const drops = columns.map((col) => `DROP COLUMN ${qi(col)}`).join(", ");
@@ -2190,6 +2236,8 @@ var PostgresAdapter = class PostgresAdapter extends _atscript_db.BaseDbAdapter {
2190
2236
  };
2191
2237
  /** A text / character type: the value is converted by the assignment into the column. */
2192
2238
  const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpchar)\b/i;
2239
+ /** A floating-point / decimal column type (live or mapped). */
2240
+ const FRACTIONAL_TYPE = /^\s*(?:double precision|float[48]?|real|numeric|decimal)\b/i;
2193
2241
  /**
2194
2242
  * The expression converting column `col` (quoted) to `sqlType`, the field's
2195
2243
  * new type — one rule for `ALTER COLUMN … TYPE … USING` and the recreate copy.
@@ -2197,11 +2245,19 @@ const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpc
2197
2245
  * fails the statement instead of being coerced. A TEXT / VARCHAR(n) / CHAR(n)
2198
2246
  * target stops at `::text`: an explicit cast to VARCHAR(n) / CHAR(n) would
2199
2247
  * truncate an over-long value, the assignment into the column rejects it
2200
- * (since 0.1.137).
2248
+ * (since 0.1.137). An epoch-ms fractional column (`existingType`) becoming
2249
+ * the `BIGINT` of `@db.default.now` (`number.timestamp` →
2250
+ * `number.timestamp.created`) is rounded (since 0.1.155): its text
2251
+ * (`1700000000123.5`) does not parse as an integer. Any other fractional
2252
+ * value still fails a change to an integer type.
2201
2253
  */
2202
- function convertColumnExpr(col, field, sqlType) {
2203
- if (field.isGeoPoint && sqlType.startsWith("geography")) return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${col}->>0)::float8, (${col}->>1)::float8), 4326)::geography END`;
2254
+ function convertColumnExpr(col, field, sqlType, existingType) {
2255
+ if (field.isGeoPoint && sqlType.startsWith("geography")) {
2256
+ const json = `${col}::jsonb`;
2257
+ return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${json}->>0)::float8, (${json}->>1)::float8), 4326)::geography END`;
2258
+ }
2204
2259
  if (CHARACTER_TYPE.test(sqlType)) return `${col}::text`;
2260
+ if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now" && /^BIGINT$/i.test(sqlType.trim()) && existingType && FRACTIONAL_TYPE.test(existingType)) return `round(${col})::bigint`;
2205
2261
  return `${col}::text::${sqlType}`;
2206
2262
  }
2207
2263
  /**
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { TFilterVisitorOptions, TSqlFragment } from "@atscript/db-sql-tools";
4
4
 
@@ -293,6 +293,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
293
293
  /** `schema.name` when the adapter targets a named schema. */
294
294
  private _qualify;
295
295
  hasRows(tableName?: string): Promise<boolean>;
296
+ countKeyViolations(columns: readonly string[], tableName?: string): Promise<number>;
296
297
  /**
297
298
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
298
299
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -304,8 +305,9 @@ declare class PostgresAdapter extends BaseDbAdapter {
304
305
  dropTablesByName(tableNames: string[]): Promise<void>;
305
306
  /**
306
307
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
307
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
308
- * key columns NOT NULL. A demoted identity column is handled by the
308
+ * (atomic: rows a concurrent write made violate the new key fail it with
309
+ * the old key in place). Called on an empty table or one whose rows satisfy
310
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
309
311
  * `defaultChanged` diff (`DROP IDENTITY`).
310
312
  */
311
313
  rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
@@ -346,6 +348,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
346
348
  * dependents.
347
349
  */
348
350
  dropTable(): Promise<void>;
351
+ copyFromJsonColumn(source: string, targets: readonly TJsonCopyTarget[]): Promise<void>;
352
+ jsonifyTextColumn(column: string): Promise<void>;
349
353
  dropColumns(columns: string[]): Promise<void>;
350
354
  dropIndexesForColumns(columns: string[]): Promise<void>;
351
355
  dropTableByName(tableName: string): Promise<void>;
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TFilterVisitorOptions, TSqlFragment } from "@atscript/db-sql-tools";
3
3
  import { TMetadataMap } from "@atscript/typescript/utils";
4
4
 
@@ -293,6 +293,7 @@ declare class PostgresAdapter extends BaseDbAdapter {
293
293
  /** `schema.name` when the adapter targets a named schema. */
294
294
  private _qualify;
295
295
  hasRows(tableName?: string): Promise<boolean>;
296
+ countKeyViolations(columns: readonly string[], tableName?: string): Promise<number>;
296
297
  /**
297
298
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
298
299
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -304,8 +305,9 @@ declare class PostgresAdapter extends BaseDbAdapter {
304
305
  dropTablesByName(tableNames: string[]): Promise<void>;
305
306
  /**
306
307
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
307
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
308
- * key columns NOT NULL. A demoted identity column is handled by the
308
+ * (atomic: rows a concurrent write made violate the new key fail it with
309
+ * the old key in place). Called on an empty table or one whose rows satisfy
310
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
309
311
  * `defaultChanged` diff (`DROP IDENTITY`).
310
312
  */
311
313
  rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
@@ -346,6 +348,8 @@ declare class PostgresAdapter extends BaseDbAdapter {
346
348
  * dependents.
347
349
  */
348
350
  dropTable(): Promise<void>;
351
+ copyFromJsonColumn(source: string, targets: readonly TJsonCopyTarget[]): Promise<void>;
352
+ jsonifyTextColumn(column: string): Promise<void>;
349
353
  dropColumns(columns: string[]): Promise<void>;
350
354
  dropIndexesForColumns(columns: string[]): Promise<void>;
351
355
  dropTableByName(tableName: string): Promise<void>;
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable, defaultFulltextIndex, describeFulltext, fkColumns, isColumnTypeChanged, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, vectorDistanceSource } from "@atscript/db-sql-tools";
3
+ import { SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, quotedJsonPathSegments, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, vectorDistanceSource } from "@atscript/db-sql-tools";
4
4
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
5
5
  //#region src/insert-ignore.ts
6
6
  /**
@@ -137,6 +137,12 @@ function toPgValue(value) {
137
137
  if (typeof value === "object") return JSON.stringify(value);
138
138
  return value;
139
139
  }
140
+ /**
141
+ * Text a JSON column can take as it is (`SqlDialect.jsonFromText`): an
142
+ * object / array (by its first characters), or a whole JSON string, number,
143
+ * `true`, `false` or `null` — so `42 Main St` is no number.
144
+ */
145
+ const PG_JSON_LIKE = String.raw`^\s*(\{\s*["}]|\[\s*(["{[\]tfn0-9-])|("([^"\\]|\\.)*"|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?|true|false|null)\s*$)`;
140
146
  const pgDialect = {
141
147
  quoteIdentifier(name) {
142
148
  return qi(name);
@@ -170,6 +176,9 @@ const pgDialect = {
170
176
  },
171
177
  calendarBucket: pgCalendarBucket,
172
178
  jsonExtract: pgJsonExtract,
179
+ jsonExtractText: (quotedCol, path) => `((${quotedCol})::jsonb #>> ${pgJsonPath(path)})`,
180
+ jsonExtractValue: (quotedCol, path) => `NULLIF((${quotedCol})::jsonb #> ${pgJsonPath(path)}, 'null'::jsonb)`,
181
+ jsonFromText: (quotedCol) => `CASE WHEN ${quotedCol} ~ '${PG_JSON_LIKE}' THEN ${quotedCol} ELSE to_jsonb(${quotedCol})::text END`,
173
182
  castDouble: (expr) => `CAST(${expr} AS DOUBLE PRECISION)`,
174
183
  anyValue: pgAnyValue,
175
184
  booleanAggregates: {
@@ -203,6 +212,10 @@ const PG_JSON_LEAF_CAST = {
203
212
  number: "::double precision",
204
213
  boolean: "::boolean"
205
214
  };
215
+ /** A `#>` / `#>>` path literal (`'{"a","b"}'`). */
216
+ function pgJsonPath(path) {
217
+ return sqlStringLiteral(`{${quotedJsonPathSegments(path).join(",")}}`);
218
+ }
206
219
  /**
207
220
  * Typed read of a JSON leaf (`SqlDialect.jsonExtract`): `jsonb_typeof()`
208
221
  * guards the declared type, so a missing path, JSON `null` or another JSON
@@ -210,7 +223,7 @@ const PG_JSON_LEAF_CAST = {
210
223
  * native. The column is cast `::jsonb`, so JSON and JSONB storage both work.
211
224
  */
212
225
  function pgJsonExtract(quotedCol, path, type) {
213
- const p = sqlStringLiteral(`{${quotedJsonPathSegments(path).join(",")}}`);
226
+ const p = pgJsonPath(path);
214
227
  const j = `(${quotedCol})::jsonb`;
215
228
  const cast = PG_JSON_LEAF_CAST[type];
216
229
  return `CASE jsonb_typeof(${j} #> ${p}) WHEN '${type}' THEN ${cast ? `(${j} #>> ${p})${cast}` : `${j} #>> ${p}`} END`;
@@ -512,6 +525,13 @@ const GEO_PROBE_SQL = `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname =
512
525
  LEFT JOIN pg_type b ON b.oid = NULLIF(t.typbasetype, 0)
513
526
  WHERE a.attrelid = to_regclass($1::text) AND a.attnum > 0 AND NOT a.attisdropped
514
527
  AND a.attname = ANY($2::text[])) AS "columns"`;
528
+ const identity = (value) => value;
529
+ /** A column type node-postgres parses on read. */
530
+ const PG_JSON_TYPE = /^\s*jsonb?\s*$/i;
531
+ /** A JSON string value as JSON text — see `formatValue`. */
532
+ function reencodeJsonString(value) {
533
+ return typeof value === "string" ? JSON.stringify(value) : value;
534
+ }
515
535
  /** A PostGIS column type — a geo value binds as EWKT. Anything else (JSONB, TEXT) takes the JSON form. */
516
536
  const POSTGIS_TYPES = new Set(["geography", "geometry"]);
517
537
  /** The SQL `$geoWithin` renders (`pgDialect.geoWithin`) — PostGIS only. */
@@ -867,6 +887,10 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
867
887
  }
868
888
  };
869
889
  }
890
+ if (field.storage === "json" && !field.encrypted && PG_JSON_TYPE.test(this.typeMapper(field))) return {
891
+ toStorage: identity,
892
+ fromStorage: reencodeJsonString
893
+ };
870
894
  }
871
895
  /**
872
896
  * Wraps an async write operation to catch PostgreSQL constraint errors
@@ -1304,6 +1328,12 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1304
1328
  this._log(sql);
1305
1329
  return (await this._exec().get(sql, []))?.present ?? false;
1306
1330
  }
1331
+ async countKeyViolations(columns, tableName) {
1332
+ const sql = buildKeyViolationCount(pgDialect, tableName ? this._qualify(tableName) : quoteTableName(this.resolveTableName()), columns);
1333
+ this._log(sql);
1334
+ const row = await this._exec().get(sql, []);
1335
+ return Number(row?.violations ?? 0);
1336
+ }
1307
1337
  /**
1308
1338
  * Live foreign keys referencing `tableName`, via `pg_constraint` — exact for
1309
1339
  * composite keys (`conkey`/`confkey` are positionally aligned, unlike the
@@ -1349,8 +1379,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1349
1379
  }
1350
1380
  /**
1351
1381
  * `ALTER TABLE … DROP CONSTRAINT <pk>, ADD PRIMARY KEY (…)` in ONE statement
1352
- * (atomic). Called on an empty table only; `ADD PRIMARY KEY` makes the new
1353
- * key columns NOT NULL. A demoted identity column is handled by the
1382
+ * (atomic: rows a concurrent write made violate the new key fail it with
1383
+ * the old key in place). Called on an empty table or one whose rows satisfy
1384
+ * the new key; `ADD PRIMARY KEY` makes the new key columns NOT NULL. A demoted identity column is handled by the
1354
1385
  * `defaultChanged` diff (`DROP IDENTITY`).
1355
1386
  */
1356
1387
  async rebuildPrimaryKey(change) {
@@ -1444,10 +1475,10 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1444
1475
  this._log(ddl);
1445
1476
  await this._exec().exec(ddl);
1446
1477
  }
1447
- for (const { field } of diff.typeChanged ?? []) {
1478
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1448
1479
  const sqlType = this.typeMapper(field);
1449
1480
  const col = qi(field.physicalName);
1450
- const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType)}`;
1481
+ const ddl = `ALTER TABLE ${quoteTableName(tableName)} ALTER COLUMN ${col} TYPE ${sqlType} USING ${convertColumnExpr(col, field, sqlType, existingType)}`;
1451
1482
  this._log(ddl);
1452
1483
  await this._exec().exec(ddl);
1453
1484
  }
@@ -1555,8 +1586,9 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1555
1586
  const selectExprs = commonFields.map((field) => {
1556
1587
  const c = qi(field.physicalName);
1557
1588
  const sqlType = this.typeMapper(field);
1558
- const changed = isColumnTypeChanged(oldTypes.get(field.physicalName), sqlType);
1559
- const value = changed ? convertColumnExpr(c, field, sqlType) : c;
1589
+ const oldType = oldTypes.get(field.physicalName);
1590
+ const changed = isColumnTypeChanged(oldType, sqlType);
1591
+ const value = changed ? convertColumnExpr(c, field, sqlType, oldType) : c;
1560
1592
  if (!field.optional && !field.isPrimaryKey) return `COALESCE(${value}, ${field.defaultValue?.kind === "value" ? defaultValueToSqlLiteral$1(field.designType, field.defaultValue.value) : defaultValueForType$1(field.designType)}) AS ${c}`;
1561
1593
  return changed ? `${value} AS ${c}` : c;
1562
1594
  }).join(", ");
@@ -1673,6 +1705,20 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
1673
1705
  this._log(ddl);
1674
1706
  await this._exec().exec(ddl);
1675
1707
  }
1708
+ async copyFromJsonColumn(source, targets) {
1709
+ const sql = buildJsonColumnCopy(pgDialect, this.resolveTableName(), source, targets, (expr, target) => {
1710
+ if (target.kind !== "text") return expr;
1711
+ const type = this.typeMapper(target.field);
1712
+ return /^(VARCHAR|CHAR)\b/i.test(type) ? expr : `(${expr})::${type}`;
1713
+ });
1714
+ this._log(sql);
1715
+ await this._exec().exec(sql);
1716
+ }
1717
+ async jsonifyTextColumn(column) {
1718
+ const sql = buildJsonifyText(pgDialect, this.resolveTableName(), column);
1719
+ this._log(sql);
1720
+ await this._exec().exec(sql);
1721
+ }
1676
1722
  async dropColumns(columns) {
1677
1723
  const tableName = this.resolveTableName();
1678
1724
  const drops = columns.map((col) => `DROP COLUMN ${qi(col)}`).join(", ");
@@ -2189,6 +2235,8 @@ var PostgresAdapter = class PostgresAdapter extends BaseDbAdapter {
2189
2235
  };
2190
2236
  /** A text / character type: the value is converted by the assignment into the column. */
2191
2237
  const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpchar)\b/i;
2238
+ /** A floating-point / decimal column type (live or mapped). */
2239
+ const FRACTIONAL_TYPE = /^\s*(?:double precision|float[48]?|real|numeric|decimal)\b/i;
2192
2240
  /**
2193
2241
  * The expression converting column `col` (quoted) to `sqlType`, the field's
2194
2242
  * new type — one rule for `ALTER COLUMN … TYPE … USING` and the recreate copy.
@@ -2196,11 +2244,19 @@ const CHARACTER_TYPE = /^\s*(?:text|varchar|character varying|char|character|bpc
2196
2244
  * fails the statement instead of being coerced. A TEXT / VARCHAR(n) / CHAR(n)
2197
2245
  * target stops at `::text`: an explicit cast to VARCHAR(n) / CHAR(n) would
2198
2246
  * truncate an over-long value, the assignment into the column rejects it
2199
- * (since 0.1.137).
2247
+ * (since 0.1.137). An epoch-ms fractional column (`existingType`) becoming
2248
+ * the `BIGINT` of `@db.default.now` (`number.timestamp` →
2249
+ * `number.timestamp.created`) is rounded (since 0.1.155): its text
2250
+ * (`1700000000123.5`) does not parse as an integer. Any other fractional
2251
+ * value still fails a change to an integer type.
2200
2252
  */
2201
- function convertColumnExpr(col, field, sqlType) {
2202
- if (field.isGeoPoint && sqlType.startsWith("geography")) return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${col}->>0)::float8, (${col}->>1)::float8), 4326)::geography END`;
2253
+ function convertColumnExpr(col, field, sqlType, existingType) {
2254
+ if (field.isGeoPoint && sqlType.startsWith("geography")) {
2255
+ const json = `${col}::jsonb`;
2256
+ return `CASE WHEN ${col} IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint((${json}->>0)::float8, (${json}->>1)::float8), 4326)::geography END`;
2257
+ }
2203
2258
  if (CHARACTER_TYPE.test(sqlType)) return `${col}::text`;
2259
+ if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now" && /^BIGINT$/i.test(sqlType.trim()) && existingType && FRACTIONAL_TYPE.test(existingType)) return `round(${col})::bigint`;
2204
2260
  return `${col}::text::${sqlType}`;
2205
2261
  }
2206
2262
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-postgres",
3
- "version": "0.1.154",
3
+ "version": "0.1.155",
4
4
  "description": "PostgreSQL adapter for @atscript/db with pg (node-postgres) driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -43,20 +43,20 @@
43
43
  "access": "public"
44
44
  },
45
45
  "devDependencies": {
46
- "@atscript/core": "^0.1.103",
47
- "@atscript/typescript": "^0.1.103",
46
+ "@atscript/core": "^0.1.105",
47
+ "@atscript/typescript": "^0.1.105",
48
48
  "@types/pg": "^8.11.0",
49
49
  "@uniqu/core": "^0.1.15",
50
50
  "pg": "^8.13.0",
51
- "unplugin-atscript": "^0.1.103"
51
+ "unplugin-atscript": "^0.1.105"
52
52
  },
53
53
  "peerDependencies": {
54
- "@atscript/core": "^0.1.103",
55
- "@atscript/typescript": "^0.1.103",
54
+ "@atscript/core": "^0.1.105",
55
+ "@atscript/typescript": "^0.1.105",
56
56
  "@uniqu/core": "^0.1.15",
57
57
  "pg": ">=8.0.0",
58
- "@atscript/db": "^0.1.154",
59
- "@atscript/db-sql-tools": "^0.1.154"
58
+ "@atscript/db": "^0.1.155",
59
+ "@atscript/db-sql-tools": "^0.1.155"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "pg": {