ts-prorm-orm 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1111 -0
- package/LICENSE +21 -0
- package/README.md +573 -0
- package/dist/audit/history-query.js +180 -0
- package/dist/audit/index.js +23 -0
- package/dist/audit/logger.js +236 -0
- package/dist/cache/cache-manager.js +84 -0
- package/dist/cache/index.js +16 -0
- package/dist/cache/redis-cluster-cache.js +557 -0
- package/dist/cli.js +2200 -0
- package/dist/compliance/audit-trail.js +68 -0
- package/dist/compliance/backup-verification.js +685 -0
- package/dist/compliance/breach-detector.js +505 -0
- package/dist/compliance/consent-record.js +227 -0
- package/dist/compliance/consent-versioning.js +331 -0
- package/dist/compliance/cross-border-log.js +530 -0
- package/dist/compliance/data-classifier.js +258 -0
- package/dist/compliance/data-lineage.js +565 -0
- package/dist/compliance/data-masker.js +303 -0
- package/dist/compliance/data-portability.js +265 -0
- package/dist/compliance/data-retention.js +195 -0
- package/dist/compliance/dsar-workflow.js +342 -0
- package/dist/compliance/field-encryption.js +223 -0
- package/dist/compliance/immutable-record.js +148 -0
- package/dist/compliance/index.js +260 -0
- package/dist/compliance/privacy-impact-assessment.js +364 -0
- package/dist/compliance/pseudonymization.js +269 -0
- package/dist/compliance/query-firewall.js +1182 -0
- package/dist/compliance/rate-limiter.js +580 -0
- package/dist/compliance/right-to-erasure.js +117 -0
- package/dist/compliance/row-level-security.js +246 -0
- package/dist/compliance/security-decorator.js +574 -0
- package/dist/compliance/security-monitor.js +525 -0
- package/dist/compliance/sensitive-data-discovery.js +476 -0
- package/dist/compliance/session-isolation.js +472 -0
- package/dist/compliance/tls-enforcer.js +108 -0
- package/dist/compliance/worm-storage.js +712 -0
- package/dist/connection-manager.js +198 -0
- package/dist/connection-pool.js +519 -0
- package/dist/decorators/audit.js +136 -0
- package/dist/decorators/belongs-to-many.js +115 -0
- package/dist/decorators/belongs-to.js +115 -0
- package/dist/decorators/check.js +435 -0
- package/dist/decorators/collate.js +329 -0
- package/dist/decorators/comment.js +205 -0
- package/dist/decorators/database-settings.js +236 -0
- package/dist/decorators/default.js +244 -0
- package/dist/decorators/encryption.js +235 -0
- package/dist/decorators/engine.js +97 -0
- package/dist/decorators/fk-constraints.js +594 -0
- package/dist/decorators/foreign-table.js +136 -0
- package/dist/decorators/generated.js +274 -0
- package/dist/decorators/has-many.js +127 -0
- package/dist/decorators/has-one.js +116 -0
- package/dist/decorators/hstore.js +129 -0
- package/dist/decorators/index.js +355 -0
- package/dist/decorators/json-column.js +83 -0
- package/dist/decorators/jsonb.js +101 -0
- package/dist/decorators/orm-decorators.js +425 -0
- package/dist/decorators/permissions.js +294 -0
- package/dist/decorators/procedure.js +210 -0
- package/dist/decorators/query-options.js +556 -0
- package/dist/decorators/range.js +167 -0
- package/dist/decorators/set-column.js +83 -0
- package/dist/decorators/spatial.js +114 -0
- package/dist/decorators/storage.js +580 -0
- package/dist/decorators/timezone.js +512 -0
- package/dist/decorators/trigger.js +90 -0
- package/dist/decorators/uuid.js +135 -0
- package/dist/decorators/view.js +258 -0
- package/dist/diagrams/chen-diagram.js +354 -0
- package/dist/diagrams/class-diagram.js +384 -0
- package/dist/diagrams/dependency-diagram.js +432 -0
- package/dist/diagrams/er-diagram.js +605 -0
- package/dist/diagrams/flow-diagram.js +394 -0
- package/dist/diagrams/gantt-diagram.js +411 -0
- package/dist/diagrams/index-diagram.js +353 -0
- package/dist/diagrams/index.js +184 -0
- package/dist/diagrams/migration-diagram.js +316 -0
- package/dist/diagrams/model-diagram.js +616 -0
- package/dist/diagrams/package-diagram.js +376 -0
- package/dist/diagrams/palette.js +85 -0
- package/dist/diagrams/relational-diagram.js +455 -0
- package/dist/diagrams/schemadoc-diagram.js +309 -0
- package/dist/diagrams/sequence-diagram.js +307 -0
- package/dist/diagrams/state-diagram.js +344 -0
- package/dist/diagrams/svg-dom.js +111 -0
- package/dist/diagrams/tree-diagram.js +250 -0
- package/dist/dialects/clickhouse/index.js +1742 -0
- package/dist/dialects/cockroachdb/index.js +4677 -0
- package/dist/dialects/cratedb/index.js +441 -0
- package/dist/dialects/databricks/index.js +517 -0
- package/dist/dialects/db2/index.js +2225 -0
- package/dist/dialects/dialect.js +720 -0
- package/dist/dialects/duckdb/index.js +2014 -0
- package/dist/dialects/exasol/index.js +357 -0
- package/dist/dialects/firebird/index.js +495 -0
- package/dist/dialects/greenplum/index.js +457 -0
- package/dist/dialects/hana/index.js +1741 -0
- package/dist/dialects/mariadb/index.js +3590 -0
- package/dist/dialects/mssql/index.js +2296 -0
- package/dist/dialects/mysql/index.js +4058 -0
- package/dist/dialects/oracle/index.js +2999 -0
- package/dist/dialects/postgres/index.js +5319 -0
- package/dist/dialects/query-stream-helper.js +152 -0
- package/dist/dialects/questdb/index.js +433 -0
- package/dist/dialects/redshift/index.js +2277 -0
- package/dist/dialects/singlestore/index.js +354 -0
- package/dist/dialects/snowflake/index.js +2082 -0
- package/dist/dialects/spanner/index.js +1768 -0
- package/dist/dialects/sqlite/index.js +3382 -0
- package/dist/dialects/tidb/index.js +377 -0
- package/dist/dialects/timescaledb/index.js +164 -0
- package/dist/dialects/trino/index.js +420 -0
- package/dist/dialects/turso/index.js +227 -0
- package/dist/dialects/vertica/index.js +317 -0
- package/dist/dialects/yugabytedb/index.js +424 -0
- package/dist/errors/index.js +472 -0
- package/dist/errors/utils.js +340 -0
- package/dist/errors.js +21 -0
- package/dist/extensions/catalog/cloud-warehouse-features.js +207 -0
- package/dist/extensions/catalog/mssql-features.js +147 -0
- package/dist/extensions/catalog/mysql-mariadb-plugins.js +229 -0
- package/dist/extensions/catalog/oracle-db2-features.js +196 -0
- package/dist/extensions/catalog/postgres-extensions.js +516 -0
- package/dist/extensions/index.js +71 -0
- package/dist/extensions/types.js +13 -0
- package/dist/foreign-data.js +173 -0
- package/dist/hooks/hooks-manager.js +350 -0
- package/dist/hooks/index.js +37 -0
- package/dist/index.js +463 -0
- package/dist/logging.js +335 -0
- package/dist/migrations/index.js +40 -0
- package/dist/migrations/migration.js +196 -0
- package/dist/migrations/migrator.js +411 -0
- package/dist/migrations/prormmigration.js +275 -0
- package/dist/migrations/query-interface.js +435 -0
- package/dist/migrations/seeder.js +353 -0
- package/dist/models/associations.js +852 -0
- package/dist/models/constraints.js +288 -0
- package/dist/models/data-types.js +2518 -0
- package/dist/models/decorators.js +445 -0
- package/dist/models/index.js +33 -0
- package/dist/models/indexes.js +531 -0
- package/dist/models/methods.js +382 -0
- package/dist/models/model-manager.js +103 -0
- package/dist/models/model.js +5349 -0
- package/dist/models/operators.js +67 -0
- package/dist/models/scopes.js +189 -0
- package/dist/models/typescript-types.js +26 -0
- package/dist/nosql/aerospike/index.js +205 -0
- package/dist/nosql/allegrograph/index.js +169 -0
- package/dist/nosql/arangodb/index.js +364 -0
- package/dist/nosql/azure-blob/index.js +206 -0
- package/dist/nosql/beanstalkd/index.js +231 -0
- package/dist/nosql/beequeue/index.js +210 -0
- package/dist/nosql/bigchaindb/index.js +195 -0
- package/dist/nosql/bigtable/index.js +224 -0
- package/dist/nosql/blazegraph/index.js +173 -0
- package/dist/nosql/bullmq/index.js +191 -0
- package/dist/nosql/cassandra/index.js +174 -0
- package/dist/nosql/chroma/index.js +190 -0
- package/dist/nosql/cloudflare-kv/index.js +220 -0
- package/dist/nosql/coherence/index.js +200 -0
- package/dist/nosql/cosmosdb/index.js +157 -0
- package/dist/nosql/couchbase/index.js +213 -0
- package/dist/nosql/dax/index.js +212 -0
- package/dist/nosql/deno-kv/index.js +206 -0
- package/dist/nosql/dgraph/index.js +171 -0
- package/dist/nosql/doris/index.js +169 -0
- package/dist/nosql/druid/index.js +162 -0
- package/dist/nosql/dynamodb/index.js +937 -0
- package/dist/nosql/elasticsearch/index.js +377 -0
- package/dist/nosql/etcd/index.js +502 -0
- package/dist/nosql/eventhubs/index.js +213 -0
- package/dist/nosql/eventstore/index.js +254 -0
- package/dist/nosql/faunadb/index.js +188 -0
- package/dist/nosql/firestore/index.js +177 -0
- package/dist/nosql/fluree/index.js +148 -0
- package/dist/nosql/fuseki/index.js +170 -0
- package/dist/nosql/gcs/index.js +172 -0
- package/dist/nosql/gearman/index.js +160 -0
- package/dist/nosql/geode/index.js +196 -0
- package/dist/nosql/graphdb/index.js +169 -0
- package/dist/nosql/graylog/index.js +188 -0
- package/dist/nosql/gridgain/index.js +171 -0
- package/dist/nosql/hazelcast/index.js +162 -0
- package/dist/nosql/hbase/index.js +230 -0
- package/dist/nosql/ignite/index.js +173 -0
- package/dist/nosql/immudb/index.js +184 -0
- package/dist/nosql/index.js +232 -0
- package/dist/nosql/infinispan/index.js +200 -0
- package/dist/nosql/influxdb/index.js +0 -0
- package/dist/nosql/kafka/index.js +234 -0
- package/dist/nosql/keyspaces/index.js +189 -0
- package/dist/nosql/kinesis/index.js +253 -0
- package/dist/nosql/leveldb/index.js +153 -0
- package/dist/nosql/lmdb/index.js +160 -0
- package/dist/nosql/loki/index.js +201 -0
- package/dist/nosql/marklogic/index.js +204 -0
- package/dist/nosql/materialize/index.js +145 -0
- package/dist/nosql/meilisearch/index.js +154 -0
- package/dist/nosql/memcached/index.js +223 -0
- package/dist/nosql/milvus/index.js +410 -0
- package/dist/nosql/minio/index.js +265 -0
- package/dist/nosql/momento/index.js +179 -0
- package/dist/nosql/mongodb/index.js +461 -0
- package/dist/nosql/nats/index.js +247 -0
- package/dist/nosql/nedb/index.js +164 -0
- package/dist/nosql/neo4j/index.js +450 -0
- package/dist/nosql/neptune/index.js +470 -0
- package/dist/nosql/nsq/index.js +200 -0
- package/dist/nosql/opensearch/index.js +186 -0
- package/dist/nosql/orientdb/index.js +175 -0
- package/dist/nosql/papertrail/index.js +200 -0
- package/dist/nosql/pinecone/index.js +0 -0
- package/dist/nosql/pinot/index.js +133 -0
- package/dist/nosql/pouchdb/index.js +172 -0
- package/dist/nosql/prometheus/index.js +174 -0
- package/dist/nosql/provendb/index.js +147 -0
- package/dist/nosql/pubsub/index.js +187 -0
- package/dist/nosql/pulsar/index.js +232 -0
- package/dist/nosql/qdrant/index.js +295 -0
- package/dist/nosql/qldb/index.js +192 -0
- package/dist/nosql/r2/index.js +281 -0
- package/dist/nosql/rabbitmq/index.js +237 -0
- package/dist/nosql/ravendb/index.js +175 -0
- package/dist/nosql/redis/index.js +607 -0
- package/dist/nosql/redpanda/index.js +237 -0
- package/dist/nosql/resque/index.js +203 -0
- package/dist/nosql/rethinkdb/index.js +232 -0
- package/dist/nosql/rocksdb/index.js +152 -0
- package/dist/nosql/rockset/index.js +126 -0
- package/dist/nosql/s3/index.js +298 -0
- package/dist/nosql/scylladb/index.js +178 -0
- package/dist/nosql/signoz/index.js +227 -0
- package/dist/nosql/sns/index.js +201 -0
- package/dist/nosql/solr/index.js +231 -0
- package/dist/nosql/splunk/index.js +227 -0
- package/dist/nosql/sqs/index.js +246 -0
- package/dist/nosql/stardog/index.js +169 -0
- package/dist/nosql/starrocks/index.js +170 -0
- package/dist/nosql/store.js +2 -0
- package/dist/nosql/sumologic/index.js +213 -0
- package/dist/nosql/surrealdb/index.js +179 -0
- package/dist/nosql/terminusdb/index.js +151 -0
- package/dist/nosql/tigergraph/index.js +469 -0
- package/dist/nosql/typesense/index.js +148 -0
- package/dist/nosql/unqlite/index.js +157 -0
- package/dist/nosql/upstash/index.js +167 -0
- package/dist/nosql/vercel-kv/index.js +166 -0
- package/dist/nosql/victoriametrics/index.js +213 -0
- package/dist/nosql/virtuoso/index.js +169 -0
- package/dist/nosql/weaviate/index.js +276 -0
- package/dist/operators/index.js +108 -0
- package/dist/operators.js +2690 -0
- package/dist/prisma-migrate/index.js +38 -0
- package/dist/prisma-migrate/migration-generator.js +125 -0
- package/dist/prisma-migrate/model-generator.js +172 -0
- package/dist/prisma-migrate/relations.js +100 -0
- package/dist/prisma-migrate/schema-parser.js +167 -0
- package/dist/prisma-migrate/type-mapper.js +40 -0
- package/dist/prorm.js +6832 -0
- package/dist/query-builders/cte-builder.js +80 -0
- package/dist/query-builders/functions/aggregate.js +390 -0
- package/dist/query-builders/functions/conditional.js +503 -0
- package/dist/query-builders/functions/datetime.js +695 -0
- package/dist/query-builders/functions/fulltext.js +439 -0
- package/dist/query-builders/functions/index.js +93 -0
- package/dist/query-builders/functions/json.js +427 -0
- package/dist/query-builders/functions/math.js +399 -0
- package/dist/query-builders/functions/string.js +518 -0
- package/dist/query-builders/functions/window.js +328 -0
- package/dist/query-builders/include-builder.js +323 -0
- package/dist/query-builders/index-expression-builder.js +242 -0
- package/dist/query-builders/index.js +161 -0
- package/dist/query-builders/insert-builder.js +164 -0
- package/dist/query-builders/model-helpers.js +38 -0
- package/dist/query-builders/order-limit-builder.js +239 -0
- package/dist/query-builders/sql-compiler.js +2040 -0
- package/dist/query-builders/subquery-builder.js +152 -0
- package/dist/query-builders/update-builder.js +182 -0
- package/dist/query-builders/view-builder.js +218 -0
- package/dist/query-builders/where-builder.js +1532 -0
- package/dist/query-interface.js +622 -0
- package/dist/query-optimizers/batch-optimizer.js +426 -0
- package/dist/query-optimizers/explain-plans.js +415 -0
- package/dist/query-optimizers/index.js +51 -0
- package/dist/query-optimizers/prepared-statement-cache.js +423 -0
- package/dist/query-optimizers/query-hints.js +438 -0
- package/dist/query-optimizers/query-optimizer.js +278 -0
- package/dist/query-optimizers/slow-query-logger.js +271 -0
- package/dist/replica-manager.js +507 -0
- package/dist/schema/index.js +15 -0
- package/dist/schema/migration-generator.js +374 -0
- package/dist/schema/schema-differ.js +549 -0
- package/dist/schema/types.js +6 -0
- package/dist/sql-constants.js +300 -0
- package/dist/sqlite-advanced.js +1047 -0
- package/dist/streams/index.js +12 -0
- package/dist/streams/transforms.js +178 -0
- package/dist/transaction.js +456 -0
- package/dist/types/index.js +180 -0
- package/dist/user-management.js +127 -0
- package/dist/utils/date.js +300 -0
- package/dist/utils/index.js +1079 -0
- package/dist/utils/string.js +161 -0
- package/dist/validators/index.js +19 -0
- package/dist/validators/validator.js +911 -0
- package/package.json +190 -0
|
@@ -0,0 +1,4677 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CockroachDB dialect implementation for the TypeScript ORM
|
|
4
|
+
*
|
|
5
|
+
* CockroachDB speaks the CockroachDB wire protocol and is largely SQL-compatible
|
|
6
|
+
* with CockroachDB, so this dialect reuses the `pg` driver (same as the Postgres
|
|
7
|
+
* dialect) and mirrors most of its query-building behavior. Notable differences
|
|
8
|
+
* from the Postgres dialect:
|
|
9
|
+
*
|
|
10
|
+
* - Default port is 26257 (not 5432).
|
|
11
|
+
* - showTables/describeTable/showIndexes/showConstraints prefer CockroachDB's
|
|
12
|
+
* `SHOW TABLES` / `SHOW COLUMNS FROM` / `SHOW INDEXES FROM` / `SHOW CONSTRAINTS FROM`
|
|
13
|
+
* statements instead of querying information_schema/pg_catalog directly.
|
|
14
|
+
* - `CREATE INDEX ... CONCURRENTLY` is not supported (and not needed) in CockroachDB
|
|
15
|
+
* because index creation is online/non-blocking by default; the `concurrently`
|
|
16
|
+
* option is ignored with a warning instead of being emitted in the SQL.
|
|
17
|
+
* - Interleaved tables (CockroachDB's now-deprecated `INTERLEAVE IN PARENT` syntax)
|
|
18
|
+
* are intentionally NOT implemented.
|
|
19
|
+
* - SERIAL/BIGSERIAL auto-increment columns are kept for parity with the Postgres
|
|
20
|
+
* dialect; CockroachDB accepts these as a Postgres-compatibility shorthand that
|
|
21
|
+
* maps to an implicit sequence internally (CockroachDB's default unique ID
|
|
22
|
+
* generation otherwise uses `unique_rowid()`).
|
|
23
|
+
* - `buildUpsertQuery`/`buildInsertQuery` default to Postgres-style `ON CONFLICT
|
|
24
|
+
* ... DO UPDATE` for consistency with the Postgres dialect's style, but
|
|
25
|
+
* accept a `nativeUpsert` option to opt into CockroachDB's native
|
|
26
|
+
* `UPSERT INTO ... VALUES (...)` shorthand, which replaces the whole row
|
|
27
|
+
* without requiring a conflict target.
|
|
28
|
+
* - Adds `isRetryableError()` to detect CockroachDB's serialization failure
|
|
29
|
+
* SQLSTATE (40001), which requires the client to retry the transaction.
|
|
30
|
+
* `runTransaction()` uses it to implement CockroachDB's client-side
|
|
31
|
+
* transaction retry loop (`SAVEPOINT cockroach_restart`), and it's also
|
|
32
|
+
* folded into the default query-retry match lists so single-statement
|
|
33
|
+
* retries cover it too.
|
|
34
|
+
*/
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.CockroachDBTransaction = exports.CockroachDBDialect = void 0;
|
|
40
|
+
exports.createCockroachDBDialect = createCockroachDBDialect;
|
|
41
|
+
const pg_1 = __importDefault(require("pg"));
|
|
42
|
+
const prorm_1 = require("../../prorm");
|
|
43
|
+
const { Pool: PgPool } = pg_1.default;
|
|
44
|
+
/**
|
|
45
|
+
* Build a CockroachDB OPTIONS (...) clause from a plain object.
|
|
46
|
+
* Single-quotes each value and escapes existing single-quotes.
|
|
47
|
+
*/
|
|
48
|
+
function buildPgOptionsClause(opts) {
|
|
49
|
+
const pairs = Object.entries(opts).map(([k, v]) => `${k} '${v.replace(/'/g, "''")}'`);
|
|
50
|
+
return `OPTIONS (${pairs.join(', ')})`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Default retry options for CockroachDB connection
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_RETRY_OPTIONS = {
|
|
56
|
+
max: 3,
|
|
57
|
+
timeout: 1000,
|
|
58
|
+
match: [
|
|
59
|
+
'ECONNREFUSED',
|
|
60
|
+
'ENOTFOUND',
|
|
61
|
+
'ETIMEDOUT',
|
|
62
|
+
'connection timeout',
|
|
63
|
+
'connect timeout',
|
|
64
|
+
'too many connections',
|
|
65
|
+
'remaining connection slots are reserved',
|
|
66
|
+
'Lock not available',
|
|
67
|
+
// CockroachDB's SQLSTATE 40001 ("serialization_failure"), surfaced to
|
|
68
|
+
// clients as a "restart transaction" error, is a routine occurrence
|
|
69
|
+
// under SERIALIZABLE isolation when transactions conflict. It's part of
|
|
70
|
+
// CockroachDB's documented client contract that these be retried, so
|
|
71
|
+
// single-statement retries (see query()/executeWithRetry()) need to
|
|
72
|
+
// match on it too.
|
|
73
|
+
'40001',
|
|
74
|
+
'serialization_failure',
|
|
75
|
+
'restart transaction',
|
|
76
|
+
],
|
|
77
|
+
backoff: false,
|
|
78
|
+
backoffMultiplier: 2,
|
|
79
|
+
backoffMax: 10000,
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Default pool options for CockroachDB
|
|
83
|
+
*/
|
|
84
|
+
const DEFAULT_POOL_OPTIONS = {
|
|
85
|
+
max: 20,
|
|
86
|
+
min: 0,
|
|
87
|
+
acquire: 60000,
|
|
88
|
+
idle: 30000,
|
|
89
|
+
evict: 1000,
|
|
90
|
+
handleDisconnects: false,
|
|
91
|
+
onConnect: undefined,
|
|
92
|
+
onDisconnect: undefined,
|
|
93
|
+
enableEvents: false,
|
|
94
|
+
logPoolOperations: false,
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* CockroachDB dialect class that implements the Dialect interface
|
|
98
|
+
*/
|
|
99
|
+
class CockroachDBDialect {
|
|
100
|
+
constructor(config) {
|
|
101
|
+
this.name = 'cockroachdb';
|
|
102
|
+
this.library = 'pg';
|
|
103
|
+
this.pool = null;
|
|
104
|
+
this._isConnected = false;
|
|
105
|
+
this.transactionDepth = 0;
|
|
106
|
+
this.config = {
|
|
107
|
+
host: 'localhost',
|
|
108
|
+
// CockroachDB's default SQL port is 26257 (not Postgres's 5432).
|
|
109
|
+
port: 26257,
|
|
110
|
+
max: 20,
|
|
111
|
+
idleTimeoutMillis: 30000,
|
|
112
|
+
connectionTimeoutMillis: 2000,
|
|
113
|
+
...config,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Determine whether an error is retryable, i.e. whether the client should
|
|
118
|
+
* retry the transaction that produced it.
|
|
119
|
+
*
|
|
120
|
+
* CockroachDB uses SQLSTATE 40001 ("serialization_failure", surfaced as the
|
|
121
|
+
* "restart transaction" error) to signal that a transaction could not be
|
|
122
|
+
* committed due to a conflict with a concurrent transaction under
|
|
123
|
+
* SERIALIZABLE isolation, and that the client should retry it from the
|
|
124
|
+
* beginning. See: https://www.cockroachlabs.com/docs/stable/transaction-retry-error-reference
|
|
125
|
+
*
|
|
126
|
+
* @param error - The error to check (expected to expose a `.code` property,
|
|
127
|
+
* as errors from the `pg` driver do).
|
|
128
|
+
* @returns True if the error's SQLSTATE is 40001.
|
|
129
|
+
*/
|
|
130
|
+
isRetryableError(error) {
|
|
131
|
+
if (!error) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (error.code === '40001') {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
// Some call paths (e.g. this.query()'s own error handling, or drivers/
|
|
138
|
+
// wrappers that stringify the original pg error) lose the `.code`
|
|
139
|
+
// property along the way. Fall back to checking nested causes and the
|
|
140
|
+
// error message text for CockroachDB's retry signal so callers using
|
|
141
|
+
// those paths can still detect a retryable error.
|
|
142
|
+
if (error.cause && error.cause !== error && this.isRetryableError(error.cause)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const message = typeof error.message === 'string' ? error.message : String(error);
|
|
146
|
+
const normalized = message.toLowerCase();
|
|
147
|
+
return (message.includes('40001') ||
|
|
148
|
+
normalized.includes('serialization_failure') ||
|
|
149
|
+
normalized.includes('restart transaction'));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Connect to the CockroachDB database
|
|
153
|
+
*/
|
|
154
|
+
async connect() {
|
|
155
|
+
try {
|
|
156
|
+
this.pool = new PgPool({
|
|
157
|
+
host: this.config.host,
|
|
158
|
+
port: this.config.port,
|
|
159
|
+
user: this.config.username,
|
|
160
|
+
password: this.config.password,
|
|
161
|
+
database: this.config.database,
|
|
162
|
+
max: this.config.max,
|
|
163
|
+
idleTimeoutMillis: this.config.idleTimeoutMillis,
|
|
164
|
+
connectionTimeoutMillis: this.config.connectionTimeoutMillis,
|
|
165
|
+
ssl: this.config.ssl,
|
|
166
|
+
statement_timeout: this.config.statementTimeout,
|
|
167
|
+
query_timeout: this.config.queryTimeout,
|
|
168
|
+
});
|
|
169
|
+
// Test the connection
|
|
170
|
+
const client = await this.pool.connect();
|
|
171
|
+
client.release();
|
|
172
|
+
this._isConnected = true;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new Error(`Failed to connect to CockroachDB database: ${error}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Disconnect from the CockroachDB database
|
|
180
|
+
*/
|
|
181
|
+
async disconnect() {
|
|
182
|
+
if (this.pool) {
|
|
183
|
+
await this.pool.end();
|
|
184
|
+
this.pool = null;
|
|
185
|
+
this._isConnected = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get the current connection pool
|
|
190
|
+
*/
|
|
191
|
+
getConnection() {
|
|
192
|
+
return this.pool;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if connected
|
|
196
|
+
*/
|
|
197
|
+
isConnected() {
|
|
198
|
+
return this._isConnected && this.pool !== null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Execute a raw SQL query with retry support
|
|
202
|
+
*/
|
|
203
|
+
async query(sql, options) {
|
|
204
|
+
// Default retry options for queries
|
|
205
|
+
const defaultQueryRetryOptions = {
|
|
206
|
+
max: 3,
|
|
207
|
+
timeout: 1000,
|
|
208
|
+
match: [
|
|
209
|
+
'ECONNREFUSED',
|
|
210
|
+
'ENOTFOUND',
|
|
211
|
+
'ETIMEDOUT',
|
|
212
|
+
'connection timeout',
|
|
213
|
+
'connect timeout',
|
|
214
|
+
'too many connections',
|
|
215
|
+
'remaining connection slots are reserved',
|
|
216
|
+
'Lock not available',
|
|
217
|
+
'deadlock',
|
|
218
|
+
// See DEFAULT_RETRY_OPTIONS above: CockroachDB's 40001/
|
|
219
|
+
// serialization_failure ("restart transaction") error is the
|
|
220
|
+
// standard signal that a single statement should be retried.
|
|
221
|
+
'40001',
|
|
222
|
+
'serialization_failure',
|
|
223
|
+
'restart transaction',
|
|
224
|
+
],
|
|
225
|
+
backoff: false,
|
|
226
|
+
backoffMultiplier: 2,
|
|
227
|
+
backoffMax: 10000,
|
|
228
|
+
};
|
|
229
|
+
const retryOptions = options?.retry;
|
|
230
|
+
const effectiveRetry = retryOptions
|
|
231
|
+
? {
|
|
232
|
+
...defaultQueryRetryOptions,
|
|
233
|
+
...retryOptions,
|
|
234
|
+
match: retryOptions.match || defaultQueryRetryOptions.match,
|
|
235
|
+
}
|
|
236
|
+
: null;
|
|
237
|
+
const executeQuery = async () => {
|
|
238
|
+
if (!this.pool) {
|
|
239
|
+
throw new Error('Not connected to database');
|
|
240
|
+
}
|
|
241
|
+
const isSelect = sql.trim().toUpperCase().startsWith('SELECT') ||
|
|
242
|
+
sql.trim().toUpperCase().startsWith('WITH') ||
|
|
243
|
+
sql.trim().toUpperCase().startsWith('SHOW') ||
|
|
244
|
+
sql.trim().toUpperCase().startsWith('EXPLAIN') ||
|
|
245
|
+
sql.trim().toUpperCase().startsWith('DESCRIBE');
|
|
246
|
+
try {
|
|
247
|
+
// Apply any $1/$2 or :name replacements before sending the query. `replacements`
|
|
248
|
+
// and `bindings` are both used across this file to pass values for placeholder
|
|
249
|
+
// substitution (see replaceReplacements()); without this the SQL is sent with
|
|
250
|
+
// literal, unbound placeholders and CockroachDB raises "there is no parameter $N".
|
|
251
|
+
const replacements = options?.replacements ?? options?.bindings;
|
|
252
|
+
const finalSql = replacements !== undefined ? this.replaceReplacements(sql, replacements) : sql;
|
|
253
|
+
const result = (await this.pool.query(finalSql));
|
|
254
|
+
// Transform fields
|
|
255
|
+
const fieldInfo = result.fields
|
|
256
|
+
? result.fields.map((field) => ({
|
|
257
|
+
name: field.name,
|
|
258
|
+
type: field.dataTypeID.toString(),
|
|
259
|
+
length: field.dataTypeModifier || 0,
|
|
260
|
+
tableID: 0,
|
|
261
|
+
columnID: 0,
|
|
262
|
+
nullable: true,
|
|
263
|
+
isEnum: field.dataTypeID === 114 /* OID for enum */,
|
|
264
|
+
isPrimaryKey: false,
|
|
265
|
+
}))
|
|
266
|
+
: [];
|
|
267
|
+
if (isSelect) {
|
|
268
|
+
return {
|
|
269
|
+
rows: result.rows,
|
|
270
|
+
rowCount: result.rowCount || result.rows.length,
|
|
271
|
+
fields: fieldInfo,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// For INSERT, UPDATE, DELETE - return row count
|
|
276
|
+
const rowCount = result.rowCount || 0;
|
|
277
|
+
// Handle RETURNING clause
|
|
278
|
+
if (result.rows && result.rows.length > 0) {
|
|
279
|
+
return {
|
|
280
|
+
rows: result.rows,
|
|
281
|
+
rowCount: result.rows.length,
|
|
282
|
+
fields: fieldInfo,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
rows: [],
|
|
287
|
+
rowCount,
|
|
288
|
+
fields: fieldInfo,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
throw new Error(`CockroachDB Query error: ${error}`);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
// If retry options are provided, use retry logic
|
|
297
|
+
if (effectiveRetry && effectiveRetry.max > 0) {
|
|
298
|
+
return this.executeWithRetry(executeQuery, effectiveRetry);
|
|
299
|
+
}
|
|
300
|
+
return executeQuery();
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Execute a query and stream results using CockroachDB cursor
|
|
304
|
+
* Uses server-side cursors for efficient memory usage with large datasets
|
|
305
|
+
* @param sql - The SQL query string
|
|
306
|
+
* @param options - Stream options
|
|
307
|
+
* @returns Readable stream for streaming results
|
|
308
|
+
*/
|
|
309
|
+
queryStream(sql, options) {
|
|
310
|
+
const { Readable } = require('stream');
|
|
311
|
+
const batchSize = options?.batchSize || 1000;
|
|
312
|
+
const highWaterMark = options?.highWaterMark || 1000;
|
|
313
|
+
// Create a custom readable stream
|
|
314
|
+
const stream = new Readable({
|
|
315
|
+
objectMode: true,
|
|
316
|
+
highWaterMark,
|
|
317
|
+
read() { },
|
|
318
|
+
});
|
|
319
|
+
// Use cursor for streaming - requires a client from the pool
|
|
320
|
+
if (!this.pool) {
|
|
321
|
+
stream.destroy(new Error('Not connected to database'));
|
|
322
|
+
return stream;
|
|
323
|
+
}
|
|
324
|
+
// Create a client for cursor operations
|
|
325
|
+
this.pool.connect((err, client, done) => {
|
|
326
|
+
if (err) {
|
|
327
|
+
stream.destroy(err);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Use a cursor for streaming
|
|
331
|
+
const cursorName = `cursor_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
332
|
+
const cursorSql = sql.replace(/\bLIMIT\s+\d+\s*$/i, '').trim();
|
|
333
|
+
// Declare cursor with the query
|
|
334
|
+
const declareSql = `DECLARE ${cursorName} CURSOR FOR ${cursorSql}`;
|
|
335
|
+
client.query(declareSql, (declareErr) => {
|
|
336
|
+
if (declareErr) {
|
|
337
|
+
done();
|
|
338
|
+
stream.destroy(declareErr);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Fetch rows in batches
|
|
342
|
+
const fetchBatch = () => {
|
|
343
|
+
client.query(`FETCH ${batchSize} FROM ${cursorName}`, (fetchErr, result) => {
|
|
344
|
+
if (fetchErr) {
|
|
345
|
+
done();
|
|
346
|
+
stream.destroy(fetchErr);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const rows = result?.rows || [];
|
|
350
|
+
if (rows.length === 0) {
|
|
351
|
+
// No more rows - close cursor and release client
|
|
352
|
+
client.query(`CLOSE ${cursorName}`, () => {
|
|
353
|
+
done();
|
|
354
|
+
stream.push(null);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Push each row to the stream
|
|
359
|
+
for (const row of rows) {
|
|
360
|
+
if (options?.mapToModel && options?.model) {
|
|
361
|
+
// Map to model instance if requested
|
|
362
|
+
const instance = new options.model();
|
|
363
|
+
Object.assign(instance, row);
|
|
364
|
+
stream.push(instance);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
stream.push(row);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Continue fetching
|
|
371
|
+
process.nextTick(fetchBatch);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
// Start fetching
|
|
376
|
+
fetchBatch();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
return stream;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Execute a function with retry logic for query execution
|
|
383
|
+
*/
|
|
384
|
+
async executeWithRetry(fn, retryOptions) {
|
|
385
|
+
let lastError;
|
|
386
|
+
const { backoff, backoffMultiplier, backoffMax } = retryOptions;
|
|
387
|
+
for (let attempt = 0; attempt <= retryOptions.max; attempt++) {
|
|
388
|
+
try {
|
|
389
|
+
return await fn();
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
lastError = error;
|
|
393
|
+
const errorMessage = lastError.message;
|
|
394
|
+
// Check if this error should be retried
|
|
395
|
+
const shouldRetry = retryOptions.match.some((pattern) => errorMessage.toLowerCase().includes(pattern.toLowerCase()));
|
|
396
|
+
if (!shouldRetry || attempt === retryOptions.max) {
|
|
397
|
+
throw lastError;
|
|
398
|
+
}
|
|
399
|
+
// Calculate delay - use exponential backoff if enabled
|
|
400
|
+
let delay;
|
|
401
|
+
if (backoff) {
|
|
402
|
+
delay = retryOptions.timeout * Math.pow(backoffMultiplier || 2, attempt);
|
|
403
|
+
delay = Math.min(delay, backoffMax || 10000);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
delay = retryOptions.timeout;
|
|
407
|
+
}
|
|
408
|
+
// Add some jitter to avoid thundering herd
|
|
409
|
+
const jitter = Math.random() * 100;
|
|
410
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
throw lastError;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Escape a value for use in a query
|
|
417
|
+
*/
|
|
418
|
+
escape(value) {
|
|
419
|
+
// Handle Literal (raw SQL) values - insert directly without escaping
|
|
420
|
+
if (value instanceof prorm_1.Literal) {
|
|
421
|
+
return value.val;
|
|
422
|
+
}
|
|
423
|
+
if (value === null) {
|
|
424
|
+
return 'NULL';
|
|
425
|
+
}
|
|
426
|
+
if (typeof value === 'string') {
|
|
427
|
+
return `'${this.escapeString(value)}'`;
|
|
428
|
+
}
|
|
429
|
+
if (typeof value === 'number') {
|
|
430
|
+
return String(value);
|
|
431
|
+
}
|
|
432
|
+
if (typeof value === 'bigint') {
|
|
433
|
+
return value.toString();
|
|
434
|
+
}
|
|
435
|
+
if (typeof value === 'boolean') {
|
|
436
|
+
return value ? 'TRUE' : 'FALSE';
|
|
437
|
+
}
|
|
438
|
+
if (value instanceof Date) {
|
|
439
|
+
return `'${this.formatDate(value)}'`;
|
|
440
|
+
}
|
|
441
|
+
if (Buffer.isBuffer(value)) {
|
|
442
|
+
return `E'${value.toString('hex')}'`;
|
|
443
|
+
}
|
|
444
|
+
if (Array.isArray(value)) {
|
|
445
|
+
// Use CockroachDB ARRAY[...] literal syntax, not JSON, so the value is
|
|
446
|
+
// usable directly in an array-typed column/expression.
|
|
447
|
+
return this.escapeArray(value);
|
|
448
|
+
}
|
|
449
|
+
// JSON stringify objects
|
|
450
|
+
return `'${this.escapeString(JSON.stringify(value))}'`;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Escape a string for SQL
|
|
454
|
+
*/
|
|
455
|
+
escapeString(str) {
|
|
456
|
+
return str.replace(/'/g, "''").replace(/\\/g, '\\\\');
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Format a date for CockroachDB
|
|
460
|
+
*/
|
|
461
|
+
formatDate(date) {
|
|
462
|
+
const year = date.getFullYear();
|
|
463
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
464
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
465
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
466
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
467
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
468
|
+
const milliseconds = String(date.getMilliseconds()).padStart(3, '0');
|
|
469
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Escape an identifier (table name, column name, etc.)
|
|
473
|
+
* CockroachDB uses double quotes for identifiers
|
|
474
|
+
*/
|
|
475
|
+
escapeId(identifier) {
|
|
476
|
+
const id = String(identifier ?? '');
|
|
477
|
+
return `"${id.replace(/"/g, '""')}"`;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Quote an identifier (column name, table name)
|
|
481
|
+
* CockroachDB uses double quotes for identifiers
|
|
482
|
+
*/
|
|
483
|
+
quoteIdentifier(identifier) {
|
|
484
|
+
return this.escapeId(identifier);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Quote a table name
|
|
488
|
+
* Includes schema prefix if provided
|
|
489
|
+
*/
|
|
490
|
+
quoteTable(tableName, schema) {
|
|
491
|
+
if (schema && schema !== 'public') {
|
|
492
|
+
return `${this.escapeId(schema)}.${this.escapeId(tableName)}`;
|
|
493
|
+
}
|
|
494
|
+
return this.escapeId(tableName);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Get the database version
|
|
498
|
+
*/
|
|
499
|
+
async getDatabaseVersion() {
|
|
500
|
+
if (!this.pool) {
|
|
501
|
+
throw new Error('Not connected to database');
|
|
502
|
+
}
|
|
503
|
+
const result = await this.query('SELECT version() as version');
|
|
504
|
+
return result.rows[0]?.version || 'Unknown';
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Create a database schema
|
|
508
|
+
* @param schema - The schema name to create
|
|
509
|
+
*/
|
|
510
|
+
async createSchema(schema) {
|
|
511
|
+
await this.query(`CREATE SCHEMA ${this.escapeId(schema)}`);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Drop a database schema
|
|
515
|
+
* @param schema - The schema name to drop
|
|
516
|
+
* @param options - Drop options (e.g., cascade)
|
|
517
|
+
*/
|
|
518
|
+
async dropSchema(schema, options) {
|
|
519
|
+
let sql = 'DROP SCHEMA';
|
|
520
|
+
if (options?.ifExists) {
|
|
521
|
+
sql += ' IF EXISTS';
|
|
522
|
+
}
|
|
523
|
+
sql += ` ${this.escapeId(schema)}`;
|
|
524
|
+
if (options?.cascade) {
|
|
525
|
+
sql += ' CASCADE';
|
|
526
|
+
}
|
|
527
|
+
await this.query(sql);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Show all schemas in the database
|
|
531
|
+
*/
|
|
532
|
+
async showAllSchemas() {
|
|
533
|
+
const result = await this.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema') ORDER BY schema_name`, { raw: true });
|
|
534
|
+
return result.rows?.map((row) => row.schema_name) || [];
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* List all schemas in the database
|
|
538
|
+
*/
|
|
539
|
+
async listSchemas() {
|
|
540
|
+
return this.showAllSchemas();
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Replace placeholders in SQL with actual values
|
|
544
|
+
* @param sql - SQL string with placeholders
|
|
545
|
+
* @param replacements - Object or array of replacement values
|
|
546
|
+
*/
|
|
547
|
+
replaceReplacements(sql, replacements) {
|
|
548
|
+
if (!replacements) {
|
|
549
|
+
return sql;
|
|
550
|
+
}
|
|
551
|
+
if (Array.isArray(replacements)) {
|
|
552
|
+
// Array-based replacements: $1, $2, etc.
|
|
553
|
+
let result = sql;
|
|
554
|
+
replacements.forEach((value, index) => {
|
|
555
|
+
const placeholder = `$${index + 1}`;
|
|
556
|
+
if (value === null) {
|
|
557
|
+
result = result.replace(new RegExp(placeholder.replace('$', '\\$'), 'g'), 'NULL');
|
|
558
|
+
}
|
|
559
|
+
else if (typeof value === 'string') {
|
|
560
|
+
result = result.replace(new RegExp(placeholder.replace('$', '\\$'), 'g'), `'${value.replace(/'/g, "''")}'`);
|
|
561
|
+
}
|
|
562
|
+
else if (value instanceof Date) {
|
|
563
|
+
result = result.replace(new RegExp(placeholder.replace('$', '\\$'), 'g'), `'${value.toISOString()}'`);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
result = result.replace(new RegExp(placeholder.replace('$', '\\$'), 'g'), String(value));
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
// Object-based replacements: :name, :value, etc.
|
|
573
|
+
let result = sql;
|
|
574
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
575
|
+
const placeholder = `:${key}`;
|
|
576
|
+
const regex = new RegExp(placeholder.replace(/:/g, '\\:'), 'g');
|
|
577
|
+
if (value === null) {
|
|
578
|
+
result = result.replace(regex, 'NULL');
|
|
579
|
+
}
|
|
580
|
+
else if (typeof value === 'string') {
|
|
581
|
+
result = result.replace(regex, `'${value.replace(/'/g, "''")}'`);
|
|
582
|
+
}
|
|
583
|
+
else if (value instanceof Date) {
|
|
584
|
+
result = result.replace(regex, `'${value.toISOString()}'`);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
result = result.replace(regex, String(value));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Create a new table
|
|
595
|
+
*/
|
|
596
|
+
async createTable(tableName, columns, options) {
|
|
597
|
+
const columnDefs = [];
|
|
598
|
+
for (const [columnName, definition] of Object.entries(columns)) {
|
|
599
|
+
columnDefs.push(this.getColumnDefinitionSql(columnName, definition));
|
|
600
|
+
}
|
|
601
|
+
// Add constraints from options
|
|
602
|
+
if (options?.constraints) {
|
|
603
|
+
for (const constraint of options.constraints) {
|
|
604
|
+
const constraintSql = this.buildConstraintSql(constraint);
|
|
605
|
+
if (constraintSql) {
|
|
606
|
+
columnDefs.push(constraintSql);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Column families (CockroachDB storage-layout hint): `FAMILY name (col1, col2)`.
|
|
611
|
+
// CockroachDB groups columns into families to control how they're
|
|
612
|
+
// co-located in the underlying key-value store; explicit families let
|
|
613
|
+
// callers separate hot/cold columns for wide tables.
|
|
614
|
+
// See: https://www.cockroachlabs.com/docs/stable/column-families
|
|
615
|
+
if (options?.families && options.families.length > 0) {
|
|
616
|
+
for (const family of options.families) {
|
|
617
|
+
if (!family.columns || family.columns.length === 0) {
|
|
618
|
+
throw new Error('CockroachDB column family must specify at least one column.');
|
|
619
|
+
}
|
|
620
|
+
const namePart = family.name ? `${this.escapeId(family.name)} ` : '';
|
|
621
|
+
const cols = family.columns.map((c) => this.escapeId(c)).join(', ');
|
|
622
|
+
columnDefs.push(`FAMILY ${namePart}(${cols})`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
let sql = 'CREATE TABLE';
|
|
626
|
+
if (options?.ifNotExists) {
|
|
627
|
+
sql += ' IF NOT EXISTS';
|
|
628
|
+
}
|
|
629
|
+
sql += ` ${this.escapeId(tableName)} (${columnDefs.join(', ')})`;
|
|
630
|
+
// Handle CockroachDB-specific options
|
|
631
|
+
// Tablespace (CockroachDB-specific)
|
|
632
|
+
if (options?.tablespace) {
|
|
633
|
+
sql += ` TABLESPACE ${options.tablespace}`;
|
|
634
|
+
}
|
|
635
|
+
// rowFormat is not supported in CockroachDB
|
|
636
|
+
if (options?.rowFormat) {
|
|
637
|
+
console.warn(`CockroachDB: rowFormat option '${options.rowFormat}' is not supported and will be ignored`);
|
|
638
|
+
}
|
|
639
|
+
// engine and charset are not supported in CockroachDB
|
|
640
|
+
if (options?.engine) {
|
|
641
|
+
console.warn(`CockroachDB: engine option '${options.engine}' is not supported and will be ignored`);
|
|
642
|
+
}
|
|
643
|
+
if (options?.charset) {
|
|
644
|
+
console.warn(`CockroachDB: charset option '${options.charset}' is not supported and will be ignored`);
|
|
645
|
+
}
|
|
646
|
+
// Inherit from parent table (CockroachDB-specific)
|
|
647
|
+
if (options?.inherit) {
|
|
648
|
+
sql += ` INHERITS (${this.escapeId(options.inherit)})`;
|
|
649
|
+
}
|
|
650
|
+
// Partition by clause (CockroachDB)
|
|
651
|
+
if (options?.partitionBy) {
|
|
652
|
+
sql += ` PARTITION BY ${options.partitionBy}`;
|
|
653
|
+
}
|
|
654
|
+
// Multi-region table locality (CockroachDB): `LOCALITY GLOBAL` /
|
|
655
|
+
// `LOCALITY REGIONAL BY TABLE [IN region]` / `LOCALITY REGIONAL BY ROW [AS column]`.
|
|
656
|
+
// Only valid against a multi-region database (one with a primary region set).
|
|
657
|
+
// See: https://www.cockroachlabs.com/docs/stable/table-localities
|
|
658
|
+
if (options?.locality) {
|
|
659
|
+
sql += ` ${this.buildLocalityClause(options.locality)}`;
|
|
660
|
+
}
|
|
661
|
+
// Handle charset and collate (limited support in CockroachDB - affects default for new columns)
|
|
662
|
+
if (options?.collate) {
|
|
663
|
+
sql += ` COLLATE ${options.collate}`;
|
|
664
|
+
}
|
|
665
|
+
await this.query(sql);
|
|
666
|
+
// Handle uniqueKeys (CockroachDB supports unique constraints)
|
|
667
|
+
// Support both old format (Record<string, string[]>) and new format (UniqueKeyOptions[])
|
|
668
|
+
if (options?.uniqueKeys) {
|
|
669
|
+
// Check if it's the new array format
|
|
670
|
+
if (Array.isArray(options.uniqueKeys)) {
|
|
671
|
+
for (const uk of options.uniqueKeys) {
|
|
672
|
+
const constraintName = uk.name || `${tableName}_${uk.fields.join('_')}_key`;
|
|
673
|
+
const constraintSql = `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintName)} UNIQUE (${uk.fields.map((f) => this.escapeId(f)).join(', ')})`;
|
|
674
|
+
await this.query(constraintSql);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
// Legacy object format
|
|
679
|
+
for (const [keyName, fields] of Object.entries(options.uniqueKeys)) {
|
|
680
|
+
const constraintName = keyName || `${tableName}_${fields.join('_')}_key`;
|
|
681
|
+
const constraintSql = `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintName)} UNIQUE (${fields.map((f) => this.escapeId(f)).join(', ')})`;
|
|
682
|
+
await this.query(constraintSql);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// Add table comment (CockroachDB)
|
|
687
|
+
if (options?.comment) {
|
|
688
|
+
await this.query(`COMMENT ON TABLE ${this.escapeId(tableName)} IS '${this.escapeString(options.comment)}'`);
|
|
689
|
+
}
|
|
690
|
+
// Handle initialAutoIncrement for CockroachDB - set sequence start value
|
|
691
|
+
if (options?.initialAutoIncrement) {
|
|
692
|
+
// Find auto-increment column and set its sequence
|
|
693
|
+
for (const [columnName, definition] of Object.entries(columns)) {
|
|
694
|
+
if (definition.autoIncrement) {
|
|
695
|
+
const sequenceName = `${tableName}_${columnName}_seq`;
|
|
696
|
+
await this.query(`ALTER SEQUENCE ${this.escapeId(sequenceName)} START WITH ${options.initialAutoIncrement}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Create indexes after table creation
|
|
701
|
+
if (options?.indexes) {
|
|
702
|
+
for (const index of options.indexes) {
|
|
703
|
+
await this.addIndex(tableName, index.name || `idx_${tableName}_${index.fields.join('_')}`, index.fields, {
|
|
704
|
+
unique: index.unique,
|
|
705
|
+
type: index.type,
|
|
706
|
+
using: index.using,
|
|
707
|
+
where: index.where,
|
|
708
|
+
expression: index.expression,
|
|
709
|
+
include: index.include,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Build constraint SQL for table creation
|
|
716
|
+
*/
|
|
717
|
+
buildConstraintSql(constraint) {
|
|
718
|
+
const name = constraint.name ? `"${constraint.name}"` : '';
|
|
719
|
+
const fields = constraint.fields?.map((f) => this.escapeId(f)).join(', ') || '';
|
|
720
|
+
switch (constraint.type) {
|
|
721
|
+
case 'PRIMARY KEY':
|
|
722
|
+
return `${name ? name + ' ' : ''}PRIMARY KEY (${fields})`;
|
|
723
|
+
case 'UNIQUE':
|
|
724
|
+
return `${name ? name + ' ' : ''}UNIQUE (${fields})`;
|
|
725
|
+
case 'FOREIGN KEY':
|
|
726
|
+
if (!constraint.references)
|
|
727
|
+
return null;
|
|
728
|
+
// Handle composite foreign keys (string | string[])
|
|
729
|
+
const refField = constraint.references.field;
|
|
730
|
+
const refFieldSql = Array.isArray(refField)
|
|
731
|
+
? `(${refField.map((f) => this.escapeId(f)).join(', ')})`
|
|
732
|
+
: `(${this.escapeId(refField)})`;
|
|
733
|
+
let fkSql = `${name ? name + ' ' : ''}FOREIGN KEY (${fields}) `;
|
|
734
|
+
fkSql += `REFERENCES ${this.escapeId(constraint.references.table)}${refFieldSql}`;
|
|
735
|
+
if (constraint.references.onDelete) {
|
|
736
|
+
fkSql += ` ON DELETE ${constraint.references.onDelete}`;
|
|
737
|
+
}
|
|
738
|
+
if (constraint.references.onUpdate) {
|
|
739
|
+
fkSql += ` ON UPDATE ${constraint.references.onUpdate}`;
|
|
740
|
+
}
|
|
741
|
+
return fkSql;
|
|
742
|
+
case 'CHECK':
|
|
743
|
+
if (!constraint.check)
|
|
744
|
+
return null;
|
|
745
|
+
return `${name ? name + ' ' : ''}CHECK (${constraint.check})`;
|
|
746
|
+
default:
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Generate column definition SQL for CockroachDB
|
|
752
|
+
*/
|
|
753
|
+
getColumnDefinitionSql(columnName, definition) {
|
|
754
|
+
// Check if this is an auto-increment column and use SERIAL/BIGSERIAL
|
|
755
|
+
const dt = definition.type;
|
|
756
|
+
if (definition.autoIncrement && (dt.key === 'INTEGER' || !dt.key)) {
|
|
757
|
+
// For auto-increment INTEGER columns, use SERIAL
|
|
758
|
+
let sql = `${this.escapeId(columnName)} SERIAL`;
|
|
759
|
+
if (definition.primaryKey) {
|
|
760
|
+
sql += ' PRIMARY KEY';
|
|
761
|
+
}
|
|
762
|
+
if (definition.allowNull === false) {
|
|
763
|
+
sql += ' NOT NULL';
|
|
764
|
+
}
|
|
765
|
+
if (definition.unique) {
|
|
766
|
+
sql += ' UNIQUE';
|
|
767
|
+
}
|
|
768
|
+
if (definition.references) {
|
|
769
|
+
// Handle composite foreign keys (string | string[])
|
|
770
|
+
const refField3 = definition.references.field;
|
|
771
|
+
const refFieldSql3 = Array.isArray(refField3)
|
|
772
|
+
? `(${refField3.map((f) => this.escapeId(f)).join(', ')})`
|
|
773
|
+
: this.escapeId(refField3);
|
|
774
|
+
sql += ` REFERENCES ${this.escapeId(definition.references.table)}(${refFieldSql3})`;
|
|
775
|
+
if (definition.references.onDelete) {
|
|
776
|
+
sql += ` ON DELETE ${definition.references.onDelete}`;
|
|
777
|
+
}
|
|
778
|
+
if (definition.references.onUpdate) {
|
|
779
|
+
sql += ` ON UPDATE ${definition.references.onUpdate}`;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return sql;
|
|
783
|
+
}
|
|
784
|
+
let sql = `${this.escapeId(columnName)} ${this.getDataTypeSql(definition.type)}`;
|
|
785
|
+
if (definition.allowNull === false) {
|
|
786
|
+
sql += ' NOT NULL';
|
|
787
|
+
}
|
|
788
|
+
if (definition.defaultValue !== undefined) {
|
|
789
|
+
sql += ` DEFAULT ${this.getDefaultValue(definition.defaultValue)}`;
|
|
790
|
+
}
|
|
791
|
+
if (definition.primaryKey) {
|
|
792
|
+
sql += ' PRIMARY KEY';
|
|
793
|
+
}
|
|
794
|
+
if (definition.unique) {
|
|
795
|
+
if (typeof definition.unique === 'string') {
|
|
796
|
+
sql += ` UNIQUE (${this.escapeId(definition.unique)})`;
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
sql += ' UNIQUE';
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (definition.references) {
|
|
803
|
+
// Handle composite foreign keys (string | string[])
|
|
804
|
+
const refField2 = definition.references.field;
|
|
805
|
+
const refFieldSql2 = Array.isArray(refField2)
|
|
806
|
+
? `(${refField2.map((f) => this.escapeId(f)).join(', ')})`
|
|
807
|
+
: this.escapeId(refField2);
|
|
808
|
+
sql += ` REFERENCES ${this.escapeId(definition.references.table)}(${refFieldSql2})`;
|
|
809
|
+
if (definition.references.onDelete) {
|
|
810
|
+
sql += ` ON DELETE ${definition.references.onDelete}`;
|
|
811
|
+
}
|
|
812
|
+
if (definition.references.onUpdate) {
|
|
813
|
+
sql += ` ON UPDATE ${definition.references.onUpdate}`;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (definition.comment) {
|
|
817
|
+
sql += ` COMMENT '${this.escapeString(definition.comment)}'`;
|
|
818
|
+
}
|
|
819
|
+
return sql;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Get default value SQL
|
|
823
|
+
*/
|
|
824
|
+
getDefaultValue(value) {
|
|
825
|
+
if (value === null) {
|
|
826
|
+
return 'NULL';
|
|
827
|
+
}
|
|
828
|
+
if (typeof value === 'string') {
|
|
829
|
+
// Check for CockroachDB functions
|
|
830
|
+
if (value.toUpperCase().includes('CURRENT_') || value.toUpperCase() === 'NULL') {
|
|
831
|
+
return value;
|
|
832
|
+
}
|
|
833
|
+
return `'${this.escapeString(value)}'`;
|
|
834
|
+
}
|
|
835
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
836
|
+
return String(value);
|
|
837
|
+
}
|
|
838
|
+
if (value instanceof Date) {
|
|
839
|
+
return `'${this.formatDate(value)}'`;
|
|
840
|
+
}
|
|
841
|
+
// Handle DataType instances with toDefaultValue method (e.g., DataTypes.NOW)
|
|
842
|
+
if (typeof value === 'object' &&
|
|
843
|
+
value !== null &&
|
|
844
|
+
typeof value.toDefaultValue === 'function') {
|
|
845
|
+
return value.toDefaultValue();
|
|
846
|
+
}
|
|
847
|
+
return `'${this.escapeString(String(value))}'`;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Drop a table
|
|
851
|
+
*/
|
|
852
|
+
async dropTable(tableName, options) {
|
|
853
|
+
let sql = 'DROP TABLE';
|
|
854
|
+
if (options?.ifExists) {
|
|
855
|
+
sql += ' IF EXISTS';
|
|
856
|
+
}
|
|
857
|
+
sql += ` ${this.escapeId(tableName)}`;
|
|
858
|
+
if (options?.cascade) {
|
|
859
|
+
sql += ' CASCADE';
|
|
860
|
+
}
|
|
861
|
+
await this.query(sql);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Create a partitioned table (CockroachDB)
|
|
865
|
+
*
|
|
866
|
+
* @param tableName - Name of the table to create
|
|
867
|
+
* @param columns - Column definitions
|
|
868
|
+
* @param options - Table options including partition configuration
|
|
869
|
+
*/
|
|
870
|
+
async createPartitionedTable(tableName, columns, options) {
|
|
871
|
+
const columnDefs = [];
|
|
872
|
+
for (const [columnName, definition] of Object.entries(columns)) {
|
|
873
|
+
columnDefs.push(this.getColumnDefinitionSql(columnName, definition));
|
|
874
|
+
}
|
|
875
|
+
// Add constraints from options
|
|
876
|
+
if (options?.constraints) {
|
|
877
|
+
for (const constraint of options.constraints) {
|
|
878
|
+
const constraintSql = this.buildConstraintSql(constraint);
|
|
879
|
+
if (constraintSql) {
|
|
880
|
+
columnDefs.push(constraintSql);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
let sql = 'CREATE TABLE';
|
|
885
|
+
if (options?.ifNotExists) {
|
|
886
|
+
sql += ' IF NOT EXISTS';
|
|
887
|
+
}
|
|
888
|
+
sql += ` ${this.escapeId(tableName)} (${columnDefs.join(', ')})`;
|
|
889
|
+
// Partition by clause (CockroachDB)
|
|
890
|
+
if (options?.partitionBy) {
|
|
891
|
+
const columns = Array.isArray(options.partitionBy.column)
|
|
892
|
+
? options.partitionBy.column.join(', ')
|
|
893
|
+
: options.partitionBy.column;
|
|
894
|
+
sql += ` PARTITION BY ${options.partitionBy.type.toUpperCase()} (${columns})`;
|
|
895
|
+
}
|
|
896
|
+
await this.query(sql);
|
|
897
|
+
// Create initial partitions if specified
|
|
898
|
+
if (options?.partitions && options.partitions.length > 0) {
|
|
899
|
+
for (const partition of options.partitions) {
|
|
900
|
+
await this.createPartition({
|
|
901
|
+
parentTable: tableName,
|
|
902
|
+
name: partition.name,
|
|
903
|
+
bound: partition.bound,
|
|
904
|
+
tablespace: partition.tablespace,
|
|
905
|
+
storageParameters: partition.storageParameters,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Create a partition for an existing partitioned table (CockroachDB)
|
|
912
|
+
*
|
|
913
|
+
* @param options - Partition creation options
|
|
914
|
+
*/
|
|
915
|
+
async createPartition(options) {
|
|
916
|
+
let sql = `CREATE TABLE ${this.escapeId(options.name)} PARTITION OF ${this.escapeId(options.parentTable)}`;
|
|
917
|
+
if (options.bound) {
|
|
918
|
+
const bound = options.bound;
|
|
919
|
+
// Check for list partition
|
|
920
|
+
if ('values' in bound && bound.values && Array.isArray(bound.values)) {
|
|
921
|
+
const values = bound.values.map((v) => (typeof v === 'string' ? `'${v}'` : v)).join(', ');
|
|
922
|
+
sql += ` FOR VALUES IN (${values})`;
|
|
923
|
+
}
|
|
924
|
+
// Check for hash partition
|
|
925
|
+
else if ('modulus' in bound && bound.modulus !== undefined) {
|
|
926
|
+
const hashBound = bound;
|
|
927
|
+
sql += ` FOR VALUES WITH (MODULUS ${hashBound.modulus}, REMAINDER ${hashBound.remainder})`;
|
|
928
|
+
}
|
|
929
|
+
// Default to range partition
|
|
930
|
+
else {
|
|
931
|
+
const rangeBound = bound;
|
|
932
|
+
const from = rangeBound.from;
|
|
933
|
+
const to = rangeBound.to || 'MAXVALUE';
|
|
934
|
+
const fromStr = from instanceof Date ? from.toISOString().split('T')[0] : from;
|
|
935
|
+
const toStr = to instanceof Date ? to.toISOString().split('T')[0] : to;
|
|
936
|
+
sql += ` FOR VALUES FROM (${fromStr}) TO (${toStr})`;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (options.tablespace) {
|
|
940
|
+
sql += ` TABLESPACE ${options.tablespace}`;
|
|
941
|
+
}
|
|
942
|
+
if (options.storageParameters) {
|
|
943
|
+
const params = Object.entries(options.storageParameters)
|
|
944
|
+
.map(([key, value]) => `${key} = ${value}`)
|
|
945
|
+
.join(', ');
|
|
946
|
+
sql += ` WITH (${params})`;
|
|
947
|
+
}
|
|
948
|
+
await this.query(sql);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Attach a partition to a partitioned table (CockroachDB)
|
|
952
|
+
*
|
|
953
|
+
* @param options - Partition attachment options
|
|
954
|
+
*/
|
|
955
|
+
async attachPartition(options) {
|
|
956
|
+
const sql = `ALTER TABLE ${this.escapeId(options.parentTable)} ATTACH PARTITION ${this.escapeId(options.partitionName)}`;
|
|
957
|
+
await this.query(sql);
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Detach a partition from a partitioned table (CockroachDB)
|
|
961
|
+
*
|
|
962
|
+
* @param options - Partition detachment options
|
|
963
|
+
*/
|
|
964
|
+
async detachPartition(options) {
|
|
965
|
+
let sql = `ALTER TABLE ${this.escapeId(options.partitionName)} DETACH PARTITION`;
|
|
966
|
+
if (options.validate === false) {
|
|
967
|
+
sql += ' NOT VALIDATE';
|
|
968
|
+
}
|
|
969
|
+
await this.query(sql);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Drop a partition (CockroachDB)
|
|
973
|
+
*
|
|
974
|
+
* @param partitionName - Name of the partition to drop
|
|
975
|
+
* @param options - Drop options
|
|
976
|
+
*/
|
|
977
|
+
async dropPartition(partitionName, options) {
|
|
978
|
+
let sql = 'DROP TABLE';
|
|
979
|
+
if (options?.ifExists) {
|
|
980
|
+
sql += ' IF EXISTS';
|
|
981
|
+
}
|
|
982
|
+
sql += ` ${this.escapeId(partitionName)}`;
|
|
983
|
+
if (options?.cascade) {
|
|
984
|
+
sql += ' CASCADE';
|
|
985
|
+
}
|
|
986
|
+
await this.query(sql);
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Add a partition to an existing partitioned table (CockroachDB)
|
|
990
|
+
* @param tableName - Name of the partitioned table
|
|
991
|
+
* @param partitionName - Name for the new partition
|
|
992
|
+
* @param partitionSpec - Partition specification
|
|
993
|
+
*/
|
|
994
|
+
async addPartition(tableName, partitionName, partitionSpec) {
|
|
995
|
+
let sql = `ALTER TABLE ${this.escapeId(tableName)} ATTACH PARTITION ${this.escapeId(partitionName)}`;
|
|
996
|
+
if (partitionSpec.values) {
|
|
997
|
+
sql += ` FOR VALUES ${partitionSpec.values}`;
|
|
998
|
+
}
|
|
999
|
+
else if (partitionSpec.forValues) {
|
|
1000
|
+
sql += ` FOR VALUES ${partitionSpec.forValues}`;
|
|
1001
|
+
}
|
|
1002
|
+
await this.query(sql);
|
|
1003
|
+
}
|
|
1004
|
+
// ==================== Multi-region (LOCALITY) ====================
|
|
1005
|
+
/**
|
|
1006
|
+
* Build the `LOCALITY ...` clause fragment (without a leading space, but
|
|
1007
|
+
* including the `LOCALITY` keyword itself) for a given locality setting.
|
|
1008
|
+
* Shared by `createTable` and `setTableLocality`.
|
|
1009
|
+
*/
|
|
1010
|
+
buildLocalityClause(locality) {
|
|
1011
|
+
switch (locality.type) {
|
|
1012
|
+
case 'global':
|
|
1013
|
+
return 'LOCALITY GLOBAL';
|
|
1014
|
+
case 'regional-by-table':
|
|
1015
|
+
return locality.region
|
|
1016
|
+
? `LOCALITY REGIONAL BY TABLE IN ${this.escapeId(locality.region)}`
|
|
1017
|
+
: 'LOCALITY REGIONAL BY TABLE';
|
|
1018
|
+
case 'regional-by-row':
|
|
1019
|
+
return locality.column
|
|
1020
|
+
? `LOCALITY REGIONAL BY ROW AS ${this.escapeId(locality.column)}`
|
|
1021
|
+
: 'LOCALITY REGIONAL BY ROW';
|
|
1022
|
+
default:
|
|
1023
|
+
throw new Error(`Unknown CockroachDB locality type: ${locality.type}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Change the multi-region locality of an existing table
|
|
1028
|
+
* (`ALTER TABLE ... SET LOCALITY ...`).
|
|
1029
|
+
*
|
|
1030
|
+
* Requires the database to already be multi-region (see
|
|
1031
|
+
* `setPrimaryRegion`/`addRegion`).
|
|
1032
|
+
*
|
|
1033
|
+
* @see https://www.cockroachlabs.com/docs/stable/table-localities
|
|
1034
|
+
*/
|
|
1035
|
+
async setTableLocality(tableName, locality) {
|
|
1036
|
+
const sql = `ALTER TABLE ${this.escapeId(tableName)} SET ${this.buildLocalityClause(locality)}`;
|
|
1037
|
+
await this.query(sql);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Add a region to a multi-region database (`ALTER DATABASE ... ADD REGION ...`).
|
|
1041
|
+
*
|
|
1042
|
+
* @param region - Region name (e.g. `'us-east1'`)
|
|
1043
|
+
* @param options - `database` to target a database other than the current one
|
|
1044
|
+
*/
|
|
1045
|
+
async addRegion(region, options) {
|
|
1046
|
+
const dbPart = options?.database ? `${this.escapeId(options.database)} ` : '';
|
|
1047
|
+
const ifNotExists = options?.ifNotExists ? 'IF NOT EXISTS ' : '';
|
|
1048
|
+
const sql = `ALTER DATABASE ${dbPart}ADD REGION ${ifNotExists}${this.escapeId(region)}`;
|
|
1049
|
+
await this.query(sql);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Remove a region from a multi-region database (`ALTER DATABASE ... DROP REGION ...`).
|
|
1053
|
+
*/
|
|
1054
|
+
async dropRegion(region, options) {
|
|
1055
|
+
const dbPart = options?.database ? `${this.escapeId(options.database)} ` : '';
|
|
1056
|
+
const sql = `ALTER DATABASE ${dbPart}DROP REGION ${this.escapeId(region)}`;
|
|
1057
|
+
await this.query(sql);
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Set (or change) the primary region of a database
|
|
1061
|
+
* (`ALTER DATABASE ... {SET|PRIMARY REGION} ...`). Setting a primary
|
|
1062
|
+
* region for the first time promotes the database to a multi-region
|
|
1063
|
+
* database; CockroachDB automatically adds the region as a side effect if
|
|
1064
|
+
* it hasn't been added yet.
|
|
1065
|
+
*
|
|
1066
|
+
* @see https://www.cockroachlabs.com/docs/stable/multiregion-overview
|
|
1067
|
+
*/
|
|
1068
|
+
async setPrimaryRegion(region, options) {
|
|
1069
|
+
const dbPart = options?.database ? `${this.escapeId(options.database)} ` : '';
|
|
1070
|
+
const sql = `ALTER DATABASE ${dbPart}SET PRIMARY REGION ${this.escapeId(region)}`;
|
|
1071
|
+
await this.query(sql);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Set the survival goal of a multi-region database
|
|
1075
|
+
* (`ALTER DATABASE ... SURVIVE {ZONE|REGION} FAILURE`).
|
|
1076
|
+
*/
|
|
1077
|
+
async setSurvivalGoal(goal, options) {
|
|
1078
|
+
const dbPart = options?.database ? `${this.escapeId(options.database)} ` : '';
|
|
1079
|
+
const failureClause = goal === 'zone' ? 'ZONE FAILURE' : 'REGION FAILURE';
|
|
1080
|
+
const sql = `ALTER DATABASE ${dbPart}SURVIVE ${failureClause}`;
|
|
1081
|
+
await this.query(sql);
|
|
1082
|
+
}
|
|
1083
|
+
// ==================== Zone configuration ====================
|
|
1084
|
+
/**
|
|
1085
|
+
* Resolve a `CockroachZoneTarget` to the SQL fragment that follows
|
|
1086
|
+
* `ALTER ...` in `CONFIGURE ZONE`/`SPLIT AT`/`UNSPLIT AT` statements,
|
|
1087
|
+
* e.g. `TABLE "orders"`, `INDEX "orders"@"idx_name"`, or
|
|
1088
|
+
* `PARTITION "p1" OF TABLE "orders"`.
|
|
1089
|
+
*/
|
|
1090
|
+
resolveZoneTarget(target) {
|
|
1091
|
+
switch (target.kind) {
|
|
1092
|
+
case 'table':
|
|
1093
|
+
return `TABLE ${this.escapeId(target.name)}`;
|
|
1094
|
+
case 'index':
|
|
1095
|
+
return `INDEX ${this.escapeId(target.table)}@${this.escapeId(target.index)}`;
|
|
1096
|
+
case 'partition': {
|
|
1097
|
+
const ofClause = target.index
|
|
1098
|
+
? `INDEX ${this.escapeId(target.table)}@${this.escapeId(target.index)}`
|
|
1099
|
+
: `TABLE ${this.escapeId(target.table)}`;
|
|
1100
|
+
return `PARTITION ${this.escapeId(target.name)} OF ${ofClause}`;
|
|
1101
|
+
}
|
|
1102
|
+
case 'database':
|
|
1103
|
+
return `DATABASE ${this.escapeId(target.name)}`;
|
|
1104
|
+
default:
|
|
1105
|
+
throw new Error(`Unknown CockroachDB zone target kind: ${target.kind}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Configure replication/placement settings for a table, index, partition,
|
|
1110
|
+
* or database (`ALTER ... CONFIGURE ZONE USING ...`).
|
|
1111
|
+
*
|
|
1112
|
+
* Exposes the subset of zone-configuration variables that are reasonable
|
|
1113
|
+
* for an ORM to surface: replica count, placement constraints, lease
|
|
1114
|
+
* preferences, GC TTL, and range size bounds. For anything more advanced,
|
|
1115
|
+
* callers can fall back to raw `query()`.
|
|
1116
|
+
*
|
|
1117
|
+
* @see https://www.cockroachlabs.com/docs/stable/configure-zone
|
|
1118
|
+
*/
|
|
1119
|
+
async configureZone(target, options) {
|
|
1120
|
+
const assignments = [];
|
|
1121
|
+
if (options.numReplicas !== undefined) {
|
|
1122
|
+
assignments.push(`num_replicas = ${options.numReplicas}`);
|
|
1123
|
+
}
|
|
1124
|
+
if (options.numVoters !== undefined) {
|
|
1125
|
+
assignments.push(`num_voters = ${options.numVoters}`);
|
|
1126
|
+
}
|
|
1127
|
+
if (options.constraints !== undefined) {
|
|
1128
|
+
assignments.push(`constraints = '${this.escapeString(options.constraints)}'`);
|
|
1129
|
+
}
|
|
1130
|
+
if (options.leasePreferences !== undefined) {
|
|
1131
|
+
const prefs = `[${options.leasePreferences.map((p) => `'${this.escapeString(p)}'`).join(', ')}]`;
|
|
1132
|
+
assignments.push(`lease_preferences = ${prefs}`);
|
|
1133
|
+
}
|
|
1134
|
+
if (options.gcTtlSeconds !== undefined) {
|
|
1135
|
+
assignments.push(`gc.ttlseconds = ${options.gcTtlSeconds}`);
|
|
1136
|
+
}
|
|
1137
|
+
if (options.rangeMinBytes !== undefined) {
|
|
1138
|
+
assignments.push(`range_min_bytes = ${options.rangeMinBytes}`);
|
|
1139
|
+
}
|
|
1140
|
+
if (options.rangeMaxBytes !== undefined) {
|
|
1141
|
+
assignments.push(`range_max_bytes = ${options.rangeMaxBytes}`);
|
|
1142
|
+
}
|
|
1143
|
+
if (assignments.length === 0) {
|
|
1144
|
+
throw new Error('configureZone() requires at least one zone-configuration option.');
|
|
1145
|
+
}
|
|
1146
|
+
const sql = `ALTER ${this.resolveZoneTarget(target)} CONFIGURE ZONE USING ${assignments.join(', ')}`;
|
|
1147
|
+
await this.query(sql);
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Reset a zone configuration back to its inherited default
|
|
1151
|
+
* (`ALTER ... CONFIGURE ZONE DISCARD`).
|
|
1152
|
+
*/
|
|
1153
|
+
async resetZoneConfig(target) {
|
|
1154
|
+
const sql = `ALTER ${this.resolveZoneTarget(target)} CONFIGURE ZONE DISCARD`;
|
|
1155
|
+
await this.query(sql);
|
|
1156
|
+
}
|
|
1157
|
+
// ==================== Manual range splitting ====================
|
|
1158
|
+
/**
|
|
1159
|
+
* Format a single split/unsplit key value for use inside a `VALUES (...)`
|
|
1160
|
+
* tuple: quote strings, pass numbers/booleans through, and stringify
|
|
1161
|
+
* `Date`s as ISO timestamps.
|
|
1162
|
+
*/
|
|
1163
|
+
formatSplitValue(value) {
|
|
1164
|
+
if (value === null || value === undefined) {
|
|
1165
|
+
return 'NULL';
|
|
1166
|
+
}
|
|
1167
|
+
if (value instanceof Date) {
|
|
1168
|
+
return `'${value.toISOString()}'`;
|
|
1169
|
+
}
|
|
1170
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1171
|
+
return String(value);
|
|
1172
|
+
}
|
|
1173
|
+
return `'${this.escapeString(String(value))}'`;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Manually split a range at the given key value(s)
|
|
1177
|
+
* (`ALTER TABLE/INDEX ... SPLIT AT VALUES (...)`), to pre-emptively
|
|
1178
|
+
* distribute a hot/monotonically-growing key range across nodes before
|
|
1179
|
+
* CockroachDB's automatic range-size-based splitting would kick in.
|
|
1180
|
+
*
|
|
1181
|
+
* `values` is an array of row tuples matching the index prefix; pass
|
|
1182
|
+
* multiple tuples to create multiple split points in one call.
|
|
1183
|
+
*
|
|
1184
|
+
* @see https://www.cockroachlabs.com/docs/stable/alter-table#split-at
|
|
1185
|
+
*/
|
|
1186
|
+
async splitAt(target, values, options) {
|
|
1187
|
+
if (!values || values.length === 0) {
|
|
1188
|
+
throw new Error('splitAt() requires at least one value tuple.');
|
|
1189
|
+
}
|
|
1190
|
+
const tuples = values
|
|
1191
|
+
.map((tuple) => `(${tuple.map((v) => this.formatSplitValue(v)).join(', ')})`)
|
|
1192
|
+
.join(', ');
|
|
1193
|
+
let sql = `ALTER ${this.resolveZoneTarget(target)} SPLIT AT VALUES ${tuples}`;
|
|
1194
|
+
if (options?.expiration) {
|
|
1195
|
+
sql += ` WITH EXPIRATION ${this.formatSplitValue(options.expiration)}`;
|
|
1196
|
+
}
|
|
1197
|
+
await this.query(sql);
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Undo a manual (or expired) range split
|
|
1201
|
+
* (`ALTER TABLE/INDEX ... UNSPLIT AT VALUES (...)`), or unsplit every
|
|
1202
|
+
* manually-created split point on the target when `values` is omitted
|
|
1203
|
+
* (`UNSPLIT ALL`).
|
|
1204
|
+
*
|
|
1205
|
+
* @see https://www.cockroachlabs.com/docs/stable/alter-table#unsplit-at
|
|
1206
|
+
*/
|
|
1207
|
+
async unsplitAt(target, values) {
|
|
1208
|
+
if (!values || values.length === 0) {
|
|
1209
|
+
const sql = `ALTER ${this.resolveZoneTarget(target)} UNSPLIT ALL`;
|
|
1210
|
+
await this.query(sql);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const tuples = values
|
|
1214
|
+
.map((tuple) => `(${tuple.map((v) => this.formatSplitValue(v)).join(', ')})`)
|
|
1215
|
+
.join(', ');
|
|
1216
|
+
const sql = `ALTER ${this.resolveZoneTarget(target)} UNSPLIT AT VALUES ${tuples}`;
|
|
1217
|
+
await this.query(sql);
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Create a database view
|
|
1221
|
+
*/
|
|
1222
|
+
async createView(viewName, query, options) {
|
|
1223
|
+
const viewNameWithSchema = options?.schema
|
|
1224
|
+
? `${this.escapeId(options.schema)}.${this.escapeId(viewName)}`
|
|
1225
|
+
: this.escapeId(viewName);
|
|
1226
|
+
let sql = 'CREATE VIEW';
|
|
1227
|
+
if (options?.replace) {
|
|
1228
|
+
sql = 'CREATE OR REPLACE VIEW';
|
|
1229
|
+
}
|
|
1230
|
+
sql += ` ${viewNameWithSchema} AS ${query}`;
|
|
1231
|
+
await this.query(sql);
|
|
1232
|
+
if (options?.comment) {
|
|
1233
|
+
const commentSql = `COMMENT ON VIEW ${viewNameWithSchema} IS ${this.escape(options.comment)}`;
|
|
1234
|
+
await this.query(commentSql);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Drop a database view
|
|
1239
|
+
*/
|
|
1240
|
+
async dropView(viewName, options) {
|
|
1241
|
+
let sql = 'DROP VIEW';
|
|
1242
|
+
if (options?.ifExists) {
|
|
1243
|
+
sql += ' IF EXISTS';
|
|
1244
|
+
}
|
|
1245
|
+
sql += ` ${this.escapeId(viewName)}`;
|
|
1246
|
+
if (options?.cascade) {
|
|
1247
|
+
sql += ' CASCADE';
|
|
1248
|
+
}
|
|
1249
|
+
await this.query(sql);
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Show all views in the database
|
|
1253
|
+
*/
|
|
1254
|
+
async showViews() {
|
|
1255
|
+
const result = await this.query(`SELECT table_name FROM information_schema.views WHERE table_schema = 'public' ORDER BY table_name`, { raw: true });
|
|
1256
|
+
return result.rows?.map((row) => row.table_name) || [];
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Create a materialized view
|
|
1260
|
+
* @param options - Materialized view options
|
|
1261
|
+
*/
|
|
1262
|
+
async createMaterializedView(options) {
|
|
1263
|
+
const viewNameWithSchema = options.schema
|
|
1264
|
+
? `${this.escapeId(options.schema)}.${this.escapeId(options.name)}`
|
|
1265
|
+
: this.escapeId(options.name);
|
|
1266
|
+
let sql = 'CREATE MATERIALIZED VIEW';
|
|
1267
|
+
if (options.replace) {
|
|
1268
|
+
sql = 'CREATE OR REPLACE MATERIALIZED VIEW';
|
|
1269
|
+
}
|
|
1270
|
+
else if (options.ifNotExists) {
|
|
1271
|
+
sql = 'CREATE MATERIALIZED VIEW IF NOT EXISTS';
|
|
1272
|
+
}
|
|
1273
|
+
sql += ` ${viewNameWithSchema} AS ${options.query}`;
|
|
1274
|
+
if (!options.withData) {
|
|
1275
|
+
sql += ' WITH NO DATA';
|
|
1276
|
+
}
|
|
1277
|
+
await this.query(sql);
|
|
1278
|
+
// Create unique index if provided (required for CONCURRENTLY refresh)
|
|
1279
|
+
if (options.uniqueIndex) {
|
|
1280
|
+
const indexSql = `CREATE UNIQUE INDEX ${this.escapeId(options.uniqueIndex)} ON ${viewNameWithSchema} (${options.uniqueIndex})`;
|
|
1281
|
+
await this.query(indexSql);
|
|
1282
|
+
}
|
|
1283
|
+
if (options.comment) {
|
|
1284
|
+
const commentSql = `COMMENT ON MATERIALIZED VIEW ${viewNameWithSchema} IS ${this.escape(options.comment)}`;
|
|
1285
|
+
await this.query(commentSql);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Refresh a materialized view
|
|
1290
|
+
* @param viewName - Name of the materialized view to refresh
|
|
1291
|
+
* @param options - Refresh options
|
|
1292
|
+
*/
|
|
1293
|
+
async refreshMaterializedView(viewName, options) {
|
|
1294
|
+
let sql = 'REFRESH MATERIALIZED VIEW';
|
|
1295
|
+
if (options?.concurrently) {
|
|
1296
|
+
sql += ' CONCURRENTLY';
|
|
1297
|
+
}
|
|
1298
|
+
sql += ` ${this.escapeId(viewName)}`;
|
|
1299
|
+
if (options?.withNoData) {
|
|
1300
|
+
sql += ' WITH NO DATA';
|
|
1301
|
+
}
|
|
1302
|
+
await this.query(sql);
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Drop a materialized view
|
|
1306
|
+
* @param viewName - Name of the materialized view to drop
|
|
1307
|
+
* @param options - Drop options
|
|
1308
|
+
*/
|
|
1309
|
+
async dropMaterializedView(viewName, options) {
|
|
1310
|
+
let sql = 'DROP MATERIALIZED VIEW';
|
|
1311
|
+
if (options?.ifExists) {
|
|
1312
|
+
sql += ' IF EXISTS';
|
|
1313
|
+
}
|
|
1314
|
+
sql += ` ${this.escapeId(viewName)}`;
|
|
1315
|
+
if (options?.cascade) {
|
|
1316
|
+
sql += ' CASCADE';
|
|
1317
|
+
}
|
|
1318
|
+
await this.query(sql);
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Check if a materialized view exists
|
|
1322
|
+
*
|
|
1323
|
+
* Queries `pg_class`/`pg_namespace` directly (filtering on
|
|
1324
|
+
* `relkind = 'm'`) rather than the derived `pg_matviews` view. CockroachDB's
|
|
1325
|
+
* `pg_catalog` compatibility layer is partial, and `pg_matviews` — itself
|
|
1326
|
+
* normally just a view defined on top of `pg_class` in real Postgres — is
|
|
1327
|
+
* not guaranteed to exist/be populated the same way on CockroachDB.
|
|
1328
|
+
* `pg_class` is the more fundamental catalog and a safer bet for accurate
|
|
1329
|
+
* results (an absent/empty `pg_matviews` would otherwise silently report a
|
|
1330
|
+
* false "materialized view doesn't exist").
|
|
1331
|
+
* @param viewName - Name of the materialized view
|
|
1332
|
+
* @returns True if the materialized view exists
|
|
1333
|
+
*/
|
|
1334
|
+
async hasMaterializedView(viewName) {
|
|
1335
|
+
const result = await this.query(`SELECT 1 FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid ` +
|
|
1336
|
+
`WHERE c.relkind = 'm' AND c.relname = ${this.escape(viewName)} AND n.nspname = 'public'`, { raw: true });
|
|
1337
|
+
return result.rows?.length > 0;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Show all materialized views in the database
|
|
1341
|
+
*
|
|
1342
|
+
* See `hasMaterializedView` for why this queries `pg_class`/`pg_namespace`
|
|
1343
|
+
* (`relkind = 'm'`) instead of the `pg_matviews` compatibility view.
|
|
1344
|
+
*/
|
|
1345
|
+
async showMaterializedViews() {
|
|
1346
|
+
const result = await this.query(`SELECT c.relname AS matviewname FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid ` +
|
|
1347
|
+
`WHERE c.relkind = 'm' AND n.nspname = 'public' ORDER BY c.relname`, { raw: true });
|
|
1348
|
+
return result.rows?.map((row) => row.matviewname) || [];
|
|
1349
|
+
}
|
|
1350
|
+
// ==================== Stored Procedures ====================
|
|
1351
|
+
/**
|
|
1352
|
+
* Create a stored procedure (CockroachDB)
|
|
1353
|
+
*/
|
|
1354
|
+
async createStoredProcedure(options) {
|
|
1355
|
+
const schema = options.schema || 'public';
|
|
1356
|
+
const procName = `${this.escapeId(schema)}.${this.escapeId(options.name)}`;
|
|
1357
|
+
let sql = 'CREATE PROCEDURE';
|
|
1358
|
+
if (options.replace) {
|
|
1359
|
+
sql = 'CREATE OR REPLACE PROCEDURE';
|
|
1360
|
+
}
|
|
1361
|
+
else if (options.ifNotExists) {
|
|
1362
|
+
sql = 'CREATE PROCEDURE IF NOT EXISTS';
|
|
1363
|
+
}
|
|
1364
|
+
// Add parameters
|
|
1365
|
+
if (options.params && options.params.length > 0) {
|
|
1366
|
+
const params = options.params
|
|
1367
|
+
.map((p) => {
|
|
1368
|
+
const mode = p.mode ? `${p.mode} ` : '';
|
|
1369
|
+
const defaultPart = p.defaultValue !== undefined ? ` DEFAULT ${this.escape(p.defaultValue)}` : '';
|
|
1370
|
+
return `${mode}${this.escapeId(p.name)} ${p.type}${defaultPart}`;
|
|
1371
|
+
})
|
|
1372
|
+
.join(', ');
|
|
1373
|
+
sql += ` ${procName} (${params})`;
|
|
1374
|
+
}
|
|
1375
|
+
else {
|
|
1376
|
+
sql += ` ${procName}`;
|
|
1377
|
+
}
|
|
1378
|
+
sql += ` LANGUAGE plpgsql\n${options.body}`;
|
|
1379
|
+
await this.query(sql);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Create a foreign data wrapper — not supported in CockroachDB.
|
|
1383
|
+
* @param _fdwName - Foreign data wrapper name (unused; kept for interface parity)
|
|
1384
|
+
* @param _options - Options (unused; kept for interface parity)
|
|
1385
|
+
*/
|
|
1386
|
+
async createForeignDataWrapper(_fdwName, _options) {
|
|
1387
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1388
|
+
}
|
|
1389
|
+
async dropForeignDataWrapper(_fdwName, _options) {
|
|
1390
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1391
|
+
}
|
|
1392
|
+
async createForeignServer(_serverName, _fdwName, _options) {
|
|
1393
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1394
|
+
}
|
|
1395
|
+
async dropForeignServer(_serverName, _options) {
|
|
1396
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1397
|
+
}
|
|
1398
|
+
async createForeignTable(_tableName, _columns, _options) {
|
|
1399
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1400
|
+
}
|
|
1401
|
+
async changeOwner(newOwner, tableName) {
|
|
1402
|
+
const sql = `ALTER TABLE ${this.escapeId(tableName)} OWNER TO ${this.escapeId(newOwner)}`;
|
|
1403
|
+
await this.query(sql);
|
|
1404
|
+
}
|
|
1405
|
+
async addConstraint(tableName, options) {
|
|
1406
|
+
const constraintName = options.name || `${tableName}_${options.fields.join('_')}_${options.type.toLowerCase()}`;
|
|
1407
|
+
const fieldsSql = options.fields.map((f) => this.escapeId(f)).join(', ');
|
|
1408
|
+
let sql = `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintName)} `;
|
|
1409
|
+
switch (options.type) {
|
|
1410
|
+
case 'UNIQUE':
|
|
1411
|
+
sql += `UNIQUE (${fieldsSql})`;
|
|
1412
|
+
break;
|
|
1413
|
+
case 'PRIMARY KEY':
|
|
1414
|
+
sql += `PRIMARY KEY (${fieldsSql})`;
|
|
1415
|
+
break;
|
|
1416
|
+
case 'FOREIGN KEY':
|
|
1417
|
+
sql += `FOREIGN KEY (${fieldsSql}) REFERENCES ${this.escapeId(options.references.table)} (${options.references.fields.map((f) => this.escapeId(f)).join(', ')})`;
|
|
1418
|
+
break;
|
|
1419
|
+
case 'CHECK':
|
|
1420
|
+
sql += `CHECK (${options.check})`;
|
|
1421
|
+
break;
|
|
1422
|
+
}
|
|
1423
|
+
await this.query(sql);
|
|
1424
|
+
}
|
|
1425
|
+
async removeConstraint(tableName, constraintName) {
|
|
1426
|
+
const sql = `ALTER TABLE ${this.escapeId(tableName)} DROP CONSTRAINT ${this.escapeId(constraintName)}`;
|
|
1427
|
+
await this.query(sql);
|
|
1428
|
+
}
|
|
1429
|
+
async createSecurityPolicy(_policyName, _tableName, _options) {
|
|
1430
|
+
throw new Error('Security policies are not supported in CockroachDB');
|
|
1431
|
+
}
|
|
1432
|
+
async dropSecurityPolicy(_policyName, _tableName) {
|
|
1433
|
+
throw new Error('Security policies are not supported in CockroachDB');
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Drop a stored procedure
|
|
1437
|
+
*/
|
|
1438
|
+
async dropStoredProcedure(procedureName, options) {
|
|
1439
|
+
const schema = options?.schema || 'public';
|
|
1440
|
+
const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
|
|
1441
|
+
const cascade = options?.cascade ? ' CASCADE' : '';
|
|
1442
|
+
const sql = `DROP PROCEDURE ${ifExists}${this.escapeId(schema)}.${this.escapeId(procedureName)}${cascade}`;
|
|
1443
|
+
await this.query(sql);
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Drop a stored procedure (alias for dropStoredProcedure)
|
|
1447
|
+
*/
|
|
1448
|
+
async dropProcedure(procedureName, options) {
|
|
1449
|
+
return this.dropStoredProcedure(procedureName, options);
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Create a stored procedure (alias for createStoredProcedure)
|
|
1453
|
+
*/
|
|
1454
|
+
async createProcedure(options) {
|
|
1455
|
+
return this.createStoredProcedure(options);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Execute a stored procedure
|
|
1459
|
+
*/
|
|
1460
|
+
async executeStoredProcedure(options) {
|
|
1461
|
+
const schema = options.schema || 'public';
|
|
1462
|
+
const procName = `${this.escapeId(schema)}.${this.escapeId(options.procedureName)}`;
|
|
1463
|
+
let sql = `CALL ${procName}`;
|
|
1464
|
+
if (options.params && Object.keys(options.params).length > 0) {
|
|
1465
|
+
const params = Object.entries(options.params)
|
|
1466
|
+
.map(([name, value]) => `${this.escapeId(name)} => ${this.escape(value)}`)
|
|
1467
|
+
.join(', ');
|
|
1468
|
+
sql += `(${params})`;
|
|
1469
|
+
}
|
|
1470
|
+
return this.query(sql, { timeout: options.timeout });
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Check if a stored procedure exists
|
|
1474
|
+
*/
|
|
1475
|
+
async hasStoredProcedure(procedureName, schema) {
|
|
1476
|
+
const db = schema || 'public';
|
|
1477
|
+
const sql = `SELECT proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${db}' AND proname = '${procedureName}' AND prokind = 'p'`;
|
|
1478
|
+
const result = await this.query(sql, { plain: true });
|
|
1479
|
+
return !!result;
|
|
1480
|
+
}
|
|
1481
|
+
// ==================== User-Defined Functions (UDFs) ====================
|
|
1482
|
+
/**
|
|
1483
|
+
* Create a user-defined function (CockroachDB)
|
|
1484
|
+
*
|
|
1485
|
+
* CockroachDB has supported `CREATE FUNCTION` for SQL and PL/pgSQL
|
|
1486
|
+
* functions since v22.2/23.1, and unlike `CREATE PROCEDURE` (see
|
|
1487
|
+
* `createStoredProcedure`), functions can be used directly in SELECT
|
|
1488
|
+
* lists, computed column expressions, and CHECK constraints. A function
|
|
1489
|
+
* must declare a `RETURNS` type, and may optionally declare a volatility
|
|
1490
|
+
* category:
|
|
1491
|
+
* - `VOLATILE` (the default when omitted): may modify the database
|
|
1492
|
+
* and/or return different results on successive calls with the same
|
|
1493
|
+
* arguments.
|
|
1494
|
+
* - `STABLE`: cannot modify the database and returns the same result
|
|
1495
|
+
* for the same arguments within a single statement; lets the
|
|
1496
|
+
* optimizer avoid re-evaluating it per-row when safe.
|
|
1497
|
+
* - `IMMUTABLE`: cannot modify the database and always returns the same
|
|
1498
|
+
* result for the same arguments. Required for a function to be used
|
|
1499
|
+
* in a computed column expression.
|
|
1500
|
+
* @see https://www.cockroachlabs.com/docs/stable/create-function
|
|
1501
|
+
*/
|
|
1502
|
+
async createFunction(options) {
|
|
1503
|
+
const schema = options.schema || 'public';
|
|
1504
|
+
const funcName = `${this.escapeId(schema)}.${this.escapeId(options.name)}`;
|
|
1505
|
+
let sql = 'CREATE FUNCTION';
|
|
1506
|
+
if (options.replace) {
|
|
1507
|
+
sql = 'CREATE OR REPLACE FUNCTION';
|
|
1508
|
+
}
|
|
1509
|
+
else if (options.ifNotExists) {
|
|
1510
|
+
sql = 'CREATE FUNCTION IF NOT EXISTS';
|
|
1511
|
+
}
|
|
1512
|
+
// Add parameters
|
|
1513
|
+
if (options.params && options.params.length > 0) {
|
|
1514
|
+
const params = options.params
|
|
1515
|
+
.map((p) => {
|
|
1516
|
+
const mode = p.mode && p.mode !== 'IN' ? `${p.mode} ` : '';
|
|
1517
|
+
const defaultPart = p.defaultValue !== undefined ? ` DEFAULT ${this.escape(p.defaultValue)}` : '';
|
|
1518
|
+
return `${mode}${this.escapeId(p.name)} ${p.type}${defaultPart}`;
|
|
1519
|
+
})
|
|
1520
|
+
.join(', ');
|
|
1521
|
+
sql += ` ${funcName} (${params})`;
|
|
1522
|
+
}
|
|
1523
|
+
else {
|
|
1524
|
+
sql += ` ${funcName} ()`;
|
|
1525
|
+
}
|
|
1526
|
+
sql += ` RETURNS ${options.returnType}`;
|
|
1527
|
+
if (options.volatility) {
|
|
1528
|
+
sql += ` ${options.volatility}`;
|
|
1529
|
+
}
|
|
1530
|
+
const language = options.language || 'SQL';
|
|
1531
|
+
sql += ` LANGUAGE ${language} AS $$\n${options.body}\n$$`;
|
|
1532
|
+
await this.query(sql);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Drop a user-defined function
|
|
1536
|
+
*/
|
|
1537
|
+
async dropFunction(functionName, options) {
|
|
1538
|
+
const schema = options?.schema || 'public';
|
|
1539
|
+
const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
|
|
1540
|
+
const cascade = options?.cascade ? ' CASCADE' : '';
|
|
1541
|
+
const paramTypes = options?.paramTypes && options.paramTypes.length > 0
|
|
1542
|
+
? `(${options.paramTypes.join(', ')})`
|
|
1543
|
+
: '';
|
|
1544
|
+
const sql = `DROP FUNCTION ${ifExists}${this.escapeId(schema)}.${this.escapeId(functionName)}${paramTypes}${cascade}`;
|
|
1545
|
+
await this.query(sql);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Check if a user-defined function exists
|
|
1549
|
+
*/
|
|
1550
|
+
async hasFunction(functionName, schema) {
|
|
1551
|
+
const db = schema || 'public';
|
|
1552
|
+
const sql = `SELECT proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${db}' AND proname = '${functionName}' AND prokind = 'f'`;
|
|
1553
|
+
const result = await this.query(sql, { plain: true });
|
|
1554
|
+
return !!result;
|
|
1555
|
+
}
|
|
1556
|
+
// ==================== Triggers ====================
|
|
1557
|
+
/**
|
|
1558
|
+
* Create a trigger
|
|
1559
|
+
*
|
|
1560
|
+
* CockroachDB's trigger support (added as a preview feature and
|
|
1561
|
+
* subsequently stabilized) is deliberately narrower than Postgres':
|
|
1562
|
+
* - Only row-level triggers are supported (`FOR EACH ROW`); there is no
|
|
1563
|
+
* `FOR EACH STATEMENT` execution.
|
|
1564
|
+
* - `INSTEAD OF` triggers are not supported (CockroachDB views are not
|
|
1565
|
+
* updatable in the way Postgres views can be).
|
|
1566
|
+
* - The `REFERENCING` clause (transition tables, e.g.
|
|
1567
|
+
* `REFERENCING NEW TABLE AS ...`) is not supported.
|
|
1568
|
+
* - Constraint triggers (`CREATE CONSTRAINT TRIGGER`) are not supported.
|
|
1569
|
+
* Rather than silently emitting Postgres-only DDL that will fail (or
|
|
1570
|
+
* behave unexpectedly) against CockroachDB, these unsupported options
|
|
1571
|
+
* throw a clear error.
|
|
1572
|
+
*
|
|
1573
|
+
* @see https://www.cockroachlabs.com/docs/stable/triggers
|
|
1574
|
+
*/
|
|
1575
|
+
async createTrigger(options) {
|
|
1576
|
+
const level = options.level || 'ROW';
|
|
1577
|
+
if (level !== 'ROW') {
|
|
1578
|
+
throw new Error('CockroachDB only supports row-level triggers (FOR EACH ROW); ' +
|
|
1579
|
+
'FOR EACH STATEMENT triggers are not supported.');
|
|
1580
|
+
}
|
|
1581
|
+
if (options.timing === 'INSTEAD OF') {
|
|
1582
|
+
throw new Error('CockroachDB does not support INSTEAD OF triggers.');
|
|
1583
|
+
}
|
|
1584
|
+
if (options.referencing) {
|
|
1585
|
+
throw new Error('CockroachDB does not support the REFERENCING clause (transition tables) for triggers.');
|
|
1586
|
+
}
|
|
1587
|
+
if (options.constraint) {
|
|
1588
|
+
throw new Error('CockroachDB does not support constraint triggers.');
|
|
1589
|
+
}
|
|
1590
|
+
const timing = options.timing;
|
|
1591
|
+
const events = (options.events || []).join(' OR ');
|
|
1592
|
+
const tableName = options.schema
|
|
1593
|
+
? `${this.escapeId(options.schema)}.${this.escapeId(options.tableName)}`
|
|
1594
|
+
: this.quoteTable(options.tableName);
|
|
1595
|
+
let sql = `CREATE TRIGGER ${this.escapeId(options.name)} ${timing} ${events} ON ${tableName} FOR EACH ROW`;
|
|
1596
|
+
sql += `\nEXECUTE FUNCTION ${options.body}`;
|
|
1597
|
+
if (options.replace) {
|
|
1598
|
+
sql = sql.replace('CREATE TRIGGER', 'CREATE OR REPLACE TRIGGER');
|
|
1599
|
+
}
|
|
1600
|
+
await this.query(sql);
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Drop a trigger
|
|
1604
|
+
*/
|
|
1605
|
+
async dropTrigger(triggerName, tableName, options) {
|
|
1606
|
+
const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
|
|
1607
|
+
const cascade = options?.cascade ? ' CASCADE' : '';
|
|
1608
|
+
const schemaPart = options?.schema ? `${this.escapeId(options.schema)}.` : '';
|
|
1609
|
+
const sql = `DROP TRIGGER ${ifExists}${this.escapeId(triggerName)} ON ${schemaPart}${this.escapeId(tableName)}${cascade}`;
|
|
1610
|
+
await this.query(sql);
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* List triggers defined on a table.
|
|
1614
|
+
*
|
|
1615
|
+
* Uses CockroachDB's own `SHOW TRIGGERS FROM <table>` introspection
|
|
1616
|
+
* statement (in the same family as `SHOW INDEXES FROM`/`SHOW CONSTRAINTS
|
|
1617
|
+
* FROM` used elsewhere in this dialect) rather than querying the
|
|
1618
|
+
* `pg_trigger` catalog directly. CockroachDB's `pg_catalog` compatibility
|
|
1619
|
+
* layer is partial, and there is no guarantee `pg_trigger` is populated
|
|
1620
|
+
* for CockroachDB-native triggers the same way it is in real Postgres, so
|
|
1621
|
+
* relying on it risks silently reporting "no trigger" when one exists.
|
|
1622
|
+
*/
|
|
1623
|
+
async showTriggers(tableName) {
|
|
1624
|
+
const sql = `SHOW TRIGGERS FROM ${this.escapeId(tableName)}`;
|
|
1625
|
+
const result = await this.query(sql);
|
|
1626
|
+
return result.rows;
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Check if a trigger exists
|
|
1630
|
+
*/
|
|
1631
|
+
async hasTrigger(triggerName, tableName) {
|
|
1632
|
+
const triggers = await this.showTriggers(tableName);
|
|
1633
|
+
return triggers.some((row) => row.trigger_name === triggerName || row.name === triggerName);
|
|
1634
|
+
}
|
|
1635
|
+
// ==================== Sequences ====================
|
|
1636
|
+
/**
|
|
1637
|
+
* Create a sequence
|
|
1638
|
+
*/
|
|
1639
|
+
async createSequence(options) {
|
|
1640
|
+
const schema = options.schema || 'public';
|
|
1641
|
+
const seqName = `${this.escapeId(schema)}.${this.escapeId(options.name)}`;
|
|
1642
|
+
let sql = 'CREATE SEQUENCE';
|
|
1643
|
+
if (options.replace) {
|
|
1644
|
+
sql = 'CREATE OR REPLACE SEQUENCE';
|
|
1645
|
+
}
|
|
1646
|
+
else if (options.ifNotExists) {
|
|
1647
|
+
sql = 'CREATE SEQUENCE IF NOT EXISTS';
|
|
1648
|
+
}
|
|
1649
|
+
if (options.temporary) {
|
|
1650
|
+
sql = sql.replace('CREATE', 'CREATE TEMPORARY SEQUENCE');
|
|
1651
|
+
}
|
|
1652
|
+
sql += ` ${seqName}`;
|
|
1653
|
+
if (options.startWith)
|
|
1654
|
+
sql += ` START WITH ${options.startWith}`;
|
|
1655
|
+
if (options.incrementBy)
|
|
1656
|
+
sql += ` INCREMENT BY ${options.incrementBy}`;
|
|
1657
|
+
if (options.minvalue)
|
|
1658
|
+
sql += ` MINVALUE ${options.minvalue}`;
|
|
1659
|
+
if (options.maxvalue)
|
|
1660
|
+
sql += ` MAXVALUE ${options.maxvalue}`;
|
|
1661
|
+
if (options.cycle)
|
|
1662
|
+
sql += ` CYCLE`;
|
|
1663
|
+
if (options.cache)
|
|
1664
|
+
sql += ` CACHE ${options.cache}`;
|
|
1665
|
+
if (options.ownedBy)
|
|
1666
|
+
sql += ` OWNED BY ${options.ownedBy}`;
|
|
1667
|
+
await this.query(sql);
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Drop a sequence
|
|
1671
|
+
*/
|
|
1672
|
+
async dropSequence(sequenceName, options) {
|
|
1673
|
+
const schema = options?.schema || 'public';
|
|
1674
|
+
const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
|
|
1675
|
+
const cascade = options?.cascade ? ' CASCADE' : '';
|
|
1676
|
+
const sql = `DROP SEQUENCE ${ifExists}${this.escapeId(schema)}.${this.escapeId(sequenceName)}${cascade}`;
|
|
1677
|
+
await this.query(sql);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Get next value from a sequence
|
|
1681
|
+
*/
|
|
1682
|
+
async nextSequenceValue(sequenceName) {
|
|
1683
|
+
const sql = `SELECT nextval(${this.escape(sequenceName)}) as value`;
|
|
1684
|
+
const result = await this.query(sql, { raw: true });
|
|
1685
|
+
return result.rows?.[0]?.value;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Check if a sequence exists
|
|
1689
|
+
*/
|
|
1690
|
+
async hasSequence(sequenceName) {
|
|
1691
|
+
const sql = `SELECT sequence_name FROM information_schema.sequences WHERE sequence_name = '${sequenceName}'`;
|
|
1692
|
+
const result = await this.query(sql, { plain: true });
|
|
1693
|
+
return !!result;
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* List all sequences in the database (CockroachDB)
|
|
1697
|
+
* @returns Array of sequence names
|
|
1698
|
+
*/
|
|
1699
|
+
async listSequences() {
|
|
1700
|
+
const sql = `SELECT sequence_name FROM information_schema.sequences ORDER BY sequence_name`;
|
|
1701
|
+
const result = await this.query(sql);
|
|
1702
|
+
return result.rows.map((row) => row.sequence_name);
|
|
1703
|
+
}
|
|
1704
|
+
// ==================== Row-Level Security (RLS) ====================
|
|
1705
|
+
/**
|
|
1706
|
+
* Create a policy (CockroachDB RLS)
|
|
1707
|
+
*/
|
|
1708
|
+
async createPolicy(options) {
|
|
1709
|
+
const schema = options.schema || 'public';
|
|
1710
|
+
const tableName = `${this.escapeId(schema)}.${this.escapeId(options.tableName)}`;
|
|
1711
|
+
const policyName = this.escapeId(options.name);
|
|
1712
|
+
let sql = `CREATE POLICY ${policyName} ON ${tableName}`;
|
|
1713
|
+
if (options.permissive) {
|
|
1714
|
+
sql += ' FOR SELECT USING (true)';
|
|
1715
|
+
}
|
|
1716
|
+
if (options.roles && options.roles.length > 0) {
|
|
1717
|
+
sql += ` TO ${options.roles.join(', ')}`;
|
|
1718
|
+
}
|
|
1719
|
+
if (options.using) {
|
|
1720
|
+
sql += ` USING (${options.using})`;
|
|
1721
|
+
}
|
|
1722
|
+
if (options.withCheck) {
|
|
1723
|
+
sql += ` WITH CHECK (${options.withCheck})`;
|
|
1724
|
+
}
|
|
1725
|
+
await this.query(sql);
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Drop a policy (CockroachDB RLS)
|
|
1729
|
+
*/
|
|
1730
|
+
async dropPolicy(policyName, tableName, options) {
|
|
1731
|
+
const schema = options?.schema || 'public';
|
|
1732
|
+
const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
|
|
1733
|
+
const cascade = options?.cascade ? ' CASCADE' : '';
|
|
1734
|
+
const sql = `DROP POLICY ${ifExists}${this.escapeId(policyName)} ON ${this.escapeId(schema)}.${this.escapeId(tableName)}${cascade}`;
|
|
1735
|
+
await this.query(sql);
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Enable row-level security on a table (CockroachDB)
|
|
1739
|
+
*/
|
|
1740
|
+
async enableRLS(tableName, schema) {
|
|
1741
|
+
const schemaName = schema || 'public';
|
|
1742
|
+
const sql = `ALTER TABLE ${this.escapeId(schemaName)}.${this.escapeId(tableName)} ENABLE ROW LEVEL SECURITY`;
|
|
1743
|
+
await this.query(sql);
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Enable row-level security on a table (alias for enableRLS)
|
|
1747
|
+
*/
|
|
1748
|
+
async enableRowLevelSecurity(tableName, schema) {
|
|
1749
|
+
return this.enableRLS(tableName, schema);
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Disable row-level security on a table (CockroachDB)
|
|
1753
|
+
*/
|
|
1754
|
+
async disableRLS(tableName, schema) {
|
|
1755
|
+
const schemaName = schema || 'public';
|
|
1756
|
+
const sql = `ALTER TABLE ${this.escapeId(schemaName)}.${this.escapeId(tableName)} DISABLE ROW LEVEL SECURITY`;
|
|
1757
|
+
await this.query(sql);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Check if a policy exists (CockroachDB RLS)
|
|
1761
|
+
*/
|
|
1762
|
+
async hasPolicy(policyName, tableName) {
|
|
1763
|
+
const sql = `SELECT policyname FROM pg_policies WHERE policyname = '${policyName}' AND tablename = '${tableName}'`;
|
|
1764
|
+
const result = await this.query(sql, { plain: true });
|
|
1765
|
+
return !!result;
|
|
1766
|
+
}
|
|
1767
|
+
// ==================== Comments ====================
|
|
1768
|
+
/**
|
|
1769
|
+
* Add comment to a table
|
|
1770
|
+
*/
|
|
1771
|
+
async commentTable(tableName, comment) {
|
|
1772
|
+
const sql = `COMMENT ON TABLE ${this.quoteTable(tableName)} IS ${this.escape(comment)}`;
|
|
1773
|
+
await this.query(sql);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Add comment to a column
|
|
1777
|
+
*/
|
|
1778
|
+
async commentColumn(tableName, columnName, comment) {
|
|
1779
|
+
const sql = `COMMENT ON COLUMN ${this.quoteTable(tableName)}.${this.quoteIdentifier(columnName)} IS ${this.escape(comment)}`;
|
|
1780
|
+
await this.query(sql);
|
|
1781
|
+
}
|
|
1782
|
+
// ==================== Advanced Indexes ====================
|
|
1783
|
+
/**
|
|
1784
|
+
* Create a partial index (index with WHERE clause)
|
|
1785
|
+
*/
|
|
1786
|
+
async createPartialIndex(tableName, indexName, fields, where, options) {
|
|
1787
|
+
const fieldsSql = fields.map((f) => this.escapeId(f)).join(', ');
|
|
1788
|
+
let sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.quoteTable(tableName)} (${fieldsSql}) WHERE ${where}`;
|
|
1789
|
+
if (options?.unique)
|
|
1790
|
+
sql = sql.replace('CREATE INDEX', 'CREATE UNIQUE INDEX');
|
|
1791
|
+
if (options?.ifNotExists)
|
|
1792
|
+
sql = sql.replace('CREATE INDEX', 'CREATE INDEX IF NOT EXISTS');
|
|
1793
|
+
await this.query(sql);
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Create an expression index
|
|
1797
|
+
*/
|
|
1798
|
+
async createExpressionIndex(tableName, indexName, expression, options) {
|
|
1799
|
+
let sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.quoteTable(tableName)} ((${expression}))`;
|
|
1800
|
+
if (options?.unique)
|
|
1801
|
+
sql = sql.replace('CREATE INDEX', 'CREATE UNIQUE INDEX');
|
|
1802
|
+
if (options?.ifNotExists)
|
|
1803
|
+
sql = sql.replace('CREATE INDEX', 'CREATE INDEX IF NOT EXISTS');
|
|
1804
|
+
await this.query(sql);
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Create a fulltext index (CockroachDB)
|
|
1808
|
+
* Uses GIN index for fulltext search
|
|
1809
|
+
* @param tableName - Table name
|
|
1810
|
+
* @param indexName - Index name
|
|
1811
|
+
* @param fields - Fields to index
|
|
1812
|
+
* @param options - Fulltext index options
|
|
1813
|
+
*/
|
|
1814
|
+
async createFulltextIndex(tableName, indexName, fields, options) {
|
|
1815
|
+
const usingClause = options?.parser ? ` USING gin(to_tsvector(${options.parser}, ` : ' USING gin(to_tsvector(';
|
|
1816
|
+
const sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.quoteTable(tableName)}${usingClause}${fields.map((f) => `${this.escapeId(f)}`).join(' || ')}))`;
|
|
1817
|
+
await this.query(sql);
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Create a spatial index (CockroachDB)
|
|
1821
|
+
* Uses GIST index for spatial data
|
|
1822
|
+
* @param tableName - Table name
|
|
1823
|
+
* @param indexName - Index name
|
|
1824
|
+
* @param fields - Fields to index
|
|
1825
|
+
* @param options - Spatial index options
|
|
1826
|
+
*/
|
|
1827
|
+
async createSpatialIndex(tableName, indexName, fields, options) {
|
|
1828
|
+
const sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.quoteTable(tableName)} USING gist(${fields.map((f) => this.escapeId(f)).join(', ')})`;
|
|
1829
|
+
await this.query(sql);
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Bulk insert records into a table
|
|
1833
|
+
*/
|
|
1834
|
+
async bulkInsert(tableName, records, _options) {
|
|
1835
|
+
if (records.length === 0) {
|
|
1836
|
+
return { rows: [], rowCount: 0, fields: [] };
|
|
1837
|
+
}
|
|
1838
|
+
const values = [];
|
|
1839
|
+
const placeholders = [];
|
|
1840
|
+
const columns = Object.keys(records[0]);
|
|
1841
|
+
for (const record of records) {
|
|
1842
|
+
const rowPlaceholders = [];
|
|
1843
|
+
for (let i = 0; i < columns.length; i++) {
|
|
1844
|
+
rowPlaceholders.push(`$${values.length + i + 1}`);
|
|
1845
|
+
values.push(record[columns[i]]);
|
|
1846
|
+
}
|
|
1847
|
+
placeholders.push(`(${rowPlaceholders.join(', ')})`);
|
|
1848
|
+
}
|
|
1849
|
+
const sql = `INSERT INTO ${this.quoteTable(tableName)} (${columns.map((c) => this.escapeId(c)).join(', ')}) VALUES ${placeholders.join(', ')}`;
|
|
1850
|
+
return this.query(sql, { replacements: values });
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Add a foreign key to a table
|
|
1854
|
+
*/
|
|
1855
|
+
async addForeignKey(tableName, columnName, referencedTableName, referencedColumnName, options) {
|
|
1856
|
+
const constraintName = options?.name || `${tableName}_${columnName}_fkey`;
|
|
1857
|
+
let sql = `ALTER TABLE ${this.quoteTable(tableName)} ADD CONSTRAINT ${this.escapeId(constraintName)} FOREIGN KEY (${this.escapeId(columnName)}) REFERENCES ${this.quoteTable(referencedTableName)}(${this.escapeId(referencedColumnName)})`;
|
|
1858
|
+
const clauses = [];
|
|
1859
|
+
if (options?.onDelete) {
|
|
1860
|
+
clauses.push(`ON DELETE ${options.onDelete}`);
|
|
1861
|
+
}
|
|
1862
|
+
if (options?.onUpdate) {
|
|
1863
|
+
clauses.push(`ON UPDATE ${options.onUpdate}`);
|
|
1864
|
+
}
|
|
1865
|
+
if (clauses.length > 0) {
|
|
1866
|
+
sql += ' ' + clauses.join(' ');
|
|
1867
|
+
}
|
|
1868
|
+
await this.query(sql);
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Rename a column
|
|
1872
|
+
*/
|
|
1873
|
+
async renameColumn(tableName, oldColumnName, newColumnName) {
|
|
1874
|
+
const sql = `ALTER TABLE ${this.quoteTable(tableName)} RENAME COLUMN ${this.escapeId(oldColumnName)} TO ${this.escapeId(newColumnName)}`;
|
|
1875
|
+
await this.query(sql);
|
|
1876
|
+
}
|
|
1877
|
+
// ==================== Identity & Computed Columns ====================
|
|
1878
|
+
/**
|
|
1879
|
+
* Create an identity column
|
|
1880
|
+
*/
|
|
1881
|
+
async createIdentityColumn(tableName, columnName, options) {
|
|
1882
|
+
let sql = `ALTER TABLE ${this.quoteTable(tableName)} ALTER COLUMN ${this.escapeId(columnName)} ADD GENERATED ALWAYS AS IDENTITY`;
|
|
1883
|
+
if (options?.startWith || options?.incrementBy) {
|
|
1884
|
+
const startWith = options.startWith || 1;
|
|
1885
|
+
const incrementBy = options.incrementBy || 1;
|
|
1886
|
+
sql = `ALTER TABLE ${this.quoteTable(tableName)} ALTER COLUMN ${this.escapeId(columnName)} ADD GENERATED ALWAYS AS IDENTITY (START WITH ${startWith} INCREMENT BY ${incrementBy})`;
|
|
1887
|
+
}
|
|
1888
|
+
await this.query(sql);
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Create a computed column (generated column)
|
|
1892
|
+
*/
|
|
1893
|
+
async createComputedColumn(tableName, columnName, expression, options) {
|
|
1894
|
+
const persisted = options?.persisted ? 'STORED' : 'VIRTUAL';
|
|
1895
|
+
const type = options?.type || 'ANY';
|
|
1896
|
+
const sql = `ALTER TABLE ${this.quoteTable(tableName)} ADD COLUMN ${this.escapeId(columnName)} ${type} GENERATED ALWAYS AS (${expression}) ${persisted}`;
|
|
1897
|
+
await this.query(sql);
|
|
1898
|
+
}
|
|
1899
|
+
assertExtensionShimSupported(extensionName) {
|
|
1900
|
+
const normalized = extensionName.toLowerCase();
|
|
1901
|
+
if (!CockroachDBDialect.SUPPORTED_EXTENSION_SHIMS.has(normalized)) {
|
|
1902
|
+
throw new Error(`Extension '${extensionName}' is not supported by CockroachDB. CockroachDB only has partial ` +
|
|
1903
|
+
'PostgreSQL extension compatibility: CREATE/DROP EXTENSION is accepted as a no-op compatibility ' +
|
|
1904
|
+
`shim for a small allowlist of extension names (${Array.from(CockroachDBDialect.SUPPORTED_EXTENSION_SHIMS).join(', ')}), and no actual third-party extension code can be loaded.`);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Create a CockroachDB extension (no-op compatibility shim; see
|
|
1909
|
+
* SUPPORTED_EXTENSION_SHIMS above for CockroachDB's partial extension compatibility)
|
|
1910
|
+
* @param extensionName - Name of the extension to create
|
|
1911
|
+
* @param options - Extension options
|
|
1912
|
+
*/
|
|
1913
|
+
async createExtension(extensionName, options) {
|
|
1914
|
+
this.assertExtensionShimSupported(extensionName);
|
|
1915
|
+
const ifNotExists = options?.ifNotExists ? 'IF NOT EXISTS ' : '';
|
|
1916
|
+
const schema = options?.schema ? ` SCHEMA ${this.escapeId(options.schema)}` : '';
|
|
1917
|
+
const version = options?.version ? ` VERSION ${options.version}` : '';
|
|
1918
|
+
const sql = `CREATE EXTENSION ${ifNotExists}${this.escapeId(extensionName)}${schema}${version}`;
|
|
1919
|
+
await this.query(sql);
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Drop a CockroachDB extension (no-op compatibility shim; see
|
|
1923
|
+
* SUPPORTED_EXTENSION_SHIMS above for CockroachDB's partial extension compatibility)
|
|
1924
|
+
* @param extensionName - Name of the extension to drop
|
|
1925
|
+
* @param options - Drop options
|
|
1926
|
+
*/
|
|
1927
|
+
async dropExtension(extensionName, options) {
|
|
1928
|
+
this.assertExtensionShimSupported(extensionName);
|
|
1929
|
+
const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
|
|
1930
|
+
const cascade = options?.cascade ? ' CASCADE' : '';
|
|
1931
|
+
const sql = `DROP EXTENSION ${ifExists}${this.escapeId(extensionName)}${cascade}`;
|
|
1932
|
+
await this.query(sql);
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Get all installed CockroachDB extensions
|
|
1936
|
+
* @returns Array of extension information
|
|
1937
|
+
*/
|
|
1938
|
+
async getExtensions() {
|
|
1939
|
+
const sql = `
|
|
1940
|
+
SELECT
|
|
1941
|
+
e.extname as name,
|
|
1942
|
+
e.extversion as installed_version,
|
|
1943
|
+
obj_description(e.oid, 'pg_extension') as comment
|
|
1944
|
+
FROM pg_extension e
|
|
1945
|
+
WHERE e.extname NOT IN ('plpgsql')
|
|
1946
|
+
ORDER BY e.extname
|
|
1947
|
+
`;
|
|
1948
|
+
const result = await this.query(sql, { raw: true });
|
|
1949
|
+
return (result.rows?.map((row) => ({
|
|
1950
|
+
name: row.name,
|
|
1951
|
+
defaultVersion: null,
|
|
1952
|
+
installedVersion: row.installed_version,
|
|
1953
|
+
comment: row.comment,
|
|
1954
|
+
})) || []);
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Check if a CockroachDB extension is installed. Extensions outside
|
|
1958
|
+
* CockroachDB's small no-op compatibility allowlist (see
|
|
1959
|
+
* SUPPORTED_EXTENSION_SHIMS above) can never be "installed" on CockroachDB,
|
|
1960
|
+
* so this returns false for them without a round-trip to the server.
|
|
1961
|
+
* @param extensionName - Name of the extension
|
|
1962
|
+
* @returns True if the extension is installed
|
|
1963
|
+
*/
|
|
1964
|
+
async hasExtension(extensionName) {
|
|
1965
|
+
if (!CockroachDBDialect.SUPPORTED_EXTENSION_SHIMS.has(extensionName.toLowerCase())) {
|
|
1966
|
+
return false;
|
|
1967
|
+
}
|
|
1968
|
+
const sql = `
|
|
1969
|
+
SELECT 1 FROM pg_extension WHERE extname = $1
|
|
1970
|
+
`;
|
|
1971
|
+
const result = await this.query(sql, { replacements: [extensionName] });
|
|
1972
|
+
return (result.rows?.length ?? 0) > 0;
|
|
1973
|
+
}
|
|
1974
|
+
// ---------------------------------------------------------------------------
|
|
1975
|
+
// Foreign Data Wrapper SQL builders
|
|
1976
|
+
//
|
|
1977
|
+
// CockroachDB implements none of the Postgres FDW machinery (no
|
|
1978
|
+
// `CREATE FOREIGN SERVER`/`CREATE USER MAPPING`/`CREATE FOREIGN TABLE`/
|
|
1979
|
+
// `IMPORT FOREIGN SCHEMA`, and no `pg_foreign_server`/
|
|
1980
|
+
// `pg_foreign_data_wrapper` catalogs). These builders are part of the
|
|
1981
|
+
// shared `Dialect` interface (implemented for real by the Postgres
|
|
1982
|
+
// dialect); rather than returning Postgres-only DDL/catalog queries that
|
|
1983
|
+
// would fail against a real CockroachDB cluster, they throw a clear
|
|
1984
|
+
// "not supported" error instead.
|
|
1985
|
+
// ---------------------------------------------------------------------------
|
|
1986
|
+
buildCreateServerQuery(_name, _opts) {
|
|
1987
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1988
|
+
}
|
|
1989
|
+
buildAlterServerQuery(_name, _opts) {
|
|
1990
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1991
|
+
}
|
|
1992
|
+
buildDropServerQuery(_name, _opts) {
|
|
1993
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Return the SQL to list all foreign servers from the catalog — not
|
|
1997
|
+
* supported in CockroachDB (no `pg_foreign_server`/`pg_foreign_data_wrapper`).
|
|
1998
|
+
*/
|
|
1999
|
+
getServersQuery() {
|
|
2000
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2001
|
+
}
|
|
2002
|
+
buildCreateUserMappingQuery(_opts) {
|
|
2003
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2004
|
+
}
|
|
2005
|
+
buildAlterUserMappingQuery(_opts) {
|
|
2006
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2007
|
+
}
|
|
2008
|
+
buildDropUserMappingQuery(_serverName, _user, _opts) {
|
|
2009
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Create a user mapping for a foreign server — not supported in CockroachDB.
|
|
2013
|
+
*/
|
|
2014
|
+
async createUserMapping(_userName, _serverName, _options) {
|
|
2015
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Drop a user mapping for a foreign server — not supported in CockroachDB.
|
|
2019
|
+
*/
|
|
2020
|
+
async dropUserMapping(_userName, _serverName, _options) {
|
|
2021
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2022
|
+
}
|
|
2023
|
+
buildCreateForeignTableQuery(_tableName, _opts) {
|
|
2024
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2025
|
+
}
|
|
2026
|
+
buildDropForeignTableQuery(_tableName, _opts) {
|
|
2027
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2028
|
+
}
|
|
2029
|
+
buildImportForeignSchemaQuery(_remoteSchema, _serverName, _opts) {
|
|
2030
|
+
throw new Error(CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE);
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Add a column to a table
|
|
2034
|
+
*/
|
|
2035
|
+
async addColumn(tableName, columnName, definition) {
|
|
2036
|
+
const sql = `ALTER TABLE ${this.escapeId(tableName)} ADD COLUMN ${this.getColumnDefinitionSql(columnName, definition)}`;
|
|
2037
|
+
await this.query(sql);
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Remove a column from a table
|
|
2041
|
+
*/
|
|
2042
|
+
async removeColumn(tableName, columnName) {
|
|
2043
|
+
const sql = `ALTER TABLE ${this.escapeId(tableName)} DROP COLUMN ${this.escapeId(columnName)}`;
|
|
2044
|
+
await this.query(sql);
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Change a column definition
|
|
2048
|
+
*/
|
|
2049
|
+
async changeColumn(tableName, columnName, definition) {
|
|
2050
|
+
const sql = `ALTER TABLE ${this.escapeId(tableName)} ALTER COLUMN ${this.escapeId(columnName)} TYPE ${this.getDataTypeSql(definition.type)}`;
|
|
2051
|
+
await this.query(sql);
|
|
2052
|
+
if (definition.allowNull === false) {
|
|
2053
|
+
await this.query(`ALTER TABLE ${this.escapeId(tableName)} ALTER COLUMN ${this.escapeId(columnName)} SET NOT NULL`);
|
|
2054
|
+
}
|
|
2055
|
+
else if (definition.allowNull === true) {
|
|
2056
|
+
await this.query(`ALTER TABLE ${this.escapeId(tableName)} ALTER COLUMN ${this.escapeId(columnName)} DROP NOT NULL`);
|
|
2057
|
+
}
|
|
2058
|
+
if (definition.defaultValue !== undefined) {
|
|
2059
|
+
await this.query(`ALTER TABLE ${this.escapeId(tableName)} ALTER COLUMN ${this.escapeId(columnName)} SET DEFAULT ${this.getDefaultValue(definition.defaultValue)}`);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Show all tables in the database
|
|
2064
|
+
*
|
|
2065
|
+
* Uses CockroachDB's `SHOW TABLES` statement (rather than querying
|
|
2066
|
+
* information_schema directly, as the Postgres dialect does) so this
|
|
2067
|
+
* dialect exercises CockroachDB-specific introspection syntax. `SHOW
|
|
2068
|
+
* TABLES` returns rows shaped like
|
|
2069
|
+
* `{ schema_name, table_name, type, owner, estimated_row_count, locality }`
|
|
2070
|
+
* for the current database's `public` schema by default.
|
|
2071
|
+
*/
|
|
2072
|
+
async showTables() {
|
|
2073
|
+
const sql = 'SHOW TABLES';
|
|
2074
|
+
const result = await this.query(sql);
|
|
2075
|
+
return result.rows
|
|
2076
|
+
.filter((row) => !row.type || row.type === 'table')
|
|
2077
|
+
.map((row) => row.table_name);
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Get table status (CockroachDB implementation)
|
|
2081
|
+
*/
|
|
2082
|
+
async getTableStatus(tableName) {
|
|
2083
|
+
const sql = tableName
|
|
2084
|
+
? `SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`
|
|
2085
|
+
: `SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'public'`;
|
|
2086
|
+
const result = tableName
|
|
2087
|
+
? await this.query(sql, { bindings: [tableName] })
|
|
2088
|
+
: await this.query(sql);
|
|
2089
|
+
return result.rows;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Get table create statement (CockroachDB implementation)
|
|
2093
|
+
*/
|
|
2094
|
+
async getCreateTable(tableName) {
|
|
2095
|
+
const sql = `SELECT pg_get_constraintdef(oid) as definition FROM pg_class WHERE relname = $1`;
|
|
2096
|
+
// For full CREATE TABLE statement, use pg_dump or similar
|
|
2097
|
+
const tableSql = `SELECT pg_get_table_def(oid) as create_sql FROM pg_class WHERE relname = $1`;
|
|
2098
|
+
const result = await this.query(tableSql, { bindings: [tableName] });
|
|
2099
|
+
return result.rows[0]?.create_sql || '';
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Check if a table has partitions (CockroachDB implementation)
|
|
2103
|
+
*/
|
|
2104
|
+
async hasPartition(tableName) {
|
|
2105
|
+
const sql = `
|
|
2106
|
+
SELECT 1 FROM pg_tables
|
|
2107
|
+
WHERE tablename = $1 AND schemaname = 'public'
|
|
2108
|
+
`;
|
|
2109
|
+
const result = await this.query(sql, { bindings: [tableName] });
|
|
2110
|
+
// Check if it's a partitioned table
|
|
2111
|
+
const partSql = `
|
|
2112
|
+
SELECT 1 FROM pg_partitions
|
|
2113
|
+
WHERE tablename = $1 AND schemaname = 'public'
|
|
2114
|
+
`;
|
|
2115
|
+
const partResult = await this.query(partSql, { bindings: [tableName] });
|
|
2116
|
+
return partResult.rows.length > 0;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Show constraints for a table
|
|
2120
|
+
*
|
|
2121
|
+
* Uses CockroachDB's `SHOW CONSTRAINTS FROM <table>` statement, which
|
|
2122
|
+
* returns rows shaped like
|
|
2123
|
+
* `{ table_name, constraint_name, constraint_type, details, validated }`.
|
|
2124
|
+
*/
|
|
2125
|
+
async showConstraints(tableName) {
|
|
2126
|
+
const sql = `SHOW CONSTRAINTS FROM ${this.escapeId(tableName)}`;
|
|
2127
|
+
const result = await this.query(sql);
|
|
2128
|
+
return result.rows;
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Show indexes for a table
|
|
2132
|
+
*
|
|
2133
|
+
* Uses CockroachDB's `SHOW INDEXES FROM <table>` statement, which returns
|
|
2134
|
+
* rows shaped like
|
|
2135
|
+
* `{ table_name, index_name, non_unique, seq_in_index, column_name,
|
|
2136
|
+
* direction, storing, implicit }`.
|
|
2137
|
+
*/
|
|
2138
|
+
async showIndexes(tableName) {
|
|
2139
|
+
const sql = `SHOW INDEXES FROM ${this.escapeId(tableName)}`;
|
|
2140
|
+
const result = await this.query(sql);
|
|
2141
|
+
return result.rows;
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Describe a table (get column information)
|
|
2145
|
+
*
|
|
2146
|
+
* Uses CockroachDB's `SHOW COLUMNS FROM <table>` statement, which returns
|
|
2147
|
+
* rows shaped like
|
|
2148
|
+
* `{ column_name, data_type, is_nullable, column_default,
|
|
2149
|
+
* generation_expression, indices, is_hidden }`, plus
|
|
2150
|
+
* `SHOW CONSTRAINTS FROM <table>` to determine which column(s) form the
|
|
2151
|
+
* primary key.
|
|
2152
|
+
*/
|
|
2153
|
+
async describeTable(tableName) {
|
|
2154
|
+
const sql = `SHOW COLUMNS FROM ${this.escapeId(tableName)}`;
|
|
2155
|
+
const result = await this.query(sql);
|
|
2156
|
+
const description = {};
|
|
2157
|
+
for (const row of result.rows) {
|
|
2158
|
+
const rowData = row;
|
|
2159
|
+
const columnName = rowData.column_name;
|
|
2160
|
+
if (!columnName)
|
|
2161
|
+
continue;
|
|
2162
|
+
// Check if it's an auto-increment column (SERIAL/BIGSERIAL columns
|
|
2163
|
+
// default to `unique_rowid()` or `nextval(...)` in CockroachDB).
|
|
2164
|
+
const isSerial = typeof rowData.column_default === 'string' &&
|
|
2165
|
+
(rowData.column_default.includes('nextval') ||
|
|
2166
|
+
rowData.column_default.includes('unique_rowid'));
|
|
2167
|
+
description[columnName] = {
|
|
2168
|
+
type: rowData.data_type,
|
|
2169
|
+
allowNull: rowData.is_nullable === true || rowData.is_nullable === 'true',
|
|
2170
|
+
defaultValue: rowData.column_default,
|
|
2171
|
+
primaryKey: false, // Determined below via SHOW CONSTRAINTS
|
|
2172
|
+
autoIncrement: !!isSerial,
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
// Check for primary key via SHOW CONSTRAINTS FROM <table>
|
|
2176
|
+
const constraints = await this.showConstraints(tableName);
|
|
2177
|
+
for (const constraint of constraints) {
|
|
2178
|
+
const c = constraint;
|
|
2179
|
+
if (c.constraint_type !== 'PRIMARY KEY')
|
|
2180
|
+
continue;
|
|
2181
|
+
// `details` is typically formatted like `PRIMARY KEY (id ASC)`; extract
|
|
2182
|
+
// the column name(s) between the parentheses.
|
|
2183
|
+
const match = typeof c.details === 'string' ? c.details.match(/\(([^)]+)\)/) : null;
|
|
2184
|
+
if (match) {
|
|
2185
|
+
for (const part of match[1].split(',')) {
|
|
2186
|
+
const colName = part.trim().split(/\s+/)[0];
|
|
2187
|
+
if (description[colName]) {
|
|
2188
|
+
description[colName].primaryKey = true;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
return description;
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* Rename a table
|
|
2197
|
+
*/
|
|
2198
|
+
async renameTable(oldName, newName) {
|
|
2199
|
+
const sql = `ALTER TABLE ${this.escapeId(oldName)} RENAME TO ${this.escapeId(newName)}`;
|
|
2200
|
+
await this.query(sql);
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Resolve and validate the requested hash-sharded index bucket count
|
|
2204
|
+
* (`bucketCount`/`shard`) from an options object. Returns `undefined` if
|
|
2205
|
+
* neither is set.
|
|
2206
|
+
*/
|
|
2207
|
+
resolveHashBucketCount(options) {
|
|
2208
|
+
const bucketCount = options?.bucketCount ?? options?.shard;
|
|
2209
|
+
if (bucketCount === undefined) {
|
|
2210
|
+
return undefined;
|
|
2211
|
+
}
|
|
2212
|
+
if (!Number.isInteger(bucketCount) || bucketCount < 2) {
|
|
2213
|
+
throw new Error(`Invalid hash-sharded index bucket count: ${bucketCount}. Must be an integer >= 2.`);
|
|
2214
|
+
}
|
|
2215
|
+
return bucketCount;
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Add an index to a table
|
|
2219
|
+
*
|
|
2220
|
+
* Note: unlike Postgres, CockroachDB does not support (or need)
|
|
2221
|
+
* `CREATE INDEX CONCURRENTLY` — index backfills are already online and
|
|
2222
|
+
* non-blocking by default. If a caller requests `concurrently`, it is
|
|
2223
|
+
* ignored (with a warning) rather than emitted in the SQL.
|
|
2224
|
+
*/
|
|
2225
|
+
async addIndex(tableName, indexName, fields = [], options) {
|
|
2226
|
+
if (options?.concurrently) {
|
|
2227
|
+
console.warn('CockroachDB: CREATE INDEX CONCURRENTLY is not supported (or needed) — ' +
|
|
2228
|
+
'CockroachDB index creation is online by default. The `concurrently` option will be ignored.');
|
|
2229
|
+
}
|
|
2230
|
+
let sql = 'CREATE';
|
|
2231
|
+
if (options?.unique) {
|
|
2232
|
+
sql += ' UNIQUE';
|
|
2233
|
+
}
|
|
2234
|
+
if (options?.type) {
|
|
2235
|
+
sql += ` INDEX ${options.type}`;
|
|
2236
|
+
}
|
|
2237
|
+
else {
|
|
2238
|
+
sql += ' INDEX';
|
|
2239
|
+
}
|
|
2240
|
+
sql += ` ${this.escapeId(indexName)} ON ${this.escapeId(tableName)}`;
|
|
2241
|
+
// Handle expression index
|
|
2242
|
+
if (options?.expression) {
|
|
2243
|
+
sql += ` ((${options.expression}))`;
|
|
2244
|
+
}
|
|
2245
|
+
else {
|
|
2246
|
+
sql += ` (${fields.map((f) => this.escapeId(f)).join(', ')})`;
|
|
2247
|
+
}
|
|
2248
|
+
// Hash-sharded index (CockroachDB-specific hotspot-avoidance mechanism
|
|
2249
|
+
// for sequential/monotonic keys): `USING HASH WITH (bucket_count = N)`.
|
|
2250
|
+
// See: https://www.cockroachlabs.com/docs/stable/hash-sharded-indexes
|
|
2251
|
+
const bucketCount = this.resolveHashBucketCount(options);
|
|
2252
|
+
if (bucketCount !== undefined) {
|
|
2253
|
+
sql += ` USING HASH WITH (bucket_count = ${bucketCount})`;
|
|
2254
|
+
}
|
|
2255
|
+
else if (options?.using) {
|
|
2256
|
+
sql += ` USING ${options.using}`;
|
|
2257
|
+
}
|
|
2258
|
+
// Add INCLUDE columns for covering (storing) indexes
|
|
2259
|
+
if (options?.include && options.include.length > 0) {
|
|
2260
|
+
sql += ` INCLUDE (${options.include.map((f) => this.escapeId(f)).join(', ')})`;
|
|
2261
|
+
}
|
|
2262
|
+
if (options?.where) {
|
|
2263
|
+
const whereClause = this.buildWhereClause(options.where);
|
|
2264
|
+
sql += ` WHERE ${whereClause.sql}`;
|
|
2265
|
+
}
|
|
2266
|
+
await this.query(sql);
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Remove an index from a table
|
|
2270
|
+
*/
|
|
2271
|
+
async removeIndex(tableName, indexName) {
|
|
2272
|
+
const sql = `DROP INDEX ${this.escapeId(indexName)}`;
|
|
2273
|
+
await this.query(sql);
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Create an index on a table with full options support
|
|
2277
|
+
*
|
|
2278
|
+
* Note: CockroachDB does not support `CREATE INDEX CONCURRENTLY` — index
|
|
2279
|
+
* creation is already online/non-blocking, so a `concurrently` flag (if
|
|
2280
|
+
* present on `indexDef`) is ignored with a warning instead of being
|
|
2281
|
+
* emitted in the SQL.
|
|
2282
|
+
*/
|
|
2283
|
+
async createIndex(tableName, indexDef) {
|
|
2284
|
+
if (indexDef.concurrently) {
|
|
2285
|
+
console.warn('CockroachDB: CREATE INDEX CONCURRENTLY is not supported (or needed) — ' +
|
|
2286
|
+
'CockroachDB index creation is online by default. The `concurrently` option will be ignored.');
|
|
2287
|
+
}
|
|
2288
|
+
const fields = indexDef.fields || [];
|
|
2289
|
+
let sql = 'CREATE';
|
|
2290
|
+
if (indexDef.unique) {
|
|
2291
|
+
sql += ' UNIQUE';
|
|
2292
|
+
}
|
|
2293
|
+
if (indexDef.type) {
|
|
2294
|
+
sql += ` INDEX ${indexDef.type}`;
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
sql += ' INDEX';
|
|
2298
|
+
}
|
|
2299
|
+
sql += ` ${this.escapeId(indexDef.name)} ON ${this.escapeId(tableName)}`;
|
|
2300
|
+
// Handle expression index
|
|
2301
|
+
if (indexDef.expression) {
|
|
2302
|
+
sql += ` ((${indexDef.expression}))`;
|
|
2303
|
+
}
|
|
2304
|
+
else {
|
|
2305
|
+
sql += ` (${fields.map((f) => this.escapeId(f)).join(', ')})`;
|
|
2306
|
+
}
|
|
2307
|
+
// Hash-sharded index (CockroachDB-specific hotspot-avoidance mechanism
|
|
2308
|
+
// for sequential/monotonic keys): `USING HASH WITH (bucket_count = N)`.
|
|
2309
|
+
const bucketCount = this.resolveHashBucketCount(indexDef);
|
|
2310
|
+
if (bucketCount !== undefined) {
|
|
2311
|
+
sql += ` USING HASH WITH (bucket_count = ${bucketCount})`;
|
|
2312
|
+
}
|
|
2313
|
+
else if (indexDef.using) {
|
|
2314
|
+
sql += ` USING ${indexDef.using}`;
|
|
2315
|
+
}
|
|
2316
|
+
// Add INCLUDE columns for covering index (CockroachDB)
|
|
2317
|
+
if (indexDef.include && indexDef.include.length > 0) {
|
|
2318
|
+
sql += ` INCLUDE (${indexDef.include.map((f) => this.escapeId(f)).join(', ')})`;
|
|
2319
|
+
}
|
|
2320
|
+
if (indexDef.where && Object.keys(indexDef.where).length > 0) {
|
|
2321
|
+
const whereClause = this.buildWhereClause(indexDef.where);
|
|
2322
|
+
sql += ` WHERE ${whereClause.sql}`;
|
|
2323
|
+
}
|
|
2324
|
+
await this.query(sql);
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Create a GIN index for full-text search
|
|
2328
|
+
* CockroachDB-specific GIN (Generalized Inverted Index) for tsvector
|
|
2329
|
+
*/
|
|
2330
|
+
async createGINIndex(tableName, indexName, column, options) {
|
|
2331
|
+
const config = options?.config || 'english';
|
|
2332
|
+
const sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.escapeId(tableName)} USING GIN(to_tsvector('${config}', ${this.escapeId(column)}))${options?.storageParameters
|
|
2333
|
+
? ' WITH (' +
|
|
2334
|
+
Object.entries(options.storageParameters)
|
|
2335
|
+
.map(([k, v]) => `${k} = ${v}`)
|
|
2336
|
+
.join(', ') +
|
|
2337
|
+
')'
|
|
2338
|
+
: ''}`;
|
|
2339
|
+
await this.query(sql);
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* Build a to_tsvector expression
|
|
2343
|
+
* SQL: to_tsvector([config,] document)
|
|
2344
|
+
*/
|
|
2345
|
+
buildTsVector(column, config) {
|
|
2346
|
+
const cfg = config || 'english';
|
|
2347
|
+
const cols = Array.isArray(column) ? column : [column];
|
|
2348
|
+
const columnList = cols.map((c) => this.escapeId(c)).join(" || ' ' || ");
|
|
2349
|
+
return `to_tsvector('${cfg}', ${columnList})`;
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* Build a to_tsquery expression
|
|
2353
|
+
* SQL: to_tsquery([config,] query)
|
|
2354
|
+
*/
|
|
2355
|
+
buildTsQuery(query, config) {
|
|
2356
|
+
const cfg = config || 'english';
|
|
2357
|
+
return `to_tsquery('${cfg}', ${this.escape(query)})`;
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Build a plainto_tsquery expression (for phrase searching)
|
|
2361
|
+
* SQL: plainto_tsquery([config,] query)
|
|
2362
|
+
* Converts a phrase into a tsquery that matches documents containing all the words
|
|
2363
|
+
*/
|
|
2364
|
+
buildPlainTsQuery(query, config) {
|
|
2365
|
+
const cfg = config || 'english';
|
|
2366
|
+
return `plainto_tsquery('${cfg}', ${this.escape(query)})`;
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Build a phraseto_tsquery expression (for exact phrase matching)
|
|
2370
|
+
* SQL: phraseto_tsquery([config,] query)
|
|
2371
|
+
* Converts a phrase into a tsquery that matches documents containing the exact phrase
|
|
2372
|
+
*/
|
|
2373
|
+
buildPhraseTsQuery(query, config) {
|
|
2374
|
+
const cfg = config || 'english';
|
|
2375
|
+
return `phraseto_tsquery('${cfg}', ${this.escape(query)})`;
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Build a websearch_to_tsquery expression (for web search style queries)
|
|
2379
|
+
* SQL: websearch_to_tsquery([config,] query)
|
|
2380
|
+
* Supports web search operators like +, -, "quotes", etc.
|
|
2381
|
+
*/
|
|
2382
|
+
buildWebsearchTsQuery(query, config) {
|
|
2383
|
+
const cfg = config || 'english';
|
|
2384
|
+
return `websearch_to_tsquery('${cfg}', ${this.escape(query)})`;
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Build a ts_rank expression for ranking full-text search results
|
|
2388
|
+
* SQL: ts_rank([weights,] vector, query [, normalization])
|
|
2389
|
+
*/
|
|
2390
|
+
buildTsRank(vector, query, options) {
|
|
2391
|
+
let sql = 'ts_rank(';
|
|
2392
|
+
if (options?.weights) {
|
|
2393
|
+
sql += `'{${options.weights.join(',')}}', `;
|
|
2394
|
+
}
|
|
2395
|
+
sql += `${vector}, to_tsquery('english', ${this.escape(query)})`;
|
|
2396
|
+
if (options?.normalization !== undefined) {
|
|
2397
|
+
sql += `, ${options.normalization}`;
|
|
2398
|
+
}
|
|
2399
|
+
sql += ')';
|
|
2400
|
+
return sql;
|
|
2401
|
+
}
|
|
2402
|
+
// ============================================
|
|
2403
|
+
// PostGIS spatial functions
|
|
2404
|
+
// ============================================
|
|
2405
|
+
/**
|
|
2406
|
+
* Create a GIST index for spatial data
|
|
2407
|
+
* CockroachDB GIST (Generalized Search Tree) index for geometry/geography columns
|
|
2408
|
+
*/
|
|
2409
|
+
async createGISTIndex(tableName, indexName, column, options) {
|
|
2410
|
+
let sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.escapeId(tableName)} USING GIST (${this.escapeId(column)})`;
|
|
2411
|
+
// Add constraints if specified
|
|
2412
|
+
if (options?.geometryType || options?.srid) {
|
|
2413
|
+
const constraints = [];
|
|
2414
|
+
if (options.geometryType) {
|
|
2415
|
+
constraints.push(`geometry_type = '${options.geometryType}'`);
|
|
2416
|
+
}
|
|
2417
|
+
if (options.srid) {
|
|
2418
|
+
constraints.push(`srid = ${options.srid}`);
|
|
2419
|
+
}
|
|
2420
|
+
if (constraints.length > 0) {
|
|
2421
|
+
sql += ` WHERE ${constraints.join(' AND ')}`;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
await this.query(sql);
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Create a GIN index for spatial data (PostGIS 2.1+)
|
|
2428
|
+
* GIN indexes are slower to build but faster for very large datasets with many overlapping geometries
|
|
2429
|
+
*/
|
|
2430
|
+
async createGINSpatialIndex(tableName, indexName, column) {
|
|
2431
|
+
const sql = `CREATE INDEX ${this.escapeId(indexName)} ON ${this.escapeId(tableName)} USING GIN (${this.escapeId(column)})`;
|
|
2432
|
+
await this.query(sql);
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* ST_Distance - calculate distance between two geometries
|
|
2436
|
+
* For geography, returns distance in meters
|
|
2437
|
+
*/
|
|
2438
|
+
stDistance(geom1, geom2, useGeography) {
|
|
2439
|
+
if (useGeography) {
|
|
2440
|
+
return `ST_Distance(${geom1}::geography, ${geom2}::geography)`;
|
|
2441
|
+
}
|
|
2442
|
+
return `ST_Distance(${geom1}, ${geom2})`;
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* ST_DWithin - check if geometries are within a given distance
|
|
2446
|
+
* For geography, distance is in meters
|
|
2447
|
+
*/
|
|
2448
|
+
stDWithin(geom1, geom2, distance, useGeography) {
|
|
2449
|
+
if (useGeography) {
|
|
2450
|
+
return `ST_DWithin(${geom1}::geography, ${geom2}::geography, ${distance})`;
|
|
2451
|
+
}
|
|
2452
|
+
return `ST_DWithin(${geom1}, ${geom2}, ${distance})`;
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* ST_Within - check if geometry A is within geometry B
|
|
2456
|
+
*/
|
|
2457
|
+
stWithin(geom1, geom2) {
|
|
2458
|
+
return `ST_Within(${geom1}, ${geom2})`;
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* ST_Contains - check if geometry A contains geometry B
|
|
2462
|
+
*/
|
|
2463
|
+
stContains(geom1, geom2) {
|
|
2464
|
+
return `ST_Contains(${geom1}, ${geom2})`;
|
|
2465
|
+
}
|
|
2466
|
+
/**
|
|
2467
|
+
* ST_Intersects - check if two geometries intersect
|
|
2468
|
+
*/
|
|
2469
|
+
stIntersects(geom1, geom2) {
|
|
2470
|
+
return `ST_Intersects(${geom1}, ${geom2})`;
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* ST_Crosses - check if two geometries cross
|
|
2474
|
+
*/
|
|
2475
|
+
stCrosses(geom1, geom2) {
|
|
2476
|
+
return `ST_Crosses(${geom1}, ${geom2})`;
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* ST_Overlaps - check if two geometries overlap
|
|
2480
|
+
*/
|
|
2481
|
+
stOverlaps(geom1, geom2) {
|
|
2482
|
+
return `ST_Overlaps(${geom1}, ${geom2})`;
|
|
2483
|
+
}
|
|
2484
|
+
/**
|
|
2485
|
+
* ST_Touches - check if two geometries touch
|
|
2486
|
+
*/
|
|
2487
|
+
stTouches(geom1, geom2) {
|
|
2488
|
+
return `ST_Touches(${geom1}, ${geom2})`;
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* ST_Equals - check if two geometries are equal
|
|
2492
|
+
*/
|
|
2493
|
+
stEquals(geom1, geom2) {
|
|
2494
|
+
return `ST_Equals(${geom1}, ${geom2})`;
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* ST_IsValid - check if a geometry is valid
|
|
2498
|
+
*/
|
|
2499
|
+
stIsValid(geom) {
|
|
2500
|
+
return `ST_IsValid(${geom})`;
|
|
2501
|
+
}
|
|
2502
|
+
/**
|
|
2503
|
+
* ST_GeomFromText - create geometry from WKT text
|
|
2504
|
+
*/
|
|
2505
|
+
stGeomFromText(wkt, srid) {
|
|
2506
|
+
if (srid) {
|
|
2507
|
+
return `ST_GeomFromText('${wkt}', ${srid})`;
|
|
2508
|
+
}
|
|
2509
|
+
return `ST_GeomFromText('${wkt}')`;
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* ST_GeomFromGeoJSON - create geometry from GeoJSON
|
|
2513
|
+
*/
|
|
2514
|
+
stGeomFromGeoJSON(geojson, srid) {
|
|
2515
|
+
if (srid) {
|
|
2516
|
+
return `ST_GeomFromGeoJSON('${geojson}'${srid})`;
|
|
2517
|
+
}
|
|
2518
|
+
return `ST_GeomFromGeoJSON('${geojson}')`;
|
|
2519
|
+
}
|
|
2520
|
+
/**
|
|
2521
|
+
* ST_AsGeoJSON - convert geometry to GeoJSON
|
|
2522
|
+
*/
|
|
2523
|
+
stAsGeoJSON(geom, options) {
|
|
2524
|
+
let sql = `ST_AsGeoJSON(${geom}`;
|
|
2525
|
+
if (options?.precision !== undefined) {
|
|
2526
|
+
sql += `, ${options.precision}`;
|
|
2527
|
+
}
|
|
2528
|
+
if (options?.longCRS) {
|
|
2529
|
+
sql += ', 0, 1';
|
|
2530
|
+
}
|
|
2531
|
+
sql += ')';
|
|
2532
|
+
return sql;
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* ST_AsText - convert geometry to WKT
|
|
2536
|
+
*/
|
|
2537
|
+
stAsText(geom) {
|
|
2538
|
+
return `ST_AsText(${geom})`;
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* ST_Centroid - get the centroid of a geometry
|
|
2542
|
+
*/
|
|
2543
|
+
stCentroid(geom) {
|
|
2544
|
+
return `ST_Centroid(${geom})`;
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* ST_Area - calculate the area of a polygon
|
|
2548
|
+
* For geography, returns area in square meters
|
|
2549
|
+
*/
|
|
2550
|
+
stArea(geom, useGeography) {
|
|
2551
|
+
if (useGeography) {
|
|
2552
|
+
return `ST_Area(${geom}::geography)`;
|
|
2553
|
+
}
|
|
2554
|
+
return `ST_Area(${geom})`;
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* ST_Length - calculate the length of a line
|
|
2558
|
+
* For geography, returns length in meters
|
|
2559
|
+
*/
|
|
2560
|
+
stLength(geom, useGeography) {
|
|
2561
|
+
if (useGeography) {
|
|
2562
|
+
return `ST_Length(${geom}::geography)`;
|
|
2563
|
+
}
|
|
2564
|
+
return `ST_Length(${geom})`;
|
|
2565
|
+
}
|
|
2566
|
+
/**
|
|
2567
|
+
* ST_Point - create a point from coordinates
|
|
2568
|
+
*/
|
|
2569
|
+
stPoint(long, lat) {
|
|
2570
|
+
return `ST_Point(${long}, ${lat})`;
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* ST_SetSRID - set the SRID of a geometry
|
|
2574
|
+
*/
|
|
2575
|
+
stSetSRID(geom, srid) {
|
|
2576
|
+
return `ST_SetSRID(${geom}, ${srid})`;
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* ST_SRID - get the SRID of a geometry
|
|
2580
|
+
*/
|
|
2581
|
+
stSRID(geom) {
|
|
2582
|
+
return `ST_SRID(${geom})`;
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* ST_X - get the X coordinate of a point
|
|
2586
|
+
*/
|
|
2587
|
+
stX(geom) {
|
|
2588
|
+
return `ST_X(${geom})`;
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* ST_Y - get the Y coordinate of a point
|
|
2592
|
+
*/
|
|
2593
|
+
stY(geom) {
|
|
2594
|
+
return `ST_Y(${geom})`;
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* ST_Distance_Sphere - calculate distance using sphere (less accurate but faster)
|
|
2598
|
+
* Returns distance in meters
|
|
2599
|
+
*/
|
|
2600
|
+
stDistanceSphere(geom1, geom2) {
|
|
2601
|
+
return `ST_Distance_Sphere(${geom1}, ${geom2})`;
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* ST_Distance_Spheroid - calculate distance using spheroid (accurate)
|
|
2605
|
+
* Returns distance in meters
|
|
2606
|
+
*/
|
|
2607
|
+
stDistanceSpheroid(geom1, geom2) {
|
|
2608
|
+
return `ST_Distance_Spheroid(${geom1}, ${geom2}, 'SPHEROID["WGS 84",6378137,298.257223563]')`;
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Drop an index from a table
|
|
2612
|
+
*/
|
|
2613
|
+
async dropIndex(tableName, indexName, options) {
|
|
2614
|
+
let sql = 'DROP INDEX';
|
|
2615
|
+
if (options?.ifExists) {
|
|
2616
|
+
sql += ' IF EXISTS';
|
|
2617
|
+
}
|
|
2618
|
+
sql += ` ${this.escapeId(indexName)}`;
|
|
2619
|
+
if (options?.cascade) {
|
|
2620
|
+
sql += ' CASCADE';
|
|
2621
|
+
}
|
|
2622
|
+
await this.query(sql);
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Create a constraint on a table
|
|
2626
|
+
*/
|
|
2627
|
+
async createConstraint(tableName, constraintDef) {
|
|
2628
|
+
const fields = constraintDef.fields?.map((f) => this.escapeId(f)).join(', ');
|
|
2629
|
+
switch (constraintDef.type) {
|
|
2630
|
+
case 'PRIMARY KEY':
|
|
2631
|
+
if (!fields) {
|
|
2632
|
+
throw new Error('Primary key constraint requires fields');
|
|
2633
|
+
}
|
|
2634
|
+
await this.query(`ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintDef.name)} PRIMARY KEY (${fields})`);
|
|
2635
|
+
return;
|
|
2636
|
+
case 'UNIQUE':
|
|
2637
|
+
if (!fields) {
|
|
2638
|
+
throw new Error('Unique constraint requires fields');
|
|
2639
|
+
}
|
|
2640
|
+
await this.query(`ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintDef.name)} UNIQUE (${fields})`);
|
|
2641
|
+
return;
|
|
2642
|
+
case 'FOREIGN KEY': {
|
|
2643
|
+
if (!fields || !constraintDef.references) {
|
|
2644
|
+
throw new Error('Foreign key constraint requires fields and references');
|
|
2645
|
+
}
|
|
2646
|
+
// Handle composite foreign keys (string | string[])
|
|
2647
|
+
const refField = constraintDef.references.field;
|
|
2648
|
+
const refFieldSql = Array.isArray(refField)
|
|
2649
|
+
? `(${refField.map((f) => this.escapeId(f)).join(', ')})`
|
|
2650
|
+
: `(${this.escapeId(refField)})`;
|
|
2651
|
+
let sql = `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintDef.name)} `;
|
|
2652
|
+
sql += `FOREIGN KEY (${fields}) REFERENCES ${this.escapeId(constraintDef.references.table)}${refFieldSql}`;
|
|
2653
|
+
if (constraintDef.references.onDelete) {
|
|
2654
|
+
sql += ` ON DELETE ${constraintDef.references.onDelete}`;
|
|
2655
|
+
}
|
|
2656
|
+
if (constraintDef.references.onUpdate) {
|
|
2657
|
+
sql += ` ON UPDATE ${constraintDef.references.onUpdate}`;
|
|
2658
|
+
}
|
|
2659
|
+
if (constraintDef.deferrable) {
|
|
2660
|
+
sql += ` DEFERRABLE ${constraintDef.deferrable}`;
|
|
2661
|
+
}
|
|
2662
|
+
await this.query(sql);
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
case 'CHECK':
|
|
2666
|
+
if (!constraintDef.check) {
|
|
2667
|
+
throw new Error('Check constraint requires a check expression');
|
|
2668
|
+
}
|
|
2669
|
+
await this.query(`ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${this.escapeId(constraintDef.name)} CHECK (${constraintDef.check})`);
|
|
2670
|
+
return;
|
|
2671
|
+
default:
|
|
2672
|
+
throw new Error(`Unknown constraint type: ${constraintDef.type}`);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Drop a constraint from a table
|
|
2677
|
+
*/
|
|
2678
|
+
async dropConstraint(tableName, constraintName, options) {
|
|
2679
|
+
let sql = 'ALTER TABLE';
|
|
2680
|
+
if (options?.ifExists) {
|
|
2681
|
+
sql += ' IF EXISTS';
|
|
2682
|
+
}
|
|
2683
|
+
sql += ` ${this.escapeId(tableName)} DROP CONSTRAINT ${this.escapeId(constraintName)}`;
|
|
2684
|
+
if (options?.cascade) {
|
|
2685
|
+
sql += ' CASCADE';
|
|
2686
|
+
}
|
|
2687
|
+
await this.query(sql);
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Begin a new transaction
|
|
2691
|
+
*/
|
|
2692
|
+
async startTransaction(options) {
|
|
2693
|
+
if (!this.pool) {
|
|
2694
|
+
throw new Error('Not connected to database');
|
|
2695
|
+
}
|
|
2696
|
+
const client = await this.pool.connect();
|
|
2697
|
+
if (options?.isolationLevel) {
|
|
2698
|
+
const isolationLevelSql = this.getIsolationLevelSql(options.isolationLevel);
|
|
2699
|
+
if (isolationLevelSql) {
|
|
2700
|
+
await client.query(`BEGIN ${isolationLevelSql}`);
|
|
2701
|
+
}
|
|
2702
|
+
else {
|
|
2703
|
+
await client.query('BEGIN');
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
else {
|
|
2707
|
+
await client.query('BEGIN');
|
|
2708
|
+
}
|
|
2709
|
+
const transaction = new CockroachDBTransaction(client, this.transactionDepth++, options);
|
|
2710
|
+
return transaction;
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Run a function inside a CockroachDB transaction, automatically retrying
|
|
2714
|
+
* the *entire transaction body* when it fails with a serialization failure
|
|
2715
|
+
* (SQLSTATE `40001`, surfaced as a "restart transaction" error).
|
|
2716
|
+
*
|
|
2717
|
+
* Under SERIALIZABLE isolation (CockroachDB's default and only isolation
|
|
2718
|
+
* level), conflicting concurrent transactions routinely abort with
|
|
2719
|
+
* `40001`; CockroachDB's documented client contract is to retry the whole
|
|
2720
|
+
* transaction from the beginning rather than treating it as a fatal error.
|
|
2721
|
+
* This implements that contract using the standard
|
|
2722
|
+
* `SAVEPOINT cockroach_restart` pattern: a savepoint is created right after
|
|
2723
|
+
* `BEGIN`, and on a retryable error the transaction is rolled back to that
|
|
2724
|
+
* savepoint (instead of being aborted outright) so `fn` can be re-run.
|
|
2725
|
+
*
|
|
2726
|
+
* @see https://www.cockroachlabs.com/docs/stable/transaction-retry-error-reference
|
|
2727
|
+
* @see https://www.cockroachlabs.com/docs/stable/transactions#client-side-intervention
|
|
2728
|
+
*
|
|
2729
|
+
* @param fn - Callback that receives the transaction and performs the work
|
|
2730
|
+
* to (re)try. It may be invoked more than once, so it should be
|
|
2731
|
+
* idempotent / avoid externally-visible side effects other than through
|
|
2732
|
+
* the database.
|
|
2733
|
+
* @param options - Transaction options, plus an optional `maxRetries`
|
|
2734
|
+
* (defaults to 5) capping how many times `fn` will be retried after a
|
|
2735
|
+
* `40001` before the error is rethrown.
|
|
2736
|
+
*/
|
|
2737
|
+
async runTransaction(fn, options) {
|
|
2738
|
+
const maxRetries = options?.maxRetries ?? 5;
|
|
2739
|
+
const transaction = (await this.startTransaction(options));
|
|
2740
|
+
const client = transaction.client;
|
|
2741
|
+
if (!client) {
|
|
2742
|
+
throw new Error('Not connected to database');
|
|
2743
|
+
}
|
|
2744
|
+
const restartSavepoint = 'cockroach_restart';
|
|
2745
|
+
try {
|
|
2746
|
+
await transaction.createSavepoint(restartSavepoint);
|
|
2747
|
+
for (let attempt = 0;; attempt++) {
|
|
2748
|
+
try {
|
|
2749
|
+
const result = await fn(transaction);
|
|
2750
|
+
await transaction.releaseSavepoint(restartSavepoint);
|
|
2751
|
+
await client.query('COMMIT');
|
|
2752
|
+
transaction.finished = true;
|
|
2753
|
+
return result;
|
|
2754
|
+
}
|
|
2755
|
+
catch (error) {
|
|
2756
|
+
if (this.isRetryableError(error) && attempt < maxRetries) {
|
|
2757
|
+
await transaction.rollbackToSavepoint(restartSavepoint);
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
try {
|
|
2761
|
+
await client.query('ROLLBACK');
|
|
2762
|
+
}
|
|
2763
|
+
catch {
|
|
2764
|
+
// The connection/transaction may already be unusable; ignore the
|
|
2765
|
+
// rollback failure and propagate the original error instead.
|
|
2766
|
+
}
|
|
2767
|
+
transaction.finished = true;
|
|
2768
|
+
throw error;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
finally {
|
|
2773
|
+
transaction.finished = true;
|
|
2774
|
+
client.release();
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Commit a transaction
|
|
2779
|
+
*/
|
|
2780
|
+
async commitTransaction(transaction) {
|
|
2781
|
+
const postgresTx = transaction;
|
|
2782
|
+
if (postgresTx.client) {
|
|
2783
|
+
await postgresTx.client.query('COMMIT');
|
|
2784
|
+
postgresTx.client.release();
|
|
2785
|
+
postgresTx.finished = true;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Rollback a transaction
|
|
2790
|
+
*/
|
|
2791
|
+
async rollbackTransaction(transaction) {
|
|
2792
|
+
const postgresTx = transaction;
|
|
2793
|
+
if (postgresTx.client) {
|
|
2794
|
+
await postgresTx.client.query('ROLLBACK');
|
|
2795
|
+
postgresTx.client.release();
|
|
2796
|
+
postgresTx.finished = true;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* Get the SQL for a data type
|
|
2801
|
+
*/
|
|
2802
|
+
getDataTypeSql(dataType) {
|
|
2803
|
+
if (typeof dataType === 'string') {
|
|
2804
|
+
return dataType;
|
|
2805
|
+
}
|
|
2806
|
+
if (!dataType || typeof dataType !== 'object') {
|
|
2807
|
+
return 'VARCHAR(255)';
|
|
2808
|
+
}
|
|
2809
|
+
const dt = dataType;
|
|
2810
|
+
switch (dt.key) {
|
|
2811
|
+
case 'STRING':
|
|
2812
|
+
return `VARCHAR(${dt.length || 255})`;
|
|
2813
|
+
case 'CHAR':
|
|
2814
|
+
return `CHAR(${dt.length || 1})`;
|
|
2815
|
+
case 'TEXT':
|
|
2816
|
+
if (dt.length) {
|
|
2817
|
+
if (dt.length === 256)
|
|
2818
|
+
return 'TEXT';
|
|
2819
|
+
if (dt.length === 65535)
|
|
2820
|
+
return 'TEXT';
|
|
2821
|
+
return `VARCHAR(${dt.length})`;
|
|
2822
|
+
}
|
|
2823
|
+
return 'TEXT';
|
|
2824
|
+
case 'INTEGER':
|
|
2825
|
+
// Handle SERIAL for auto-increment
|
|
2826
|
+
if (dt.autoIncrement) {
|
|
2827
|
+
return 'SERIAL';
|
|
2828
|
+
}
|
|
2829
|
+
return 'INTEGER';
|
|
2830
|
+
case 'BIGINT':
|
|
2831
|
+
if (dt.autoIncrement) {
|
|
2832
|
+
return 'BIGSERIAL';
|
|
2833
|
+
}
|
|
2834
|
+
return 'BIGINT';
|
|
2835
|
+
case 'FLOAT':
|
|
2836
|
+
return 'REAL';
|
|
2837
|
+
case 'DOUBLE':
|
|
2838
|
+
return 'DOUBLE PRECISION';
|
|
2839
|
+
case 'DECIMAL':
|
|
2840
|
+
return `DECIMAL(${dt.precision || 10},${dt.scale || 0})`;
|
|
2841
|
+
case 'BOOLEAN':
|
|
2842
|
+
return 'BOOLEAN';
|
|
2843
|
+
case 'DATE':
|
|
2844
|
+
return dt.precision ? `TIMESTAMP(${dt.precision})` : 'TIMESTAMP';
|
|
2845
|
+
case 'DATEONLY':
|
|
2846
|
+
return 'DATE';
|
|
2847
|
+
case 'TIME':
|
|
2848
|
+
return dt.precision ? `TIME(${dt.precision})` : 'TIME';
|
|
2849
|
+
case 'BLOB':
|
|
2850
|
+
return 'BYTEA';
|
|
2851
|
+
case 'ENUM':
|
|
2852
|
+
const values = dt.values || [];
|
|
2853
|
+
return `ENUM(${values.map((v) => `'${v}'`).join(',')})`;
|
|
2854
|
+
case 'JSON':
|
|
2855
|
+
return 'JSON';
|
|
2856
|
+
case 'JSONB':
|
|
2857
|
+
return 'JSONB';
|
|
2858
|
+
case 'UUID':
|
|
2859
|
+
return 'UUID';
|
|
2860
|
+
case 'HSTORE':
|
|
2861
|
+
return 'HSTORE';
|
|
2862
|
+
case 'RANGE':
|
|
2863
|
+
const rangeSubtype = dt.subtype || 'int4range';
|
|
2864
|
+
return rangeSubtype;
|
|
2865
|
+
case 'INET':
|
|
2866
|
+
return 'INET';
|
|
2867
|
+
case 'CIDR':
|
|
2868
|
+
return 'CIDR';
|
|
2869
|
+
case 'MACADDR':
|
|
2870
|
+
return 'MACADDR';
|
|
2871
|
+
case 'GEOMETRY':
|
|
2872
|
+
const geoType = dt.type || 'GEOMETRY';
|
|
2873
|
+
const srid = dt.srid;
|
|
2874
|
+
if (srid) {
|
|
2875
|
+
return `${geoType}(${srid})`;
|
|
2876
|
+
}
|
|
2877
|
+
return geoType;
|
|
2878
|
+
case 'GEOGRAPHY':
|
|
2879
|
+
const geoogType = dt.type || 'GEOGRAPHY';
|
|
2880
|
+
const geoSrid = dt.srid || 4326;
|
|
2881
|
+
return `${geoogType}(POINT, ${geoSrid})`;
|
|
2882
|
+
case 'ARRAY':
|
|
2883
|
+
// Support both 'type' (for DataTypes.ARRAY(DataTypes.INTEGER)) and 'subtype' (for { type: 'ARRAY', subtype: 'INTEGER' })
|
|
2884
|
+
const arrayType = dt.type || dt.subtype;
|
|
2885
|
+
if (arrayType) {
|
|
2886
|
+
const baseType = this.getDataTypeSql(arrayType);
|
|
2887
|
+
return `${baseType}[]`;
|
|
2888
|
+
}
|
|
2889
|
+
return 'ARRAY';
|
|
2890
|
+
case 'GEOGRAPHY':
|
|
2891
|
+
const geogType = dt.type || 'GEOGRAPHY';
|
|
2892
|
+
const geogSrid = dt.srid || 4326;
|
|
2893
|
+
return `${geogType}(${geogSrid})`;
|
|
2894
|
+
case 'VIRTUAL':
|
|
2895
|
+
// Virtual fields don't create a database column - return empty string
|
|
2896
|
+
return '';
|
|
2897
|
+
default:
|
|
2898
|
+
return 'VARCHAR(255)';
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Get the SQL for an ARRAY type
|
|
2903
|
+
*/
|
|
2904
|
+
getArrayTypeSql(elementType) {
|
|
2905
|
+
return `${this.getDataTypeSql(elementType)}[]`;
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* Get the isolation level SQL
|
|
2909
|
+
*/
|
|
2910
|
+
getIsolationLevelSql(isolationLevel) {
|
|
2911
|
+
if (!isolationLevel) {
|
|
2912
|
+
return '';
|
|
2913
|
+
}
|
|
2914
|
+
const levels = {
|
|
2915
|
+
'READ UNCOMMITTED': 'READ UNCOMMITTED',
|
|
2916
|
+
'READ COMMITTED': 'READ COMMITTED',
|
|
2917
|
+
'REPEATABLE READ': 'REPEATABLE READ',
|
|
2918
|
+
SERIALIZABLE: 'SERIALIZABLE',
|
|
2919
|
+
};
|
|
2920
|
+
const level = levels[isolationLevel];
|
|
2921
|
+
return level ? `ISOLATION LEVEL ${level}` : '';
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* Build a WHERE clause from a WhereOptions object
|
|
2925
|
+
*/
|
|
2926
|
+
buildWhereClause(where, options) {
|
|
2927
|
+
const values = [];
|
|
2928
|
+
if (!where || Object.keys(where).length === 0) {
|
|
2929
|
+
return { sql: '', values };
|
|
2930
|
+
}
|
|
2931
|
+
const buildCondition = (condition) => {
|
|
2932
|
+
if (!condition) {
|
|
2933
|
+
return { sql: '', values: [] };
|
|
2934
|
+
}
|
|
2935
|
+
if (typeof condition !== 'object') {
|
|
2936
|
+
values.push(condition);
|
|
2937
|
+
return { sql: '$' + values.length, values: [condition] };
|
|
2938
|
+
}
|
|
2939
|
+
const cond = condition;
|
|
2940
|
+
// Handle logical operators
|
|
2941
|
+
if (cond.$and || cond.$or || cond.$not) {
|
|
2942
|
+
const conditions = [];
|
|
2943
|
+
if (cond.$and) {
|
|
2944
|
+
const andConditions = cond.$and.map((c) => {
|
|
2945
|
+
const result = buildCondition(c);
|
|
2946
|
+
return result.sql;
|
|
2947
|
+
});
|
|
2948
|
+
conditions.push(`(${andConditions.join(' AND ')})`);
|
|
2949
|
+
}
|
|
2950
|
+
if (cond.$or) {
|
|
2951
|
+
const orConditions = cond.$or.map((c) => {
|
|
2952
|
+
const result = buildCondition(c);
|
|
2953
|
+
return result.sql;
|
|
2954
|
+
});
|
|
2955
|
+
conditions.push(`(${orConditions.join(' OR ')})`);
|
|
2956
|
+
}
|
|
2957
|
+
if (cond.$not) {
|
|
2958
|
+
const result = buildCondition(cond.$not);
|
|
2959
|
+
conditions.push(`NOT (${result.sql})`);
|
|
2960
|
+
}
|
|
2961
|
+
return { sql: conditions.join(' AND '), values };
|
|
2962
|
+
}
|
|
2963
|
+
// Handle field conditions
|
|
2964
|
+
const fieldConditions = [];
|
|
2965
|
+
for (const [key, value] of Object.entries(cond)) {
|
|
2966
|
+
if (key.startsWith('$'))
|
|
2967
|
+
continue;
|
|
2968
|
+
if (value && typeof value === 'object') {
|
|
2969
|
+
const valueObj = value;
|
|
2970
|
+
if (valueObj.$eq !== undefined) {
|
|
2971
|
+
values.push(valueObj.$eq);
|
|
2972
|
+
fieldConditions.push(`${this.escapeId(key)} = $${values.length}`);
|
|
2973
|
+
}
|
|
2974
|
+
else if (valueObj.$ne !== undefined) {
|
|
2975
|
+
values.push(valueObj.$ne);
|
|
2976
|
+
fieldConditions.push(`${this.escapeId(key)} != $${values.length}`);
|
|
2977
|
+
}
|
|
2978
|
+
else if (valueObj.$gt !== undefined) {
|
|
2979
|
+
values.push(valueObj.$gt);
|
|
2980
|
+
fieldConditions.push(`${this.escapeId(key)} > $${values.length}`);
|
|
2981
|
+
}
|
|
2982
|
+
else if (valueObj.$gte !== undefined) {
|
|
2983
|
+
values.push(valueObj.$gte);
|
|
2984
|
+
fieldConditions.push(`${this.escapeId(key)} >= $${values.length}`);
|
|
2985
|
+
}
|
|
2986
|
+
else if (valueObj.$lt !== undefined) {
|
|
2987
|
+
values.push(valueObj.$lt);
|
|
2988
|
+
fieldConditions.push(`${this.escapeId(key)} < $${values.length}`);
|
|
2989
|
+
}
|
|
2990
|
+
else if (valueObj.$lte !== undefined) {
|
|
2991
|
+
values.push(valueObj.$lte);
|
|
2992
|
+
fieldConditions.push(`${this.escapeId(key)} <= $${values.length}`);
|
|
2993
|
+
}
|
|
2994
|
+
else if (valueObj.$like !== undefined) {
|
|
2995
|
+
values.push(valueObj.$like);
|
|
2996
|
+
fieldConditions.push(`${this.escapeId(key)} LIKE $${values.length}`);
|
|
2997
|
+
}
|
|
2998
|
+
else if (valueObj.$notLike !== undefined) {
|
|
2999
|
+
values.push(valueObj.$notLike);
|
|
3000
|
+
fieldConditions.push(`${this.escapeId(key)} NOT LIKE $${values.length}`);
|
|
3001
|
+
}
|
|
3002
|
+
else if (valueObj.$iLike !== undefined) {
|
|
3003
|
+
values.push(valueObj.$iLike);
|
|
3004
|
+
fieldConditions.push(`${this.escapeId(key)} ILIKE $${values.length}`);
|
|
3005
|
+
}
|
|
3006
|
+
else if (valueObj.$notILike !== undefined) {
|
|
3007
|
+
values.push(valueObj.$notILike);
|
|
3008
|
+
fieldConditions.push(`${this.escapeId(key)} NOT ILIKE $${values.length}`);
|
|
3009
|
+
}
|
|
3010
|
+
else if (valueObj.$startsWith !== undefined) {
|
|
3011
|
+
values.push(valueObj.$startsWith + '%');
|
|
3012
|
+
fieldConditions.push(`${this.escapeId(key)} ILIKE $${values.length}`);
|
|
3013
|
+
}
|
|
3014
|
+
else if (valueObj.$notStartsWith !== undefined) {
|
|
3015
|
+
values.push(valueObj.$notStartsWith + '%');
|
|
3016
|
+
fieldConditions.push(`${this.escapeId(key)} NOT ILIKE $${values.length}`);
|
|
3017
|
+
}
|
|
3018
|
+
else if (valueObj.$endsWith !== undefined) {
|
|
3019
|
+
values.push('%' + valueObj.$endsWith);
|
|
3020
|
+
fieldConditions.push(`${this.escapeId(key)} ILIKE $${values.length}`);
|
|
3021
|
+
}
|
|
3022
|
+
else if (valueObj.$notEndsWith !== undefined) {
|
|
3023
|
+
values.push('%' + valueObj.$notEndsWith);
|
|
3024
|
+
fieldConditions.push(`${this.escapeId(key)} NOT ILIKE $${values.length}`);
|
|
3025
|
+
}
|
|
3026
|
+
else if (valueObj.$substring !== undefined) {
|
|
3027
|
+
values.push('%' + valueObj.$substring + '%');
|
|
3028
|
+
fieldConditions.push(`${this.escapeId(key)} ILIKE $${values.length}`);
|
|
3029
|
+
}
|
|
3030
|
+
else if (valueObj.$notSubstring !== undefined) {
|
|
3031
|
+
values.push('%' + valueObj.$notSubstring + '%');
|
|
3032
|
+
fieldConditions.push(`${this.escapeId(key)} NOT ILIKE $${values.length}`);
|
|
3033
|
+
}
|
|
3034
|
+
else if (valueObj.$any !== undefined) {
|
|
3035
|
+
const anyValues = Array.isArray(valueObj.$any) ? valueObj.$any : [valueObj.$any];
|
|
3036
|
+
values.push(anyValues);
|
|
3037
|
+
fieldConditions.push(`${this.escapeId(key)} = ANY($${values.length})`);
|
|
3038
|
+
}
|
|
3039
|
+
else if (valueObj.$all !== undefined) {
|
|
3040
|
+
const allValues = Array.isArray(valueObj.$all) ? valueObj.$all : [valueObj.$all];
|
|
3041
|
+
values.push(allValues);
|
|
3042
|
+
fieldConditions.push(`${this.escapeId(key)} = ALL($${values.length})`);
|
|
3043
|
+
}
|
|
3044
|
+
else if (valueObj.$in) {
|
|
3045
|
+
const inValues = valueObj.$in;
|
|
3046
|
+
values.push(...inValues);
|
|
3047
|
+
const placeholders = inValues
|
|
3048
|
+
.map((_, i) => `$${values.length - inValues.length + i + 1}`)
|
|
3049
|
+
.join(',');
|
|
3050
|
+
fieldConditions.push(`${this.escapeId(key)} IN (${placeholders})`);
|
|
3051
|
+
}
|
|
3052
|
+
else if (valueObj.$notIn) {
|
|
3053
|
+
const notInValues = valueObj.$notIn;
|
|
3054
|
+
values.push(...notInValues);
|
|
3055
|
+
const placeholders = notInValues
|
|
3056
|
+
.map((_, i) => `$${values.length - notInValues.length + i + 1}`)
|
|
3057
|
+
.join(',');
|
|
3058
|
+
fieldConditions.push(`${this.escapeId(key)} NOT IN (${placeholders})`);
|
|
3059
|
+
}
|
|
3060
|
+
else if (valueObj.$between) {
|
|
3061
|
+
const between = valueObj.$between;
|
|
3062
|
+
values.push(between[0], between[1]);
|
|
3063
|
+
fieldConditions.push(`${this.escapeId(key)} BETWEEN $${values.length - 1} AND $${values.length}`);
|
|
3064
|
+
}
|
|
3065
|
+
else if (valueObj.$notBetween) {
|
|
3066
|
+
const notBetween = valueObj.$notBetween;
|
|
3067
|
+
values.push(notBetween[0], notBetween[1]);
|
|
3068
|
+
fieldConditions.push(`${this.escapeId(key)} NOT BETWEEN $${values.length - 1} AND $${values.length}`);
|
|
3069
|
+
}
|
|
3070
|
+
else if (valueObj.$isNull !== undefined) {
|
|
3071
|
+
if (valueObj.$isNull) {
|
|
3072
|
+
fieldConditions.push(`${this.escapeId(key)} IS NULL`);
|
|
3073
|
+
}
|
|
3074
|
+
else {
|
|
3075
|
+
fieldConditions.push(`${this.escapeId(key)} IS NOT NULL`);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
else if (valueObj.$exists !== undefined) {
|
|
3079
|
+
if (valueObj.$exists) {
|
|
3080
|
+
fieldConditions.push(`EXISTS (${valueObj.$query})`);
|
|
3081
|
+
}
|
|
3082
|
+
else {
|
|
3083
|
+
fieldConditions.push(`NOT EXISTS (${valueObj.$query})`);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
else if (valueObj.$jsonConcat !== undefined) {
|
|
3087
|
+
// JSONB concatenation (||)
|
|
3088
|
+
values.push(JSON.stringify(valueObj.$jsonConcat));
|
|
3089
|
+
fieldConditions.push(`${this.escapeId(key)} || $${values.length}`);
|
|
3090
|
+
}
|
|
3091
|
+
else if (valueObj.$jsonDelete !== undefined) {
|
|
3092
|
+
// JSONB delete key (-)
|
|
3093
|
+
fieldConditions.push(`${this.escapeId(key)} - '${valueObj.$jsonDelete}'`);
|
|
3094
|
+
}
|
|
3095
|
+
else if (valueObj.$jsonDeletePath !== undefined) {
|
|
3096
|
+
// JSONB delete by path (#-)
|
|
3097
|
+
const deletePath = valueObj.$jsonDeletePath;
|
|
3098
|
+
const pgPath = Array.isArray(deletePath)
|
|
3099
|
+
? `{${deletePath.join(', ')}}`
|
|
3100
|
+
: `{${deletePath}}`;
|
|
3101
|
+
fieldConditions.push(`${this.escapeId(key)} #- '${pgPath}'`);
|
|
3102
|
+
}
|
|
3103
|
+
else if (valueObj.$jsonPathExists !== undefined) {
|
|
3104
|
+
// JSON path exists (@?)
|
|
3105
|
+
fieldConditions.push(`${this.escapeId(key)} @? '${valueObj.$jsonPathExists}'`);
|
|
3106
|
+
}
|
|
3107
|
+
else if (valueObj.$jsonPathQuery !== undefined) {
|
|
3108
|
+
// JSON path query (@@)
|
|
3109
|
+
fieldConditions.push(`${this.escapeId(key)} @@ '${valueObj.$jsonPathQuery}'`);
|
|
3110
|
+
}
|
|
3111
|
+
else if (valueObj.$jsonTypeOf !== undefined) {
|
|
3112
|
+
// JSON type of (json_typeof)
|
|
3113
|
+
values.push(valueObj.$jsonTypeOf);
|
|
3114
|
+
fieldConditions.push(`json_typeof(${this.escapeId(key)}) = $${values.length}`);
|
|
3115
|
+
}
|
|
3116
|
+
else {
|
|
3117
|
+
values.push(value);
|
|
3118
|
+
fieldConditions.push(`${this.escapeId(key)} = $${values.length}`);
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
else {
|
|
3122
|
+
values.push(value);
|
|
3123
|
+
fieldConditions.push(`${this.escapeId(key)} = $${values.length}`);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
return { sql: fieldConditions.join(' AND '), values };
|
|
3127
|
+
};
|
|
3128
|
+
const result = buildCondition(where);
|
|
3129
|
+
return { sql: result.sql, values: result.values };
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Build an ORDER BY clause
|
|
3133
|
+
*/
|
|
3134
|
+
buildOrderClause(order, options) {
|
|
3135
|
+
const orderArray = order;
|
|
3136
|
+
if (!orderArray || !Array.isArray(orderArray) || orderArray.length === 0) {
|
|
3137
|
+
return '';
|
|
3138
|
+
}
|
|
3139
|
+
const orderParts = [];
|
|
3140
|
+
for (const item of orderArray) {
|
|
3141
|
+
if (Array.isArray(item)) {
|
|
3142
|
+
const field = typeof item[0] === 'string' ? this.escapeId(item[0]) : item[0];
|
|
3143
|
+
const direction = item[1] ? ` ${item[1]}` : '';
|
|
3144
|
+
orderParts.push(`${field}${direction}`);
|
|
3145
|
+
}
|
|
3146
|
+
else if (typeof item === 'string') {
|
|
3147
|
+
orderParts.push(item);
|
|
3148
|
+
}
|
|
3149
|
+
else if (item && typeof item === 'object') {
|
|
3150
|
+
const itemArr = item;
|
|
3151
|
+
const [modelOrString, field] = itemArr;
|
|
3152
|
+
const model = typeof modelOrString === 'string'
|
|
3153
|
+
? modelOrString
|
|
3154
|
+
: modelOrString.tableName || '';
|
|
3155
|
+
const fieldStr = typeof field === 'string' ? field : '';
|
|
3156
|
+
orderParts.push(`${model ? `${this.escapeId(model)}.` : ''}${this.escapeId(fieldStr)}`);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
return orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : '';
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Build a LIMIT/OFFSET clause
|
|
3163
|
+
*/
|
|
3164
|
+
buildLimitOffset(limit, offset) {
|
|
3165
|
+
let sql = '';
|
|
3166
|
+
if (limit !== undefined) {
|
|
3167
|
+
sql += ` LIMIT ${Number(limit)}`;
|
|
3168
|
+
if (offset !== undefined) {
|
|
3169
|
+
sql += ` OFFSET ${Number(offset)}`;
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
return sql;
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Build an INSERT query with RETURNING support
|
|
3176
|
+
*/
|
|
3177
|
+
buildInsertQuery(tableName, values, options) {
|
|
3178
|
+
const columns = Object.keys(values);
|
|
3179
|
+
const processedValues = [];
|
|
3180
|
+
const placeholders = [];
|
|
3181
|
+
let paramIndex = 1;
|
|
3182
|
+
// Process each value - handle Literal differently
|
|
3183
|
+
for (const value of Object.values(values)) {
|
|
3184
|
+
if (value instanceof prorm_1.Literal) {
|
|
3185
|
+
// For Literal values, inline the SQL directly
|
|
3186
|
+
placeholders.push(value.val);
|
|
3187
|
+
}
|
|
3188
|
+
else {
|
|
3189
|
+
// For regular values, use parameterized placeholder
|
|
3190
|
+
placeholders.push(`$${paramIndex}`);
|
|
3191
|
+
processedValues.push(value);
|
|
3192
|
+
paramIndex++;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
// CockroachDB's native `UPSERT INTO` shorthand replaces the row matching
|
|
3196
|
+
// the primary key wholesale and needs no conflict target, unlike
|
|
3197
|
+
// `INSERT ... ON CONFLICT ... DO UPDATE`.
|
|
3198
|
+
// See: https://www.cockroachlabs.com/docs/stable/upsert
|
|
3199
|
+
const useNativeUpsert = !!(options?.upsert && options.nativeUpsert);
|
|
3200
|
+
let sql = `${useNativeUpsert ? 'UPSERT' : 'INSERT'} INTO ${this.escapeId(tableName)} (${columns.map((c) => this.escapeId(c)).join(', ')}) VALUES (${placeholders.join(', ')})`;
|
|
3201
|
+
// Handle upsert (ON CONFLICT), unless the native UPSERT shorthand above
|
|
3202
|
+
// already provides replace-on-conflict semantics.
|
|
3203
|
+
if (options?.upsert && !useNativeUpsert) {
|
|
3204
|
+
const updateColumns = columns
|
|
3205
|
+
.map((c) => `${this.escapeId(c)} = excluded.${this.escapeId(c)}`)
|
|
3206
|
+
.join(', ');
|
|
3207
|
+
const conflictFields = options.conflictFields || columns;
|
|
3208
|
+
sql += ` ON CONFLICT (${conflictFields.map((c) => this.escapeId(c)).join(', ')}) DO UPDATE SET ${updateColumns}`;
|
|
3209
|
+
}
|
|
3210
|
+
// Handle RETURNING clause
|
|
3211
|
+
if (options?.returning) {
|
|
3212
|
+
if (options.returning === true) {
|
|
3213
|
+
sql += ' RETURNING *';
|
|
3214
|
+
}
|
|
3215
|
+
else if (Array.isArray(options.returning)) {
|
|
3216
|
+
sql += ` RETURNING ${options.returning.map((c) => this.escapeId(c)).join(', ')}`;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
return { sql, values: processedValues };
|
|
3220
|
+
}
|
|
3221
|
+
/**
|
|
3222
|
+
* Build an UPSERT query for CockroachDB
|
|
3223
|
+
* Uses `ON CONFLICT ... DO UPDATE SET` syntax by default, or CockroachDB's
|
|
3224
|
+
* native `UPSERT INTO ... VALUES (...)` shorthand when `options.nativeUpsert`
|
|
3225
|
+
* is set (which replaces the row matching the primary key wholesale and
|
|
3226
|
+
* needs no conflict target).
|
|
3227
|
+
* @see https://www.cockroachlabs.com/docs/stable/upsert
|
|
3228
|
+
*/
|
|
3229
|
+
buildUpsertQuery(tableName, values, options) {
|
|
3230
|
+
const columns = Object.keys(values);
|
|
3231
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`).join(', ');
|
|
3232
|
+
const queryValues = Object.values(values);
|
|
3233
|
+
const useNativeUpsert = !!options?.nativeUpsert;
|
|
3234
|
+
let sql = `${useNativeUpsert ? 'UPSERT' : 'INSERT'} INTO ${this.escapeId(tableName)} (${columns.map((c) => this.escapeId(c)).join(', ')}) VALUES (${placeholders})`;
|
|
3235
|
+
if (!useNativeUpsert) {
|
|
3236
|
+
// Determine which fields to update
|
|
3237
|
+
const updateFields = options?.updateOnDuplicate && options.updateOnDuplicate.length > 0
|
|
3238
|
+
? options.updateOnDuplicate
|
|
3239
|
+
: columns;
|
|
3240
|
+
// Add ON CONFLICT clause
|
|
3241
|
+
const conflictFields = options?.conflictFields || columns;
|
|
3242
|
+
const updateClauses = updateFields
|
|
3243
|
+
.map((c) => `${this.escapeId(c)} = excluded.${this.escapeId(c)}`)
|
|
3244
|
+
.join(', ');
|
|
3245
|
+
sql += ` ON CONFLICT (${conflictFields.map((c) => this.escapeId(c)).join(', ')}) DO UPDATE SET ${updateClauses}`;
|
|
3246
|
+
}
|
|
3247
|
+
// Handle RETURNING clause
|
|
3248
|
+
if (options?.returning) {
|
|
3249
|
+
if (options.returning === true) {
|
|
3250
|
+
sql += ' RETURNING *';
|
|
3251
|
+
}
|
|
3252
|
+
else if (Array.isArray(options.returning)) {
|
|
3253
|
+
sql += ` RETURNING ${options.returning.map((c) => this.escapeId(c)).join(', ')}`;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return { sql, values: queryValues };
|
|
3257
|
+
}
|
|
3258
|
+
/**
|
|
3259
|
+
* Build an increment query
|
|
3260
|
+
* @param tableName - Table name
|
|
3261
|
+
* @param fields - Fields to increment
|
|
3262
|
+
* @param where - Where clause
|
|
3263
|
+
* @param options - Query options (by: number)
|
|
3264
|
+
*/
|
|
3265
|
+
buildIncrementQuery(tableName, fields, where, options) {
|
|
3266
|
+
const by = options?.by ?? 1;
|
|
3267
|
+
const setClauses = [];
|
|
3268
|
+
const queryValues = [];
|
|
3269
|
+
let paramIndex = 1;
|
|
3270
|
+
// Handle different field formats
|
|
3271
|
+
if (typeof fields === 'string') {
|
|
3272
|
+
// Single field: increment('count')
|
|
3273
|
+
setClauses.push(`${this.escapeId(fields)} = ${this.escapeId(fields)} + $${paramIndex}`);
|
|
3274
|
+
queryValues.push(by);
|
|
3275
|
+
paramIndex++;
|
|
3276
|
+
}
|
|
3277
|
+
else if (Array.isArray(fields)) {
|
|
3278
|
+
// Array of fields: increment(['count', 'value'], { by: 5 })
|
|
3279
|
+
for (const field of fields) {
|
|
3280
|
+
setClauses.push(`${this.escapeId(field)} = ${this.escapeId(field)} + $${paramIndex}`);
|
|
3281
|
+
queryValues.push(by);
|
|
3282
|
+
paramIndex++;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
else {
|
|
3286
|
+
// Object: increment({ count: 1, value: 10 })
|
|
3287
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
3288
|
+
setClauses.push(`${this.escapeId(field)} = ${this.escapeId(field)} + $${paramIndex}`);
|
|
3289
|
+
queryValues.push(value);
|
|
3290
|
+
paramIndex++;
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
// Build WHERE clause
|
|
3294
|
+
const whereClause = this.buildWhereClause(where);
|
|
3295
|
+
// Adjust parameter indices for where clause
|
|
3296
|
+
const adjustedWhereClause = {
|
|
3297
|
+
sql: whereClause.sql.replace(/\$(\d+)/g, (_, num) => `$${paramIndex + parseInt(num, 10) - 1}`),
|
|
3298
|
+
values: whereClause.values,
|
|
3299
|
+
};
|
|
3300
|
+
queryValues.push(...adjustedWhereClause.values);
|
|
3301
|
+
const sql = `UPDATE ${this.escapeId(tableName)} SET ${setClauses.join(', ')} WHERE ${adjustedWhereClause.sql}`;
|
|
3302
|
+
return { sql, values: queryValues };
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Build an UPDATE query with RETURNING support
|
|
3306
|
+
*/
|
|
3307
|
+
buildUpdateQuery(tableName, values, where, options) {
|
|
3308
|
+
const setClauses = [];
|
|
3309
|
+
const queryValues = [];
|
|
3310
|
+
let paramIndex = 1;
|
|
3311
|
+
for (const [key, value] of Object.entries(values)) {
|
|
3312
|
+
if (value instanceof prorm_1.Literal) {
|
|
3313
|
+
// For Literal values, inline the SQL directly
|
|
3314
|
+
setClauses.push(`${this.escapeId(key)} = ${value.val}`);
|
|
3315
|
+
}
|
|
3316
|
+
else {
|
|
3317
|
+
// For regular values, use parameterized placeholder
|
|
3318
|
+
setClauses.push(`${this.escapeId(key)} = $${paramIndex}`);
|
|
3319
|
+
queryValues.push(value);
|
|
3320
|
+
paramIndex++;
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
const whereClause = this.buildWhereClause(where);
|
|
3324
|
+
// Adjust parameter indices for where clause
|
|
3325
|
+
const adjustedWhereClause = {
|
|
3326
|
+
sql: whereClause.sql,
|
|
3327
|
+
values: [...queryValues, ...whereClause.values],
|
|
3328
|
+
};
|
|
3329
|
+
let sql = `UPDATE ${this.escapeId(tableName)} SET ${setClauses.join(', ')}`;
|
|
3330
|
+
if (adjustedWhereClause.sql) {
|
|
3331
|
+
sql += ` WHERE ${adjustedWhereClause.sql}`;
|
|
3332
|
+
}
|
|
3333
|
+
if (options?.limit) {
|
|
3334
|
+
sql += ` LIMIT ${options.limit}`;
|
|
3335
|
+
}
|
|
3336
|
+
// Handle RETURNING clause
|
|
3337
|
+
if (options?.returning) {
|
|
3338
|
+
if (options.returning === true) {
|
|
3339
|
+
sql += ' RETURNING *';
|
|
3340
|
+
}
|
|
3341
|
+
else if (Array.isArray(options.returning)) {
|
|
3342
|
+
sql += ` RETURNING ${options.returning.map((c) => this.escapeId(c)).join(', ')}`;
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
return { sql, values: adjustedWhereClause.values };
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Build a DELETE query with RETURNING support
|
|
3349
|
+
*/
|
|
3350
|
+
buildDeleteQuery(tableName, where, options) {
|
|
3351
|
+
const whereClause = this.buildWhereClause(where);
|
|
3352
|
+
let sql = `DELETE FROM ${this.escapeId(tableName)}`;
|
|
3353
|
+
if (whereClause.sql) {
|
|
3354
|
+
sql += ` WHERE ${whereClause.sql}`;
|
|
3355
|
+
}
|
|
3356
|
+
if (options?.limit) {
|
|
3357
|
+
sql += ` LIMIT ${options.limit}`;
|
|
3358
|
+
}
|
|
3359
|
+
// Handle RETURNING clause
|
|
3360
|
+
if (options?.returning) {
|
|
3361
|
+
if (options.returning === true) {
|
|
3362
|
+
sql += ' RETURNING *';
|
|
3363
|
+
}
|
|
3364
|
+
else if (Array.isArray(options.returning)) {
|
|
3365
|
+
sql += ` RETURNING ${options.returning.map((c) => this.escapeId(c)).join(', ')}`;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
// Handle TRUNCATE
|
|
3369
|
+
if (options?.truncate) {
|
|
3370
|
+
const pgOptions = options;
|
|
3371
|
+
sql = `TRUNCATE ${this.escapeId(tableName)}`;
|
|
3372
|
+
if (pgOptions.restartIdentity) {
|
|
3373
|
+
sql += ' RESTART IDENTITY';
|
|
3374
|
+
}
|
|
3375
|
+
if (pgOptions.cascade) {
|
|
3376
|
+
sql += ' CASCADE';
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
return { sql, values: whereClause.values };
|
|
3380
|
+
}
|
|
3381
|
+
/**
|
|
3382
|
+
* Check if a value is a function expression (fn('COUNT', ...))
|
|
3383
|
+
*/
|
|
3384
|
+
isFnExpression(value) {
|
|
3385
|
+
return value !== null && typeof value === 'object' && value.__type === 'fn';
|
|
3386
|
+
}
|
|
3387
|
+
/**
|
|
3388
|
+
* Check if a value is a column expression (col('name'))
|
|
3389
|
+
*/
|
|
3390
|
+
isColExpression(value) {
|
|
3391
|
+
return value !== null && typeof value === 'object' && value.__type === 'col';
|
|
3392
|
+
}
|
|
3393
|
+
/**
|
|
3394
|
+
* Check if a value is a literal expression (literal('sql'))
|
|
3395
|
+
*/
|
|
3396
|
+
isLiteralExpression(value) {
|
|
3397
|
+
return value !== null && typeof value === 'object' && value.__type === 'literal';
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* Compile a function expression to SQL
|
|
3401
|
+
*/
|
|
3402
|
+
compileFnExpression(fnExpr) {
|
|
3403
|
+
const args = fnExpr.args.map((arg) => {
|
|
3404
|
+
if (this.isColExpression(arg)) {
|
|
3405
|
+
return this.escapeId(arg.col);
|
|
3406
|
+
}
|
|
3407
|
+
else if (typeof arg === 'string') {
|
|
3408
|
+
return this.escape(arg);
|
|
3409
|
+
}
|
|
3410
|
+
else if (this.isFnExpression(arg)) {
|
|
3411
|
+
return this.compileFnExpression(arg);
|
|
3412
|
+
}
|
|
3413
|
+
else {
|
|
3414
|
+
return String(arg);
|
|
3415
|
+
}
|
|
3416
|
+
});
|
|
3417
|
+
return `${fnExpr.fn}(${args.join(', ')})`;
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* Build SELECT clause with support for function expressions and aliases
|
|
3421
|
+
* Handles: 'field', ['field'], ['field', 'alias'], [fn('COUNT', 'id'), 'count']
|
|
3422
|
+
*/
|
|
3423
|
+
buildSelectClause(attributes) {
|
|
3424
|
+
if (!attributes)
|
|
3425
|
+
return '*';
|
|
3426
|
+
if (Array.isArray(attributes)) {
|
|
3427
|
+
const parts = attributes.map((attr) => {
|
|
3428
|
+
// Handle array format: [expression, alias] or [fn, alias]
|
|
3429
|
+
if (Array.isArray(attr)) {
|
|
3430
|
+
const [expr, alias] = attr;
|
|
3431
|
+
let sql;
|
|
3432
|
+
if (this.isFnExpression(expr)) {
|
|
3433
|
+
sql = this.compileFnExpression(expr);
|
|
3434
|
+
}
|
|
3435
|
+
else if (this.isColExpression(expr)) {
|
|
3436
|
+
sql = this.escapeId(expr.col);
|
|
3437
|
+
}
|
|
3438
|
+
else if (this.isLiteralExpression(expr)) {
|
|
3439
|
+
sql = expr.sql;
|
|
3440
|
+
}
|
|
3441
|
+
else if (typeof expr === 'string') {
|
|
3442
|
+
// Check if it's a column reference or literal SQL
|
|
3443
|
+
if (expr.includes('(') || expr.includes(' ')) {
|
|
3444
|
+
sql = expr; // Literal SQL
|
|
3445
|
+
}
|
|
3446
|
+
else {
|
|
3447
|
+
sql = this.escapeId(expr);
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
else {
|
|
3451
|
+
sql = String(expr);
|
|
3452
|
+
}
|
|
3453
|
+
return `${sql} AS ${this.escapeId(alias)}`;
|
|
3454
|
+
}
|
|
3455
|
+
else if (this.isFnExpression(attr)) {
|
|
3456
|
+
// Function without alias - let DB assign a name
|
|
3457
|
+
return this.compileFnExpression(attr);
|
|
3458
|
+
}
|
|
3459
|
+
else if (this.isColExpression(attr)) {
|
|
3460
|
+
return this.escapeId(attr.col);
|
|
3461
|
+
}
|
|
3462
|
+
else if (this.isLiteralExpression(attr)) {
|
|
3463
|
+
return attr.sql;
|
|
3464
|
+
}
|
|
3465
|
+
else if (typeof attr === 'string') {
|
|
3466
|
+
// Plain column name
|
|
3467
|
+
return this.escapeId(attr);
|
|
3468
|
+
}
|
|
3469
|
+
return String(attr);
|
|
3470
|
+
});
|
|
3471
|
+
return parts.join(', ');
|
|
3472
|
+
}
|
|
3473
|
+
else if (attributes.include) {
|
|
3474
|
+
return attributes.include.map((a) => this.escapeId(a)).join(', ');
|
|
3475
|
+
}
|
|
3476
|
+
else if (attributes.exclude) {
|
|
3477
|
+
// CockroachDB supports EXCEPT
|
|
3478
|
+
return `* EXCEPT (${attributes.exclude.map((a) => this.escapeId(a)).join(', ')})`;
|
|
3479
|
+
}
|
|
3480
|
+
return '*';
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* Build GROUP BY clause with support for function expressions and aliases
|
|
3484
|
+
* Handles: 'field', ['field1', 'field2'], [[fn('COUNT', 'id'), 'count']]
|
|
3485
|
+
*/
|
|
3486
|
+
buildGroupByClause(group) {
|
|
3487
|
+
if (!group)
|
|
3488
|
+
return '';
|
|
3489
|
+
const groupArray = Array.isArray(group) ? group : [group];
|
|
3490
|
+
if (groupArray.length === 0)
|
|
3491
|
+
return '';
|
|
3492
|
+
const parts = groupArray.map((field) => {
|
|
3493
|
+
if (Array.isArray(field)) {
|
|
3494
|
+
// Array format: [expression, alias] or [fn, alias]
|
|
3495
|
+
const [expr, alias] = field;
|
|
3496
|
+
let sql;
|
|
3497
|
+
if (this.isFnExpression(expr)) {
|
|
3498
|
+
sql = this.compileFnExpression(expr);
|
|
3499
|
+
}
|
|
3500
|
+
else if (this.isColExpression(expr)) {
|
|
3501
|
+
sql = this.escapeId(expr.col);
|
|
3502
|
+
}
|
|
3503
|
+
else if (this.isLiteralExpression(expr)) {
|
|
3504
|
+
sql = expr.sql;
|
|
3505
|
+
}
|
|
3506
|
+
else if (typeof expr === 'string') {
|
|
3507
|
+
sql = this.escapeId(expr);
|
|
3508
|
+
}
|
|
3509
|
+
else {
|
|
3510
|
+
sql = String(expr);
|
|
3511
|
+
}
|
|
3512
|
+
// Alias is optional in GROUP BY
|
|
3513
|
+
if (alias) {
|
|
3514
|
+
return `${sql} AS ${this.escapeId(alias)}`;
|
|
3515
|
+
}
|
|
3516
|
+
return sql;
|
|
3517
|
+
}
|
|
3518
|
+
else if (this.isFnExpression(field)) {
|
|
3519
|
+
return this.compileFnExpression(field);
|
|
3520
|
+
}
|
|
3521
|
+
else if (this.isColExpression(field)) {
|
|
3522
|
+
return this.escapeId(field.col);
|
|
3523
|
+
}
|
|
3524
|
+
else if (this.isLiteralExpression(field)) {
|
|
3525
|
+
return field.sql;
|
|
3526
|
+
}
|
|
3527
|
+
else if (typeof field === 'string') {
|
|
3528
|
+
if (field.includes('.')) {
|
|
3529
|
+
return field
|
|
3530
|
+
.split('.')
|
|
3531
|
+
.map((part) => this.escapeId(part))
|
|
3532
|
+
.join('.');
|
|
3533
|
+
}
|
|
3534
|
+
return this.escapeId(field);
|
|
3535
|
+
}
|
|
3536
|
+
return String(field);
|
|
3537
|
+
});
|
|
3538
|
+
return `GROUP BY ${parts.join(', ')}`;
|
|
3539
|
+
}
|
|
3540
|
+
/**
|
|
3541
|
+
* Build a WHERE clause condition using standard operators (Op.gt, Op.eq, etc.)
|
|
3542
|
+
* CockroachDB uses $1, $2, etc. for parameter placeholders
|
|
3543
|
+
*/
|
|
3544
|
+
buildCondition(condition, values, paramPrefix = '$') {
|
|
3545
|
+
if (!condition || (typeof condition === 'object' && Object.keys(condition).length === 0)) {
|
|
3546
|
+
return '';
|
|
3547
|
+
}
|
|
3548
|
+
// Handle logical operators
|
|
3549
|
+
if ('$and' in condition) {
|
|
3550
|
+
const andParts = condition.$and.map((c) => this.buildCondition(c, values, paramPrefix));
|
|
3551
|
+
return `(${andParts.join(' AND ')})`;
|
|
3552
|
+
}
|
|
3553
|
+
if ('$or' in condition) {
|
|
3554
|
+
const orParts = condition.$or.map((c) => this.buildCondition(c, values, paramPrefix));
|
|
3555
|
+
return `(${orParts.join(' OR ')})`;
|
|
3556
|
+
}
|
|
3557
|
+
if ('$not' in condition) {
|
|
3558
|
+
return `NOT (${this.buildCondition(condition.$not, values, paramPrefix)})`;
|
|
3559
|
+
}
|
|
3560
|
+
// Handle regular conditions
|
|
3561
|
+
const conditions = [];
|
|
3562
|
+
for (const [key, value] of Object.entries(condition)) {
|
|
3563
|
+
if (key.startsWith('$'))
|
|
3564
|
+
continue; // Skip logical operators
|
|
3565
|
+
let sql = '';
|
|
3566
|
+
const paramIndex = values.length + 1;
|
|
3567
|
+
// Check if value is an operator object
|
|
3568
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
3569
|
+
const opKeys = Object.keys(value);
|
|
3570
|
+
if (opKeys.length === 1) {
|
|
3571
|
+
const opKey = opKeys[0];
|
|
3572
|
+
const opValue = value[opKey];
|
|
3573
|
+
// Map Op symbol to string
|
|
3574
|
+
if (opKey === 'Symbol(eq)' || opKey === 'Symbol(operators.eq)') {
|
|
3575
|
+
sql = `${this.escapeId(key)} = ${paramPrefix}${paramIndex}`;
|
|
3576
|
+
values.push(opValue);
|
|
3577
|
+
}
|
|
3578
|
+
else if (opKey === 'Symbol(ne)' || opKey === 'Symbol(operators.ne)') {
|
|
3579
|
+
sql = `${this.escapeId(key)} != ${paramPrefix}${paramIndex}`;
|
|
3580
|
+
values.push(opValue);
|
|
3581
|
+
}
|
|
3582
|
+
else if (opKey === 'Symbol(gt)' || opKey === 'Symbol(operators.gt)') {
|
|
3583
|
+
sql = `${this.escapeId(key)} > ${paramPrefix}${paramIndex}`;
|
|
3584
|
+
values.push(opValue);
|
|
3585
|
+
}
|
|
3586
|
+
else if (opKey === 'Symbol(gte)' || opKey === 'Symbol(operators.gte)') {
|
|
3587
|
+
sql = `${this.escapeId(key)} >= ${paramPrefix}${paramIndex}`;
|
|
3588
|
+
values.push(opValue);
|
|
3589
|
+
}
|
|
3590
|
+
else if (opKey === 'Symbol(lt)' || opKey === 'Symbol(operators.lt)') {
|
|
3591
|
+
sql = `${this.escapeId(key)} < ${paramPrefix}${paramIndex}`;
|
|
3592
|
+
values.push(opValue);
|
|
3593
|
+
}
|
|
3594
|
+
else if (opKey === 'Symbol(lte)' || opKey === 'Symbol(operators.lte)') {
|
|
3595
|
+
sql = `${this.escapeId(key)} <= ${paramPrefix}${paramIndex}`;
|
|
3596
|
+
values.push(opValue);
|
|
3597
|
+
}
|
|
3598
|
+
else if (opKey === 'Symbol(in)' || opKey === 'Symbol(operators.in)') {
|
|
3599
|
+
const placeholders = opValue
|
|
3600
|
+
.map((_, i) => `${paramPrefix}${paramIndex + i}`)
|
|
3601
|
+
.join(', ');
|
|
3602
|
+
sql = `${this.escapeId(key)} IN (${placeholders})`;
|
|
3603
|
+
values.push(...opValue);
|
|
3604
|
+
}
|
|
3605
|
+
else if (opKey === 'Symbol(notIn)' || opKey === 'Symbol(operators.notIn)') {
|
|
3606
|
+
const placeholders = opValue
|
|
3607
|
+
.map((_, i) => `${paramPrefix}${paramIndex + i}`)
|
|
3608
|
+
.join(', ');
|
|
3609
|
+
sql = `${this.escapeId(key)} NOT IN (${placeholders})`;
|
|
3610
|
+
values.push(...opValue);
|
|
3611
|
+
}
|
|
3612
|
+
else if (opKey === 'Symbol(between)' || opKey === 'Symbol(operators.between)') {
|
|
3613
|
+
sql = `${this.escapeId(key)} BETWEEN ${paramPrefix}${paramIndex} AND ${paramPrefix}${paramIndex + 1}`;
|
|
3614
|
+
values.push(...opValue);
|
|
3615
|
+
}
|
|
3616
|
+
else if (opKey === 'Symbol(notBetween)' || opKey === 'Symbol(operators.notBetween)') {
|
|
3617
|
+
sql = `${this.escapeId(key)} NOT BETWEEN ${paramPrefix}${paramIndex} AND ${paramPrefix}${paramIndex + 1}`;
|
|
3618
|
+
values.push(...opValue);
|
|
3619
|
+
}
|
|
3620
|
+
else if (opKey === 'Symbol(isNull)' || opKey === 'Symbol(operators.isNull)') {
|
|
3621
|
+
sql = `${this.escapeId(key)} IS NULL`;
|
|
3622
|
+
}
|
|
3623
|
+
else if (opKey === 'Symbol(isNotNull)' || opKey === 'Symbol(operators.isNotNull)') {
|
|
3624
|
+
sql = `${this.escapeId(key)} IS NOT NULL`;
|
|
3625
|
+
}
|
|
3626
|
+
else if (opKey === 'Symbol(like)' || opKey === 'Symbol(operators.like)') {
|
|
3627
|
+
sql = `${this.escapeId(key)} LIKE ${paramPrefix}${paramIndex}`;
|
|
3628
|
+
values.push(opValue);
|
|
3629
|
+
}
|
|
3630
|
+
else if (opKey === 'Symbol(notLike)' || opKey === 'Symbol(operators.notLike)') {
|
|
3631
|
+
sql = `${this.escapeId(key)} NOT LIKE ${paramPrefix}${paramIndex}`;
|
|
3632
|
+
values.push(opValue);
|
|
3633
|
+
}
|
|
3634
|
+
else if (opKey === 'Symbol(iLike)' || opKey === 'Symbol(operators.iLike)') {
|
|
3635
|
+
sql = `${this.escapeId(key)} ILIKE ${paramPrefix}${paramIndex}`;
|
|
3636
|
+
values.push(opValue);
|
|
3637
|
+
}
|
|
3638
|
+
else if (opKey === 'Symbol(notILike)' || opKey === 'Symbol(operators.notILike)') {
|
|
3639
|
+
sql = `${this.escapeId(key)} NOT ILIKE ${paramPrefix}${paramIndex}`;
|
|
3640
|
+
values.push(opValue);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
if (!sql) {
|
|
3644
|
+
// Fallback: treat as equals
|
|
3645
|
+
sql = `${this.escapeId(key)} = ${paramPrefix}${paramIndex}`;
|
|
3646
|
+
values.push(value);
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
else if (Array.isArray(value)) {
|
|
3650
|
+
// Handle IN arrays
|
|
3651
|
+
const placeholders = value.map((_, i) => `${paramPrefix}${paramIndex + i}`).join(', ');
|
|
3652
|
+
sql = `${this.escapeId(key)} IN (${placeholders})`;
|
|
3653
|
+
values.push(...value);
|
|
3654
|
+
}
|
|
3655
|
+
else {
|
|
3656
|
+
// Simple equality
|
|
3657
|
+
sql = `${this.escapeId(key)} = ${paramPrefix}${paramIndex}`;
|
|
3658
|
+
values.push(value);
|
|
3659
|
+
}
|
|
3660
|
+
if (sql) {
|
|
3661
|
+
conditions.push(sql);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
return conditions.join(' AND ');
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* Build a HAVING clause with support for standard Op operators
|
|
3668
|
+
*/
|
|
3669
|
+
buildHavingClause(having) {
|
|
3670
|
+
const values = [];
|
|
3671
|
+
if (!having || (typeof having === 'object' && Object.keys(having).length === 0)) {
|
|
3672
|
+
return { sql: '', values };
|
|
3673
|
+
}
|
|
3674
|
+
// Use the condition builder with Op operator support
|
|
3675
|
+
const sql = this.buildCondition(having, values);
|
|
3676
|
+
return { sql, values };
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* Build the expression used after `AS OF SYSTEM TIME` for CockroachDB
|
|
3680
|
+
* time-travel reads.
|
|
3681
|
+
*
|
|
3682
|
+
* Accepts:
|
|
3683
|
+
* - A relative interval shorthand, e.g. `'-10s'`, `'-500ms'`, `'-1h'`.
|
|
3684
|
+
* - An absolute ISO-8601 timestamp, e.g. `'2024-01-01T00:00:00Z'`.
|
|
3685
|
+
* - The literal function call `follower_read_timestamp()` (unquoted).
|
|
3686
|
+
*
|
|
3687
|
+
* The value is validated against a strict allow-list pattern before being
|
|
3688
|
+
* embedded in the SQL string (rather than passed as a bound parameter,
|
|
3689
|
+
* which CockroachDB's `AS OF SYSTEM TIME` clause does not accept) to avoid
|
|
3690
|
+
* SQL injection.
|
|
3691
|
+
*
|
|
3692
|
+
* @see https://www.cockroachlabs.com/docs/stable/as-of-system-time
|
|
3693
|
+
*/
|
|
3694
|
+
buildAsOfSystemTimeClause(value) {
|
|
3695
|
+
const trimmed = value.trim();
|
|
3696
|
+
// `follower_read_timestamp()` (and the bounded-staleness variant
|
|
3697
|
+
// `with_min_timestamp`/`with_max_staleness`) are function calls and must
|
|
3698
|
+
// not be quoted.
|
|
3699
|
+
if (/^follower_read_timestamp\(\s*\)$/i.test(trimmed)) {
|
|
3700
|
+
return trimmed;
|
|
3701
|
+
}
|
|
3702
|
+
// Relative interval shorthand: optional leading `-`, digits, optional
|
|
3703
|
+
// decimal portion, optional unit (ms|s|m|h|d).
|
|
3704
|
+
const isRelativeInterval = /^-?\d+(\.\d+)?\s*(ms|s|m|h|d)?$/i.test(trimmed);
|
|
3705
|
+
// Absolute timestamp (ISO-8601-ish: date, optional time, optional
|
|
3706
|
+
// fractional seconds, optional timezone offset/Z).
|
|
3707
|
+
const isAbsoluteTimestamp = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.test(trimmed);
|
|
3708
|
+
if (!isRelativeInterval && !isAbsoluteTimestamp) {
|
|
3709
|
+
throw new Error(`Invalid AS OF SYSTEM TIME value: "${value}". Expected a relative interval ` +
|
|
3710
|
+
`(e.g. "-10s"), an ISO-8601 timestamp, or "follower_read_timestamp()".`);
|
|
3711
|
+
}
|
|
3712
|
+
return `'${trimmed.replace(/'/g, "''")}'`;
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Run a SELECT query as of a historical timestamp using CockroachDB's
|
|
3716
|
+
* `AS OF SYSTEM TIME` time-travel read feature.
|
|
3717
|
+
*
|
|
3718
|
+
* @param options - Standard SelectOptions plus the historical timestamp.
|
|
3719
|
+
* @param asOfSystemTime - Relative interval (e.g. `'-10s'`), ISO timestamp,
|
|
3720
|
+
* or `'follower_read_timestamp()'`.
|
|
3721
|
+
*/
|
|
3722
|
+
async queryAsOfSystemTime(options, asOfSystemTime) {
|
|
3723
|
+
const { sql, values } = this.buildSelectQuery({
|
|
3724
|
+
...options,
|
|
3725
|
+
asOfSystemTime,
|
|
3726
|
+
});
|
|
3727
|
+
return this.query(sql, { replacements: values });
|
|
3728
|
+
}
|
|
3729
|
+
/**
|
|
3730
|
+
* Build a SELECT query with CockroachDB-specific features
|
|
3731
|
+
*/
|
|
3732
|
+
buildSelectQuery(options) {
|
|
3733
|
+
const queryValues = [];
|
|
3734
|
+
// Build SELECT clause
|
|
3735
|
+
let selectSql = '*';
|
|
3736
|
+
// Handle DISTINCT ON and DISTINCT with column
|
|
3737
|
+
const distinctOn = options.distinctOn;
|
|
3738
|
+
let distinctClause = '';
|
|
3739
|
+
if (options.distinct) {
|
|
3740
|
+
if (distinctOn && Array.isArray(distinctOn)) {
|
|
3741
|
+
distinctClause = `DISTINCT ON (${distinctOn.map((c) => this.escapeId(c)).join(', ')}) `;
|
|
3742
|
+
}
|
|
3743
|
+
else if (options.col) {
|
|
3744
|
+
// DISTINCT with specific column (e.g., COUNT(DISTINCT col))
|
|
3745
|
+
distinctClause = `DISTINCT ${this.escapeId(options.col)} `;
|
|
3746
|
+
}
|
|
3747
|
+
else {
|
|
3748
|
+
distinctClause = 'DISTINCT ';
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
if (options.attributes) {
|
|
3752
|
+
if (Array.isArray(options.attributes)) {
|
|
3753
|
+
selectSql = options.attributes.map((a) => this.escapeId(a)).join(', ');
|
|
3754
|
+
}
|
|
3755
|
+
else if (options.attributes.include) {
|
|
3756
|
+
selectSql = options.attributes.include.map((a) => this.escapeId(a)).join(', ');
|
|
3757
|
+
}
|
|
3758
|
+
else if (options.attributes.exclude) {
|
|
3759
|
+
// CockroachDB supports EXCEPT
|
|
3760
|
+
selectSql = `* EXCEPT (${options.attributes.exclude.map((a) => this.escapeId(a)).join(', ')})`;
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
let sql = `SELECT ${distinctClause}${selectSql} FROM ${this.escapeId(options.tableName)}`;
|
|
3764
|
+
// AS OF SYSTEM TIME (CockroachDB time-travel reads). Must immediately
|
|
3765
|
+
// follow the table reference in the FROM clause, before any JOINs/WHERE.
|
|
3766
|
+
// See: https://www.cockroachlabs.com/docs/stable/as-of-system-time
|
|
3767
|
+
const asOfSystemTime = options.asOfSystemTime;
|
|
3768
|
+
if (asOfSystemTime) {
|
|
3769
|
+
sql += ` AS OF SYSTEM TIME ${this.buildAsOfSystemTimeClause(asOfSystemTime)}`;
|
|
3770
|
+
}
|
|
3771
|
+
// Build JOIN clause (for includes)
|
|
3772
|
+
if (options.include && options.include.length > 0) {
|
|
3773
|
+
for (const include of options.include) {
|
|
3774
|
+
// Determine join type - check joinType option first, then fall back to required
|
|
3775
|
+
let joinType;
|
|
3776
|
+
if (include.joinType) {
|
|
3777
|
+
switch (include.joinType) {
|
|
3778
|
+
case 'INNER':
|
|
3779
|
+
joinType = 'INNER JOIN';
|
|
3780
|
+
break;
|
|
3781
|
+
case 'LEFT':
|
|
3782
|
+
joinType = 'LEFT JOIN';
|
|
3783
|
+
break;
|
|
3784
|
+
case 'RIGHT':
|
|
3785
|
+
joinType = 'RIGHT JOIN';
|
|
3786
|
+
break;
|
|
3787
|
+
case 'FULL':
|
|
3788
|
+
joinType = 'FULL OUTER JOIN';
|
|
3789
|
+
break;
|
|
3790
|
+
case 'CROSS':
|
|
3791
|
+
joinType = 'CROSS JOIN';
|
|
3792
|
+
break;
|
|
3793
|
+
default:
|
|
3794
|
+
joinType = include.required ? 'INNER JOIN' : 'LEFT JOIN';
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
else {
|
|
3798
|
+
joinType = include.required ? 'INNER JOIN' : 'LEFT JOIN';
|
|
3799
|
+
}
|
|
3800
|
+
const modelTableName = include.model.tableName;
|
|
3801
|
+
const modelName = modelTableName ? modelTableName : include.model.toString();
|
|
3802
|
+
const alias = include.as || modelName;
|
|
3803
|
+
// Handle LATERAL keyword (CockroachDB specific)
|
|
3804
|
+
const lateralKeyword = include.lateral ? 'LATERAL ' : '';
|
|
3805
|
+
sql += ` ${joinType}${lateralKeyword ? ' ' + lateralKeyword : ''}${this.escapeId(modelName)} ${this.escapeId(alias)}`;
|
|
3806
|
+
if (include.where) {
|
|
3807
|
+
const whereClause = this.buildWhereClause(include.where);
|
|
3808
|
+
sql += ` ON ${whereClause.sql}`;
|
|
3809
|
+
queryValues.push(...whereClause.values);
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
// Build WHERE clause
|
|
3814
|
+
if (options.where) {
|
|
3815
|
+
const whereClause = this.buildWhereClause(options.where);
|
|
3816
|
+
if (whereClause.sql) {
|
|
3817
|
+
sql += ` WHERE ${whereClause.sql}`;
|
|
3818
|
+
queryValues.push(...whereClause.values);
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
// Build GROUP BY clause
|
|
3822
|
+
if (options.group) {
|
|
3823
|
+
const groupByClause = this.buildGroupByClause(options.group);
|
|
3824
|
+
if (groupByClause) {
|
|
3825
|
+
sql += ` ${groupByClause}`;
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
// Build HAVING clause
|
|
3829
|
+
if (options.having) {
|
|
3830
|
+
const havingClause = this.buildHavingClause(options.having);
|
|
3831
|
+
if (havingClause.sql) {
|
|
3832
|
+
sql += ` HAVING ${havingClause.sql}`;
|
|
3833
|
+
queryValues.push(...havingClause.values);
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
// Build ORDER BY clause
|
|
3837
|
+
if (options.order) {
|
|
3838
|
+
const orderClause = this.buildOrderClause(options.order);
|
|
3839
|
+
if (orderClause) {
|
|
3840
|
+
sql += ` ${orderClause}`;
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
// Build LIMIT/OFFSET clause
|
|
3844
|
+
sql += this.buildLimitOffset(options.limit, options.offset);
|
|
3845
|
+
// Handle row-level locking (CockroachDB specific)
|
|
3846
|
+
if (options.lock) {
|
|
3847
|
+
let lockType;
|
|
3848
|
+
// lock: true is equivalent to 'UPDATE'
|
|
3849
|
+
if (options.lock === true) {
|
|
3850
|
+
lockType = 'UPDATE';
|
|
3851
|
+
}
|
|
3852
|
+
else if (typeof options.lock === 'string') {
|
|
3853
|
+
lockType = options.lock;
|
|
3854
|
+
}
|
|
3855
|
+
else if (typeof options.lock === 'object' && options.lock.of) {
|
|
3856
|
+
// lock: { of: Model } - defaults to FOR UPDATE for table-specific locking
|
|
3857
|
+
lockType = 'UPDATE';
|
|
3858
|
+
}
|
|
3859
|
+
if (lockType === 'UPDATE') {
|
|
3860
|
+
sql += ' FOR UPDATE';
|
|
3861
|
+
}
|
|
3862
|
+
else if (lockType === 'SHARE') {
|
|
3863
|
+
sql += ' FOR SHARE';
|
|
3864
|
+
}
|
|
3865
|
+
else if (lockType === 'KEY SHARE') {
|
|
3866
|
+
sql += ' FOR KEY SHARE';
|
|
3867
|
+
}
|
|
3868
|
+
// Handle lock on specific table (CockroachDB supports OF clause)
|
|
3869
|
+
if (typeof options.lock === 'object' && options.lock.of) {
|
|
3870
|
+
const model = options.lock.of;
|
|
3871
|
+
sql += ` OF ${this.escapeId(model.tableName || '')}`;
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
// Build UNION clause if provided
|
|
3875
|
+
if (options.union && options.union.length > 0) {
|
|
3876
|
+
const unionType = options.unionType || 'UNION';
|
|
3877
|
+
for (const unionQuery of options.union) {
|
|
3878
|
+
const unionModel = unionQuery.model;
|
|
3879
|
+
const unionTableName = unionModel.tableName || unionModel.name || '';
|
|
3880
|
+
// Build the union query
|
|
3881
|
+
let unionSql = `SELECT * FROM ${this.escapeId(unionTableName)}`;
|
|
3882
|
+
// Add WHERE clause for union query
|
|
3883
|
+
if (unionQuery.where) {
|
|
3884
|
+
const whereClause = this.buildWhereClause(unionQuery.where);
|
|
3885
|
+
if (whereClause.sql) {
|
|
3886
|
+
unionSql += ` WHERE ${whereClause.sql}`;
|
|
3887
|
+
queryValues.push(...whereClause.values);
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
// Add ORDER BY for union query
|
|
3891
|
+
if (unionQuery.order) {
|
|
3892
|
+
const orderClause = this.buildOrderClause(unionQuery.order);
|
|
3893
|
+
if (orderClause) {
|
|
3894
|
+
unionSql += ` ${orderClause}`;
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
// Add LIMIT for union query
|
|
3898
|
+
if (unionQuery.limit) {
|
|
3899
|
+
unionSql += ` LIMIT ${unionQuery.limit}`;
|
|
3900
|
+
}
|
|
3901
|
+
// Add OFFSET for union query
|
|
3902
|
+
if (unionQuery.offset) {
|
|
3903
|
+
unionSql += ` OFFSET ${unionQuery.offset}`;
|
|
3904
|
+
}
|
|
3905
|
+
sql += ` ${unionType} ${unionSql}`;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
return { sql, values: queryValues };
|
|
3909
|
+
}
|
|
3910
|
+
/**
|
|
3911
|
+
* Escape a value for use in a CockroachDB array literal
|
|
3912
|
+
*/
|
|
3913
|
+
escapeArray(values) {
|
|
3914
|
+
const escaped = values.map((v) => {
|
|
3915
|
+
if (v === null) {
|
|
3916
|
+
return 'NULL';
|
|
3917
|
+
}
|
|
3918
|
+
if (typeof v === 'string') {
|
|
3919
|
+
return `"${v.replace(/"/g, '""')}"`;
|
|
3920
|
+
}
|
|
3921
|
+
if (typeof v === 'number' || typeof v === 'boolean') {
|
|
3922
|
+
return String(v);
|
|
3923
|
+
}
|
|
3924
|
+
return `"${JSON.stringify(v).replace(/"/g, '""')}"`;
|
|
3925
|
+
});
|
|
3926
|
+
return `ARRAY[${escaped.join(', ')}]`;
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* Build an array contains operator query (@>)
|
|
3930
|
+
* SELECT * FROM table WHERE column @> ARRAY['elem1', 'elem2']
|
|
3931
|
+
*/
|
|
3932
|
+
buildArrayContains(column, values) {
|
|
3933
|
+
const arrayLiteral = this.escapeArray(values);
|
|
3934
|
+
return `${this.escapeId(column)} @> ${arrayLiteral}`;
|
|
3935
|
+
}
|
|
3936
|
+
/**
|
|
3937
|
+
* Build an array contained by operator query (<@)
|
|
3938
|
+
* SELECT * FROM table WHERE column <@ ARRAY['elem1', 'elem2']
|
|
3939
|
+
*/
|
|
3940
|
+
buildArrayContainedBy(column, values) {
|
|
3941
|
+
const arrayLiteral = this.escapeArray(values);
|
|
3942
|
+
return `${this.escapeId(column)} <@ ${arrayLiteral}`;
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* Build an array overlaps operator query (&&)
|
|
3946
|
+
* SELECT * FROM table WHERE column && ARRAY['elem1', 'elem2']
|
|
3947
|
+
*/
|
|
3948
|
+
buildArrayOverlaps(column, values) {
|
|
3949
|
+
const arrayLiteral = this.escapeArray(values);
|
|
3950
|
+
return `${this.escapeId(column)} && ${arrayLiteral}`;
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* Build an array ANY operator query
|
|
3954
|
+
* SELECT * FROM table WHERE 'value' = ANY(column)
|
|
3955
|
+
*/
|
|
3956
|
+
buildArrayAny(column, value) {
|
|
3957
|
+
const escaped = this.escape(value);
|
|
3958
|
+
return `${escaped} = ANY(${this.escapeId(column)})`;
|
|
3959
|
+
}
|
|
3960
|
+
/**
|
|
3961
|
+
* Build an array ALL operator query
|
|
3962
|
+
* SELECT * FROM table WHERE 'value' = ALL(column)
|
|
3963
|
+
* or with comparison: SELECT * FROM table WHERE value > ALL(column)
|
|
3964
|
+
*/
|
|
3965
|
+
buildArrayAll(column, value, operator) {
|
|
3966
|
+
if (operator) {
|
|
3967
|
+
const escaped = this.escape(value);
|
|
3968
|
+
return `${escaped} ${operator} ALL(${this.escapeId(column)})`;
|
|
3969
|
+
}
|
|
3970
|
+
const escaped = this.escape(value);
|
|
3971
|
+
return `${escaped} = ALL(${this.escapeId(column)})`;
|
|
3972
|
+
}
|
|
3973
|
+
/**
|
|
3974
|
+
* Build a full-text search query
|
|
3975
|
+
*/
|
|
3976
|
+
buildFullTextSearchQuery(tableName, searchColumns, searchTerm, options) {
|
|
3977
|
+
const columnList = searchColumns.map((c) => this.escapeId(c)).join(" || ' ' || ");
|
|
3978
|
+
const language = options?.language || 'english';
|
|
3979
|
+
const ranking = options?.ranking || 'ts_rank';
|
|
3980
|
+
const queryValues = [searchTerm];
|
|
3981
|
+
let sql = `SELECT *, ${ranking}(to_tsvector('${language}', ${columnList}), plainto_tsquery('${language}', $1)) as _rank`;
|
|
3982
|
+
sql += ` FROM ${this.escapeId(tableName)}`;
|
|
3983
|
+
sql += ` WHERE to_tsvector('${language}', ${columnList}) @@ plainto_tsquery('${language}', $1)`;
|
|
3984
|
+
if (options?.orderBy) {
|
|
3985
|
+
const orderClause = this.buildOrderClause(options.orderBy);
|
|
3986
|
+
if (orderClause) {
|
|
3987
|
+
sql += ` ${orderClause}`;
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
return { sql, values: queryValues };
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* Build a JSON/JSONB path query
|
|
3994
|
+
*/
|
|
3995
|
+
buildJsonPathQuery(column, path, value, operator) {
|
|
3996
|
+
const values = [value];
|
|
3997
|
+
const columnRef = this.escapeId(column);
|
|
3998
|
+
// JSONB operators
|
|
3999
|
+
const jsonOperators = {
|
|
4000
|
+
'@>': 'jsonb_path_query_first',
|
|
4001
|
+
'<@': 'jsonb_path_query_first',
|
|
4002
|
+
'?': 'jsonb_exists',
|
|
4003
|
+
'?|': 'jsonb_exists',
|
|
4004
|
+
'~': '~',
|
|
4005
|
+
'~*': '~*',
|
|
4006
|
+
};
|
|
4007
|
+
let sql;
|
|
4008
|
+
if (jsonOperators[operator]) {
|
|
4009
|
+
sql = `${columnRef} ${operator} $1`;
|
|
4010
|
+
}
|
|
4011
|
+
else if (path) {
|
|
4012
|
+
sql = `${columnRef} #> $1 ${operator} $2`;
|
|
4013
|
+
values.unshift(path);
|
|
4014
|
+
}
|
|
4015
|
+
else {
|
|
4016
|
+
sql = `${columnRef} ${operator} $1`;
|
|
4017
|
+
}
|
|
4018
|
+
return { sql, values };
|
|
4019
|
+
}
|
|
4020
|
+
/**
|
|
4021
|
+
* Get CockroachDB specific lock options
|
|
4022
|
+
*/
|
|
4023
|
+
getLockOptions(lock) {
|
|
4024
|
+
const params = [];
|
|
4025
|
+
let sql = '';
|
|
4026
|
+
const lockType = typeof lock === 'string' ? lock : lock?.level;
|
|
4027
|
+
switch (lockType) {
|
|
4028
|
+
case 'UPDATE':
|
|
4029
|
+
sql = 'FOR UPDATE';
|
|
4030
|
+
break;
|
|
4031
|
+
case 'SHARE':
|
|
4032
|
+
sql = 'FOR SHARE';
|
|
4033
|
+
break;
|
|
4034
|
+
case 'KEY SHARE':
|
|
4035
|
+
sql = 'FOR KEY SHARE';
|
|
4036
|
+
break;
|
|
4037
|
+
default:
|
|
4038
|
+
return { sql: '', params };
|
|
4039
|
+
}
|
|
4040
|
+
if (typeof lock === 'object' && lock?.of) {
|
|
4041
|
+
const model = lock.of;
|
|
4042
|
+
sql += ` OF ${this.escapeId(model.tableName || '')}`;
|
|
4043
|
+
}
|
|
4044
|
+
if (typeof lock === 'object' && lock?.nowait) {
|
|
4045
|
+
sql += ' NOWAIT';
|
|
4046
|
+
}
|
|
4047
|
+
else if (typeof lock === 'object' && lock?.skipLocked) {
|
|
4048
|
+
sql += ' SKIP LOCKED';
|
|
4049
|
+
}
|
|
4050
|
+
return { sql, params };
|
|
4051
|
+
}
|
|
4052
|
+
// ---------------------------------------------------------------------------
|
|
4053
|
+
// User / Privilege management — CockroachDB dialect
|
|
4054
|
+
// ---------------------------------------------------------------------------
|
|
4055
|
+
/**
|
|
4056
|
+
* Escape a single-quoted string value (replace ' with '').
|
|
4057
|
+
*/
|
|
4058
|
+
escapeStringValue(value) {
|
|
4059
|
+
return value.replace(/'/g, "''");
|
|
4060
|
+
}
|
|
4061
|
+
/**
|
|
4062
|
+
* Build the shared WITH-clause attribute list used by both CREATE USER and
|
|
4063
|
+
* ALTER USER. Returns a (possibly empty) array of attribute tokens.
|
|
4064
|
+
*/
|
|
4065
|
+
buildUserAttributes(options) {
|
|
4066
|
+
const attrs = [];
|
|
4067
|
+
if (options.password !== undefined) {
|
|
4068
|
+
attrs.push(`WITH PASSWORD '${this.escapeStringValue(String(options.password))}'`);
|
|
4069
|
+
}
|
|
4070
|
+
if (options.superuser === true) {
|
|
4071
|
+
attrs.push('SUPERUSER');
|
|
4072
|
+
}
|
|
4073
|
+
else if (options.superuser === false) {
|
|
4074
|
+
attrs.push('NOSUPERUSER');
|
|
4075
|
+
}
|
|
4076
|
+
if (options.createdb === true) {
|
|
4077
|
+
attrs.push('CREATEDB');
|
|
4078
|
+
}
|
|
4079
|
+
else if (options.createdb === false) {
|
|
4080
|
+
attrs.push('NOCREATEDB');
|
|
4081
|
+
}
|
|
4082
|
+
if (options.createrole === true) {
|
|
4083
|
+
attrs.push('CREATEROLE');
|
|
4084
|
+
}
|
|
4085
|
+
else if (options.createrole === false) {
|
|
4086
|
+
attrs.push('NOCREATEROLE');
|
|
4087
|
+
}
|
|
4088
|
+
// accountLocked takes precedence over login
|
|
4089
|
+
if (options.accountLocked === true) {
|
|
4090
|
+
attrs.push('NOLOGIN');
|
|
4091
|
+
}
|
|
4092
|
+
else if (options.login === false) {
|
|
4093
|
+
attrs.push('NOLOGIN');
|
|
4094
|
+
}
|
|
4095
|
+
else {
|
|
4096
|
+
// default: LOGIN
|
|
4097
|
+
attrs.push('LOGIN');
|
|
4098
|
+
}
|
|
4099
|
+
if (options.replication === true) {
|
|
4100
|
+
attrs.push('REPLICATION');
|
|
4101
|
+
}
|
|
4102
|
+
else if (options.replication === false) {
|
|
4103
|
+
attrs.push('NOREPLICATION');
|
|
4104
|
+
}
|
|
4105
|
+
if (options.bypassrls === true) {
|
|
4106
|
+
attrs.push('BYPASSRLS');
|
|
4107
|
+
}
|
|
4108
|
+
else if (options.bypassrls === false) {
|
|
4109
|
+
attrs.push('NOBYPASSRLS');
|
|
4110
|
+
}
|
|
4111
|
+
if (options.maxConnections !== undefined) {
|
|
4112
|
+
attrs.push(`CONNECTION LIMIT ${Number(options.maxConnections)}`);
|
|
4113
|
+
}
|
|
4114
|
+
if (options.expirePassword !== undefined) {
|
|
4115
|
+
if (options.expirePassword === true) {
|
|
4116
|
+
attrs.push(`VALID UNTIL 'epoch'`);
|
|
4117
|
+
}
|
|
4118
|
+
else if (options.expirePassword instanceof Date) {
|
|
4119
|
+
attrs.push(`VALID UNTIL '${this.escapeStringValue(options.expirePassword.toISOString())}'`);
|
|
4120
|
+
}
|
|
4121
|
+
else if (typeof options.expirePassword === 'string') {
|
|
4122
|
+
attrs.push(`VALID UNTIL '${this.escapeStringValue(options.expirePassword)}'`);
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
return attrs;
|
|
4126
|
+
}
|
|
4127
|
+
/**
|
|
4128
|
+
* Build a CREATE USER statement.
|
|
4129
|
+
*
|
|
4130
|
+
* CockroachDB treats users as roles with LOGIN. Supported options:
|
|
4131
|
+
* ifNotExists, password, superuser, createdb, createrole, login,
|
|
4132
|
+
* replication, bypassrls, maxConnections, accountLocked, expirePassword,
|
|
4133
|
+
* defaultRole, requireSSL (noted but skipped — not a standard PG attribute).
|
|
4134
|
+
*/
|
|
4135
|
+
buildCreateUserQuery(username, options) {
|
|
4136
|
+
const opts = options || {};
|
|
4137
|
+
let sql = 'CREATE USER';
|
|
4138
|
+
if (opts.ifNotExists) {
|
|
4139
|
+
sql += ' IF NOT EXISTS';
|
|
4140
|
+
}
|
|
4141
|
+
sql += ` "${this.escapeStringValue(username)}"`;
|
|
4142
|
+
// requireSSL is not a standard CockroachDB role attribute; emit a comment.
|
|
4143
|
+
if (opts.requireSSL) {
|
|
4144
|
+
sql += ' /* requireSSL: use pg_hba.conf hostssl rules instead */';
|
|
4145
|
+
}
|
|
4146
|
+
const attrs = this.buildUserAttributes(opts);
|
|
4147
|
+
if (attrs.length > 0) {
|
|
4148
|
+
sql += ` ${attrs.join(' ')}`;
|
|
4149
|
+
}
|
|
4150
|
+
if (opts.defaultRole !== undefined) {
|
|
4151
|
+
const roles = Array.isArray(opts.defaultRole) ? opts.defaultRole : [opts.defaultRole];
|
|
4152
|
+
sql += ` IN ROLE ${roles.map((r) => `"${this.escapeStringValue(r)}"`).join(', ')}`;
|
|
4153
|
+
}
|
|
4154
|
+
return sql;
|
|
4155
|
+
}
|
|
4156
|
+
/**
|
|
4157
|
+
* Build an ALTER USER statement.
|
|
4158
|
+
*
|
|
4159
|
+
* Supports the same attribute options as buildCreateUserQuery plus
|
|
4160
|
+
* `options.renameTo` to rename the role.
|
|
4161
|
+
*/
|
|
4162
|
+
buildAlterUserQuery(username, options) {
|
|
4163
|
+
const opts = options || {};
|
|
4164
|
+
let sql = `ALTER USER "${this.escapeStringValue(username)}"`;
|
|
4165
|
+
if (opts.renameTo !== undefined) {
|
|
4166
|
+
sql += ` RENAME TO "${this.escapeStringValue(String(opts.renameTo))}"`;
|
|
4167
|
+
// RENAME TO cannot be combined with other clauses in the same statement.
|
|
4168
|
+
return sql;
|
|
4169
|
+
}
|
|
4170
|
+
const attrs = this.buildUserAttributes(opts);
|
|
4171
|
+
if (attrs.length > 0) {
|
|
4172
|
+
sql += ` ${attrs.join(' ')}`;
|
|
4173
|
+
}
|
|
4174
|
+
if (opts.defaultRole !== undefined) {
|
|
4175
|
+
const roles = Array.isArray(opts.defaultRole) ? opts.defaultRole : [opts.defaultRole];
|
|
4176
|
+
sql += ` IN ROLE ${roles.map((r) => `"${this.escapeStringValue(r)}"`).join(', ')}`;
|
|
4177
|
+
}
|
|
4178
|
+
return sql;
|
|
4179
|
+
}
|
|
4180
|
+
/**
|
|
4181
|
+
* Build a DROP USER statement.
|
|
4182
|
+
*
|
|
4183
|
+
* Options: ifExists, cascade, restrict.
|
|
4184
|
+
*/
|
|
4185
|
+
buildDropUserQuery(username, options) {
|
|
4186
|
+
const opts = options || {};
|
|
4187
|
+
let sql = 'DROP USER';
|
|
4188
|
+
if (opts.ifExists) {
|
|
4189
|
+
sql += ' IF EXISTS';
|
|
4190
|
+
}
|
|
4191
|
+
sql += ` "${this.escapeStringValue(username)}"`;
|
|
4192
|
+
if (opts.cascade) {
|
|
4193
|
+
sql += ' CASCADE';
|
|
4194
|
+
}
|
|
4195
|
+
else if (opts.restrict) {
|
|
4196
|
+
sql += ' RESTRICT';
|
|
4197
|
+
}
|
|
4198
|
+
return sql;
|
|
4199
|
+
}
|
|
4200
|
+
/**
|
|
4201
|
+
* Return a query that lists all CockroachDB users (roles with LOGIN).
|
|
4202
|
+
*/
|
|
4203
|
+
getUsersQuery() {
|
|
4204
|
+
return [
|
|
4205
|
+
'SELECT usename AS "user", \'\' AS "host",',
|
|
4206
|
+
' rolcanlogin AS "login", rolsuper AS "superuser",',
|
|
4207
|
+
' rolpassword IS NOT NULL AS "hasPassword"',
|
|
4208
|
+
'FROM pg_user',
|
|
4209
|
+
'JOIN pg_authid ON pg_user.usesysid = pg_authid.oid',
|
|
4210
|
+
'ORDER BY usename;',
|
|
4211
|
+
].join('\n');
|
|
4212
|
+
}
|
|
4213
|
+
/**
|
|
4214
|
+
* Create a database user
|
|
4215
|
+
*/
|
|
4216
|
+
async createUser(username, options) {
|
|
4217
|
+
const sql = this.buildCreateUserQuery(username, options);
|
|
4218
|
+
await this.query(sql);
|
|
4219
|
+
}
|
|
4220
|
+
/**
|
|
4221
|
+
* Map a generic privilege name to a CockroachDB privilege name.
|
|
4222
|
+
* Returns null when the privilege has no CockroachDB equivalent and
|
|
4223
|
+
* should be silently skipped.
|
|
4224
|
+
*/
|
|
4225
|
+
mapPrivilege(priv) {
|
|
4226
|
+
const upper = priv.toUpperCase();
|
|
4227
|
+
if (upper === 'ALL')
|
|
4228
|
+
return 'ALL PRIVILEGES';
|
|
4229
|
+
// MySQL-specific — not valid in CockroachDB
|
|
4230
|
+
if (upper === 'INDEX')
|
|
4231
|
+
return null;
|
|
4232
|
+
return priv;
|
|
4233
|
+
}
|
|
4234
|
+
/**
|
|
4235
|
+
* Build the ON <scope> clause for GRANT / REVOKE.
|
|
4236
|
+
*
|
|
4237
|
+
* Scope shapes:
|
|
4238
|
+
* { level: 'global' }
|
|
4239
|
+
* { level: 'database', database: 'dbname' }
|
|
4240
|
+
* { level: 'schema', schema: 'schemaname' }
|
|
4241
|
+
* { level: 'table', table: 'tablename' }
|
|
4242
|
+
* { level: 'column', table: 'tablename', columns: ['col1', 'col2'] }
|
|
4243
|
+
* { level: 'sequence', schema: 'schemaname' }
|
|
4244
|
+
*/
|
|
4245
|
+
buildGrantScope(options) {
|
|
4246
|
+
const level = (options.level || 'global').toLowerCase();
|
|
4247
|
+
switch (level) {
|
|
4248
|
+
case 'database':
|
|
4249
|
+
return `ON DATABASE "${this.escapeStringValue(options.database || '')}"`;
|
|
4250
|
+
case 'schema':
|
|
4251
|
+
return `ON SCHEMA "${this.escapeStringValue(options.schema || '')}"`;
|
|
4252
|
+
case 'table':
|
|
4253
|
+
return `ON TABLE "${this.escapeStringValue(options.table || '')}"`;
|
|
4254
|
+
case 'sequence':
|
|
4255
|
+
return `ON ALL SEQUENCES IN SCHEMA "${this.escapeStringValue(options.schema || 'public')}"`;
|
|
4256
|
+
case 'column':
|
|
4257
|
+
// Handled separately — the caller inserts column names into the
|
|
4258
|
+
// privilege list, not here.
|
|
4259
|
+
return `ON "${this.escapeStringValue(options.table || '')}"`;
|
|
4260
|
+
case 'global':
|
|
4261
|
+
default:
|
|
4262
|
+
return `ON ALL TABLES IN SCHEMA "${this.escapeStringValue(options.schema || 'public')}"`;
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
/**
|
|
4266
|
+
* Build a GRANT statement.
|
|
4267
|
+
*
|
|
4268
|
+
* options.privileges — string | string[] (e.g. ['SELECT', 'INSERT'])
|
|
4269
|
+
* options.on — scope object (see buildGrantScope)
|
|
4270
|
+
* options.to — string | string[] (grantees)
|
|
4271
|
+
* options.withGrantOption — boolean
|
|
4272
|
+
* options.asUser — string (GRANTED BY, PG 14+)
|
|
4273
|
+
*
|
|
4274
|
+
* For column-level grants (options.on.level === 'column') the columns are
|
|
4275
|
+
* embedded in each privilege token: SELECT (col1, col2).
|
|
4276
|
+
*/
|
|
4277
|
+
buildGrantQuery(options) {
|
|
4278
|
+
const rawPrivileges = Array.isArray(options.privileges)
|
|
4279
|
+
? options.privileges
|
|
4280
|
+
: [options.privileges || 'ALL'];
|
|
4281
|
+
const isColumnLevel = (options.on || {}).level === 'column';
|
|
4282
|
+
const columnList = isColumnLevel
|
|
4283
|
+
? (options.on.columns || []).map((c) => `"${this.escapeStringValue(c)}"`)
|
|
4284
|
+
: [];
|
|
4285
|
+
const mappedPrivileges = rawPrivileges
|
|
4286
|
+
.map((p) => this.mapPrivilege(p))
|
|
4287
|
+
.filter((p) => p !== null)
|
|
4288
|
+
.map((p) => isColumnLevel && columnList.length > 0 ? `${p} (${columnList.join(', ')})` : p);
|
|
4289
|
+
if (mappedPrivileges.length === 0) {
|
|
4290
|
+
return '-- No applicable privileges to grant';
|
|
4291
|
+
}
|
|
4292
|
+
const scope = this.buildGrantScope(options.on || {});
|
|
4293
|
+
const grantees = Array.isArray(options.to) ? options.to : [options.to || ''];
|
|
4294
|
+
let sql = `GRANT ${mappedPrivileges.join(', ')} ${scope} TO ${grantees.join(', ')}`;
|
|
4295
|
+
if (options.withGrantOption) {
|
|
4296
|
+
sql += ' WITH GRANT OPTION';
|
|
4297
|
+
}
|
|
4298
|
+
if (options.asUser !== undefined) {
|
|
4299
|
+
sql += ` GRANTED BY "${this.escapeStringValue(String(options.asUser))}"`;
|
|
4300
|
+
}
|
|
4301
|
+
return sql;
|
|
4302
|
+
}
|
|
4303
|
+
/**
|
|
4304
|
+
* Build a REVOKE statement.
|
|
4305
|
+
*
|
|
4306
|
+
* options.privileges — string | string[]
|
|
4307
|
+
* options.on — scope object (same as buildGrantQuery)
|
|
4308
|
+
* options.from — string | string[] (revokees)
|
|
4309
|
+
* options.grantOptionFor — boolean (revoke only the GRANT OPTION)
|
|
4310
|
+
* options.cascade — boolean
|
|
4311
|
+
* options.restrict — boolean
|
|
4312
|
+
*/
|
|
4313
|
+
buildRevokeQuery(options) {
|
|
4314
|
+
const rawPrivileges = Array.isArray(options.privileges)
|
|
4315
|
+
? options.privileges
|
|
4316
|
+
: [options.privileges || 'ALL'];
|
|
4317
|
+
const isColumnLevel = (options.on || {}).level === 'column';
|
|
4318
|
+
const columnList = isColumnLevel
|
|
4319
|
+
? (options.on.columns || []).map((c) => `"${this.escapeStringValue(c)}"`)
|
|
4320
|
+
: [];
|
|
4321
|
+
const mappedPrivileges = rawPrivileges
|
|
4322
|
+
.map((p) => this.mapPrivilege(p))
|
|
4323
|
+
.filter((p) => p !== null)
|
|
4324
|
+
.map((p) => isColumnLevel && columnList.length > 0 ? `${p} (${columnList.join(', ')})` : p);
|
|
4325
|
+
if (mappedPrivileges.length === 0) {
|
|
4326
|
+
return '-- No applicable privileges to revoke';
|
|
4327
|
+
}
|
|
4328
|
+
let sql = 'REVOKE';
|
|
4329
|
+
if (options.grantOptionFor) {
|
|
4330
|
+
sql += ' GRANT OPTION FOR';
|
|
4331
|
+
}
|
|
4332
|
+
const scope = this.buildGrantScope(options.on || {});
|
|
4333
|
+
const revokees = Array.isArray(options.from) ? options.from : [options.from || ''];
|
|
4334
|
+
sql += ` ${mappedPrivileges.join(', ')} ${scope} FROM ${revokees.join(', ')}`;
|
|
4335
|
+
if (options.cascade) {
|
|
4336
|
+
sql += ' CASCADE';
|
|
4337
|
+
}
|
|
4338
|
+
else if (options.restrict) {
|
|
4339
|
+
sql += ' RESTRICT';
|
|
4340
|
+
}
|
|
4341
|
+
return sql;
|
|
4342
|
+
}
|
|
4343
|
+
/**
|
|
4344
|
+
* Return a query that shows the privileges granted to a specific user/role.
|
|
4345
|
+
* The optional `host` parameter is ignored — CockroachDB has no host concept.
|
|
4346
|
+
*/
|
|
4347
|
+
buildShowGrantsQuery(username, _host) {
|
|
4348
|
+
const escaped = this.escapeStringValue(username);
|
|
4349
|
+
return [
|
|
4350
|
+
`SELECT grantee, privilege_type, table_schema, table_name, column_name, is_grantable`,
|
|
4351
|
+
`FROM information_schema.role_column_grants`,
|
|
4352
|
+
`WHERE grantee = '${escaped}'`,
|
|
4353
|
+
`UNION ALL`,
|
|
4354
|
+
`SELECT grantee, privilege_type, table_schema, table_name, NULL AS column_name, is_grantable`,
|
|
4355
|
+
`FROM information_schema.role_table_grants`,
|
|
4356
|
+
`WHERE grantee = '${escaped}'`,
|
|
4357
|
+
`ORDER BY table_schema, table_name, privilege_type;`,
|
|
4358
|
+
].join('\n');
|
|
4359
|
+
}
|
|
4360
|
+
/**
|
|
4361
|
+
* CockroachDB applies privilege changes automatically — FLUSH PRIVILEGES is
|
|
4362
|
+
* a MySQL concept. Return a no-op with an explanatory comment.
|
|
4363
|
+
*/
|
|
4364
|
+
buildFlushPrivilegesQuery() {
|
|
4365
|
+
return ('-- CockroachDB automatically applies privilege changes; FLUSH PRIVILEGES is not required\n' +
|
|
4366
|
+
'SELECT 1;');
|
|
4367
|
+
}
|
|
4368
|
+
/**
|
|
4369
|
+
* Build a CREATE ROLE statement.
|
|
4370
|
+
*
|
|
4371
|
+
* Unlike CREATE USER, NOLOGIN is the default for roles. Pass
|
|
4372
|
+
* `options.login = true` to enable login.
|
|
4373
|
+
*
|
|
4374
|
+
* Supported options: ifNotExists, login, password, superuser, createdb,
|
|
4375
|
+
* createrole, inherit, replication, bypassrls, maxConnections,
|
|
4376
|
+
* expirePassword, accountLocked.
|
|
4377
|
+
*/
|
|
4378
|
+
buildCreateRoleQuery(roleName, options) {
|
|
4379
|
+
const opts = options || {};
|
|
4380
|
+
let sql = 'CREATE ROLE';
|
|
4381
|
+
if (opts.ifNotExists) {
|
|
4382
|
+
sql += ' IF NOT EXISTS';
|
|
4383
|
+
}
|
|
4384
|
+
sql += ` "${this.escapeStringValue(roleName)}"`;
|
|
4385
|
+
const attrs = [];
|
|
4386
|
+
if (opts.login === true) {
|
|
4387
|
+
attrs.push('LOGIN');
|
|
4388
|
+
}
|
|
4389
|
+
else if (opts.login === false || opts.accountLocked === true) {
|
|
4390
|
+
attrs.push('NOLOGIN');
|
|
4391
|
+
}
|
|
4392
|
+
// No default LOGIN for roles (PG default is already NOLOGIN).
|
|
4393
|
+
if (opts.password !== undefined) {
|
|
4394
|
+
attrs.push(`WITH PASSWORD '${this.escapeStringValue(String(opts.password))}'`);
|
|
4395
|
+
}
|
|
4396
|
+
if (opts.superuser === true) {
|
|
4397
|
+
attrs.push('SUPERUSER');
|
|
4398
|
+
}
|
|
4399
|
+
else if (opts.superuser === false) {
|
|
4400
|
+
attrs.push('NOSUPERUSER');
|
|
4401
|
+
}
|
|
4402
|
+
if (opts.createdb === true) {
|
|
4403
|
+
attrs.push('CREATEDB');
|
|
4404
|
+
}
|
|
4405
|
+
else if (opts.createdb === false) {
|
|
4406
|
+
attrs.push('NOCREATEDB');
|
|
4407
|
+
}
|
|
4408
|
+
if (opts.createrole === true) {
|
|
4409
|
+
attrs.push('CREATEROLE');
|
|
4410
|
+
}
|
|
4411
|
+
else if (opts.createrole === false) {
|
|
4412
|
+
attrs.push('NOCREATEROLE');
|
|
4413
|
+
}
|
|
4414
|
+
if (opts.inherit === false) {
|
|
4415
|
+
attrs.push('NOINHERIT');
|
|
4416
|
+
}
|
|
4417
|
+
else if (opts.inherit === true) {
|
|
4418
|
+
attrs.push('INHERIT');
|
|
4419
|
+
}
|
|
4420
|
+
if (opts.replication === true) {
|
|
4421
|
+
attrs.push('REPLICATION');
|
|
4422
|
+
}
|
|
4423
|
+
else if (opts.replication === false) {
|
|
4424
|
+
attrs.push('NOREPLICATION');
|
|
4425
|
+
}
|
|
4426
|
+
if (opts.bypassrls === true) {
|
|
4427
|
+
attrs.push('BYPASSRLS');
|
|
4428
|
+
}
|
|
4429
|
+
else if (opts.bypassrls === false) {
|
|
4430
|
+
attrs.push('NOBYPASSRLS');
|
|
4431
|
+
}
|
|
4432
|
+
if (opts.maxConnections !== undefined) {
|
|
4433
|
+
attrs.push(`CONNECTION LIMIT ${Number(opts.maxConnections)}`);
|
|
4434
|
+
}
|
|
4435
|
+
if (opts.expirePassword !== undefined) {
|
|
4436
|
+
if (opts.expirePassword === true) {
|
|
4437
|
+
attrs.push(`VALID UNTIL 'epoch'`);
|
|
4438
|
+
}
|
|
4439
|
+
else if (opts.expirePassword instanceof Date) {
|
|
4440
|
+
attrs.push(`VALID UNTIL '${this.escapeStringValue(opts.expirePassword.toISOString())}'`);
|
|
4441
|
+
}
|
|
4442
|
+
else if (typeof opts.expirePassword === 'string') {
|
|
4443
|
+
attrs.push(`VALID UNTIL '${this.escapeStringValue(opts.expirePassword)}'`);
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
if (attrs.length > 0) {
|
|
4447
|
+
sql += ` ${attrs.join(' ')}`;
|
|
4448
|
+
}
|
|
4449
|
+
return sql;
|
|
4450
|
+
}
|
|
4451
|
+
/**
|
|
4452
|
+
* Build a DROP ROLE statement.
|
|
4453
|
+
*
|
|
4454
|
+
* Options: ifExists, cascade.
|
|
4455
|
+
*/
|
|
4456
|
+
buildDropRoleQuery(roleName, options) {
|
|
4457
|
+
const opts = options || {};
|
|
4458
|
+
let sql = 'DROP ROLE';
|
|
4459
|
+
if (opts.ifExists) {
|
|
4460
|
+
sql += ' IF EXISTS';
|
|
4461
|
+
}
|
|
4462
|
+
sql += ` "${this.escapeStringValue(roleName)}"`;
|
|
4463
|
+
if (opts.cascade) {
|
|
4464
|
+
sql += ' CASCADE';
|
|
4465
|
+
}
|
|
4466
|
+
return sql;
|
|
4467
|
+
}
|
|
4468
|
+
/**
|
|
4469
|
+
* Build a GRANT <role> TO <members> statement.
|
|
4470
|
+
*
|
|
4471
|
+
* Options: withAdminOption.
|
|
4472
|
+
*/
|
|
4473
|
+
buildGrantRoleQuery(role, to, options) {
|
|
4474
|
+
const opts = options || {};
|
|
4475
|
+
const members = Array.isArray(to) ? to : [to];
|
|
4476
|
+
let sql = `GRANT "${this.escapeStringValue(role)}" TO ${members.join(', ')}`;
|
|
4477
|
+
if (opts.withAdminOption) {
|
|
4478
|
+
sql += ' WITH ADMIN OPTION';
|
|
4479
|
+
}
|
|
4480
|
+
return sql;
|
|
4481
|
+
}
|
|
4482
|
+
/**
|
|
4483
|
+
* Build a REVOKE <role> FROM <members> statement.
|
|
4484
|
+
*
|
|
4485
|
+
* Options: adminOptionFor, cascade.
|
|
4486
|
+
*/
|
|
4487
|
+
buildRevokeRoleQuery(role, from, options) {
|
|
4488
|
+
const opts = options || {};
|
|
4489
|
+
let sql = 'REVOKE';
|
|
4490
|
+
if (opts.adminOptionFor) {
|
|
4491
|
+
sql += ' ADMIN OPTION FOR';
|
|
4492
|
+
}
|
|
4493
|
+
const members = Array.isArray(from) ? from : [from];
|
|
4494
|
+
sql += ` "${this.escapeStringValue(role)}" FROM ${members.join(', ')}`;
|
|
4495
|
+
if (opts.cascade) {
|
|
4496
|
+
sql += ' CASCADE';
|
|
4497
|
+
}
|
|
4498
|
+
return sql;
|
|
4499
|
+
}
|
|
4500
|
+
/**
|
|
4501
|
+
* Return a query that lists all CockroachDB roles that cannot log in
|
|
4502
|
+
* (i.e. "group roles" as opposed to user roles).
|
|
4503
|
+
*/
|
|
4504
|
+
getRolesQuery() {
|
|
4505
|
+
return `SELECT rolname AS "rolname" FROM pg_roles WHERE rolcanlogin = false ORDER BY rolname;`;
|
|
4506
|
+
}
|
|
4507
|
+
/**
|
|
4508
|
+
* Generate SQL for creating a CockroachDB database
|
|
4509
|
+
*/
|
|
4510
|
+
createDatabaseSQL(options) {
|
|
4511
|
+
const parts = [];
|
|
4512
|
+
parts.push(`CREATE DATABASE ${this.quoteIdentifier(options.name)}`);
|
|
4513
|
+
if (options.encoding) {
|
|
4514
|
+
parts.push(`ENCODING = '${options.encoding}'`);
|
|
4515
|
+
}
|
|
4516
|
+
if (options.lcCollate) {
|
|
4517
|
+
parts.push(`LC_COLLATE = '${options.lcCollate}'`);
|
|
4518
|
+
}
|
|
4519
|
+
if (options.lcCtype) {
|
|
4520
|
+
parts.push(`LC_CTYPE = '${options.lcCtype}'`);
|
|
4521
|
+
}
|
|
4522
|
+
if (options.template) {
|
|
4523
|
+
parts.push(`TEMPLATE = ${this.quoteIdentifier(options.template)}`);
|
|
4524
|
+
}
|
|
4525
|
+
if (options.tablespace) {
|
|
4526
|
+
parts.push(`TABLESPACE = ${this.quoteIdentifier(options.tablespace)}`);
|
|
4527
|
+
}
|
|
4528
|
+
if (options.isTemplate !== undefined) {
|
|
4529
|
+
parts.push(options.isTemplate ? 'IS_TEMPLATE = true' : 'IS_TEMPLATE = false');
|
|
4530
|
+
}
|
|
4531
|
+
return parts.join(' ');
|
|
4532
|
+
}
|
|
4533
|
+
/**
|
|
4534
|
+
* Generate SQL for dropping a CockroachDB database
|
|
4535
|
+
*/
|
|
4536
|
+
dropDatabaseSQL(name) {
|
|
4537
|
+
return `DROP DATABASE IF EXISTS ${this.quoteIdentifier(name)}`;
|
|
4538
|
+
}
|
|
4539
|
+
/**
|
|
4540
|
+
* Generate SQL for creating a savepoint
|
|
4541
|
+
*/
|
|
4542
|
+
createSavepointSQL(name) {
|
|
4543
|
+
const savepointName = name || `sp_${Date.now()}`;
|
|
4544
|
+
return `SAVEPOINT ${savepointName}`;
|
|
4545
|
+
}
|
|
4546
|
+
/**
|
|
4547
|
+
* Generate SQL for releasing a savepoint
|
|
4548
|
+
*/
|
|
4549
|
+
releaseSavepointSQL(name) {
|
|
4550
|
+
return `RELEASE SAVEPOINT ${name}`;
|
|
4551
|
+
}
|
|
4552
|
+
/**
|
|
4553
|
+
* Generate SQL for rolling back to a savepoint
|
|
4554
|
+
*/
|
|
4555
|
+
rollbackToSavepointSQL(name) {
|
|
4556
|
+
return `ROLLBACK TO SAVEPOINT ${name}`;
|
|
4557
|
+
}
|
|
4558
|
+
/**
|
|
4559
|
+
* Build a JSON_TABLE-equivalent expression to shred a JSON array into relational rows.
|
|
4560
|
+
* Not supported: CockroachDB implements a subset of Postgres's JSONB function surface
|
|
4561
|
+
* and has neither a native `JSON_TABLE` (PG17+) nor `json_to_recordset`/
|
|
4562
|
+
* `jsonb_to_recordset` (verified absent from CockroachDB's function reference as of
|
|
4563
|
+
* v23.x/v24.x). There is no row-shredding equivalent to fall back to.
|
|
4564
|
+
*/
|
|
4565
|
+
buildJsonTable(_jsonExpression, _rowPath, _columns, _alias) {
|
|
4566
|
+
throw new Error('CockroachDB does not support JSON_TABLE, OPENJSON, or json_to_recordset/jsonb_to_recordset; ' +
|
|
4567
|
+
'there is no supported way to shred a JSON array into relational rows');
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
exports.CockroachDBDialect = CockroachDBDialect;
|
|
4571
|
+
/**
|
|
4572
|
+
* CockroachDB has no Foreign Data Wrapper (FDW) machinery: no
|
|
4573
|
+
* `CREATE FOREIGN DATA WRAPPER`/`CREATE SERVER`/`CREATE FOREIGN TABLE`/
|
|
4574
|
+
* `CREATE USER MAPPING`/`IMPORT FOREIGN SCHEMA` statements, and no
|
|
4575
|
+
* `pg_foreign_data_wrapper`/`pg_foreign_server` catalogs. These methods
|
|
4576
|
+
* exist only to satisfy the shared `Dialect` interface (which the
|
|
4577
|
+
* Postgres dialect implements for real); rather than silently emitting
|
|
4578
|
+
* Postgres-only DDL that would fail against a real CockroachDB cluster,
|
|
4579
|
+
* they throw a clear "not supported" error. External-data access on
|
|
4580
|
+
* CockroachDB instead goes through `IMPORT`/`EXPORT` or
|
|
4581
|
+
* `CREATE EXTERNAL CONNECTION`, which are out of scope for this ORM's
|
|
4582
|
+
* FDW-shaped API.
|
|
4583
|
+
* @see https://www.cockroachlabs.com/docs/stable/create-external-connection
|
|
4584
|
+
*/
|
|
4585
|
+
CockroachDBDialect.FDW_NOT_SUPPORTED_MESSAGE = 'Foreign Data Wrappers are not supported in CockroachDB (no CREATE FOREIGN DATA WRAPPER/CREATE SERVER/CREATE FOREIGN TABLE support). ' +
|
|
4586
|
+
'Use IMPORT/EXPORT or CREATE EXTERNAL CONNECTION for external data access instead.';
|
|
4587
|
+
/**
|
|
4588
|
+
* CockroachDB does NOT implement PostgreSQL's extension mechanism the way
|
|
4589
|
+
* Postgres does. `CREATE EXTENSION`/`DROP EXTENSION` are accepted by CRDB
|
|
4590
|
+
* purely as no-op compatibility shims for a small, fixed allowlist of
|
|
4591
|
+
* extension names (kept in sync with CockroachDB's documented "Supported
|
|
4592
|
+
* extensions" list) so that ORMs/tools which unconditionally issue
|
|
4593
|
+
* `CREATE EXTENSION IF NOT EXISTS <name>` don't fail outright. No actual
|
|
4594
|
+
* extension code is loaded and most Postgres extensions (arbitrary compiled
|
|
4595
|
+
* extensions, most contrib modules, etc.) are NOT usable at all. Callers
|
|
4596
|
+
* asking for anything outside this allowlist get a clear error instead of
|
|
4597
|
+
* a misleading "success" that doesn't provide any real functionality.
|
|
4598
|
+
*/
|
|
4599
|
+
CockroachDBDialect.SUPPORTED_EXTENSION_SHIMS = new Set([
|
|
4600
|
+
'citext',
|
|
4601
|
+
'plpgsql',
|
|
4602
|
+
'pg_trgm',
|
|
4603
|
+
'fuzzystrmatch',
|
|
4604
|
+
'hstore',
|
|
4605
|
+
'pgcrypto',
|
|
4606
|
+
'uuid-ossp',
|
|
4607
|
+
'btree_gin',
|
|
4608
|
+
'btree_gist',
|
|
4609
|
+
'cube',
|
|
4610
|
+
'intarray',
|
|
4611
|
+
'ltree',
|
|
4612
|
+
'pg_stat_statements',
|
|
4613
|
+
'vector',
|
|
4614
|
+
]);
|
|
4615
|
+
/**
|
|
4616
|
+
* CockroachDB-specific transaction class with isolation level support and savepoints
|
|
4617
|
+
*/
|
|
4618
|
+
class CockroachDBTransaction {
|
|
4619
|
+
constructor(client, depth, options) {
|
|
4620
|
+
this.finished = false;
|
|
4621
|
+
this.parent = null;
|
|
4622
|
+
this.savepoints = [];
|
|
4623
|
+
this.client = null;
|
|
4624
|
+
this.savepointCount = 0;
|
|
4625
|
+
this.id = `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
4626
|
+
this.options = options || {};
|
|
4627
|
+
this.client = client;
|
|
4628
|
+
}
|
|
4629
|
+
/**
|
|
4630
|
+
* Create a savepoint
|
|
4631
|
+
*/
|
|
4632
|
+
async createSavepoint(name) {
|
|
4633
|
+
const savepointName = name || `sp_${this.savepointCount++}`;
|
|
4634
|
+
if (this.client) {
|
|
4635
|
+
await this.client.query(`SAVEPOINT ${savepointName}`);
|
|
4636
|
+
this.savepoints.push(savepointName);
|
|
4637
|
+
}
|
|
4638
|
+
return savepointName;
|
|
4639
|
+
}
|
|
4640
|
+
/**
|
|
4641
|
+
* Rollback to a savepoint
|
|
4642
|
+
*/
|
|
4643
|
+
async rollbackToSavepoint(name) {
|
|
4644
|
+
if (this.client) {
|
|
4645
|
+
await this.client.query(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
/**
|
|
4649
|
+
* Release a savepoint
|
|
4650
|
+
*/
|
|
4651
|
+
async releaseSavepoint(name) {
|
|
4652
|
+
if (this.client) {
|
|
4653
|
+
await this.client.query(`RELEASE SAVEPOINT ${name}`);
|
|
4654
|
+
this.savepoints = this.savepoints.filter((sp) => sp !== name);
|
|
4655
|
+
}
|
|
4656
|
+
}
|
|
4657
|
+
async commit() {
|
|
4658
|
+
if (this.client) {
|
|
4659
|
+
await this.client.query('COMMIT');
|
|
4660
|
+
}
|
|
4661
|
+
this.finished = true;
|
|
4662
|
+
}
|
|
4663
|
+
async rollback() {
|
|
4664
|
+
if (this.client) {
|
|
4665
|
+
await this.client.query('ROLLBACK');
|
|
4666
|
+
}
|
|
4667
|
+
this.finished = true;
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
exports.CockroachDBTransaction = CockroachDBTransaction;
|
|
4671
|
+
/**
|
|
4672
|
+
* Create a new CockroachDB dialect instance
|
|
4673
|
+
*/
|
|
4674
|
+
function createCockroachDBDialect(options) {
|
|
4675
|
+
return new CockroachDBDialect(options || {});
|
|
4676
|
+
}
|
|
4677
|
+
exports.default = CockroachDBDialect;
|