@quereus/store 4.3.1 → 4.3.2
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/README.md +486 -436
- package/dist/src/common/bytes.d.ts +4 -1
- package/dist/src/common/bytes.d.ts.map +1 -1
- package/dist/src/common/bytes.js +23 -2
- package/dist/src/common/bytes.js.map +1 -1
- package/dist/src/common/cached-kv-store.d.ts.map +1 -1
- package/dist/src/common/cached-kv-store.js +6 -13
- package/dist/src/common/cached-kv-store.js.map +1 -1
- package/dist/src/common/encoding.d.ts +69 -26
- package/dist/src/common/encoding.d.ts.map +1 -1
- package/dist/src/common/encoding.js +292 -230
- package/dist/src/common/encoding.js.map +1 -1
- package/dist/src/common/index.d.ts +1 -1
- package/dist/src/common/index.d.ts.map +1 -1
- package/dist/src/common/index.js +1 -1
- package/dist/src/common/index.js.map +1 -1
- package/dist/src/common/key-builder.d.ts +5 -1
- package/dist/src/common/key-builder.d.ts.map +1 -1
- package/dist/src/common/key-builder.js +30 -2
- package/dist/src/common/key-builder.js.map +1 -1
- package/dist/src/common/kv-store.d.ts +1 -1
- package/dist/src/common/memory-store.d.ts.map +1 -1
- package/dist/src/common/memory-store.js +21 -6
- package/dist/src/common/memory-store.js.map +1 -1
- package/dist/src/common/serialization.d.ts.map +1 -1
- package/dist/src/common/serialization.js +6 -3
- package/dist/src/common/serialization.js.map +1 -1
- package/dist/src/common/store-module.d.ts +293 -26
- package/dist/src/common/store-module.d.ts.map +1 -1
- package/dist/src/common/store-module.js +1543 -612
- package/dist/src/common/store-module.js.map +1 -1
- package/dist/src/common/store-table.d.ts +515 -36
- package/dist/src/common/store-table.d.ts.map +1 -1
- package/dist/src/common/store-table.js +873 -100
- package/dist/src/common/store-table.js.map +1 -1
- package/dist/src/common/transaction.d.ts +8 -5
- package/dist/src/common/transaction.d.ts.map +1 -1
- package/dist/src/common/transaction.js +32 -5
- package/dist/src/common/transaction.js.map +1 -1
- package/dist/src/testing/kv-conformance.d.ts +47 -0
- package/dist/src/testing/kv-conformance.d.ts.map +1 -0
- package/dist/src/testing/kv-conformance.js +418 -0
- package/dist/src/testing/kv-conformance.js.map +1 -0
- package/package.json +17 -5
|
@@ -12,13 +12,107 @@
|
|
|
12
12
|
* - {prefix}.__stats__ - Unified stats store (row counts for all tables)
|
|
13
13
|
* - Catalog store: __catalog__ - DDL metadata keyed by {schema}.{table}
|
|
14
14
|
*/
|
|
15
|
-
import { AccessPlanBuilder, QuereusError, StatusCode, buildColumnIndexMap, columnDefToSchema, compilePredicate, inferType, tryFoldLiteral, validateAndParse, buildAdvertisementsFromTags, resolveNamedConstraintClass, validateCollationForType, buildUniqueConstraintSchema, buildForeignKeyConstraintSchema, buildCheckConstraintSchema, validateForeignKeyOverExistingRows, extractColumnLevelCheckConstraints, extractColumnLevelForeignKeys, appendIndexToTableSchema,
|
|
15
|
+
import { AccessPlanBuilder, QuereusError, StatusCode, buildColumnIndexMap, columnDefToSchema, compilePredicate, inferType, tryFoldLiteral, validateAndParse, buildAdvertisementsFromTags, resolveNamedConstraintClass, validateCollationForType, buildUniqueConstraintSchema, buildForeignKeyConstraintSchema, buildCheckConstraintSchema, validateForeignKeyOverExistingRows, extractColumnLevelCheckConstraints, extractColumnLevelForeignKeys, appendIndexToTableSchema, logicalTypeCanHoldText, serializeRowKey, isMaintainedTable, renameColumnInIndexPredicates, renameTableInIndexPredicates, renameColumnInCheckConstraints, renameTableInCheckConstraints } from '@quereus/quereus';
|
|
16
16
|
import { TransactionCoordinator } from './transaction.js';
|
|
17
|
+
import { StoreConnection } from './store-connection.js';
|
|
17
18
|
import { StoreBackingHost } from './backing-host.js';
|
|
18
|
-
import { StoreTable, resolvePkKeyCollations } from './store-table.js';
|
|
19
|
-
import { buildCatalogKey, buildCatalogScanBounds, buildDataStoreName, buildIndexKey, buildIndexStoreName, buildFullScanBounds, buildStatsKey, buildViewCatalogKey, buildMaterializedViewCatalogKey, parseMaterializedViewCatalogKey, buildMetaCatalogKey, CLEAN_SHUTDOWN_META_NAME, STALE_MVS_META_NAME, classifyCatalogKey, } from './key-builder.js';
|
|
19
|
+
import { StoreTable, resolvePkKeyCollations, columnCanHoldText, keyOrderMatchesCollation, pkOrderPreservingPrefixLength, withImplicitUniqueIndexes, implicitUniqueIndexName } from './store-table.js';
|
|
20
|
+
import { buildCatalogKey, buildCatalogScanBounds, buildDataKey, buildDataStoreName, buildIndexKey, buildIndexStoreName, buildFullScanBounds, buildStatsKey, buildViewCatalogKey, buildMaterializedViewCatalogKey, parseMaterializedViewCatalogKey, buildMetaCatalogKey, CLEAN_SHUTDOWN_META_NAME, STALE_MVS_META_NAME, classifyCatalogKey, } from './key-builder.js';
|
|
21
|
+
import { assertNoUnpairedSurrogate } from './encoding.js';
|
|
20
22
|
import { deserializeRow } from './serialization.js';
|
|
21
23
|
import { generateTableDDL, generateIndexDDL, generateViewDDL, generateMaintainedTableDDL, generateIndexTagsDDL, isHiddenImplicitIndex, exposedImplicitIndexes } from '@quereus/quereus';
|
|
24
|
+
/**
|
|
25
|
+
* Default serialized-byte budget for a single index-build write batch. Bounds
|
|
26
|
+
* heap directly: {@link StoreModule.buildIndexEntries} flushes and starts a fresh
|
|
27
|
+
* batch whenever the accumulated key bytes cross this, so a table larger than
|
|
28
|
+
* memory never buffers its whole index in one batch. A byte budget (rather than a
|
|
29
|
+
* fixed entry count) is used because index entries vary in size, so bytes bound
|
|
30
|
+
* memory more tightly. 8 MiB trades a modest number of extra flushes against peak
|
|
31
|
+
* memory; override per store with `using store (max_batch_bytes = …)`.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_MAX_BATCH_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
34
|
+
/**
|
|
35
|
+
* Map of `lowercased implicit index name → actual name` for every NON-DERIVED
|
|
36
|
+
* UNIQUE constraint of `schema` — the set of `_uc_*` stores that SHOULD exist for
|
|
37
|
+
* that constraint set. Derived from `uniqueConstraints` (not `.indexes`), so it is
|
|
38
|
+
* correct whether or not `schema` is materialized; the diff of two such maps drives
|
|
39
|
+
* {@link StoreModule.reconcileImplicitUniqueIndexStores}.
|
|
40
|
+
*/
|
|
41
|
+
function implicitUniqueIndexNameMap(schema) {
|
|
42
|
+
const map = new Map();
|
|
43
|
+
for (const uc of schema.uniqueConstraints ?? []) {
|
|
44
|
+
if (uc.derivedFromIndex)
|
|
45
|
+
continue;
|
|
46
|
+
const name = implicitUniqueIndexName(schema, uc);
|
|
47
|
+
map.set(name.toLowerCase(), name);
|
|
48
|
+
}
|
|
49
|
+
return map;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the index-build batch byte budget from a `max_batch_bytes` module arg.
|
|
53
|
+
* A missing, non-numeric, or non-positive value falls back to
|
|
54
|
+
* {@link DEFAULT_MAX_BATCH_BYTES} — a zero or negative budget must NEVER disable
|
|
55
|
+
* flushing, which would restore the unbounded single-batch behavior this guards
|
|
56
|
+
* against.
|
|
57
|
+
*/
|
|
58
|
+
function resolveMaxBatchBytes(raw) {
|
|
59
|
+
let n;
|
|
60
|
+
if (typeof raw === 'number')
|
|
61
|
+
n = raw;
|
|
62
|
+
else if (typeof raw === 'bigint')
|
|
63
|
+
n = Number(raw);
|
|
64
|
+
else if (typeof raw === 'string' && raw.trim() !== '')
|
|
65
|
+
n = Number(raw);
|
|
66
|
+
else
|
|
67
|
+
return DEFAULT_MAX_BATCH_BYTES;
|
|
68
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
69
|
+
return DEFAULT_MAX_BATCH_BYTES;
|
|
70
|
+
return Math.floor(n);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Planner-side constraint operator groups, as `BestAccessPlanRequest.filters` spells
|
|
74
|
+
* them. Kept as one source of truth for the access-plan code below: `getBestAccessPlan`
|
|
75
|
+
* classifies each pushed filter with these, and `tryIndexAccessPlan` claims a filter as
|
|
76
|
+
* handled only when it falls in the group the engine's access-path rule will consume.
|
|
77
|
+
*/
|
|
78
|
+
const EQ_OPS = ['='];
|
|
79
|
+
const LOWER_BOUND_OPS = ['>', '>='];
|
|
80
|
+
const UPPER_BOUND_OPS = ['<', '<='];
|
|
81
|
+
const RANGE_OPS = [...LOWER_BOUND_OPS, ...UPPER_BOUND_OPS];
|
|
82
|
+
/**
|
|
83
|
+
* Build `handledFilters` by claiming ONLY the constraints `rule-select-access-path`
|
|
84
|
+
* actually consumes: per seek column the FIRST '=' (equality seek), or the FIRST lower
|
|
85
|
+
* ('>'/'>=') plus the FIRST upper ('<'/'<=') bound, selected by `colConstraints.find(...)`
|
|
86
|
+
* in `request.filters` order.
|
|
87
|
+
*
|
|
88
|
+
* That rule collapses `handledFilters` into a per-COLUMN set, so a redundant same-column,
|
|
89
|
+
* same-role constraint marked handled is neither turned into a seek bound nor kept by
|
|
90
|
+
* `ruleGrowRetrieve` as a residual — its predicate would be LOST (`where v > 10 and v > 30`
|
|
91
|
+
* would wrongly return the `v > 10` rows; `where v = 20 and v = 30` would wrongly return
|
|
92
|
+
* the `v = 20` row). The engine now reattaches such orphans defensively, but a module that
|
|
93
|
+
* over-claims still pays a redundant predicate evaluation per fetched row.
|
|
94
|
+
*
|
|
95
|
+
* Claiming must therefore be POSITIONAL and match the rule's first-match pick: claiming the
|
|
96
|
+
* tighter-but-later duplicate instead would be actively wrong (the rule would still seek on
|
|
97
|
+
* the earlier one). Any later duplicate stays unhandled so it survives in the residual Filter.
|
|
98
|
+
*/
|
|
99
|
+
function claimFirstPerRole(filters, roles) {
|
|
100
|
+
const claimed = new Set();
|
|
101
|
+
for (const { colIdx, ops } of roles) {
|
|
102
|
+
const i = filters.findIndex(f => f.columnIndex === colIdx && ops.includes(f.op));
|
|
103
|
+
if (i >= 0)
|
|
104
|
+
claimed.add(i);
|
|
105
|
+
}
|
|
106
|
+
return filters.map((_f, i) => claimed.has(i));
|
|
107
|
+
}
|
|
108
|
+
/** The lower-bound + upper-bound roles a single-column range seek on `colIdx` fills. */
|
|
109
|
+
function rangeRoles(colIdx) {
|
|
110
|
+
return [{ colIdx, ops: LOWER_BOUND_OPS }, { colIdx, ops: UPPER_BOUND_OPS }];
|
|
111
|
+
}
|
|
112
|
+
/** The one equality role each seek column of an equality/prefix seek fills. */
|
|
113
|
+
function equalityRoles(colIdxs) {
|
|
114
|
+
return colIdxs.map(colIdx => ({ colIdx, ops: EQ_OPS }));
|
|
115
|
+
}
|
|
22
116
|
/**
|
|
23
117
|
* Generic store module that works with any KVStoreProvider.
|
|
24
118
|
*
|
|
@@ -124,6 +218,13 @@ export class StoreModule {
|
|
|
124
218
|
persistent: true,
|
|
125
219
|
secondaryIndexes: true,
|
|
126
220
|
rangeScans: true,
|
|
221
|
+
// Worst-case summary across this module's DDL. The row-rewriting ALTER arms
|
|
222
|
+
// and renameTable call ddlCommitPendingOps(), which commits the WHOLE module
|
|
223
|
+
// transaction (schema change + every buffered write) at DDL time — a later
|
|
224
|
+
// rollback undoes nothing. Non-committing DDL (create index, add constraint,
|
|
225
|
+
// schema-only arms) is merely non-transactional, but the declared value is
|
|
226
|
+
// the worst case. See docs/store.md § "DDL that implicitly commits".
|
|
227
|
+
ddlTransactionality: 'auto-commit',
|
|
127
228
|
};
|
|
128
229
|
}
|
|
129
230
|
/**
|
|
@@ -383,6 +484,13 @@ export class StoreModule {
|
|
|
383
484
|
const vtabArgs = {};
|
|
384
485
|
if (options?.collation !== undefined)
|
|
385
486
|
vtabArgs.collation = options.collation;
|
|
487
|
+
// `options` is the raw DDL arg record (snake_case keys), so read `max_batch_bytes`,
|
|
488
|
+
// not the parsed `maxBatchBytes` field — the latter is never populated here, which
|
|
489
|
+
// silently dropped a configured budget across reopen. `?? maxBatchBytes` still honors
|
|
490
|
+
// a caller that hands in an already-parsed config.
|
|
491
|
+
const rawMaxBatchBytes = options?.max_batch_bytes ?? options?.maxBatchBytes;
|
|
492
|
+
if (rawMaxBatchBytes !== undefined)
|
|
493
|
+
vtabArgs.max_batch_bytes = rawMaxBatchBytes;
|
|
386
494
|
// Resolve the table schema:
|
|
387
495
|
// 1. Use importedTableSchema if provided (from catalog import or runtime)
|
|
388
496
|
// 2. Look up from schemaManager (most common case during runtime queries)
|
|
@@ -449,7 +557,9 @@ export class StoreModule {
|
|
|
449
557
|
// no-sibling case and strictly safer for the sibling case.
|
|
450
558
|
const table = this.tables.get(tableKey);
|
|
451
559
|
const currentSchema = table?.getSchema() ?? db.schemaManager.getTable(schemaName, tableName);
|
|
452
|
-
|
|
560
|
+
// Materialized index list, so the hidden `_uc_*` stores backing plain UNIQUEs are
|
|
561
|
+
// torn down too — the engine-facing `.indexes` omits them and would leak them.
|
|
562
|
+
const indexNames = this.materializedIndexNames(table, currentSchema);
|
|
453
563
|
await this.tearDownTableStorage(schemaName, tableName, indexNames);
|
|
454
564
|
// Emit schema change event for table drop
|
|
455
565
|
this.eventEmitter?.emitSchemaChange({
|
|
@@ -546,9 +656,17 @@ export class StoreModule {
|
|
|
546
656
|
}
|
|
547
657
|
/**
|
|
548
658
|
* Creates an index on a store-backed table.
|
|
659
|
+
*
|
|
660
|
+
* `rows` — an {@link EffectiveRowSource} supplied by a wrapper module (the isolation
|
|
661
|
+
* layer) — replaces this module's own rows as the set the UNIQUE duplicate check judges.
|
|
662
|
+
* When present the check runs UP FRONT, before `getIndexStore` opens the index-store
|
|
663
|
+
* directory, so a rejection leaves no directory behind; and `buildIndexEntries` then
|
|
664
|
+
* populates the physical index from this module's own (committed) rows with its in-pass
|
|
665
|
+
* dup check disabled. Those two row sets legitimately differ — the wrapper's transaction
|
|
666
|
+
* may have deleted a committed duplicate — and an index entry with no live row behind it
|
|
667
|
+
* is harmless (see the entry-resolution note in `buildIndexEntries`).
|
|
549
668
|
*/
|
|
550
|
-
async createIndex(db, schemaName, tableName, indexSchema) {
|
|
551
|
-
const tableKey = `${schemaName}.${tableName}`.toLowerCase();
|
|
669
|
+
async createIndex(db, schemaName, tableName, indexSchema, rows) {
|
|
552
670
|
const table = this.getOrReconnectTable(db, schemaName, tableName);
|
|
553
671
|
if (!table) {
|
|
554
672
|
throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'`, StatusCode.NOTFOUND);
|
|
@@ -562,13 +680,52 @@ export class StoreModule {
|
|
|
562
680
|
// which opens/creates the directory that `buildIndexEntries` then writes into.
|
|
563
681
|
const indexStoreName = buildIndexStoreName(schemaName, tableName, indexSchema.name);
|
|
564
682
|
this.assertStoreNameFree(db, schemaName, indexStoreName, `index store of new index '${indexSchema.name}' on table '${schemaName}.${tableName}'`);
|
|
565
|
-
// Create the index store
|
|
566
|
-
const indexStore = await this.provider.getIndexStore(schemaName, tableName, indexSchema.name);
|
|
567
|
-
// Build index entries for existing rows
|
|
568
|
-
const dataStore = await this.getStore(tableKey, table.getConfig());
|
|
569
683
|
const tableSchema = table.getSchema();
|
|
570
684
|
const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
|
|
571
|
-
|
|
685
|
+
// Wrapper-supplied rows are the judged set. Validate BEFORE `getIndexStore` so a
|
|
686
|
+
// rejection leaves no index-store directory behind, and disable the in-pass dup
|
|
687
|
+
// check below (which would judge this module's committed rows instead).
|
|
688
|
+
if (rows && indexSchema.unique) {
|
|
689
|
+
await this.validateUniqueIndexOverRows(rows(), tableSchema, indexSchema, db.getKeyNormalizerResolver());
|
|
690
|
+
}
|
|
691
|
+
// Create the index store
|
|
692
|
+
const indexStore = await this.provider.getIndexStore(schemaName, tableName, indexSchema.name);
|
|
693
|
+
// Build index entries for every row the table can currently SEE — committed
|
|
694
|
+
// rows merged with this transaction's pending writes (`iterateEffectiveEntries`).
|
|
695
|
+
// The committed-only stream would leave a row inserted earlier in an open
|
|
696
|
+
// transaction unindexed, and `StoreTable.findUniqueConflictViaIndex` trusts the
|
|
697
|
+
// index to hold every live row: a `CREATE UNIQUE INDEX` would miss a pending
|
|
698
|
+
// duplicate, and a later insert colliding with that pending row would be
|
|
699
|
+
// silently ACCEPTED. Entries written here for rows that a subsequent ROLLBACK
|
|
700
|
+
// discards are harmless: both readers resolve each entry to its live row and
|
|
701
|
+
// drop it when the row is gone or no longer matches (see `scanIndex`).
|
|
702
|
+
//
|
|
703
|
+
// The store is FRESHLY created above, so any build failure — a UNIQUE violation,
|
|
704
|
+
// an IO error, or a mid-stream flush failure now that the build is chunked — must
|
|
705
|
+
// tear the whole store down. Nothing else reclaims it: SchemaManager.createIndex
|
|
706
|
+
// wraps the error but does no teardown, so without this a rejected build leaks a
|
|
707
|
+
// partial/empty index-store directory. Mirrors dropIndex's teardown.
|
|
708
|
+
try {
|
|
709
|
+
await this.buildIndexEntries(table.iterateEffectiveEntries(buildFullScanBounds()), indexStore, tableSchema, indexSchema, keyCollation, db.getKeyNormalizerResolver(), rows !== undefined, table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
|
|
710
|
+
}
|
|
711
|
+
catch (buildError) {
|
|
712
|
+
// Best-effort teardown: guard it against its own throw so a teardown failure
|
|
713
|
+
// never masks the original build error the caller must see.
|
|
714
|
+
try {
|
|
715
|
+
await table.releaseIndexStore(indexSchema.name);
|
|
716
|
+
if (this.provider.deleteIndexStore) {
|
|
717
|
+
await this.provider.deleteIndexStore(schemaName, tableName, indexSchema.name);
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
await this.provider.closeIndexStore(schemaName, tableName, indexSchema.name);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
catch (teardownError) {
|
|
724
|
+
console.warn(`[StoreModule] failed to tear down index store '${indexSchema.name}' on `
|
|
725
|
+
+ `table '${schemaName}.${tableName}' after a failed CREATE INDEX: ${String(teardownError)}`);
|
|
726
|
+
}
|
|
727
|
+
throw buildError;
|
|
728
|
+
}
|
|
572
729
|
// Refresh the connected table's cached schema so subsequent DML
|
|
573
730
|
// maintains the new index (the engine's schema registry is updated
|
|
574
731
|
// separately by SchemaManager.createIndex, but the StoreTable instance
|
|
@@ -656,36 +813,52 @@ export class StoreModule {
|
|
|
656
813
|
/**
|
|
657
814
|
* Build index entries for all existing rows in a table.
|
|
658
815
|
*
|
|
816
|
+
* `dataEntries` is the row stream to index. Callers choose its visibility:
|
|
817
|
+
* `createIndex` passes the table's EFFECTIVE stream (committed + pending, so
|
|
818
|
+
* an open transaction's rows are indexed too), while `rebuildSecondaryIndexes`
|
|
819
|
+
* passes the raw committed stream — it runs immediately after an ALTER has
|
|
820
|
+
* re-encoded the data store in place, and any pending ops still address the
|
|
821
|
+
* pre-ALTER key bytes.
|
|
822
|
+
*
|
|
659
823
|
* For UNIQUE indexes, performs an in-pass duplicate check (honoring partial
|
|
660
824
|
* predicates and SQL NULL semantics: multiple NULLs are allowed) and throws
|
|
661
825
|
* CONSTRAINT before any entries are written. Mirrors the memory module's
|
|
662
826
|
* populateNewIndex so `CREATE UNIQUE INDEX` over duplicated data fails
|
|
663
|
-
* atomically.
|
|
827
|
+
* atomically. `skipDuplicateCheck` suppresses it: `createIndex` sets it when a
|
|
828
|
+
* wrapper module supplied the rows to judge, having already validated them (see
|
|
829
|
+
* {@link validateUniqueIndexOverRows}). Judging `dataEntries` too would reject a
|
|
830
|
+
* committed duplicate the wrapper's transaction has already deleted.
|
|
831
|
+
*
|
|
832
|
+
* `normalizers` MUST be the owning connection's `db.getKeyNormalizerResolver()` —
|
|
833
|
+
* the same resolver `StoreTable.encodeOptions` carries. A rebuild that resolved
|
|
834
|
+
* collations any other way would re-encode the PK suffix under different bytes than
|
|
835
|
+
* the table writes at maintenance time, silently corrupting the index.
|
|
664
836
|
*/
|
|
665
|
-
async buildIndexEntries(
|
|
837
|
+
async buildIndexEntries(dataEntries, indexStore, tableSchema, indexSchema, keyCollation, normalizers, skipDuplicateCheck = false, maxBatchBytes = DEFAULT_MAX_BATCH_BYTES) {
|
|
666
838
|
// Index COLUMN values use the table-level key collation K; the PK SUFFIX uses
|
|
667
839
|
// each PK column's own key collation, so the suffix bytes match the data-store
|
|
668
840
|
// keys (and `StoreTable.updateSecondaryIndexes`' maintenance writes) exactly.
|
|
669
|
-
const encodeOptions = { collation: keyCollation };
|
|
841
|
+
const encodeOptions = { collation: keyCollation, normalizers };
|
|
670
842
|
const pkDirections = tableSchema.primaryKeyDefinition.map(pk => !!pk.desc);
|
|
671
843
|
const pkCollations = resolvePkKeyCollations(tableSchema.primaryKeyDefinition, tableSchema.columns, keyCollation);
|
|
672
844
|
const indexDirections = indexSchema.columns.map(col => !!col.desc);
|
|
673
845
|
const predicate = indexSchema.predicate
|
|
674
|
-
? compilePredicate(indexSchema.predicate, tableSchema.columns)
|
|
846
|
+
? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name)
|
|
675
847
|
: undefined;
|
|
676
|
-
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
848
|
+
// NOTE: `seen` holds one signature per DISTINCT indexed key for the WHOLE build and
|
|
849
|
+
// is NOT bounded by the maxBatchBytes chunking below (that bounds only the write
|
|
850
|
+
// batch). A very large UNIQUE index still spikes memory on this set. Bounding it
|
|
851
|
+
// needs a sort- or store-probe-based dedup (a separate design), not a batch knob.
|
|
852
|
+
const seen = (indexSchema.unique && !skipDuplicateCheck) ? new Set() : undefined;
|
|
681
853
|
const indexColIndices = indexSchema.columns.map(col => col.index);
|
|
682
854
|
const indexNormalizers = seen
|
|
683
|
-
?
|
|
855
|
+
? indexDedupeNormalizers(tableSchema, indexSchema, normalizers)
|
|
684
856
|
: undefined;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
857
|
+
let batch = indexStore.batch();
|
|
858
|
+
// Serialized key bytes accumulated in the CURRENT batch. Flushed and reset once it
|
|
859
|
+
// crosses maxBatchBytes so a table larger than memory never buffers its whole index.
|
|
860
|
+
let batchBytes = 0;
|
|
861
|
+
for await (const entry of dataEntries) {
|
|
689
862
|
const row = deserializeRow(entry.value);
|
|
690
863
|
// Partial index: skip rows whose predicate is not unambiguously TRUE.
|
|
691
864
|
if (predicate && predicate.evaluate(row) !== true)
|
|
@@ -710,8 +883,29 @@ export class StoreModule {
|
|
|
710
883
|
}
|
|
711
884
|
// Build and store index key
|
|
712
885
|
const indexKey = buildIndexKey(indexValues, pkValues, encodeOptions, indexDirections, pkDirections, pkCollations);
|
|
713
|
-
|
|
886
|
+
// Index value = the row's encoded DATA key, so an index scan resolves each
|
|
887
|
+
// entry back to its base row via a direct data-store read (see
|
|
888
|
+
// `StoreTable.scanIndex`). Encoded under the same PK directions + per-column
|
|
889
|
+
// PK collations as `buildDataKey` / `updateSecondaryIndexes`, so the value
|
|
890
|
+
// byte-matches the data store's key for this row.
|
|
891
|
+
const dataKey = buildDataKey(pkValues, encodeOptions, pkDirections, pkCollations);
|
|
892
|
+
batch.put(indexKey, dataKey);
|
|
893
|
+
// Bound heap: once the accumulated serialized key bytes cross the budget, flush
|
|
894
|
+
// the batch and start a fresh one. Both callers ITERATE the data store and WRITE
|
|
895
|
+
// the index store — different stores — so a mid-stream flush never mutates the
|
|
896
|
+
// stream being read, and this is safe on every provider. A mid-stream flush
|
|
897
|
+
// failure in `createIndex` is torn down by its try/catch (the whole fresh index
|
|
898
|
+
// store is deleted); in `rebuildSecondaryIndexes` it leaves a partial-but-
|
|
899
|
+
// recoverable index (re-run the rebuild — clear + rebuild is idempotent).
|
|
900
|
+
batchBytes += indexKey.length + dataKey.length;
|
|
901
|
+
if (batchBytes >= maxBatchBytes) {
|
|
902
|
+
await batch.write();
|
|
903
|
+
batch = indexStore.batch();
|
|
904
|
+
batchBytes = 0;
|
|
905
|
+
}
|
|
714
906
|
}
|
|
907
|
+
// Final flush of the residual. May be empty (an empty table, or a build whose last
|
|
908
|
+
// row exactly hit the budget and flushed) — providers accept an empty write.
|
|
715
909
|
await batch.write();
|
|
716
910
|
}
|
|
717
911
|
/**
|
|
@@ -722,549 +916,923 @@ export class StoreModule {
|
|
|
722
916
|
* COLLATE` on a PK member (a PK column's key collation changes). Shared by both
|
|
723
917
|
* arms so the clear-then-rebuild stays identical. `schema` must already be the
|
|
724
918
|
* post-ALTER schema (its `primaryKeyDefinition` + column collations drive the new
|
|
725
|
-
* PK-suffix encoding via {@link buildIndexEntries}).
|
|
919
|
+
* PK-suffix encoding via {@link buildIndexEntries}). `normalizers` is the owning
|
|
920
|
+
* connection's `db.getKeyNormalizerResolver()` — see {@link buildIndexEntries}.
|
|
921
|
+
*
|
|
922
|
+
* NOTE: reads the data store committed-only and writes the index stores outside the
|
|
923
|
+
* coordinator. Sound only because both callers (the two re-key arms) call
|
|
924
|
+
* {@link ddlCommitPendingOps} first, so "committed" is "everything live". A caller
|
|
925
|
+
* that skips that flush would rebuild an index missing its transaction's pending rows.
|
|
726
926
|
*/
|
|
727
|
-
async rebuildSecondaryIndexes(schemaName, tableName,
|
|
927
|
+
async rebuildSecondaryIndexes(schemaName, tableName, table, schema, normalizers) {
|
|
728
928
|
const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
|
|
729
|
-
const dataStore = await this.getStore(
|
|
929
|
+
const dataStore = await this.getStore(schemaName, tableName, table.getConfig());
|
|
930
|
+
const maxBatchBytes = table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
|
|
730
931
|
for (const indexSchema of schema.indexes ?? []) {
|
|
731
932
|
const indexStore = await this.getIndexStore(schemaName, tableName, indexSchema.name);
|
|
933
|
+
// NOTE: this clear pass buffers EVERY existing index key into one batch. It holds
|
|
934
|
+
// only KEYS (no values), so its peak is far below the value-bearing build pass and
|
|
935
|
+
// is left unbounded for now. Chunking it is harder than the build: it deletes from
|
|
936
|
+
// the SAME store it iterates, so a mid-iteration flush risks iterate-while-mutate
|
|
937
|
+
// semantics that differ per provider (LevelDB snapshots its iterator; IndexedDB may
|
|
938
|
+
// not). If the index key set ever dominates memory, chunk this with a snapshot-safe
|
|
939
|
+
// re-seek, not a naive mid-iteration flush.
|
|
732
940
|
const clearBatch = indexStore.batch();
|
|
733
941
|
for await (const entry of indexStore.iterate(buildFullScanBounds())) {
|
|
734
942
|
clearBatch.delete(entry.key);
|
|
735
943
|
}
|
|
736
944
|
await clearBatch.write();
|
|
737
|
-
await this.buildIndexEntries(dataStore, indexStore, schema, indexSchema, keyCollation);
|
|
945
|
+
await this.buildIndexEntries(dataStore.iterate(buildFullScanBounds()), indexStore, schema, indexSchema, keyCollation, normalizers, false, maxBatchBytes);
|
|
738
946
|
}
|
|
739
947
|
}
|
|
740
948
|
/**
|
|
741
|
-
* Validates
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
949
|
+
* Validates `rows` against a UNIQUE constraint, throwing `CONSTRAINT` on the first
|
|
950
|
+
* duplicate before any schema mutation. Used by `ADD CONSTRAINT UNIQUE` (validate
|
|
951
|
+
* against the current collation) and by `SET COLLATE` (pass an `updatedSchema` whose
|
|
952
|
+
* altered column carries the NEW collation, so the dedup is performed under it).
|
|
953
|
+
* Mirrors the duplicate detection in {@link buildIndexEntries}: a `seen` Set keyed on
|
|
954
|
+
* a per-column collation-aware signature of the constrained values, with SQL NULL
|
|
955
|
+
* semantics (a row with any NULL constrained value never counts as a duplicate) and
|
|
956
|
+
* the partial `predicate` honored.
|
|
957
|
+
*
|
|
958
|
+
* `rows` MUST be the rows the DDL-issuing connection can SEE. Ordinarily that is this
|
|
959
|
+
* table's EFFECTIVE stream (committed rows merged with the open transaction's own
|
|
960
|
+
* pending puts/deletes — `StoreTable.iterateEffectiveEntries`, adapted by
|
|
961
|
+
* {@link rowsFromEntries}); when a wrapper module holds the pending rows outside this
|
|
962
|
+
* module it hands them down as an `EffectiveRowSource` instead. Either way, a
|
|
963
|
+
* committed-only stream would let a duplicate inserted earlier in the same transaction
|
|
964
|
+
* slip past validation and land in the table once the transaction commits.
|
|
750
965
|
*
|
|
751
966
|
* No index store is written — store UNIQUE enforcement is a full-scan over
|
|
752
967
|
* `uniqueConstraints` at write time. The signature is built by
|
|
753
|
-
* {@link serializeRowKey} with one normalizer per constrained column
|
|
754
|
-
* `tableSchema.columns[idx].collation
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
968
|
+
* {@link serializeRowKey} with one normalizer per constrained column, resolved from
|
|
969
|
+
* `tableSchema.columns[idx].collation` through the connection's
|
|
970
|
+
* `db.getKeyNormalizerResolver()`, so a per-column collation registered with
|
|
971
|
+
* `db.registerCollation` is honored (matching write-time `compareSqlValues`
|
|
972
|
+
* enforcement). A comparator-only collation raises: rows that cannot be bucketed
|
|
973
|
+
* cannot be deduped. Columns whose declared type can never hold text take the
|
|
974
|
+
* identity normalizer and so never raise.
|
|
975
|
+
*/
|
|
976
|
+
async validateUniqueOverExistingRows(rows, tableSchema, uc, keyNormalizers) {
|
|
977
|
+
await assertNoDuplicateRows(rows, tableSchema, uc.columns, uc.columns.map(idx => {
|
|
978
|
+
const column = tableSchema.columns[idx];
|
|
979
|
+
return keyNormalizers(logicalTypeCanHoldText(column.logicalType) ? column.collation : undefined);
|
|
980
|
+
}), uc.predicate ? compilePredicate(uc.predicate, tableSchema.columns, tableSchema.name) : undefined);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Index-shaped twin of {@link validateUniqueOverExistingRows}, used by
|
|
984
|
+
* {@link createIndex} when a wrapper module supplies the rows to judge. Dedupes on the
|
|
985
|
+
* index's own columns under the index column's COLLATE (falling back to the table
|
|
986
|
+
* column's), so it enforces exactly what `buildIndexEntries`' suppressed in-pass check
|
|
987
|
+
* would have — over the wrapper's rows instead of this module's committed ones.
|
|
988
|
+
*/
|
|
989
|
+
async validateUniqueIndexOverRows(rows, tableSchema, indexSchema, keyNormalizers) {
|
|
990
|
+
await assertNoDuplicateRows(rows, tableSchema, indexSchema.columns.map(col => col.index), indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers), indexSchema.predicate ? compilePredicate(indexSchema.predicate, tableSchema.columns, tableSchema.name) : undefined);
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Flush the module's buffered writes before a DDL operation that physically
|
|
994
|
+
* rewrites or relocates storage.
|
|
995
|
+
*
|
|
996
|
+
* Such a DDL cannot honor the coordinator's buffer: pending ops are
|
|
997
|
+
* `(keyBytes, valueBytes, store)` triples computed at DML time under the
|
|
998
|
+
* PRE-DDL schema, and every physical rewrite (`StoreTable.rekeyRows` /
|
|
999
|
+
* `migrateRows`, the provider's directory move) reads and writes the COMMITTED
|
|
1000
|
+
* store directly. Replaying stale-schema ops over the rewritten store on the
|
|
1001
|
+
* eventual commit corrupts, loses, or misfiles those rows. So an `ALTER TABLE`
|
|
1002
|
+
* that touches storage is effectively DDL-committing on a store-backed table:
|
|
1003
|
+
* flush NOW, before the first physical read, so the rewrite sees every live row
|
|
1004
|
+
* and there is nothing left to replay afterwards.
|
|
1005
|
+
*
|
|
1006
|
+
* Consequences, both deliberate:
|
|
1007
|
+
* - The coordinator is module-wide, so this commits the WHOLE module
|
|
1008
|
+
* transaction — every table's pending ops, not just the altered/renamed
|
|
1009
|
+
* table's — in one all-or-nothing batch. An ALTER cannot half-commit some
|
|
1010
|
+
* sibling tables.
|
|
1011
|
+
* - Validation running AFTER this point (e.g. `rekeyRows`' duplicate-key pass,
|
|
1012
|
+
* which must see pending rows because a pending insert can itself be the
|
|
1013
|
+
* duplicate) throws with the enclosing transaction already flushed. The store
|
|
1014
|
+
* stays unmutated — only the transaction is gone. Validation that does NOT
|
|
1015
|
+
* need pending rows belongs before the call, so the transaction survives it.
|
|
1016
|
+
*
|
|
1017
|
+
* Subsequent `commit()` calls on the same coordinator are no-ops
|
|
1018
|
+
* (`inTransaction` is cleared), which keeps the enclosing transaction safe.
|
|
1019
|
+
* The savepoint stack is cleared too, so a later `ROLLBACK TO` / `RELEASE` that
|
|
1020
|
+
* the engine still broadcasts warns and degrades rather than throwing — see
|
|
1021
|
+
* `TransactionCoordinator.rollbackToSavepoint`, which mirrors the memory
|
|
1022
|
+
* module's identical posture.
|
|
1023
|
+
*/
|
|
1024
|
+
async ddlCommitPendingOps() {
|
|
1025
|
+
if (this.moduleCoordinator?.isInTransaction()) {
|
|
1026
|
+
await this.moduleCoordinator.commit();
|
|
780
1027
|
}
|
|
781
1028
|
}
|
|
782
1029
|
/**
|
|
783
|
-
* Alters an existing store table's structure
|
|
784
|
-
*
|
|
785
|
-
*
|
|
1030
|
+
* Alters an existing store table's structure. Resolves the table, captures the
|
|
1031
|
+
* pre-alter schema, and dispatches to the per-change-type `alter*` helper below;
|
|
1032
|
+
* the helper does the arm's work (row migration, physical re-key, constraint
|
|
1033
|
+
* validation, DDL persist) and returns the updated TableSchema for the engine to
|
|
1034
|
+
* register. The shared preamble (schema subscription, reconnect, not-found throw,
|
|
1035
|
+
* `defaultNotNull`) lives here so every arm sees the same resolved state.
|
|
786
1036
|
*/
|
|
787
|
-
async alterTable(db, schemaName, tableName, change) {
|
|
1037
|
+
async alterTable(db, schemaName, tableName, change, rows) {
|
|
788
1038
|
this.ensureSchemaSubscription(db);
|
|
789
|
-
const tableKey = `${schemaName}.${tableName}`.toLowerCase();
|
|
790
1039
|
const table = this.getOrReconnectTable(db, schemaName, tableName);
|
|
791
1040
|
if (!table) {
|
|
792
1041
|
throw new QuereusError(`Store table '${tableName}' not found in schema '${schemaName}'. Cannot alter.`, StatusCode.ERROR);
|
|
793
1042
|
}
|
|
1043
|
+
// The engine-facing schema carries no `_uc_*` (the store keeps the materialized
|
|
1044
|
+
// enforcement copy internal), so the arms build `updatedSchema` off a clean schema
|
|
1045
|
+
// exactly as before this feature; `table.updateSchema` recomputes the enforcement
|
|
1046
|
+
// copy, and {@link reconcileImplicitUniqueIndexStores} (below) moves the physical
|
|
1047
|
+
// stores for any constraint-set change.
|
|
794
1048
|
const oldSchema = table.getSchema();
|
|
795
1049
|
const defaultNotNull = db.options.getStringOption('default_column_nullability') === 'not_null';
|
|
1050
|
+
let updated;
|
|
796
1051
|
switch (change.type) {
|
|
797
|
-
case 'addColumn':
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
// Build updated schema: append new column
|
|
828
|
-
const updatedColumns = Object.freeze([...oldSchema.columns, newColSchema]);
|
|
829
|
-
const updatedSchema = {
|
|
830
|
-
...oldSchema,
|
|
831
|
-
columns: updatedColumns,
|
|
832
|
-
columnIndexMap: buildColumnIndexMap(updatedColumns),
|
|
833
|
-
};
|
|
834
|
-
// Extract any column-level CHECK / FK to persist (see the persist block below).
|
|
835
|
-
// Hoisted above the row migration so a malformed constraint (e.g. a multi-column
|
|
836
|
-
// FK on a single ADD COLUMN, which `extractColumnLevelForeignKeys` rejects) throws
|
|
837
|
-
// BEFORE any rows are migrated or the in-memory schema is swapped — validate-before-
|
|
838
|
-
// mutate, matching the engine's ordering in `runAddColumn`.
|
|
839
|
-
const newCheckConstraints = extractColumnLevelCheckConstraints(change.columnDef);
|
|
840
|
-
const newForeignKeys = extractColumnLevelForeignKeys(change.columnDef, schemaName);
|
|
841
|
-
// Migrate rows: append the new column's value — a single literal default, or a
|
|
842
|
-
// per-row value derived from the existing row when a backfill evaluator is set.
|
|
843
|
-
const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
|
|
844
|
-
await table.migrateRows(remap, defaultValue, backfillEvaluator
|
|
845
|
-
? { evaluator: backfillEvaluator, notNull: newColSchema.notNull, columnName: newColSchema.name }
|
|
846
|
-
: undefined);
|
|
847
|
-
// Update table schema (column-only) and persist DDL.
|
|
848
|
-
//
|
|
849
|
-
// The engine's `runAddColumn` re-merges the column-level FK/CHECK extracted
|
|
850
|
-
// from `columnDef.constraints` into the LIVE in-memory schema AFTER this hook
|
|
851
|
-
// returns, so the schema handed back to it must stay column-only — returning a
|
|
852
|
-
// constrained schema would double the constraint in the live SchemaManager (and,
|
|
853
|
-
// on the next persist, in the DDL). But that engine-side merge is in-memory only:
|
|
854
|
-
// it never reaches the catalog, so persistence must carry the column-level
|
|
855
|
-
// CHECK/FK itself or they vanish on `rehydrateCatalog`. Build a separate
|
|
856
|
-
// `persistedSchema` for `saveTableDDL` when (and only when) the column declares
|
|
857
|
-
// such a constraint; the common path persists `updatedSchema` unchanged. This is
|
|
858
|
-
// unconditional on the default kind — a per-row (evaluator) DEFAULT extracts the
|
|
859
|
-
// same AST constraints as a literal one.
|
|
860
|
-
table.updateSchema(updatedSchema);
|
|
861
|
-
let persistedSchema = updatedSchema;
|
|
862
|
-
if (newCheckConstraints.length > 0 || newForeignKeys.length > 0) {
|
|
863
|
-
// The new column is appended last; resolve each FK's child column to its index
|
|
864
|
-
// (matching how the engine resolves `resolvedForeignKeys` via columnIndexMap).
|
|
865
|
-
const newColIdx = updatedColumns.length - 1;
|
|
866
|
-
const resolvedForeignKeys = newForeignKeys.map(fk => ({ ...fk, columns: Object.freeze([newColIdx]) }));
|
|
867
|
-
persistedSchema = {
|
|
868
|
-
...updatedSchema,
|
|
869
|
-
checkConstraints: Object.freeze([...updatedSchema.checkConstraints, ...newCheckConstraints]),
|
|
870
|
-
foreignKeys: Object.freeze([...(updatedSchema.foreignKeys ?? []), ...resolvedForeignKeys]),
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
await this.saveTableDDL(persistedSchema);
|
|
874
|
-
this.eventEmitter?.emitSchemaChange({
|
|
875
|
-
type: 'alter',
|
|
876
|
-
objectType: 'table',
|
|
877
|
-
schemaName,
|
|
878
|
-
objectName: tableName,
|
|
879
|
-
});
|
|
880
|
-
return updatedSchema;
|
|
881
|
-
}
|
|
882
|
-
case 'dropColumn': {
|
|
883
|
-
const colNameLower = change.columnName.toLowerCase();
|
|
884
|
-
const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
|
|
885
|
-
if (colIndex === -1) {
|
|
886
|
-
throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
|
|
887
|
-
}
|
|
888
|
-
// Build updated schema: remove column and reindex PK/indexes
|
|
889
|
-
// Filter by original index BEFORE remapping to avoid incorrectly
|
|
890
|
-
// removing columns that remap to the dropped column's position.
|
|
891
|
-
const updatedColumns = oldSchema.columns.filter((_, idx) => idx !== colIndex);
|
|
892
|
-
const updatedPkDef = oldSchema.primaryKeyDefinition
|
|
893
|
-
.filter(def => def.index !== colIndex)
|
|
894
|
-
.map(def => ({
|
|
895
|
-
...def,
|
|
896
|
-
index: def.index > colIndex ? def.index - 1 : def.index,
|
|
897
|
-
}));
|
|
898
|
-
const updatedIndexes = (oldSchema.indexes || [])
|
|
899
|
-
.map(idx => ({
|
|
900
|
-
...idx,
|
|
901
|
-
columns: idx.columns
|
|
902
|
-
.filter(ic => ic.index !== colIndex)
|
|
903
|
-
.map(ic => ({ ...ic, index: ic.index > colIndex ? ic.index - 1 : ic.index })),
|
|
904
|
-
}))
|
|
905
|
-
.filter(idx => idx.columns.length > 0);
|
|
906
|
-
// Prune any UNIQUE constraint over the dropped column, mirroring the index
|
|
907
|
-
// filtering above. Store-backed UNIQUE is enforced by a full scan over
|
|
908
|
-
// `uniqueConstraints`, so a stranded constraint whose column index dangles past
|
|
909
|
-
// the column array would break the next insert's validation (and the persisted
|
|
910
|
-
// DDL). A UNIQUE that includes the dropped column is removed outright; remaining
|
|
911
|
-
// constraints have their column indices shifted to track the removed slot. This
|
|
912
|
-
// also covers the engine's ADD COLUMN + inline-UNIQUE revert, which drops the
|
|
913
|
-
// just-added (uniquely-constrained) column.
|
|
914
|
-
const updatedUniqueConstraints = (oldSchema.uniqueConstraints ?? [])
|
|
915
|
-
.filter(uc => !uc.columns.includes(colIndex))
|
|
916
|
-
.map(uc => ({ ...uc, columns: Object.freeze(uc.columns.map(i => i > colIndex ? i - 1 : i)) }));
|
|
917
|
-
const updatedSchema = {
|
|
918
|
-
...oldSchema,
|
|
919
|
-
columns: Object.freeze(updatedColumns),
|
|
920
|
-
columnIndexMap: buildColumnIndexMap(updatedColumns),
|
|
921
|
-
primaryKeyDefinition: Object.freeze(updatedPkDef),
|
|
922
|
-
indexes: Object.freeze(updatedIndexes),
|
|
923
|
-
uniqueConstraints: updatedUniqueConstraints.length > 0
|
|
924
|
-
? Object.freeze(updatedUniqueConstraints)
|
|
925
|
-
: undefined,
|
|
926
|
-
};
|
|
927
|
-
// Migrate rows: remove the dropped column slot
|
|
928
|
-
const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
|
|
929
|
-
await table.migrateRows(remap, null);
|
|
930
|
-
// Update table schema and persist DDL
|
|
931
|
-
table.updateSchema(updatedSchema);
|
|
932
|
-
await this.saveTableDDL(updatedSchema);
|
|
933
|
-
this.eventEmitter?.emitSchemaChange({
|
|
934
|
-
type: 'alter',
|
|
935
|
-
objectType: 'table',
|
|
936
|
-
schemaName,
|
|
937
|
-
objectName: tableName,
|
|
938
|
-
});
|
|
939
|
-
return updatedSchema;
|
|
1052
|
+
case 'addColumn':
|
|
1053
|
+
updated = await this.alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull);
|
|
1054
|
+
break;
|
|
1055
|
+
case 'dropColumn':
|
|
1056
|
+
updated = await this.alterDropColumn(schemaName, tableName, table, oldSchema, change);
|
|
1057
|
+
break;
|
|
1058
|
+
case 'renameColumn':
|
|
1059
|
+
updated = await this.alterRenameColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull);
|
|
1060
|
+
break;
|
|
1061
|
+
case 'alterPrimaryKey':
|
|
1062
|
+
updated = await this.alterPrimaryKeyChange(db, schemaName, tableName, table, oldSchema, change);
|
|
1063
|
+
break;
|
|
1064
|
+
case 'addConstraint':
|
|
1065
|
+
updated = await this.alterAddConstraint(db, schemaName, tableName, table, oldSchema, change, rows);
|
|
1066
|
+
break;
|
|
1067
|
+
case 'dropConstraint':
|
|
1068
|
+
updated = await this.alterDropConstraint(schemaName, tableName, table, oldSchema, change);
|
|
1069
|
+
break;
|
|
1070
|
+
case 'renameConstraint':
|
|
1071
|
+
updated = await this.alterRenameConstraint(schemaName, tableName, table, oldSchema, change);
|
|
1072
|
+
break;
|
|
1073
|
+
case 'alterColumn':
|
|
1074
|
+
updated = await this.alterColumnChange(db, schemaName, tableName, table, oldSchema, change, rows);
|
|
1075
|
+
break;
|
|
1076
|
+
default: {
|
|
1077
|
+
const _exhaustive = change;
|
|
1078
|
+
void _exhaustive;
|
|
1079
|
+
throw new QuereusError(`Unhandled ALTER TABLE change type '${change.type}'`, StatusCode.INTERNAL);
|
|
940
1080
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1081
|
+
}
|
|
1082
|
+
// Move the physical `_uc_*` index stores to match the post-ALTER constraint set:
|
|
1083
|
+
// the SCHEMA entries were re-materialized by the arm's `table.updateSchema`, but a
|
|
1084
|
+
// newly-added constraint's store must be BUILT and a dropped/renamed one's TORN
|
|
1085
|
+
// DOWN. `oldSchema` carries the pre-ALTER `uniqueConstraints`; `table.getSchema()`
|
|
1086
|
+
// the post-ALTER set. A no-op when the implicit-index name set is unchanged (the
|
|
1087
|
+
// common case, incl. PK/collation/type ALTERs whose physical re-encode is already
|
|
1088
|
+
// handled by `rebuildSecondaryIndexes`).
|
|
1089
|
+
await this.reconcileImplicitUniqueIndexStores(db, schemaName, tableName, table, oldSchema);
|
|
1090
|
+
return updated;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Build / tear down the physical index stores backing implicit UNIQUE indexes
|
|
1094
|
+
* (`_uc_*`) after an ALTER, by diffing the implicit-index NAMES of the old vs new
|
|
1095
|
+
* constraint sets:
|
|
1096
|
+
* - a name newly PRESENT (ADD CONSTRAINT UNIQUE, or the target half of a
|
|
1097
|
+
* RENAME CONSTRAINT / rename of an unnamed UC's column) has its physical store
|
|
1098
|
+
* populated from this module's effective rows;
|
|
1099
|
+
* - a name newly ABSENT (DROP CONSTRAINT UNIQUE, or the source half of a rename)
|
|
1100
|
+
* has its store torn down, so a later re-ADD does not reopen stale entries.
|
|
1101
|
+
*
|
|
1102
|
+
* The SCHEMA entry itself is materialized/removed by `withImplicitUniqueIndexes`
|
|
1103
|
+
* on the arm's `table.updateSchema`; only the physical store needs this. Runs after
|
|
1104
|
+
* every ALTER but is a no-op whenever the implicit-index name set is unchanged
|
|
1105
|
+
* (incl. PK / collation / data-type ALTERs, whose same-name physical re-encode is
|
|
1106
|
+
* already done by {@link rebuildSecondaryIndexes}).
|
|
1107
|
+
*
|
|
1108
|
+
* `oldSchema` carries the pre-ALTER `uniqueConstraints` (its `.indexes` may be
|
|
1109
|
+
* de-materialized — the diff reads `uniqueConstraints`, not `.indexes`).
|
|
1110
|
+
*
|
|
1111
|
+
* NOTE: the build populates from THIS module's own effective rows (committed + this
|
|
1112
|
+
* transaction's pending), never a wrapper's — mirroring `createIndex`. Under the
|
|
1113
|
+
* isolation layer the wrapper's rows are what ADD CONSTRAINT's validation judged;
|
|
1114
|
+
* the physical index legitimately fills from this module's committed rows and an
|
|
1115
|
+
* entry with no live row behind it is harmless (both readers resolve to the live
|
|
1116
|
+
* row). Duplicates were already rejected by `validateUniqueOverExistingRows` (ADD)
|
|
1117
|
+
* or cannot exist (RENAME leaves data unchanged), so the in-pass check is skipped.
|
|
1118
|
+
*/
|
|
1119
|
+
async reconcileImplicitUniqueIndexStores(db, schemaName, tableName, table, oldSchema) {
|
|
1120
|
+
// Diff the constraint sets by implicit-index NAME (derived from `uniqueConstraints`,
|
|
1121
|
+
// so the engine-facing schemas work directly); resolve the index SHAPE to build from
|
|
1122
|
+
// the materialized enforcement schema.
|
|
1123
|
+
const newSchema = table.getSchema();
|
|
1124
|
+
const newMaterialized = table.getMaterializedSchema();
|
|
1125
|
+
const oldNames = implicitUniqueIndexNameMap(oldSchema);
|
|
1126
|
+
const newNames = implicitUniqueIndexNameMap(newSchema);
|
|
1127
|
+
// Tear down each store whose implicit index no longer exists.
|
|
1128
|
+
for (const [lower, name] of oldNames) {
|
|
1129
|
+
if (!newNames.has(lower)) {
|
|
1130
|
+
await this.tearDownImplicitUniqueIndexStore(schemaName, tableName, table, name);
|
|
972
1131
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1132
|
+
}
|
|
1133
|
+
// Build each newly-materialized implicit index's store from effective rows.
|
|
1134
|
+
for (const [lower, name] of newNames) {
|
|
1135
|
+
if (oldNames.has(lower))
|
|
1136
|
+
continue;
|
|
1137
|
+
const indexSchema = (newMaterialized.indexes ?? []).find(ix => ix.name === name);
|
|
1138
|
+
if (!indexSchema)
|
|
1139
|
+
continue; // defensive — name is derived from the same UC set
|
|
1140
|
+
// NOTE: no `assertStoreNameFree` here (unlike explicit `createIndex`): the name
|
|
1141
|
+
// is engine-derived and deterministic, and the lazy first-write build path in
|
|
1142
|
+
// `updateSecondaryIndexes` does not assert either — keep the two consistent.
|
|
1143
|
+
// NOTE: no teardown-on-failure wrapper (unlike `createIndex`): an IO error mid-build
|
|
1144
|
+
// leaves a partial `_uc_*` store while the constraint schema is already committed, so
|
|
1145
|
+
// enforcement could under-report until the store is rebuilt. Tolerated because the
|
|
1146
|
+
// build has no in-pass dup check (skipDuplicateCheck below) so the only failure is a
|
|
1147
|
+
// genuine IO error — the same non-atomicity `rebuildSecondaryIndexes` documents; a
|
|
1148
|
+
// re-run / reopen rebuilds. Add a try/catch teardown if this ever bites.
|
|
1149
|
+
const keyCollation = (table.getConfig().collation || 'NOCASE').toUpperCase();
|
|
1150
|
+
const indexStore = await this.provider.getIndexStore(schemaName, tableName, name);
|
|
1151
|
+
await this.buildIndexEntries(table.iterateEffectiveEntries(buildFullScanBounds()), indexStore, newSchema, indexSchema, keyCollation, db.getKeyNormalizerResolver(), true, // dup rejection already owned by the arm (ADD validates; RENAME is data-preserving)
|
|
1152
|
+
table.getConfig().maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* The physical index-store names a table owns for DROP / RENAME TABLE to relocate
|
|
1157
|
+
* or reclaim: the MATERIALIZED index set — every explicit `CREATE INDEX` PLUS the
|
|
1158
|
+
* hidden `_uc_*` realizing each plain UNIQUE. Reading only `getSchema().indexes`
|
|
1159
|
+
* (the engine-facing schema, which carries no `_uc_*`) would strand a `_uc_*` store
|
|
1160
|
+
* on disk after DROP TABLE, and — worse — after RENAME TABLE leave the renamed
|
|
1161
|
+
* table seeking a freshly-created EMPTY `_uc_*` store, silently accepting a
|
|
1162
|
+
* duplicate of a pre-rename row. Prefers the cached StoreTable's own materialized
|
|
1163
|
+
* schema; falls back to materializing the schema-manager copy when the instance is
|
|
1164
|
+
* already gone.
|
|
1165
|
+
*/
|
|
1166
|
+
materializedIndexNames(table, fallback) {
|
|
1167
|
+
const schema = table
|
|
1168
|
+
? table.getMaterializedSchema()
|
|
1169
|
+
: (fallback ? withImplicitUniqueIndexes(fallback) : undefined);
|
|
1170
|
+
return (schema?.indexes ?? []).map(i => i.name);
|
|
1171
|
+
}
|
|
1172
|
+
/** Close + delete the physical store of an implicit UNIQUE index. Mirrors `dropIndex`. */
|
|
1173
|
+
async tearDownImplicitUniqueIndexStore(schemaName, tableName, table, indexName) {
|
|
1174
|
+
await table.releaseIndexStore(indexName);
|
|
1175
|
+
if (this.provider.deleteIndexStore) {
|
|
1176
|
+
await this.provider.deleteIndexStore(schemaName, tableName, indexName);
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
await this.provider.closeIndexStore(schemaName, tableName, indexName);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* ADD COLUMN arm of {@link alterTable}: append the new column, eagerly migrate
|
|
1184
|
+
* each row (literal or per-row backfill), and persist. Behavior-preserving
|
|
1185
|
+
* extraction of the former `switch` arm.
|
|
1186
|
+
*/
|
|
1187
|
+
async alterAddColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
|
|
1188
|
+
// Honor the session `default_collation` for an ADD COLUMN that omits an
|
|
1189
|
+
// explicit COLLATE, matching the CREATE path so an ADD-COLUMN-ed text column
|
|
1190
|
+
// gets the same collation a CREATE-d one would. The persisted DDL re-emits an
|
|
1191
|
+
// explicit COLLATE for any non-BINARY collation, so reopen stays stable.
|
|
1192
|
+
const newColSchema = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
|
|
1193
|
+
// Extract default value from column def constraints. Use the shared
|
|
1194
|
+
// `tryFoldLiteral` helper so signed numerics like `-123.0`
|
|
1195
|
+
// (a UnaryExpr in the AST) are recognized — matching the
|
|
1196
|
+
// memory-mode path and the engine-level ALTER validation.
|
|
1197
|
+
let defaultValue = null;
|
|
1198
|
+
const defaultConstraint = change.columnDef.constraints?.find(c => c.type === 'default');
|
|
1199
|
+
if (defaultConstraint?.expr) {
|
|
1200
|
+
const folded = tryFoldLiteral(defaultConstraint.expr);
|
|
1201
|
+
if (folded !== undefined) {
|
|
1202
|
+
defaultValue = folded;
|
|
995
1203
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
...oldSchema,
|
|
1008
|
-
uniqueConstraints: Object.freeze([...(oldSchema.uniqueConstraints ?? []), uc]),
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
else if (constraint.type === 'foreignKey') {
|
|
1012
|
-
const fk = buildForeignKeyConstraintSchema(constraint, oldSchema.columnIndexMap, oldSchema.name, oldSchema.schemaName);
|
|
1013
|
-
updatedSchema = {
|
|
1014
|
-
...oldSchema,
|
|
1015
|
-
foreignKeys: Object.freeze([...(oldSchema.foreignKeys ?? []), fk]),
|
|
1016
|
-
};
|
|
1017
|
-
// Pragma-gated existing-row validation; throws before persistence on an orphan.
|
|
1018
|
-
await validateForeignKeyOverExistingRows(db, updatedSchema, fk);
|
|
1019
|
-
}
|
|
1020
|
-
else if (constraint.type === 'check') {
|
|
1021
|
-
// Schema-only: a CHECK has no physical structure and (matching the
|
|
1022
|
-
// engine's prior in-emitter behavior) no existing-row scan. Routing it
|
|
1023
|
-
// here — rather than catalog-only — keeps the persisted DDL and the
|
|
1024
|
-
// connected-table schema in lock-step so DROP/RENAME CONSTRAINT resolve it.
|
|
1025
|
-
const check = buildCheckConstraintSchema(constraint, oldSchema.checkConstraints.length);
|
|
1026
|
-
updatedSchema = {
|
|
1027
|
-
...oldSchema,
|
|
1028
|
-
checkConstraints: Object.freeze([...oldSchema.checkConstraints, check]),
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
|
-
else {
|
|
1032
|
-
throw new QuereusError(`Store table ADD CONSTRAINT does not support constraint type '${constraint.type}'`, StatusCode.UNSUPPORTED);
|
|
1033
|
-
}
|
|
1034
|
-
table.updateSchema(updatedSchema);
|
|
1035
|
-
await this.saveTableDDL(updatedSchema);
|
|
1036
|
-
this.eventEmitter?.emitSchemaChange({
|
|
1037
|
-
type: 'alter',
|
|
1038
|
-
objectType: 'table',
|
|
1039
|
-
schemaName,
|
|
1040
|
-
objectName: tableName,
|
|
1041
|
-
});
|
|
1042
|
-
return updatedSchema;
|
|
1204
|
+
}
|
|
1205
|
+
// A non-foldable DEFAULT (e.g. `new.<col>`) backfills each existing row from
|
|
1206
|
+
// its own value via the engine-supplied evaluator (mirrors the memory path).
|
|
1207
|
+
const backfillEvaluator = change.backfillEvaluator;
|
|
1208
|
+
// Refuse NOT NULL without a usable DEFAULT on a non-empty table
|
|
1209
|
+
// (SQLite-compatible). A per-row evaluator IS usable — its NOT NULL is enforced
|
|
1210
|
+
// per row during migration — so it is exempt from this no-default rejection.
|
|
1211
|
+
if (newColSchema.notNull && defaultValue === null && !backfillEvaluator) {
|
|
1212
|
+
if (await table.hasAnyRows()) {
|
|
1213
|
+
throw new QuereusError(`Cannot add NOT NULL column '${newColSchema.name}' to non-empty table `
|
|
1214
|
+
+ `'${schemaName}.${tableName}' without a DEFAULT value`, StatusCode.CONSTRAINT);
|
|
1043
1215
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1216
|
+
}
|
|
1217
|
+
// Build updated schema: append new column
|
|
1218
|
+
const updatedColumns = Object.freeze([...oldSchema.columns, newColSchema]);
|
|
1219
|
+
const updatedSchema = {
|
|
1220
|
+
...oldSchema,
|
|
1221
|
+
columns: updatedColumns,
|
|
1222
|
+
columnIndexMap: buildColumnIndexMap(updatedColumns),
|
|
1223
|
+
};
|
|
1224
|
+
// Extract any column-level CHECK / FK to persist (see the persist block below).
|
|
1225
|
+
// Hoisted above the row migration so a malformed constraint (e.g. a multi-column
|
|
1226
|
+
// FK on a single ADD COLUMN, which `extractColumnLevelForeignKeys` rejects) throws
|
|
1227
|
+
// BEFORE any rows are migrated or the in-memory schema is swapped — validate-before-
|
|
1228
|
+
// mutate, matching the engine's ordering in `runAddColumn`.
|
|
1229
|
+
const newCheckConstraints = extractColumnLevelCheckConstraints(change.columnDef);
|
|
1230
|
+
const newForeignKeys = extractColumnLevelForeignKeys(change.columnDef, schemaName);
|
|
1231
|
+
// Physical rewrite ahead: flush the module's buffered writes so `migrateRows`
|
|
1232
|
+
// (a committed-store scan + batch) sees this transaction's rows and re-encodes
|
|
1233
|
+
// them under the new column layout. See {@link ddlCommitPendingOps}. Placed
|
|
1234
|
+
// after every throw-only check above — the NOT NULL rejection reads effectively
|
|
1235
|
+
// (`hasAnyRows`) and `extractColumnLevel*` reads no rows — so a rejected ALTER
|
|
1236
|
+
// leaves the enclosing transaction intact.
|
|
1237
|
+
await this.ddlCommitPendingOps();
|
|
1238
|
+
// Migrate rows: append the new column's value — a single literal default, or a
|
|
1239
|
+
// per-row value derived from the existing row when a backfill evaluator is set.
|
|
1240
|
+
const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
|
|
1241
|
+
await table.migrateRows(remap, defaultValue, backfillEvaluator
|
|
1242
|
+
? { evaluator: backfillEvaluator, notNull: newColSchema.notNull, columnName: newColSchema.name }
|
|
1243
|
+
: undefined);
|
|
1244
|
+
// Update table schema (column-only) and persist DDL.
|
|
1245
|
+
//
|
|
1246
|
+
// The engine's `runAddColumn` re-merges the column-level FK/CHECK extracted
|
|
1247
|
+
// from `columnDef.constraints` into the LIVE in-memory schema AFTER this hook
|
|
1248
|
+
// returns, so the schema handed back to it must stay column-only — returning a
|
|
1249
|
+
// constrained schema would double the constraint in the live SchemaManager (and,
|
|
1250
|
+
// on the next persist, in the DDL). But that engine-side merge is in-memory only:
|
|
1251
|
+
// it never reaches the catalog, so persistence must carry the column-level
|
|
1252
|
+
// CHECK/FK itself or they vanish on `rehydrateCatalog`. Build a separate
|
|
1253
|
+
// `persistedSchema` for `saveTableDDL` when (and only when) the column declares
|
|
1254
|
+
// such a constraint; the common path persists `updatedSchema` unchanged. This is
|
|
1255
|
+
// unconditional on the default kind — a per-row (evaluator) DEFAULT extracts the
|
|
1256
|
+
// same AST constraints as a literal one.
|
|
1257
|
+
table.updateSchema(updatedSchema);
|
|
1258
|
+
let persistedSchema = updatedSchema;
|
|
1259
|
+
if (newCheckConstraints.length > 0 || newForeignKeys.length > 0) {
|
|
1260
|
+
// The new column is appended last; resolve each FK's child column to its index
|
|
1261
|
+
// (matching how the engine resolves `resolvedForeignKeys` via columnIndexMap).
|
|
1262
|
+
const newColIdx = updatedColumns.length - 1;
|
|
1263
|
+
const resolvedForeignKeys = newForeignKeys.map(fk => ({ ...fk, columns: Object.freeze([newColIdx]) }));
|
|
1264
|
+
persistedSchema = {
|
|
1265
|
+
...updatedSchema,
|
|
1266
|
+
checkConstraints: Object.freeze([...updatedSchema.checkConstraints, ...newCheckConstraints]),
|
|
1267
|
+
foreignKeys: Object.freeze([...(updatedSchema.foreignKeys ?? []), ...resolvedForeignKeys]),
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
await this.saveTableDDL(persistedSchema);
|
|
1271
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1272
|
+
type: 'alter',
|
|
1273
|
+
objectType: 'table',
|
|
1274
|
+
schemaName,
|
|
1275
|
+
objectName: tableName,
|
|
1276
|
+
});
|
|
1277
|
+
return updatedSchema;
|
|
1278
|
+
}
|
|
1279
|
+
/** DROP COLUMN arm of {@link alterTable}: drop the column slot, reindex PK / indexes /
|
|
1280
|
+
* UNIQUE, migrate rows, and persist. Behavior-preserving extraction. */
|
|
1281
|
+
async alterDropColumn(schemaName, tableName, table, oldSchema, change) {
|
|
1282
|
+
const colNameLower = change.columnName.toLowerCase();
|
|
1283
|
+
const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
|
|
1284
|
+
if (colIndex === -1) {
|
|
1285
|
+
throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
|
|
1286
|
+
}
|
|
1287
|
+
// Build updated schema: remove column and reindex PK/indexes
|
|
1288
|
+
// Filter by original index BEFORE remapping to avoid incorrectly
|
|
1289
|
+
// removing columns that remap to the dropped column's position.
|
|
1290
|
+
const updatedColumns = oldSchema.columns.filter((_, idx) => idx !== colIndex);
|
|
1291
|
+
const updatedPkDef = oldSchema.primaryKeyDefinition
|
|
1292
|
+
.filter(def => def.index !== colIndex)
|
|
1293
|
+
.map(def => ({
|
|
1294
|
+
...def,
|
|
1295
|
+
index: def.index > colIndex ? def.index - 1 : def.index,
|
|
1296
|
+
}));
|
|
1297
|
+
const updatedIndexes = (oldSchema.indexes || [])
|
|
1298
|
+
.map(idx => ({
|
|
1299
|
+
...idx,
|
|
1300
|
+
columns: idx.columns
|
|
1301
|
+
.filter(ic => ic.index !== colIndex)
|
|
1302
|
+
.map(ic => ({ ...ic, index: ic.index > colIndex ? ic.index - 1 : ic.index })),
|
|
1303
|
+
}))
|
|
1304
|
+
.filter(idx => idx.columns.length > 0);
|
|
1305
|
+
// Prune any UNIQUE constraint over the dropped column, mirroring the index
|
|
1306
|
+
// filtering above. Store-backed UNIQUE is enforced by a full scan over
|
|
1307
|
+
// `uniqueConstraints`, so a stranded constraint whose column index dangles past
|
|
1308
|
+
// the column array would break the next insert's validation (and the persisted
|
|
1309
|
+
// DDL). A UNIQUE that includes the dropped column is removed outright; remaining
|
|
1310
|
+
// constraints have their column indices shifted to track the removed slot. This
|
|
1311
|
+
// also covers the engine's ADD COLUMN + inline-UNIQUE revert, which drops the
|
|
1312
|
+
// just-added (uniquely-constrained) column.
|
|
1313
|
+
const updatedUniqueConstraints = (oldSchema.uniqueConstraints ?? [])
|
|
1314
|
+
.filter(uc => !uc.columns.includes(colIndex))
|
|
1315
|
+
.map(uc => ({ ...uc, columns: Object.freeze(uc.columns.map(i => i > colIndex ? i - 1 : i)) }));
|
|
1316
|
+
const updatedSchema = {
|
|
1317
|
+
...oldSchema,
|
|
1318
|
+
columns: Object.freeze(updatedColumns),
|
|
1319
|
+
columnIndexMap: buildColumnIndexMap(updatedColumns),
|
|
1320
|
+
primaryKeyDefinition: Object.freeze(updatedPkDef),
|
|
1321
|
+
indexes: Object.freeze(updatedIndexes),
|
|
1322
|
+
uniqueConstraints: updatedUniqueConstraints.length > 0
|
|
1323
|
+
? Object.freeze(updatedUniqueConstraints)
|
|
1324
|
+
: undefined,
|
|
1325
|
+
};
|
|
1326
|
+
// Physical rewrite ahead — flush buffered writes so `migrateRows` re-encodes
|
|
1327
|
+
// this transaction's rows too. See {@link ddlCommitPendingOps}.
|
|
1328
|
+
await this.ddlCommitPendingOps();
|
|
1329
|
+
// Migrate rows: remove the dropped column slot
|
|
1330
|
+
const remap = buildColumnRemap(oldSchema.columns.map(c => c.name), updatedColumns.map(c => c.name));
|
|
1331
|
+
await table.migrateRows(remap, null);
|
|
1332
|
+
// Update table schema and persist DDL
|
|
1333
|
+
table.updateSchema(updatedSchema);
|
|
1334
|
+
await this.saveTableDDL(updatedSchema);
|
|
1335
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1336
|
+
type: 'alter',
|
|
1337
|
+
objectType: 'table',
|
|
1338
|
+
schemaName,
|
|
1339
|
+
objectName: tableName,
|
|
1340
|
+
});
|
|
1341
|
+
return updatedSchema;
|
|
1342
|
+
}
|
|
1343
|
+
/** RENAME COLUMN arm of {@link alterTable}: schema-only rewrite (columns, indexes, self-FK,
|
|
1344
|
+
* in-place predicate / CHECK AST rewrite) and persist. Behavior-preserving extraction. */
|
|
1345
|
+
async alterRenameColumn(db, schemaName, tableName, table, oldSchema, change, defaultNotNull) {
|
|
1346
|
+
if (!change.newColumnDefAst) {
|
|
1347
|
+
throw new QuereusError('RENAME COLUMN requires a new column definition AST', StatusCode.INTERNAL);
|
|
1348
|
+
}
|
|
1349
|
+
const oldNameLower = change.oldName.toLowerCase();
|
|
1350
|
+
const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === oldNameLower);
|
|
1351
|
+
if (colIndex === -1) {
|
|
1352
|
+
throw new QuereusError(`Column '${change.oldName}' not found.`, StatusCode.ERROR);
|
|
1353
|
+
}
|
|
1354
|
+
const newColSchema = columnDefToSchema(change.newColumnDefAst, defaultNotNull, 'BINARY', (n) => db.isCollationRegistered(n));
|
|
1355
|
+
const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newColSchema : c);
|
|
1356
|
+
const updatedIndexes = (oldSchema.indexes || []).map(idx => ({
|
|
1357
|
+
...idx,
|
|
1358
|
+
columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, name: change.newName } : ic),
|
|
1359
|
+
}));
|
|
1360
|
+
const updatedSchema = {
|
|
1361
|
+
...oldSchema,
|
|
1362
|
+
columns: Object.freeze(updatedColumns),
|
|
1363
|
+
columnIndexMap: buildColumnIndexMap(updatedColumns),
|
|
1364
|
+
indexes: Object.freeze(updatedIndexes),
|
|
1365
|
+
// A self-referencing FK names the renamed column in `referencedColumnNames`
|
|
1366
|
+
// and is persisted by name; the engine rewrites it only in the post-hook
|
|
1367
|
+
// pass. Pure copy — no rollback needed, this schema is only adopted on the
|
|
1368
|
+
// success path.
|
|
1369
|
+
foreignKeys: renameColumnInSelfForeignKeys(oldSchema.foreignKeys, schemaName, tableName, change.oldName, change.newName),
|
|
1370
|
+
};
|
|
1371
|
+
// A partial index's WHERE clause and a CHECK constraint's expression both
|
|
1372
|
+
// still name the OLD column: the engine's `propagateColumnRename` pass runs
|
|
1373
|
+
// only after this hook returns. Persisting now would durably write a bundle
|
|
1374
|
+
// naming a column the table no longer has, and only the later propagation's
|
|
1375
|
+
// `table_modified` event would correct it — a crash in between leaves the
|
|
1376
|
+
// catalog un-rehydratable. So rewrite first, in place: each `Expression` is
|
|
1377
|
+
// shared by reference with the catalog's `TableSchema` and, for a unique
|
|
1378
|
+
// partial index, with the `derivedFromIndex` UNIQUE constraint, so one
|
|
1379
|
+
// rewrite covers all holders and makes the later propagation pass a no-op.
|
|
1380
|
+
//
|
|
1381
|
+
// The rewrites are the first statements in the `try`, and each walks its
|
|
1382
|
+
// collection one item at a time, so a throw anywhere — including partway
|
|
1383
|
+
// through a walk — must reverse them: restoring `oldSchema` cannot, since the
|
|
1384
|
+
// ASTs are shared. Reversing is a no-op wherever nothing names the new column,
|
|
1385
|
+
// and the engine has already rejected a rename onto an existing column name,
|
|
1386
|
+
// so the reverse pass cannot collide with an expression that legitimately
|
|
1387
|
+
// named it.
|
|
1388
|
+
const resolveColumnInSource = (s, t, col) => db.schemaManager.getSchema(s)?.getTable(t)?.columnIndexMap.has(col.toLowerCase()) ?? false;
|
|
1389
|
+
const rewriteColumn = (from, to) => {
|
|
1390
|
+
renameColumnInIndexPredicates(updatedIndexes, tableName, from, to, schemaName, resolveColumnInSource);
|
|
1391
|
+
renameColumnInCheckConstraints(oldSchema.checkConstraints, tableName, from, to, schemaName, resolveColumnInSource);
|
|
1392
|
+
};
|
|
1393
|
+
try {
|
|
1394
|
+
rewriteColumn(change.oldName, change.newName);
|
|
1395
|
+
// Rename is schema-only — no row migration needed
|
|
1396
|
+
table.updateSchema(updatedSchema);
|
|
1397
|
+
await this.saveTableDDL(updatedSchema);
|
|
1398
|
+
}
|
|
1399
|
+
catch (e) {
|
|
1400
|
+
rewriteColumn(change.newName, change.oldName);
|
|
1401
|
+
throw e;
|
|
1402
|
+
}
|
|
1403
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1404
|
+
type: 'alter',
|
|
1405
|
+
objectType: 'table',
|
|
1406
|
+
schemaName,
|
|
1407
|
+
objectName: tableName,
|
|
1408
|
+
});
|
|
1409
|
+
return updatedSchema;
|
|
1410
|
+
}
|
|
1411
|
+
/** ALTER PRIMARY KEY arm of {@link alterTable}: physically re-key the data store, rebuild
|
|
1412
|
+
* secondary indexes, and persist. Behavior-preserving extraction. */
|
|
1413
|
+
async alterPrimaryKeyChange(db, schemaName, tableName, table, oldSchema, change) {
|
|
1414
|
+
const newPkColumns = change.newPkColumns;
|
|
1415
|
+
const updatedSchema = {
|
|
1416
|
+
...oldSchema,
|
|
1417
|
+
primaryKeyDefinition: Object.freeze(newPkColumns.map(pk => ({ index: pk.index, desc: pk.desc }))),
|
|
1418
|
+
};
|
|
1419
|
+
// Physical re-key ahead — flush buffered writes so every live row is
|
|
1420
|
+
// re-keyed and no stale-schema op replays over the rewritten store.
|
|
1421
|
+
// `rekeyRows`' duplicate-key pass runs against the flushed store, so a
|
|
1422
|
+
// pending insert that collides under the new PK is caught. See
|
|
1423
|
+
// {@link ddlCommitPendingOps} for the transaction consequences.
|
|
1424
|
+
await this.ddlCommitPendingOps();
|
|
1425
|
+
// Re-key the data store. Throws CONSTRAINT on duplicates without
|
|
1426
|
+
// mutating the store, giving us all-or-nothing semantics for the
|
|
1427
|
+
// validation phase.
|
|
1428
|
+
await table.rekeyRows(newPkColumns);
|
|
1429
|
+
// Secondary index keys embed the PK suffix — clear + rebuild every
|
|
1430
|
+
// index against the now-rekeyed data store. Rebuild the MATERIALIZED index list
|
|
1431
|
+
// so each implicit `_uc_*` PK suffix is re-encoded too (`updatedSchema` is built
|
|
1432
|
+
// off the de-materialized `oldSchema`, so it carries no `_uc_*` on its own).
|
|
1433
|
+
await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
|
|
1434
|
+
table.updateSchema(updatedSchema);
|
|
1435
|
+
await this.saveTableDDL(updatedSchema);
|
|
1436
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1437
|
+
type: 'alter',
|
|
1438
|
+
objectType: 'table',
|
|
1439
|
+
schemaName,
|
|
1440
|
+
objectName: tableName,
|
|
1441
|
+
});
|
|
1442
|
+
return updatedSchema;
|
|
1443
|
+
}
|
|
1444
|
+
/** ADD CONSTRAINT arm of {@link alterTable}: validate existing rows as the constraint kind
|
|
1445
|
+
* (UNIQUE / FOREIGN KEY / CHECK) requires, then persist. Behavior-preserving extraction. */
|
|
1446
|
+
async alterAddConstraint(db, schemaName, tableName, table, oldSchema, change, rows) {
|
|
1447
|
+
const constraint = change.constraint;
|
|
1448
|
+
let updatedSchema;
|
|
1449
|
+
if (constraint.type === 'unique') {
|
|
1450
|
+
// Validate the existing rows against the new UNIQUE before persisting. The
|
|
1451
|
+
// implicit `_uc_*` index that backs enforcement is materialized into the
|
|
1452
|
+
// StoreTable schema by `table.updateSchema` below, and its PHYSICAL store is
|
|
1453
|
+
// built by `reconcileImplicitUniqueIndexStores` after this arm returns — the
|
|
1454
|
+
// validation here runs first, so a CONSTRAINT rejection never leaves a store
|
|
1455
|
+
// behind.
|
|
1456
|
+
//
|
|
1457
|
+
// No `ddlCommitPendingOps()` here (unlike the row-rewriting arms): this
|
|
1458
|
+
// writes no rows, and the validation scan already reads effectively, so
|
|
1459
|
+
// the transaction survives a CONSTRAINT rejection.
|
|
1460
|
+
const uc = buildUniqueConstraintSchema(constraint, oldSchema.columnIndexMap);
|
|
1461
|
+
await this.validateUniqueOverExistingRows(rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds())), oldSchema, uc, db.getKeyNormalizerResolver());
|
|
1462
|
+
updatedSchema = {
|
|
1463
|
+
...oldSchema,
|
|
1464
|
+
uniqueConstraints: Object.freeze([...(oldSchema.uniqueConstraints ?? []), uc]),
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
else if (constraint.type === 'foreignKey') {
|
|
1468
|
+
const fk = buildForeignKeyConstraintSchema(constraint, oldSchema.columnIndexMap, oldSchema.name, oldSchema.schemaName);
|
|
1469
|
+
updatedSchema = {
|
|
1470
|
+
...oldSchema,
|
|
1471
|
+
foreignKeys: Object.freeze([...(oldSchema.foreignKeys ?? []), fk]),
|
|
1472
|
+
};
|
|
1473
|
+
// Pragma-gated existing-row validation; throws before persistence on an orphan.
|
|
1474
|
+
await validateForeignKeyOverExistingRows(db, updatedSchema, fk);
|
|
1475
|
+
}
|
|
1476
|
+
else if (constraint.type === 'check') {
|
|
1477
|
+
// Schema-only: a CHECK has no physical structure and (matching the
|
|
1478
|
+
// engine's prior in-emitter behavior) no existing-row scan. Routing it
|
|
1479
|
+
// here — rather than catalog-only — keeps the persisted DDL and the
|
|
1480
|
+
// connected-table schema in lock-step so DROP/RENAME CONSTRAINT resolve it.
|
|
1481
|
+
const check = buildCheckConstraintSchema(constraint, oldSchema.checkConstraints.length);
|
|
1482
|
+
updatedSchema = {
|
|
1483
|
+
...oldSchema,
|
|
1484
|
+
checkConstraints: Object.freeze([...oldSchema.checkConstraints, check]),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
throw new QuereusError(`Store table ADD CONSTRAINT does not support constraint type '${constraint.type}'`, StatusCode.UNSUPPORTED);
|
|
1489
|
+
}
|
|
1490
|
+
table.updateSchema(updatedSchema);
|
|
1491
|
+
await this.saveTableDDL(updatedSchema);
|
|
1492
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1493
|
+
type: 'alter',
|
|
1494
|
+
objectType: 'table',
|
|
1495
|
+
schemaName,
|
|
1496
|
+
objectName: tableName,
|
|
1497
|
+
});
|
|
1498
|
+
return updatedSchema;
|
|
1499
|
+
}
|
|
1500
|
+
/** DROP CONSTRAINT arm of {@link alterTable}: schema-only catalog rewrite dropping a named
|
|
1501
|
+
* constraint, then persist. Behavior-preserving extraction. */
|
|
1502
|
+
async alterDropConstraint(schemaName, tableName, table, oldSchema, change) {
|
|
1503
|
+
// Schema-only catalog rewrite. Dropping a UNIQUE removes it from
|
|
1504
|
+
// `uniqueConstraints`, so `table.updateSchema` below de-materializes its implicit
|
|
1505
|
+
// `_uc_*` index and `reconcileImplicitUniqueIndexStores` (after this arm returns)
|
|
1506
|
+
// tears down the now-orphaned physical store — without which a later re-ADD would
|
|
1507
|
+
// reopen stale entries. A UNIQUE derived from a CREATE UNIQUE INDEX is rejected
|
|
1508
|
+
// upstream (drop the index instead), and has no `_uc_*` anyway.
|
|
1509
|
+
const constraintClass = resolveNamedConstraintClass(oldSchema, change.constraintName);
|
|
1510
|
+
const lower = change.constraintName.toLowerCase();
|
|
1511
|
+
let updatedSchema;
|
|
1512
|
+
if (constraintClass === 'check') {
|
|
1513
|
+
updatedSchema = {
|
|
1514
|
+
...oldSchema,
|
|
1515
|
+
checkConstraints: Object.freeze(oldSchema.checkConstraints.filter(c => c.name?.toLowerCase() !== lower)),
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
else if (constraintClass === 'foreignKey') {
|
|
1519
|
+
const remaining = (oldSchema.foreignKeys ?? []).filter(c => c.name?.toLowerCase() !== lower);
|
|
1520
|
+
updatedSchema = { ...oldSchema, foreignKeys: remaining.length > 0 ? Object.freeze(remaining) : undefined };
|
|
1521
|
+
}
|
|
1522
|
+
else {
|
|
1523
|
+
const remaining = (oldSchema.uniqueConstraints ?? []).filter(c => c.name?.toLowerCase() !== lower);
|
|
1524
|
+
updatedSchema = { ...oldSchema, uniqueConstraints: remaining.length > 0 ? Object.freeze(remaining) : undefined };
|
|
1525
|
+
}
|
|
1526
|
+
table.updateSchema(updatedSchema);
|
|
1527
|
+
await this.saveTableDDL(updatedSchema);
|
|
1528
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1529
|
+
type: 'alter',
|
|
1530
|
+
objectType: 'table',
|
|
1531
|
+
schemaName,
|
|
1532
|
+
objectName: tableName,
|
|
1533
|
+
});
|
|
1534
|
+
return updatedSchema;
|
|
1535
|
+
}
|
|
1536
|
+
/** RENAME CONSTRAINT arm of {@link alterTable}: schema-only rename of a named constraint,
|
|
1537
|
+
* then persist. A renamed named-UNIQUE changes its implicit `_uc_*` index name, so
|
|
1538
|
+
* `reconcileImplicitUniqueIndexStores` (run after this arm) MOVES the physical store —
|
|
1539
|
+
* tears down the old-named store and rebuilds the new-named one from effective rows. */
|
|
1540
|
+
async alterRenameConstraint(schemaName, tableName, table, oldSchema, change) {
|
|
1541
|
+
const constraintClass = resolveNamedConstraintClass(oldSchema, change.oldName);
|
|
1542
|
+
const oldLower = change.oldName.toLowerCase();
|
|
1543
|
+
let updatedSchema;
|
|
1544
|
+
if (constraintClass === 'check') {
|
|
1545
|
+
updatedSchema = {
|
|
1546
|
+
...oldSchema,
|
|
1547
|
+
checkConstraints: Object.freeze(oldSchema.checkConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
else if (constraintClass === 'foreignKey') {
|
|
1551
|
+
updatedSchema = {
|
|
1552
|
+
...oldSchema,
|
|
1553
|
+
foreignKeys: Object.freeze(oldSchema.foreignKeys.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
updatedSchema = {
|
|
1558
|
+
...oldSchema,
|
|
1559
|
+
uniqueConstraints: Object.freeze(oldSchema.uniqueConstraints.map(c => (c.name?.toLowerCase() === oldLower ? { ...c, name: change.newName } : c))),
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
table.updateSchema(updatedSchema);
|
|
1563
|
+
await this.saveTableDDL(updatedSchema);
|
|
1564
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1565
|
+
type: 'alter',
|
|
1566
|
+
objectType: 'table',
|
|
1567
|
+
schemaName,
|
|
1568
|
+
objectName: tableName,
|
|
1569
|
+
});
|
|
1570
|
+
return updatedSchema;
|
|
1571
|
+
}
|
|
1572
|
+
/** ALTER COLUMN arm of {@link alterTable}: change one attribute (NOT NULL / data type /
|
|
1573
|
+
* default / collation), running the physical re-key + existing-row re-validation the
|
|
1574
|
+
* collation / PK paths require, then persist. Behavior-preserving extraction. */
|
|
1575
|
+
async alterColumnChange(db, schemaName, tableName, table, oldSchema, change, rows) {
|
|
1576
|
+
const colNameLower = change.columnName.toLowerCase();
|
|
1577
|
+
const colIndex = oldSchema.columns.findIndex(c => c.name.toLowerCase() === colNameLower);
|
|
1578
|
+
if (colIndex === -1) {
|
|
1579
|
+
throw new QuereusError(`Column '${change.columnName}' not found.`, StatusCode.ERROR);
|
|
1580
|
+
}
|
|
1581
|
+
const oldCol = oldSchema.columns[colIndex];
|
|
1582
|
+
// Pull exactly one attribute from the change. Each sub-helper returns the new
|
|
1583
|
+
// column schema plus whether the collation bytes changed (`collationChanged` gates
|
|
1584
|
+
// the existing-row UNIQUE re-validation and PK re-key below), or null when the column
|
|
1585
|
+
// is already in the desired state — preserving the pre-refactor early exits
|
|
1586
|
+
// (SET NOT NULL no-op, SET COLLATE already-explicit) that leave the table and any
|
|
1587
|
+
// open transaction untouched.
|
|
1588
|
+
let attr;
|
|
1589
|
+
if (change.setNotNull !== undefined) {
|
|
1590
|
+
attr = await this.alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows);
|
|
1591
|
+
}
|
|
1592
|
+
else if (change.setDataType !== undefined) {
|
|
1593
|
+
attr = await this.alterColumnSetDataType(table, oldCol, colIndex, change);
|
|
1594
|
+
}
|
|
1595
|
+
else if (change.setDefault !== undefined) {
|
|
1596
|
+
attr = { newCol: { ...oldCol, defaultValue: change.setDefault }, collationChanged: false };
|
|
1597
|
+
}
|
|
1598
|
+
else if (change.setCollation !== undefined) {
|
|
1599
|
+
attr = this.alterColumnSetCollation(oldCol, change, (n) => db.isCollationRegistered(n));
|
|
1600
|
+
}
|
|
1601
|
+
else {
|
|
1602
|
+
throw new QuereusError('ALTER COLUMN requires an attribute to change', StatusCode.INTERNAL);
|
|
1603
|
+
}
|
|
1604
|
+
if (attr === null) {
|
|
1605
|
+
return oldSchema;
|
|
1606
|
+
}
|
|
1607
|
+
const { newCol, collationChanged, valuesRewritten } = attr;
|
|
1608
|
+
const updatedColumns = oldSchema.columns.map((c, i) => i === colIndex ? newCol : c);
|
|
1609
|
+
// Mirror the memory module (MemoryTableManager.alterColumn): a per-column
|
|
1610
|
+
// collation change propagates into every index column ordering by this
|
|
1611
|
+
// column, so a `derivedFromIndex` UNIQUE re-keys its enforcement under the
|
|
1612
|
+
// new collation. StoreTable.uniqueEnforcementCollations reads the index's
|
|
1613
|
+
// per-column collation, so without this the index entry would stay stale
|
|
1614
|
+
// and the derived UNIQUE would keep enforcing the OLD collation after the
|
|
1615
|
+
// ALTER. Metadata-only: the store's index KEY bytes use the table-level key
|
|
1616
|
+
// collation K (see buildIndexEntries / updateSecondaryIndexes), so no index
|
|
1617
|
+
// entry re-encode is required for a non-PK column. An index column with an
|
|
1618
|
+
// explicit COLLATE is re-collated too — matching memory, which clobbers it
|
|
1619
|
+
// the same way (no surface preserves a differing index COLLATE across an
|
|
1620
|
+
// ALTER COLUMN SET COLLATE on its column).
|
|
1621
|
+
const updatedIndexes = (collationChanged && oldSchema.indexes)
|
|
1622
|
+
? oldSchema.indexes.map(idx => ({
|
|
1623
|
+
...idx,
|
|
1624
|
+
columns: idx.columns.map(ic => ic.index === colIndex ? { ...ic, collation: newCol.collation } : ic),
|
|
1625
|
+
}))
|
|
1626
|
+
: oldSchema.indexes;
|
|
1627
|
+
const updatedSchema = {
|
|
1628
|
+
...oldSchema,
|
|
1629
|
+
columns: Object.freeze(updatedColumns),
|
|
1630
|
+
columnIndexMap: buildColumnIndexMap(updatedColumns),
|
|
1631
|
+
indexes: updatedIndexes ? Object.freeze(updatedIndexes) : updatedIndexes,
|
|
1632
|
+
};
|
|
1633
|
+
// SET COLLATE existing-row re-validation (Option A, non-PK UNIQUE): a new
|
|
1634
|
+
// per-column collation can make rows that were distinct under the old
|
|
1635
|
+
// collation collide. Re-scan every UNIQUE constraint covering the altered
|
|
1636
|
+
// column under the NEW collation (`updatedSchema` carries it). The first
|
|
1637
|
+
// collision throws CONSTRAINT BEFORE any mutation/persist, so the table is
|
|
1638
|
+
// left unchanged and writable (matches the ADD CONSTRAINT rollback shape).
|
|
1639
|
+
// The PK is intentionally excluded — it never appears in `uniqueConstraints`;
|
|
1640
|
+
// its physical re-key/re-validation is the `isPkColumn` block below.
|
|
1641
|
+
if (collationChanged) {
|
|
1642
|
+
const coveringConstraints = (updatedSchema.uniqueConstraints ?? [])
|
|
1643
|
+
.filter(uc => uc.columns.includes(colIndex));
|
|
1644
|
+
for (const uc of coveringConstraints) {
|
|
1645
|
+
// Fresh generator per constraint — an async generator is single-shot. An
|
|
1646
|
+
// `EffectiveRowSource` is re-callable for exactly this reason.
|
|
1647
|
+
await this.validateUniqueOverExistingRows(rows ? rows() : rowsFromEntries(table.iterateEffectiveEntries(buildFullScanBounds())), updatedSchema, uc, db.getKeyNormalizerResolver());
|
|
1076
1648
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1649
|
+
}
|
|
1650
|
+
// SET COLLATE on a PRIMARY KEY member (Option B physical re-key): re-encode
|
|
1651
|
+
// every data-store key under the column's new key collation, then rebuild every
|
|
1652
|
+
// secondary index (its keys embed the PK suffix). `rekeyRows` validates in a
|
|
1653
|
+
// first pass and throws CONSTRAINT on a collision under the new collation WITHOUT
|
|
1654
|
+
// mutating the store — so a coarser collation that collapses two distinct PKs
|
|
1655
|
+
// (e.g. 'a'/'A' under BINARY→NOCASE) is rejected all-or-nothing, mirroring
|
|
1656
|
+
// ALTER PRIMARY KEY. Runs AFTER the non-PK UNIQUE re-validation above so both
|
|
1657
|
+
// throw-only checks precede the first store mutation. `updatedSchema.columns`
|
|
1658
|
+
// carries the new collation, so the new key bytes follow it.
|
|
1659
|
+
if (collationChanged && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
|
|
1660
|
+
// Physical re-key ahead — flush buffered writes (see
|
|
1661
|
+
// {@link ddlCommitPendingOps}). Deliberately AFTER the non-PK UNIQUE
|
|
1662
|
+
// re-validation above: that check reads effectively and throws without
|
|
1663
|
+
// needing the flush, so it must keep the transaction alive.
|
|
1664
|
+
//
|
|
1665
|
+
// NOTE: `rekeyRows` detects PK collisions among THIS module's rows only. When a
|
|
1666
|
+
// wrapper module supplied `rows`, its staged rows are not in this store, so a
|
|
1667
|
+
// pending row that collides with a committed one under the new collation is
|
|
1668
|
+
// caught by neither side — the wrapper's overlay enforces the PK among its own
|
|
1669
|
+
// rows, this store among its own. Closing it needs a PK-dedupe pass over `rows`
|
|
1670
|
+
// under the new collation here, before the re-key.
|
|
1671
|
+
await this.ddlCommitPendingOps();
|
|
1672
|
+
await table.rekeyRows(oldSchema.primaryKeyDefinition, updatedSchema.columns);
|
|
1673
|
+
// Materialize so the implicit `_uc_*` PK suffix is re-encoded under the new
|
|
1674
|
+
// key collation too (`updatedSchema` carries none on its own).
|
|
1675
|
+
await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
|
|
1676
|
+
}
|
|
1677
|
+
// SET DATA TYPE physical conversion / SET NOT NULL DEFAULT backfill rewrote stored
|
|
1678
|
+
// column values in place (same PK, new value) via mapRowsAtIndex. Secondary index KEY
|
|
1679
|
+
// bytes encode the indexed column VALUES, so any index covering this column still points
|
|
1680
|
+
// at the OLD value bytes until rebuilt — an index-backed lookup for the new value finds
|
|
1681
|
+
// nothing (mirrors the memory module's `valuesRewritten` rebuild in MemoryTableManager.
|
|
1682
|
+
// alterColumn). The sub-helper already ran ddlCommitPendingOps before its rewrite, so the
|
|
1683
|
+
// committed data store rebuildSecondaryIndexes reads is "everything live". Mutually
|
|
1684
|
+
// exclusive with the collation re-key above (distinct attributes), so no double rebuild.
|
|
1685
|
+
// Materialize so an implicit `_uc_*` over the rewritten column is rebuilt against the
|
|
1686
|
+
// new value bytes too.
|
|
1687
|
+
if (valuesRewritten) {
|
|
1688
|
+
await this.rebuildSecondaryIndexes(schemaName, tableName, table, withImplicitUniqueIndexes(updatedSchema), db.getKeyNormalizerResolver());
|
|
1689
|
+
}
|
|
1690
|
+
table.updateSchema(updatedSchema);
|
|
1691
|
+
await this.saveTableDDL(updatedSchema);
|
|
1692
|
+
this.eventEmitter?.emitSchemaChange({
|
|
1693
|
+
type: 'alter',
|
|
1694
|
+
objectType: 'table',
|
|
1695
|
+
schemaName,
|
|
1696
|
+
objectName: tableName,
|
|
1697
|
+
});
|
|
1698
|
+
return updatedSchema;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* SET NOT NULL / DROP NOT NULL sub-branch of {@link alterColumnChange}. Returns the
|
|
1702
|
+
* new column schema, or null when the column is already in the desired nullability
|
|
1703
|
+
* (the pre-refactor `return oldSchema` no-op). The NULL-backfill probe and rewrite
|
|
1704
|
+
* order (throw-only probe before {@link ddlCommitPendingOps}) is unchanged.
|
|
1705
|
+
*
|
|
1706
|
+
* `rows` is the wrapper-supplied effective row source (the isolation overlay). When present,
|
|
1707
|
+
* the reject-vs-backfill decision scans it instead of `table.rowsWithNullAtIndex`: behind the
|
|
1708
|
+
* isolation layer the issuer's pending inserts live in the wrapper's overlay, not this store,
|
|
1709
|
+
* so the store's own count would miss them and wrongly accept the ALTER. The committed-store
|
|
1710
|
+
* `mapRowsAtIndex` backfill is unchanged — the overlay-resident pending rows are the isolation
|
|
1711
|
+
* layer's job (its overlay migration), this store only owns its committed rows.
|
|
1712
|
+
*/
|
|
1713
|
+
async alterColumnSetNotNull(table, oldSchema, oldCol, colIndex, change, rows) {
|
|
1714
|
+
let newCol;
|
|
1715
|
+
let valuesRewritten = false;
|
|
1716
|
+
if (change.setNotNull === true && !oldCol.notNull) {
|
|
1717
|
+
// Backfill NULLs from a literal DEFAULT, or throw.
|
|
1718
|
+
let defaultLiteral;
|
|
1719
|
+
const expr = oldCol.defaultValue;
|
|
1720
|
+
if (expr && expr.type === 'literal') {
|
|
1721
|
+
defaultLiteral = expr.value ?? null;
|
|
1108
1722
|
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
// Pull exactly one of the three attributes from the change.
|
|
1121
|
-
if (change.setNotNull !== undefined) {
|
|
1122
|
-
if (change.setNotNull === true && !oldCol.notNull) {
|
|
1123
|
-
// Backfill NULLs from a literal DEFAULT, or throw.
|
|
1124
|
-
let defaultLiteral;
|
|
1125
|
-
const expr = oldCol.defaultValue;
|
|
1126
|
-
if (expr && expr.type === 'literal') {
|
|
1127
|
-
defaultLiteral = expr.value ?? null;
|
|
1128
|
-
}
|
|
1129
|
-
const nullCount = await table.rowsWithNullAtIndex(colIndex);
|
|
1130
|
-
if (nullCount > 0) {
|
|
1131
|
-
if (defaultLiteral === undefined || defaultLiteral === null) {
|
|
1132
|
-
throw new QuereusError(`column ${change.columnName} contains NULL values`, StatusCode.CONSTRAINT);
|
|
1133
|
-
}
|
|
1134
|
-
const fill = defaultLiteral;
|
|
1135
|
-
await table.mapRowsAtIndex(colIndex, (v) => v === null ? fill : v);
|
|
1136
|
-
}
|
|
1137
|
-
newCol = { ...oldCol, notNull: true };
|
|
1138
|
-
}
|
|
1139
|
-
else if (change.setNotNull === false && oldCol.notNull) {
|
|
1140
|
-
if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
|
|
1141
|
-
throw new QuereusError(`Cannot DROP NOT NULL on PRIMARY KEY column '${change.columnName}'`, StatusCode.CONSTRAINT);
|
|
1142
|
-
}
|
|
1143
|
-
newCol = { ...oldCol, notNull: false };
|
|
1144
|
-
}
|
|
1145
|
-
else {
|
|
1146
|
-
return oldSchema; // already in desired state
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
else if (change.setDataType !== undefined) {
|
|
1150
|
-
const newLogicalType = inferType(change.setDataType);
|
|
1151
|
-
if (newLogicalType.physicalType !== oldCol.logicalType.physicalType) {
|
|
1152
|
-
// Physical conversion required — walk every row and attempt parse.
|
|
1153
|
-
await table.mapRowsAtIndex(colIndex, (v) => {
|
|
1154
|
-
if (v === null)
|
|
1155
|
-
return v;
|
|
1156
|
-
try {
|
|
1157
|
-
return validateAndParse(v, newLogicalType, change.columnName);
|
|
1158
|
-
}
|
|
1159
|
-
catch {
|
|
1160
|
-
throw new QuereusError(`Cannot convert value in '${change.columnName}' to ${change.setDataType}`, StatusCode.MISMATCH);
|
|
1161
|
-
}
|
|
1162
|
-
});
|
|
1723
|
+
// Decide reject-vs-backfill over the DDL transaction's EFFECTIVE rows. `rows()` (the
|
|
1724
|
+
// isolation overlay) sees the issuer's pending inserts; `rowsWithNullAtIndex` (run
|
|
1725
|
+
// directly, no wrapper) sees the store's own effective rows. Either way a NULL the
|
|
1726
|
+
// transaction can see is backfilled, or rejects the ALTER, like any other row.
|
|
1727
|
+
let anyNull;
|
|
1728
|
+
if (rows) {
|
|
1729
|
+
anyNull = false;
|
|
1730
|
+
for await (const row of rows()) {
|
|
1731
|
+
if (row[colIndex] === null) {
|
|
1732
|
+
anyNull = true;
|
|
1733
|
+
break;
|
|
1163
1734
|
}
|
|
1164
|
-
newCol = { ...oldCol, logicalType: newLogicalType };
|
|
1165
1735
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
//
|
|
1173
|
-
|
|
1174
|
-
// secondary index under the new collation (the `isPkColumn` block below),
|
|
1175
|
-
// mirroring the memory module's primary re-key. A re-key that would collide
|
|
1176
|
-
// under the new collation throws CONSTRAINT before any mutation. For non-PK
|
|
1177
|
-
// UNIQUE constraints we re-validate existing rows under the new collation
|
|
1178
|
-
// (Option A). Query-layer ORDER BY / `=` / `table_info().collation` pick the
|
|
1179
|
-
// new collation up from the column schema once this updated schema re-registers.
|
|
1180
|
-
const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName);
|
|
1181
|
-
const nameMatches = normalized === (oldCol.collation || 'BINARY');
|
|
1182
|
-
if (nameMatches && oldCol.collationExplicit) {
|
|
1183
|
-
return oldSchema; // already explicit in the desired collation — no scan, no re-key, no re-persist
|
|
1184
|
-
}
|
|
1185
|
-
// SET COLLATE is a user declaration with the same standing as a
|
|
1186
|
-
// CREATE-time COLLATE clause, so mark the collation explicit (rank 2
|
|
1187
|
-
// in the comparison lattice) regardless of the column's creation
|
|
1188
|
-
// history — including SET COLLATE binary. When only the name matches
|
|
1189
|
-
// but the column was not yet explicit (a defaulted collation, or one
|
|
1190
|
-
// inherited from session default_collation), flip the flag as a
|
|
1191
|
-
// METADATA-ONLY change: the collation bytes are unchanged, so keep
|
|
1192
|
-
// collationChanged false to skip rekeyRows / validateUniqueOverExistingRows
|
|
1193
|
-
// below while still re-registering the schema and re-persisting DDL.
|
|
1194
|
-
// A different name takes the full physical re-key path AND sets the flag.
|
|
1195
|
-
newCol = { ...oldCol, collation: normalized, collationExplicit: true };
|
|
1196
|
-
collationChanged = !nameMatches;
|
|
1197
|
-
}
|
|
1198
|
-
else {
|
|
1199
|
-
throw new QuereusError('ALTER COLUMN requires an attribute to change', StatusCode.INTERNAL);
|
|
1736
|
+
}
|
|
1737
|
+
else {
|
|
1738
|
+
anyNull = (await table.rowsWithNullAtIndex(colIndex)) > 0;
|
|
1739
|
+
}
|
|
1740
|
+
if (anyNull) {
|
|
1741
|
+
if (defaultLiteral === undefined || defaultLiteral === null) {
|
|
1742
|
+
// Throw-only, and before the DDL-commit below: the transaction survives.
|
|
1743
|
+
throw new QuereusError(`column ${change.columnName} contains NULL values`, StatusCode.CONSTRAINT);
|
|
1200
1744
|
}
|
|
1201
|
-
const
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
if (coveringConstraints.length > 0) {
|
|
1238
|
-
const dataStore = await this.getStore(tableKey, table.getConfig());
|
|
1239
|
-
for (const uc of coveringConstraints) {
|
|
1240
|
-
await this.validateUniqueOverExistingRows(dataStore, updatedSchema, uc);
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1745
|
+
const fill = defaultLiteral;
|
|
1746
|
+
// Physical rewrite ahead — flush buffered writes so `mapRowsAtIndex` backfills this
|
|
1747
|
+
// store's own NULL rows. Run directly, that also fills the transaction's rows (the
|
|
1748
|
+
// probe saw them). Behind isolation the issuer's overlay-resident NULL rows are
|
|
1749
|
+
// filled by the isolation layer's overlay migration, not here — this store never
|
|
1750
|
+
// holds them. See {@link ddlCommitPendingOps}.
|
|
1751
|
+
await this.ddlCommitPendingOps();
|
|
1752
|
+
await table.mapRowsAtIndex(colIndex, (v) => v === null ? fill : v);
|
|
1753
|
+
valuesRewritten = true;
|
|
1754
|
+
}
|
|
1755
|
+
newCol = { ...oldCol, notNull: true };
|
|
1756
|
+
}
|
|
1757
|
+
else if (change.setNotNull === false && oldCol.notNull) {
|
|
1758
|
+
if (oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
|
|
1759
|
+
throw new QuereusError(`Cannot DROP NOT NULL on PRIMARY KEY column '${change.columnName}'`, StatusCode.CONSTRAINT);
|
|
1760
|
+
}
|
|
1761
|
+
newCol = { ...oldCol, notNull: false };
|
|
1762
|
+
}
|
|
1763
|
+
else {
|
|
1764
|
+
return null; // already in desired state
|
|
1765
|
+
}
|
|
1766
|
+
return { newCol, collationChanged: false, valuesRewritten };
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* SET DATA TYPE sub-branch of {@link alterColumnChange}. Returns the retyped column
|
|
1770
|
+
* schema. When the physical type changes, a throw-only convert pass over the live
|
|
1771
|
+
* rows precedes {@link ddlCommitPendingOps} and the rewrite — order unchanged.
|
|
1772
|
+
*/
|
|
1773
|
+
async alterColumnSetDataType(table, oldCol, colIndex, change) {
|
|
1774
|
+
const newLogicalType = inferType(change.setDataType);
|
|
1775
|
+
let valuesRewritten = false;
|
|
1776
|
+
if (newLogicalType.physicalType !== oldCol.logicalType.physicalType) {
|
|
1777
|
+
// Physical conversion required — walk every row and attempt parse.
|
|
1778
|
+
const convert = (v) => {
|
|
1779
|
+
try {
|
|
1780
|
+
return validateAndParse(v, newLogicalType, change.columnName);
|
|
1243
1781
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
// secondary index (its keys embed the PK suffix). `rekeyRows` validates in a
|
|
1247
|
-
// first pass and throws CONSTRAINT on a collision under the new collation WITHOUT
|
|
1248
|
-
// mutating the store — so a coarser collation that collapses two distinct PKs
|
|
1249
|
-
// (e.g. 'a'/'A' under BINARY→NOCASE) is rejected all-or-nothing, mirroring
|
|
1250
|
-
// ALTER PRIMARY KEY. Runs AFTER the non-PK UNIQUE re-validation above so both
|
|
1251
|
-
// throw-only checks precede the first store mutation. `updatedSchema.columns`
|
|
1252
|
-
// carries the new collation, so the new key bytes follow it.
|
|
1253
|
-
if (collationChanged && oldSchema.primaryKeyDefinition.some(def => def.index === colIndex)) {
|
|
1254
|
-
await table.rekeyRows(oldSchema.primaryKeyDefinition, updatedSchema.columns);
|
|
1255
|
-
await this.rebuildSecondaryIndexes(schemaName, tableName, tableKey, table, updatedSchema);
|
|
1782
|
+
catch {
|
|
1783
|
+
throw new QuereusError(`Cannot convert value in '${change.columnName}' to ${change.setDataType}`, StatusCode.MISMATCH);
|
|
1256
1784
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
});
|
|
1265
|
-
return updatedSchema;
|
|
1785
|
+
};
|
|
1786
|
+
// Throw-only pass over the LIVE rows first, so an unconvertible value
|
|
1787
|
+
// this transaction inserted rejects the ALTER with the transaction still
|
|
1788
|
+
// intact. The rewrite below reads only committed rows.
|
|
1789
|
+
for await (const value of table.iterateEffectiveValuesAtIndex(colIndex)) {
|
|
1790
|
+
if (value !== null)
|
|
1791
|
+
convert(value);
|
|
1266
1792
|
}
|
|
1793
|
+
// Physical rewrite ahead — flush buffered writes so `mapRowsAtIndex`
|
|
1794
|
+
// converts this transaction's rows too; unflushed, they would replay
|
|
1795
|
+
// under the OLD physical type. See {@link ddlCommitPendingOps}.
|
|
1796
|
+
await this.ddlCommitPendingOps();
|
|
1797
|
+
await table.mapRowsAtIndex(colIndex, (v) => v === null ? v : convert(v));
|
|
1798
|
+
valuesRewritten = true;
|
|
1267
1799
|
}
|
|
1800
|
+
return { newCol: { ...oldCol, logicalType: newLogicalType }, collationChanged: false, valuesRewritten };
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* SET COLLATE sub-branch of {@link alterColumnChange}. Returns the recollated column
|
|
1804
|
+
* schema with `collationChanged` set when the collation bytes actually change (a bare
|
|
1805
|
+
* metadata flip keeps it false), or null when the column is already explicit in the
|
|
1806
|
+
* desired collation (the pre-refactor `return oldSchema` no-op). Synchronous — no
|
|
1807
|
+
* row scan happens here; the caller runs the re-validation / re-key the flag gates.
|
|
1808
|
+
*/
|
|
1809
|
+
alterColumnSetCollation(oldCol, change, isCollationRegistered) {
|
|
1810
|
+
// Per-column collation update. PRIMARY KEY uniqueness/ordering is enforced
|
|
1811
|
+
// PHYSICALLY in the key bytes under a PER-COLUMN key collation
|
|
1812
|
+
// (`StoreTable.pkKeyCollations`), so a PK-column SET COLLATE is honored
|
|
1813
|
+
// natively by physically re-keying the data store + rebuilding every
|
|
1814
|
+
// secondary index under the new collation (the `isPkColumn` block below),
|
|
1815
|
+
// mirroring the memory module's primary re-key. A re-key that would collide
|
|
1816
|
+
// under the new collation throws CONSTRAINT before any mutation. For non-PK
|
|
1817
|
+
// UNIQUE constraints we re-validate existing rows under the new collation
|
|
1818
|
+
// (Option A). Query-layer ORDER BY / `=` / `table_info().collation` pick the
|
|
1819
|
+
// new collation up from the column schema once this updated schema re-registers.
|
|
1820
|
+
const normalized = validateCollationForType(change.setCollation, oldCol.logicalType, change.columnName, isCollationRegistered);
|
|
1821
|
+
const nameMatches = normalized === (oldCol.collation || 'BINARY');
|
|
1822
|
+
if (nameMatches && oldCol.collationExplicit) {
|
|
1823
|
+
return null; // already explicit in the desired collation — no scan, no re-key, no re-persist
|
|
1824
|
+
}
|
|
1825
|
+
// SET COLLATE is a user declaration with the same standing as a
|
|
1826
|
+
// CREATE-time COLLATE clause, so mark the collation explicit (rank 2
|
|
1827
|
+
// in the comparison lattice) regardless of the column's creation
|
|
1828
|
+
// history — including SET COLLATE binary. When only the name matches
|
|
1829
|
+
// but the column was not yet explicit (a defaulted collation, or one
|
|
1830
|
+
// inherited from session default_collation), flip the flag as a
|
|
1831
|
+
// METADATA-ONLY change: the collation bytes are unchanged, so keep
|
|
1832
|
+
// collationChanged false to skip rekeyRows / validateUniqueOverExistingRows
|
|
1833
|
+
// below while still re-registering the schema and re-persisting DDL.
|
|
1834
|
+
// A different name takes the full physical re-key path AND sets the flag.
|
|
1835
|
+
return { newCol: { ...oldCol, collation: normalized, collationExplicit: true }, collationChanged: !nameMatches };
|
|
1268
1836
|
}
|
|
1269
1837
|
/**
|
|
1270
1838
|
* Rename a store-backed table.
|
|
@@ -1290,7 +1858,11 @@ export class StoreModule {
|
|
|
1290
1858
|
// Authoritative index list (exact store names): the provider relocates
|
|
1291
1859
|
// exactly these index stores instead of prefix-scanning `{oldName}_idx_`,
|
|
1292
1860
|
// which would also catch a sibling table named `{oldName}_idx_<x>`.
|
|
1293
|
-
|
|
1861
|
+
// MATERIALIZED, so the hidden `_uc_*` store realizing a plain UNIQUE moves with
|
|
1862
|
+
// the table — otherwise the renamed table seeks a fresh EMPTY `_uc_*` and silently
|
|
1863
|
+
// accepts a duplicate of a pre-rename row. `currentSchema` itself stays
|
|
1864
|
+
// non-materialized: the catalog DDL rewritten below must carry no `_uc_*`.
|
|
1865
|
+
const indexNames = this.materializedIndexNames(existing, currentSchema);
|
|
1294
1866
|
// Reject when ANY physical name the rename introduces — the new data store
|
|
1295
1867
|
// AND each relocated index store `{schema}.{newName}_idx_{x}` — already
|
|
1296
1868
|
// names an existing store. E.g. rename some table to `q_idx_archive` while
|
|
@@ -1308,20 +1880,10 @@ export class StoreModule {
|
|
|
1308
1880
|
for (const indexName of indexNames) {
|
|
1309
1881
|
this.assertStoreNameFree(db, schemaName, buildIndexStoreName(schemaName, newName, indexName), `index store of index '${indexName}' on table '${schemaName}.${newName}' (rename target)`, occupied);
|
|
1310
1882
|
}
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
1315
|
-
// commit() calls on the same coordinator are no-ops (inTransaction
|
|
1316
|
-
// is cleared), which keeps the enclosing transaction safe.
|
|
1317
|
-
//
|
|
1318
|
-
// The coordinator is module-wide, so this DDL-commits the WHOLE module
|
|
1319
|
-
// transaction — every table's pending ops, not just the renamed table's —
|
|
1320
|
-
// in one all-or-nothing batch. That is the correct, consistent posture
|
|
1321
|
-
// for a store DDL-commit: an ALTER cannot half-commit some sibling tables.
|
|
1322
|
-
if (this.moduleCoordinator?.isInTransaction()) {
|
|
1323
|
-
await this.moduleCoordinator.commit();
|
|
1324
|
-
}
|
|
1883
|
+
// Flush buffered writes before the old store's handle is closed: once the
|
|
1884
|
+
// on-disk directory moves, prior buffered ops address stores that no longer
|
|
1885
|
+
// exist under those names. See {@link ddlCommitPendingOps}.
|
|
1886
|
+
await this.ddlCommitPendingOps();
|
|
1325
1887
|
// Hard-dispose the evicted handle: flush any lazy stats it was buffering AND
|
|
1326
1888
|
// deregister its coordinator stats-callback pair (the renamed instance is
|
|
1327
1889
|
// gone after this — the next connect()/getOrReconnectTable mints a fresh one
|
|
@@ -1339,15 +1901,32 @@ export class StoreModule {
|
|
|
1339
1901
|
this.stores.delete(oldKey);
|
|
1340
1902
|
// The coordinator is module-wide (flushed above); it is not per-table, so
|
|
1341
1903
|
// it is not evicted here.
|
|
1342
|
-
// Evict the disposed instance's registered engine
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
// `
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
|
|
1904
|
+
// Evict the disposed instance's registered engine connections — but ONLY the
|
|
1905
|
+
// ones this module created. Unlike drop — where the engine's schema manager
|
|
1906
|
+
// calls `removeConnectionsForTable` for us — the generic rename path
|
|
1907
|
+
// (`alter-table.ts` renameTableImpl) does NOT, so the store must evict here or
|
|
1908
|
+
// a StoreConnection leaks one per rename. Safe for `StoreConnection`s (both the
|
|
1909
|
+
// StoreTable-owned DML connection and the StoreBackingHost-owned one): they hold
|
|
1910
|
+
// no state of their own, delegating to the module-wide coordinator that the
|
|
1911
|
+
// DDL-commit above already flushed, and their owning StoreTable is now disposed.
|
|
1912
|
+
//
|
|
1913
|
+
// A blanket name-keyed sweep is NOT safe: a wrapping module (the isolation layer)
|
|
1914
|
+
// registers its own connection under the same qualified name, and that connection
|
|
1915
|
+
// is the only thing that drives its staged overlay to storage at COMMIT. Evicting
|
|
1916
|
+
// it silently drops the transaction's writes.
|
|
1917
|
+
//
|
|
1918
|
+
// NOTE: a rename onto a never-before-used name leaves the wrapper's connection
|
|
1919
|
+
// registered under the stale old name (it is not retargeted here). Benign — the
|
|
1920
|
+
// commit flush resolves overlays db-wide by their re-keyed names — but a workload
|
|
1921
|
+
// that renames one table through many distinct names in a single process
|
|
1922
|
+
// accumulates one such connection per name. If that ever matters, retarget the
|
|
1923
|
+
// connection's `tableName` across the rename instead of leaving it stale.
|
|
1924
|
+
const oldQualified = `${schemaName}.${oldName}`.toLowerCase();
|
|
1925
|
+
for (const conn of db.getAllConnections()) {
|
|
1926
|
+
if (conn instanceof StoreConnection && conn.tableName.toLowerCase() === oldQualified) {
|
|
1927
|
+
db.removeConnection(conn.connectionId);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1351
1930
|
// Move physical storage (data directory + index directories).
|
|
1352
1931
|
if (this.provider.renameTableStores) {
|
|
1353
1932
|
await this.provider.renameTableStores(schemaName, oldName, newName, indexNames);
|
|
@@ -1356,18 +1935,85 @@ export class StoreModule {
|
|
|
1356
1935
|
// so a crash mid-rename leaves the table discoverable under at least one
|
|
1357
1936
|
// name rather than neither.
|
|
1358
1937
|
if (currentSchema) {
|
|
1359
|
-
const renamedSchema = {
|
|
1360
|
-
|
|
1938
|
+
const renamedSchema = {
|
|
1939
|
+
...currentSchema,
|
|
1940
|
+
name: newName,
|
|
1941
|
+
// A self-referencing FK is persisted as `references <oldName>(...)`; after
|
|
1942
|
+
// `removeTableDDL` below, that names a table no longer in the catalog. Pure
|
|
1943
|
+
// copy — no rollback needed, `renamedSchema` is local to the write.
|
|
1944
|
+
foreignKeys: retargetSelfForeignKeys(currentSchema.foreignKeys, schemaName, oldName, newName),
|
|
1945
|
+
};
|
|
1946
|
+
// A partial index's WHERE clause and a CHECK constraint's expression can each
|
|
1947
|
+
// carry a table-qualified self-reference (`where t.b > 0`) still naming the OLD
|
|
1948
|
+
// table: `propagateTableRename` runs only after this hook returns, so — exactly
|
|
1949
|
+
// as in the `renameColumn` arm of `alterTable` — persisting now would durably
|
|
1950
|
+
// write a stale qualifier that only the later propagation event corrects.
|
|
1951
|
+
// Rewrite first, in place (each `Expression` is shared with the catalog
|
|
1952
|
+
// `TableSchema` and, for a unique partial index, with its derived UNIQUE
|
|
1953
|
+
// constraint), which also makes that later pass a no-op for these fields. A
|
|
1954
|
+
// throw anywhere in the `try` — including partway through a walk — reverses the
|
|
1955
|
+
// rewrites; reversing is a no-op wherever nothing names `newName`. The physical
|
|
1956
|
+
// stores have already moved by this point, so the reverse restores only the AST,
|
|
1957
|
+
// not the on-disk layout.
|
|
1958
|
+
//
|
|
1959
|
+
// NOTE: the reverse assumes no expression legitimately named `newName` before
|
|
1960
|
+
// the rename. The rename-target guard above makes that true for a real table,
|
|
1961
|
+
// and `compilePredicate` now rejects a foreign `table` qualifier at create time,
|
|
1962
|
+
// so a live partial-index predicate can only carry a self-qualifier (`where
|
|
1963
|
+
// <thisTable>.b > 0`) or a bare reference — never `where <newName>.b > 0` for a
|
|
1964
|
+
// different table. The mis-reversal path is therefore unreachable for a live
|
|
1965
|
+
// predicate.
|
|
1966
|
+
const rewriteTable = (from, to) => {
|
|
1967
|
+
renameTableInIndexPredicates(currentSchema.indexes, from, to, schemaName);
|
|
1968
|
+
renameTableInCheckConstraints(currentSchema.checkConstraints, from, to, schemaName);
|
|
1969
|
+
};
|
|
1970
|
+
try {
|
|
1971
|
+
rewriteTable(oldName, newName);
|
|
1972
|
+
await this.saveTableDDL(renamedSchema);
|
|
1973
|
+
}
|
|
1974
|
+
catch (e) {
|
|
1975
|
+
rewriteTable(newName, oldName);
|
|
1976
|
+
throw e;
|
|
1977
|
+
}
|
|
1361
1978
|
}
|
|
1362
|
-
|
|
1363
|
-
//
|
|
1979
|
+
// NOTE: the OLD name's catalog entry is deliberately NOT removed here. Deleting it
|
|
1980
|
+
// synchronously would drop `oldName` from the catalog BEFORE the engine's post-hook
|
|
1981
|
+
// `propagateTableRename` has rewritten — let alone persisted — the OTHER tables /
|
|
1982
|
+
// views / MVs that still name `oldName` (a cross-schema FK, a CHECK expression, a
|
|
1983
|
+
// dependent view/MV body). A crash in that gap would strand a durable catalog set
|
|
1984
|
+
// naming a vanished table, which reopens as a healthy-looking database whose
|
|
1985
|
+
// dependents cannot be written to. The removal is deferred to `finalizeRename`,
|
|
1986
|
+
// which the engine calls AFTER propagation has made the dependents durable.
|
|
1987
|
+
// Migrate the stats entry (unified __stats__ store, keyed by schema.table).
|
|
1988
|
+
// The entry is RE-KEYED, not physically moved with the directory: a unified
|
|
1989
|
+
// store keys every table's stats under `schema.table` in one store, so the
|
|
1990
|
+
// value must be copied from the old key to the new key and the old key
|
|
1991
|
+
// dropped. `dispose()` above already flushed any buffered delta to disk under
|
|
1992
|
+
// the old key, so the read here sees the current row-count estimate; deleting
|
|
1993
|
+
// without copying (the prior behavior) blinded the planner — a freshly-renamed
|
|
1994
|
+
// table reported getEstimatedRowCount() === 0 until stats were re-gathered.
|
|
1995
|
+
//
|
|
1996
|
+
// NOTE: this re-key reaches the old value only when getStatsStore(newName)
|
|
1997
|
+
// returns a store that CONTAINS the old key — true for the shipped providers,
|
|
1998
|
+
// which all share one unified __stats__ store, and for any provider whose
|
|
1999
|
+
// renameTableStores physically relocates a per-table stats store. A provider
|
|
2000
|
+
// that kept per-table stats stores and did NOT relocate them in
|
|
2001
|
+
// renameTableStores would orphan the old-keyed value out of reach here (the
|
|
2002
|
+
// prior delete-only code lost it just the same — no regression). No shipped
|
|
2003
|
+
// provider does this; revisit if one ever keeps per-table, non-relocated stats.
|
|
1364
2004
|
try {
|
|
1365
2005
|
const statsStore = await this.provider.getStatsStore(schemaName, newName);
|
|
1366
2006
|
const oldStatsKey = buildStatsKey(schemaName, oldName);
|
|
2007
|
+
const statsValue = await statsStore.get(oldStatsKey);
|
|
2008
|
+
if (statsValue) {
|
|
2009
|
+
// A table with no stats yet (never flushed) returns undefined here —
|
|
2010
|
+
// skip the copy so no spurious zero-count entry lands under the new key.
|
|
2011
|
+
await statsStore.put(buildStatsKey(schemaName, newName), statsValue);
|
|
2012
|
+
}
|
|
1367
2013
|
await statsStore.delete(oldStatsKey);
|
|
1368
2014
|
}
|
|
1369
2015
|
catch {
|
|
1370
|
-
/* stats are advisory — a
|
|
2016
|
+
/* stats are advisory — a stats hiccup must never block the rename */
|
|
1371
2017
|
}
|
|
1372
2018
|
this.eventEmitter?.emitSchemaChange({
|
|
1373
2019
|
type: 'alter',
|
|
@@ -1376,6 +2022,32 @@ export class StoreModule {
|
|
|
1376
2022
|
objectName: newName,
|
|
1377
2023
|
});
|
|
1378
2024
|
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Second phase of a two-phase RENAME TABLE (see {@link renameTable}). The engine calls
|
|
2027
|
+
* this at the END of ALTER TABLE ... RENAME TO — AFTER its `propagateTableRename` has
|
|
2028
|
+
* rewritten every dependent object that named `oldName` and enqueued their corrective
|
|
2029
|
+
* catalog writes onto {@link persistQueue}. `renameTable` deliberately left `oldName`'s
|
|
2030
|
+
* catalog entry in place; here we drain those dependent writes to durability and only
|
|
2031
|
+
* THEN drop the old entry. During the window both entries coexist on disk, so every
|
|
2032
|
+
* dependent resolves against one of them and every intermediate catalog set rehydrates
|
|
2033
|
+
* into a working database.
|
|
2034
|
+
*
|
|
2035
|
+
* The old-entry delete rides `persistQueue` behind the dependents' already-enqueued
|
|
2036
|
+
* writes (FIFO), so it can only run after them, and the drain below awaits the whole
|
|
2037
|
+
* chain. Errors are swallowed+logged (the {@link enqueuePersist} contract): a failed
|
|
2038
|
+
* delete leaves the old entry present — a visible, droppable orphan, strictly safer
|
|
2039
|
+
* than a dependent stranded against a vanished table.
|
|
2040
|
+
*
|
|
2041
|
+
* NOTE: full cross-table atomicity — bundling this old-entry delete together with every
|
|
2042
|
+
* dependent rewrite into one `provider.beginAtomicBatch` commit — would eliminate even
|
|
2043
|
+
* the transient two-entry window (and the physical-move orphan `renameTableStores`
|
|
2044
|
+
* leaves), but only on atomic providers, and the dependent set is known only to the
|
|
2045
|
+
* engine post-propagate. That is the atomic-provider hardening path; out of scope here.
|
|
2046
|
+
*/
|
|
2047
|
+
async finalizeRename(_db, schemaName, oldName, _newName) {
|
|
2048
|
+
this.enqueuePersist(() => this.removeTableDDL(schemaName, oldName));
|
|
2049
|
+
await this.whenCatalogPersisted();
|
|
2050
|
+
}
|
|
1379
2051
|
/**
|
|
1380
2052
|
* Modern access planning interface.
|
|
1381
2053
|
*
|
|
@@ -1389,26 +2061,41 @@ export class StoreModule {
|
|
|
1389
2061
|
* (via `StoreTable.buildPKRangeBounds`) encodes the LT/LE/GT/GE bounds under the
|
|
1390
2062
|
* same per-column key collations the data keys use and iterates that
|
|
1391
2063
|
* seek-start/early-termination window. The window is a SUPERSET, so the post-fetch
|
|
1392
|
-
* row filter still reproduces the exact collation semantics
|
|
1393
|
-
*
|
|
1394
|
-
* memory module's advertisement.
|
|
2064
|
+
* row filter still reproduces the exact collation semantics. (A collation that cannot
|
|
2065
|
+
* key at all never reaches a PK column: `StoreTable`'s constructor rejects it at DDL
|
|
2066
|
+
* time.) Mirrors the memory module's advertisement.
|
|
2067
|
+
*
|
|
2068
|
+
* The seek/ordering claims are additionally gated on the key collation being
|
|
2069
|
+
* ORDER-PRESERVING — byte order and comparator order must coincide, or the seek would
|
|
2070
|
+
* under-fetch and the ordering advertisement would elide a Sort it must not. That gate
|
|
2071
|
+
* needs the connection's collation registry, hence `db` is threaded down.
|
|
1395
2072
|
*/
|
|
1396
|
-
getBestAccessPlan(
|
|
1397
|
-
return { ...this.computeBestAccessPlan(tableInfo, request), honorsCollatedRangeBounds: true };
|
|
2073
|
+
getBestAccessPlan(db, tableInfo, request) {
|
|
2074
|
+
return { ...this.computeBestAccessPlan(db, tableInfo, request), honorsCollatedRangeBounds: true };
|
|
1398
2075
|
}
|
|
1399
|
-
computeBestAccessPlan(tableInfo, request) {
|
|
2076
|
+
computeBestAccessPlan(db, tableInfo, request) {
|
|
1400
2077
|
const estimatedRows = request.estimatedRows ?? 1000;
|
|
1401
|
-
//
|
|
2078
|
+
// Table key collation K (default NOCASE). Any K reaching here has a key normalizer:
|
|
2079
|
+
// `StoreTable`'s constructor rejects one that cannot key, and a name we cannot resolve
|
|
2080
|
+
// to a table falls back to the built-in NOCASE. `||`, not `??`, so an empty-string
|
|
2081
|
+
// `config.collation` reads as the default here exactly as it does in
|
|
2082
|
+
// `StoreTable.encodeOptions` — the two must agree or `analyzePKAccess` would decline a
|
|
2083
|
+
// window `computeBestAccessPlan` already marked handled.
|
|
2084
|
+
const tableKeyCollation = (this.getTable(tableInfo.schemaName, tableInfo.name)?.getConfig().collation || 'NOCASE').toUpperCase();
|
|
2085
|
+
// Check for primary key equality constraints. Count DISTINCT pinned PK columns:
|
|
2086
|
+
// counting raw '=' filters would read `a = 1 and a = 2` on a composite PK (a, b)
|
|
2087
|
+
// as "both PK columns pinned", claim both filters handled, then — with no
|
|
2088
|
+
// complete PK equality set to seek — degrade to a sequential scan whose residual
|
|
2089
|
+
// has already been discarded, returning the whole table.
|
|
1402
2090
|
const pkColumns = tableInfo.primaryKeyDefinition.map(pk => pk.index);
|
|
1403
|
-
const
|
|
1404
|
-
pkColumns.includes(f.columnIndex) &&
|
|
1405
|
-
f
|
|
1406
|
-
if (
|
|
2091
|
+
const pinnedPkColumns = new Set(request.filters
|
|
2092
|
+
.filter(f => f.columnIndex !== undefined && pkColumns.includes(f.columnIndex) && f.op === '=')
|
|
2093
|
+
.map(f => f.columnIndex));
|
|
2094
|
+
if (pinnedPkColumns.size === pkColumns.length && pkColumns.length > 0) {
|
|
1407
2095
|
// Full PK match - point lookup (single row; no monotonic advertisement)
|
|
1408
|
-
const handledFilters = request.filters.map(f => pkFilters.some(pf => pf.columnIndex === f.columnIndex && pf.op === f.op));
|
|
1409
2096
|
return AccessPlanBuilder
|
|
1410
2097
|
.eqMatch(1, 0.1)
|
|
1411
|
-
.setHandledFilters(
|
|
2098
|
+
.setHandledFilters(claimFirstPerRole(request.filters, equalityRoles(pkColumns)))
|
|
1412
2099
|
.setIsSet(true)
|
|
1413
2100
|
.setIndexName('_primary_')
|
|
1414
2101
|
.setExplanation('Store primary key lookup')
|
|
@@ -1419,50 +2106,61 @@ export class StoreModule {
|
|
|
1419
2106
|
// range bounds for primaryKeyDefinition[0]; ranges on later PK columns
|
|
1420
2107
|
// are silently dropped if marked handled. So only claim handled=true
|
|
1421
2108
|
// when the range is on the first PK column.
|
|
1422
|
-
|
|
2109
|
+
//
|
|
2110
|
+
// The seek is also declined when the leading PK column's key bytes do not order the
|
|
2111
|
+
// way its comparator does (`pkOrderPreservingPrefix === 0`): `StoreTable.analyzePKAccess`
|
|
2112
|
+
// declines the byte window under exactly that condition, so claiming the range filters
|
|
2113
|
+
// handled here would drop the residual Filter and return the whole table.
|
|
2114
|
+
const pkOrderPreservingPrefix = pkOrderPreservingPrefixLength(db, tableInfo, resolvePkKeyCollations(tableInfo.primaryKeyDefinition, tableInfo.columns, tableKeyCollation), tableKeyCollation);
|
|
1423
2115
|
const firstPkColumn = tableInfo.primaryKeyDefinition[0]?.index;
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if (rangeFilters.length > 0) {
|
|
2116
|
+
const hasLeadingPkRange = firstPkColumn !== undefined
|
|
2117
|
+
&& pkOrderPreservingPrefix >= 1
|
|
2118
|
+
&& request.filters.some(f => f.columnIndex === firstPkColumn && RANGE_OPS.includes(f.op));
|
|
2119
|
+
if (hasLeadingPkRange) {
|
|
1429
2120
|
// Range scan on first PK column. Iteration is by PK key order (see
|
|
1430
2121
|
// StoreTable.scanPKRange), so we can advertise monotonic emission on
|
|
1431
2122
|
// the leading PK column. The scan seeks to the window start and
|
|
1432
2123
|
// early-terminates (StoreTable.buildPKRangeBounds derives the encoded
|
|
1433
2124
|
// bounds), and the leading-PK order guarantee holds throughout.
|
|
1434
|
-
const handledFilters = request.filters.map(f => rangeFilters.some(rf => rf.columnIndex === f.columnIndex && rf.op === f.op));
|
|
1435
2125
|
const rangeRows = Math.max(1, Math.floor(estimatedRows * 0.3));
|
|
1436
2126
|
const plan = AccessPlanBuilder
|
|
1437
2127
|
.rangeScan(rangeRows, 0.2)
|
|
1438
|
-
.setHandledFilters(
|
|
2128
|
+
.setHandledFilters(claimFirstPerRole(request.filters, rangeRoles(firstPkColumn)))
|
|
1439
2129
|
.setIndexName('_primary_')
|
|
1440
2130
|
.setSeekColumns([firstPkColumn])
|
|
1441
2131
|
.setExplanation('Store primary key range scan')
|
|
1442
2132
|
.build();
|
|
1443
|
-
return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request) };
|
|
1444
|
-
}
|
|
1445
|
-
// Check for secondary index usage
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
2133
|
+
return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
|
|
2134
|
+
}
|
|
2135
|
+
// Check for secondary index usage. `StoreTable.query` now implements the
|
|
2136
|
+
// secondary-index scan arm (leading-prefix EQ point / leading-column range),
|
|
2137
|
+
// so we advertise the index with `indexName` + `seekColumns` and mark the
|
|
2138
|
+
// covered filters handled — subject to the collation-safety guard in
|
|
2139
|
+
// {@link tryIndexAccessPlan}. A cost-only plan (no seek) is kept as a fallback
|
|
2140
|
+
// when no index yields a collation-safe seek, preserving the prior "cheaper
|
|
2141
|
+
// cost, filters unhandled, residual retained" behavior.
|
|
1451
2142
|
const indexes = tableInfo.indexes || [];
|
|
2143
|
+
let costOnlyFallback = null;
|
|
1452
2144
|
for (const index of indexes) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
}
|
|
2145
|
+
if (index.columns.length === 0)
|
|
2146
|
+
continue;
|
|
2147
|
+
const plan = this.tryIndexAccessPlan(db, tableKeyCollation, tableInfo, request, index, estimatedRows);
|
|
2148
|
+
if (!plan)
|
|
2149
|
+
continue;
|
|
2150
|
+
// A fully-handled seek (indexName + seekColumns set) wins immediately.
|
|
2151
|
+
if (plan.seekColumnIndexes && plan.seekColumnIndexes.length > 0)
|
|
2152
|
+
return plan;
|
|
2153
|
+
// Otherwise remember the first cost-only advertisement as a fallback.
|
|
2154
|
+
if (!costOnlyFallback)
|
|
2155
|
+
costOnlyFallback = plan;
|
|
1465
2156
|
}
|
|
2157
|
+
// NOTE: a cost-only plan carries no PK-order advertisement even though the store still
|
|
2158
|
+
// iterates in PK key order for it (`StoreTable.query` full-scans). The range gate makes
|
|
2159
|
+
// this arm fire more often — an index range on a BINARY text column of a default-K table
|
|
2160
|
+
// now lands here — so `... where v > 'x' order by <pk>` picks up a Sort it did not need.
|
|
2161
|
+
// If that shows up as slow, merge `buildPkOrderingAdvertisement(...)` into this return.
|
|
2162
|
+
if (costOnlyFallback)
|
|
2163
|
+
return costOnlyFallback;
|
|
1466
2164
|
// Fallback to full scan. The store iterates rows in PK key order
|
|
1467
2165
|
// (see StoreTable.query / store.iterate over buildFullScanBounds), so
|
|
1468
2166
|
// the scan is monotonic on the leading PK column. Advertise that so
|
|
@@ -1473,7 +2171,115 @@ export class StoreModule {
|
|
|
1473
2171
|
.setHandledFilters(new Array(request.filters.length).fill(false))
|
|
1474
2172
|
.setExplanation('Store full table scan')
|
|
1475
2173
|
.build();
|
|
1476
|
-
return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request) };
|
|
2174
|
+
return { ...plan, ...this.buildPkOrderingAdvertisement(tableInfo, request, pkOrderPreservingPrefix) };
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* Build the access plan for one secondary index against `request`, or null when
|
|
2178
|
+
* the index is not usable for this predicate.
|
|
2179
|
+
*
|
|
2180
|
+
* Usable = a contiguous leading-prefix EQ on the index columns (an index seek /
|
|
2181
|
+
* point), or a LT/LE/GT/GE range on the LEADING index column. These mirror the
|
|
2182
|
+
* two windows {@link StoreTable.analyzeIndexAccess} can build.
|
|
2183
|
+
*
|
|
2184
|
+
* **Collation-safety guard against under-fetch.** The store's index-column
|
|
2185
|
+
* window is encoded under the table key collation K, but `matchesFilters`
|
|
2186
|
+
* compares under the COLUMN's declared collation. Marking a filter handled drops
|
|
2187
|
+
* the residual Filter, so the K-window must be a guaranteed SUPERSET of the
|
|
2188
|
+
* qualifying rows. That holds only when K is coarser-or-equal to the column's
|
|
2189
|
+
* declared collation. To stay provably safe with minimal logic we mark the
|
|
2190
|
+
* covered filters handled — setting `indexName` + `seekColumns` — only when every
|
|
2191
|
+
* seek column is non-text, OR its declared collation equals K, OR (K = NOCASE
|
|
2192
|
+
* while the column is BINARY, i.e. K strictly coarser). Otherwise we return a
|
|
2193
|
+
* cost-only plan (cheaper cost, filters unhandled, residual retained — correct,
|
|
2194
|
+
* just not sped up). K itself always keys: `StoreTable`'s constructor rejects a
|
|
2195
|
+
* table whose key encoding would need a collation it cannot resolve to a normalizer.
|
|
2196
|
+
*
|
|
2197
|
+
* NOTE: the coarser-K relaxation is sound for EQUALITY only. A RANGE window equates
|
|
2198
|
+
* memcmp of K-normalized bytes with C's comparator order, which a merely coarser K does
|
|
2199
|
+
* not give — under K = NOCASE and C = BINARY, 'K' (U+212A) is `> 'z'` yet keys as 'k',
|
|
2200
|
+
* before 'z'. So the range arm demands `C === K` *and* K's `orderPreserving` assertion,
|
|
2201
|
+
* via the shared {@link keyOrderMatchesCollation}; `StoreTable.analyzeIndexAccess`
|
|
2202
|
+
* declines the same windows, so the two decisions cannot disagree. The cost is that a
|
|
2203
|
+
* default-K (NOCASE) table with an index on a plain BINARY text column loses its index
|
|
2204
|
+
* RANGE seek and falls back to the cost-only plan; EQ seeks are unchanged.
|
|
2205
|
+
*/
|
|
2206
|
+
tryIndexAccessPlan(db, tableKeyCollation, tableInfo, request, index, estimatedRows) {
|
|
2207
|
+
// Exclude PARTIAL indexes from access planning: neither the engine nor this
|
|
2208
|
+
// module checks that the query's WHERE implies the index predicate, so seeking
|
|
2209
|
+
// a partial index for a query it doesn't cover would silently drop the rows the
|
|
2210
|
+
// index omits (an out-of-scope predicate returns nothing). Treat partial indexes
|
|
2211
|
+
// purely as uniqueness enforcers — the query full-scans + residual instead.
|
|
2212
|
+
// Mirrors MemoryTableModule.getAvailableIndexes (`if (idx.predicate) continue`).
|
|
2213
|
+
if (index.predicate)
|
|
2214
|
+
return null;
|
|
2215
|
+
const indexColIndexes = index.columns.map(c => c.index);
|
|
2216
|
+
// Contiguous leading-prefix EQ → point/prefix seek.
|
|
2217
|
+
const eqCols = [];
|
|
2218
|
+
for (const colIdx of indexColIndexes) {
|
|
2219
|
+
if (!request.filters.some(f => f.columnIndex === colIdx && f.op === '='))
|
|
2220
|
+
break;
|
|
2221
|
+
eqCols.push(colIdx);
|
|
2222
|
+
}
|
|
2223
|
+
const leadingCol = indexColIndexes[0];
|
|
2224
|
+
const hasLeadingRange = request.filters.some(f => f.columnIndex === leadingCol && RANGE_OPS.includes(f.op));
|
|
2225
|
+
let seekCols;
|
|
2226
|
+
let isRange;
|
|
2227
|
+
if (eqCols.length > 0) {
|
|
2228
|
+
seekCols = eqCols;
|
|
2229
|
+
isRange = false;
|
|
2230
|
+
}
|
|
2231
|
+
else if (hasLeadingRange) {
|
|
2232
|
+
seekCols = [leadingCol];
|
|
2233
|
+
isRange = true;
|
|
2234
|
+
}
|
|
2235
|
+
else {
|
|
2236
|
+
return null; // this index cannot serve this predicate
|
|
2237
|
+
}
|
|
2238
|
+
const rows = isRange
|
|
2239
|
+
? Math.max(1, Math.floor(estimatedRows * 0.3))
|
|
2240
|
+
: Math.max(1, Math.floor(estimatedRows * 0.1));
|
|
2241
|
+
const costOnly = (why) => (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
|
|
2242
|
+
.setHandledFilters(new Array(request.filters.length).fill(false))
|
|
2243
|
+
.setExplanation(`Store index scan on ${index.name} (${why})`)
|
|
2244
|
+
.build();
|
|
2245
|
+
// The INDEX column's effective comparison collation C — the index column's own
|
|
2246
|
+
// COLLATE, else the table column's declared collation. C, not the table column's
|
|
2247
|
+
// declared collation, is what matchesFilters compares an index-scan row under and
|
|
2248
|
+
// what the planner matched to drop the residual, so the K-window must be a superset
|
|
2249
|
+
// relative to C.
|
|
2250
|
+
const K = tableKeyCollation;
|
|
2251
|
+
const effectiveCollation = (colIdx) => {
|
|
2252
|
+
const indexCol = index.columns.find(c => c.index === colIdx);
|
|
2253
|
+
return (indexCol?.collation ?? tableInfo.columns[colIdx]?.collation ?? 'BINARY').toUpperCase();
|
|
2254
|
+
};
|
|
2255
|
+
// EQUALITY: safe to mark handled iff K is coarser-or-equal to C. Exempt only columns
|
|
2256
|
+
// that can NEVER hold text — their key bytes are type-native and collation-independent.
|
|
2257
|
+
// A bare `isTextual` test wrongly exempts an `ANY` column (no marker, but it stores
|
|
2258
|
+
// text as text), which would seek under K, drop the residual, and lose rows.
|
|
2259
|
+
const eqSafeToHandle = (colIdx) => {
|
|
2260
|
+
const col = tableInfo.columns[colIdx];
|
|
2261
|
+
if (!columnCanHoldText(col))
|
|
2262
|
+
return true;
|
|
2263
|
+
const C = effectiveCollation(colIdx);
|
|
2264
|
+
if (C === K)
|
|
2265
|
+
return true; // equal
|
|
2266
|
+
if (K === 'NOCASE' && C === 'BINARY')
|
|
2267
|
+
return true; // K strictly coarser
|
|
2268
|
+
return false;
|
|
2269
|
+
};
|
|
2270
|
+
// RANGE: coarser is not enough — byte order must BE comparator order. See the doc above.
|
|
2271
|
+
const rangeSafeToHandle = (colIdx) => keyOrderMatchesCollation(db, tableInfo.columns[colIdx], K, effectiveCollation(colIdx));
|
|
2272
|
+
if (!seekCols.every(isRange ? rangeSafeToHandle : eqSafeToHandle)) {
|
|
2273
|
+
return costOnly('cost-only; key collation may under-fetch');
|
|
2274
|
+
}
|
|
2275
|
+
// Claim positionally — see {@link claimFirstPerRole}.
|
|
2276
|
+
const handledFilters = claimFirstPerRole(request.filters, isRange ? rangeRoles(leadingCol) : equalityRoles(eqCols));
|
|
2277
|
+
return (isRange ? AccessPlanBuilder.rangeScan(rows, 0.2) : AccessPlanBuilder.eqMatch(rows, 0.3))
|
|
2278
|
+
.setHandledFilters(handledFilters)
|
|
2279
|
+
.setIndexName(index.name)
|
|
2280
|
+
.setSeekColumns(seekCols)
|
|
2281
|
+
.setExplanation(`Store index ${isRange ? 'range scan' : 'seek'} on ${index.name}`)
|
|
2282
|
+
.build();
|
|
1477
2283
|
}
|
|
1478
2284
|
/**
|
|
1479
2285
|
* Compute the PK-ordering advertisement for a scan-style plan. Returns the
|
|
@@ -1498,10 +2304,17 @@ export class StoreModule {
|
|
|
1498
2304
|
*
|
|
1499
2305
|
* Returns an empty object when there is no PK (heap-only table) — without a
|
|
1500
2306
|
* leading key column there is no natural emit order.
|
|
2307
|
+
*
|
|
2308
|
+
* Every claim here is about the PHYSICAL key-byte order the store iterates in, but the
|
|
2309
|
+
* consumers of `providesOrdering` / `monotonicOn` reason in the columns' COLLATION order.
|
|
2310
|
+
* `orderPreservingPrefix` (from {@link pkOrderPreservingPrefixLength}) is how many leading
|
|
2311
|
+
* PK members those two orders provably agree on: the advertisement is truncated to that
|
|
2312
|
+
* prefix, and voided entirely when even the leading member disagrees — otherwise the
|
|
2313
|
+
* absorb-Sort rule would elide a Sort and hand the caller byte-ordered rows.
|
|
1501
2314
|
*/
|
|
1502
|
-
buildPkOrderingAdvertisement(tableInfo, request) {
|
|
2315
|
+
buildPkOrderingAdvertisement(tableInfo, request, orderPreservingPrefix) {
|
|
1503
2316
|
const pk = tableInfo.primaryKeyDefinition;
|
|
1504
|
-
if (pk.length === 0)
|
|
2317
|
+
if (pk.length === 0 || orderPreservingPrefix === 0)
|
|
1505
2318
|
return {};
|
|
1506
2319
|
const leading = pk[0];
|
|
1507
2320
|
const monotonicOn = {
|
|
@@ -1509,7 +2322,7 @@ export class StoreModule {
|
|
|
1509
2322
|
direction: leading.desc ? 'desc' : 'asc',
|
|
1510
2323
|
strict: pk.length === 1,
|
|
1511
2324
|
};
|
|
1512
|
-
const pkOrdering = pk.map(col => ({
|
|
2325
|
+
const pkOrdering = pk.slice(0, orderPreservingPrefix).map(col => ({
|
|
1513
2326
|
columnIndex: col.index,
|
|
1514
2327
|
desc: !!col.desc,
|
|
1515
2328
|
}));
|
|
@@ -1522,7 +2335,7 @@ export class StoreModule {
|
|
|
1522
2335
|
// matched here — if the request specifies an explicit NULLS
|
|
1523
2336
|
// FIRST/LAST, leave the Sort in place rather than assume the PK
|
|
1524
2337
|
// scan's natural NULL placement matches.
|
|
1525
|
-
if (required.length >
|
|
2338
|
+
if (required.length > pkOrdering.length)
|
|
1526
2339
|
return { monotonicOn, supportsAsofRight: true };
|
|
1527
2340
|
for (let i = 0; i < required.length; i++) {
|
|
1528
2341
|
if (required[i].columnIndex !== pkOrdering[i].columnIndex)
|
|
@@ -1548,10 +2361,10 @@ export class StoreModule {
|
|
|
1548
2361
|
/**
|
|
1549
2362
|
* Get or create a data store for a table.
|
|
1550
2363
|
*/
|
|
1551
|
-
async getStore(
|
|
2364
|
+
async getStore(schemaName, tableName, _config) {
|
|
2365
|
+
const tableKey = `${schemaName}.${tableName}`.toLowerCase();
|
|
1552
2366
|
let store = this.stores.get(tableKey);
|
|
1553
2367
|
if (!store) {
|
|
1554
|
-
const [schemaName, tableName] = tableKey.split('.');
|
|
1555
2368
|
store = await this.provider.getStore(schemaName, tableName);
|
|
1556
2369
|
if (!store) {
|
|
1557
2370
|
throw new Error(`Provider.getStore returned null/undefined for ${tableKey}`);
|
|
@@ -1631,6 +2444,10 @@ export class StoreModule {
|
|
|
1631
2444
|
* the compare-write in `persistCatalogIfChanged` relies on.
|
|
1632
2445
|
*/
|
|
1633
2446
|
buildCatalogEntry(tableSchema) {
|
|
2447
|
+
// `tableSchema` is always the engine-facing schema (no `_uc_*`; the store keeps the
|
|
2448
|
+
// materialized enforcement copy internal), so hidden implicit indexes reach here
|
|
2449
|
+
// only through the `uniqueConstraints`-driven `isHiddenImplicitIndex` /
|
|
2450
|
+
// `exposedImplicitIndexes` paths — never as a materialized `CREATE INDEX`.
|
|
1634
2451
|
const parts = [generateTableDDL(tableSchema)];
|
|
1635
2452
|
for (const idx of tableSchema.indexes ?? []) {
|
|
1636
2453
|
if (isHiddenImplicitIndex(tableSchema, idx.name))
|
|
@@ -1644,14 +2461,26 @@ export class StoreModule {
|
|
|
1644
2461
|
}
|
|
1645
2462
|
return parts.join('\n');
|
|
1646
2463
|
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Encode a bundle of persisted schema text (DDL) for the catalog store.
|
|
2466
|
+
*
|
|
2467
|
+
* Guards the FULL text, not just the object's name: a lone surrogate anywhere in it — a
|
|
2468
|
+
* quoted column name, a `default '…'` string literal, a `check` constraint's string
|
|
2469
|
+
* constant — would otherwise fold to U+FFFD under `TextEncoder` and read back as
|
|
2470
|
+
* different schema text than what was created. This does not rely on the catalog-key
|
|
2471
|
+
* builders' identifier guard having already caught it; it catches it independently.
|
|
2472
|
+
*/
|
|
2473
|
+
encodeCatalogDDL(ddl) {
|
|
2474
|
+
assertNoUnpairedSurrogate(ddl, 'persisted schema text');
|
|
2475
|
+
return new TextEncoder().encode(ddl);
|
|
2476
|
+
}
|
|
1647
2477
|
/**
|
|
1648
2478
|
* Save table DDL (bundled with its secondary index DDL) to the catalog store.
|
|
1649
2479
|
*/
|
|
1650
2480
|
async saveTableDDL(tableSchema) {
|
|
1651
2481
|
const ddl = this.buildCatalogEntry(tableSchema);
|
|
1652
2482
|
const catalogKey = buildCatalogKey(tableSchema.schemaName, tableSchema.name);
|
|
1653
|
-
const
|
|
1654
|
-
const encodedDDL = encoder.encode(ddl);
|
|
2483
|
+
const encodedDDL = this.encodeCatalogDDL(ddl);
|
|
1655
2484
|
const catalogStore = await this.provider.getCatalogStore();
|
|
1656
2485
|
await catalogStore.put(catalogKey, encodedDDL);
|
|
1657
2486
|
}
|
|
@@ -1934,7 +2763,7 @@ export class StoreModule {
|
|
|
1934
2763
|
// data writes can land closes the power-loss window where a persisted data write
|
|
1935
2764
|
// outlives a lost marker-delete and resurrects a consumed marker. (Backends
|
|
1936
2765
|
// without a durability knob no-op the hint — losing it is conservative there, and
|
|
1937
|
-
// memory has no crash.) See docs/
|
|
2766
|
+
// memory has no crash.) See docs/mv-backing-host.md § Cross-module atomicity.
|
|
1938
2767
|
await catalogStore.delete(markerKey, { sync: true }); // single-use, regardless of parse outcome
|
|
1939
2768
|
try {
|
|
1940
2769
|
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
|
@@ -1999,7 +2828,7 @@ export class StoreModule {
|
|
|
1999
2828
|
const existing = await catalogStore.get(key);
|
|
2000
2829
|
if (existing !== undefined && new TextDecoder().decode(existing) === newDDL)
|
|
2001
2830
|
return;
|
|
2002
|
-
await catalogStore.put(key,
|
|
2831
|
+
await catalogStore.put(key, this.encodeCatalogDDL(newDDL));
|
|
2003
2832
|
}
|
|
2004
2833
|
/**
|
|
2005
2834
|
* Parse module configuration from vtab args.
|
|
@@ -2007,6 +2836,9 @@ export class StoreModule {
|
|
|
2007
2836
|
parseConfig(args) {
|
|
2008
2837
|
return {
|
|
2009
2838
|
collation: args?.collation || 'NOCASE',
|
|
2839
|
+
// Index-build batch budget; malformed / non-positive clamps to the default
|
|
2840
|
+
// so a bad arg can never disable the bounded-memory flush (see resolveMaxBatchBytes).
|
|
2841
|
+
maxBatchBytes: resolveMaxBatchBytes(args?.max_batch_bytes),
|
|
2010
2842
|
};
|
|
2011
2843
|
}
|
|
2012
2844
|
// --- Engine schema-change subscription (catalog-only tag persistence) ---
|
|
@@ -2076,7 +2908,7 @@ export class StoreModule {
|
|
|
2076
2908
|
// rename-restore fires one too — so recompute on each event keeps the durable set
|
|
2077
2909
|
// current. Enqueued on `persistQueue` AFTER the dispatch above, so the `sync` lands
|
|
2078
2910
|
// after the event's own source-DDL write is queued (the durability ordering the
|
|
2079
|
-
// adopt soundness argument relies on — see `docs/
|
|
2911
|
+
// adopt soundness argument relies on — see `docs/mv-backing-host.md` § Cross-module atomicity).
|
|
2080
2912
|
this.persistStaleMvSetIfChanged();
|
|
2081
2913
|
};
|
|
2082
2914
|
/** Dispatch a single engine schema-change event to its catalog-persistence arm. */
|
|
@@ -2287,7 +3119,7 @@ export class StoreModule {
|
|
|
2287
3119
|
const existingDDL = new TextDecoder().decode(existing);
|
|
2288
3120
|
if (existingDDL === newDDL)
|
|
2289
3121
|
return; // identical — no redundant write
|
|
2290
|
-
await catalogStore.put(key,
|
|
3122
|
+
await catalogStore.put(key, this.encodeCatalogDDL(newDDL));
|
|
2291
3123
|
}
|
|
2292
3124
|
/**
|
|
2293
3125
|
* Resolve once all catalog writes queued by async schema-change listeners
|
|
@@ -2365,6 +3197,54 @@ export class StoreModule {
|
|
|
2365
3197
|
return this.tables.get(tableKey);
|
|
2366
3198
|
}
|
|
2367
3199
|
}
|
|
3200
|
+
/** Adapts a raw KV entry stream into the row stream the uniqueness validators consume. */
|
|
3201
|
+
async function* rowsFromEntries(entries) {
|
|
3202
|
+
for await (const entry of entries) {
|
|
3203
|
+
yield deserializeRow(entry.value);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
/**
|
|
3207
|
+
* Per-column key normalizers for a UNIQUE INDEX dedupe signature, drawing each column's
|
|
3208
|
+
* collation from the index column (if it carries one) else the underlying table column —
|
|
3209
|
+
* so the signature honors a per-column collation registered on this connection, matching
|
|
3210
|
+
* write-time enforcement.
|
|
3211
|
+
*
|
|
3212
|
+
* A column whose declared type can never hold text takes the identity normalizer regardless
|
|
3213
|
+
* of its collation (`serializeRowKey` normalizes only string values), so a comparator-only
|
|
3214
|
+
* collation named on an integer column is not rejected here when the engine's own hash sites
|
|
3215
|
+
* would accept it.
|
|
3216
|
+
*/
|
|
3217
|
+
function indexDedupeNormalizers(tableSchema, indexSchema, keyNormalizers) {
|
|
3218
|
+
return indexSchema.columns.map(col => {
|
|
3219
|
+
const column = tableSchema.columns[col.index];
|
|
3220
|
+
return keyNormalizers(logicalTypeCanHoldText(column.logicalType)
|
|
3221
|
+
? (col.collation ?? column.collation)
|
|
3222
|
+
: undefined);
|
|
3223
|
+
});
|
|
3224
|
+
}
|
|
3225
|
+
/**
|
|
3226
|
+
* Throws CONSTRAINT on the first pair of `rows` that share a signature over `columnIndices`.
|
|
3227
|
+
*
|
|
3228
|
+
* SQL NULL semantics: `serializeRowKey` returns null when any constrained column is NULL, and
|
|
3229
|
+
* such rows never collide. A partial `predicate` restricts the judged set to rows it accepts
|
|
3230
|
+
* unambiguously. Shared by {@link StoreModule.validateUniqueOverExistingRows} (constraint
|
|
3231
|
+
* columns) and {@link StoreModule.validateUniqueIndexOverRows} (index columns).
|
|
3232
|
+
*/
|
|
3233
|
+
async function assertNoDuplicateRows(rows, tableSchema, columnIndices, normalizers, predicate) {
|
|
3234
|
+
const seen = new Set();
|
|
3235
|
+
for await (const row of rows) {
|
|
3236
|
+
if (predicate && predicate.evaluate(row) !== true)
|
|
3237
|
+
continue;
|
|
3238
|
+
const keySig = serializeRowKey(row, columnIndices, normalizers);
|
|
3239
|
+
if (keySig === null)
|
|
3240
|
+
continue;
|
|
3241
|
+
if (seen.has(keySig)) {
|
|
3242
|
+
const colNames = columnIndices.map(i => tableSchema.columns[i]?.name ?? String(i)).join(', ');
|
|
3243
|
+
throw new QuereusError(`UNIQUE constraint failed: ${tableSchema.name} (${colNames})`, StatusCode.CONSTRAINT);
|
|
3244
|
+
}
|
|
3245
|
+
seen.add(keySig);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
2368
3248
|
/**
|
|
2369
3249
|
* Reconcile each text PRIMARY KEY column's declared collation at CREATE time.
|
|
2370
3250
|
*
|
|
@@ -2435,4 +3315,55 @@ function buildColumnRemap(oldColumnNames, newColumnNames) {
|
|
|
2435
3315
|
return oldIdx !== undefined ? oldIdx : -1;
|
|
2436
3316
|
});
|
|
2437
3317
|
}
|
|
3318
|
+
/** Whether `fk` points back at the table that owns it. */
|
|
3319
|
+
function isSelfForeignKey(fk, schemaLower, tableLower) {
|
|
3320
|
+
return (fk.referencedSchema ?? schemaLower).toLowerCase() === schemaLower
|
|
3321
|
+
&& fk.referencedTable.toLowerCase() === tableLower;
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Retarget a self-referencing foreign key's `referencedTable` across a rename of
|
|
3325
|
+
* the owning table. A pure copy — unlike the CHECK / partial-index rewrites this
|
|
3326
|
+
* touches a name field, not a shared AST, so the caller needs no rollback.
|
|
3327
|
+
* Returns the input array when nothing self-references.
|
|
3328
|
+
*/
|
|
3329
|
+
function retargetSelfForeignKeys(fks, schemaName, oldName, newName) {
|
|
3330
|
+
if (!fks)
|
|
3331
|
+
return fks;
|
|
3332
|
+
const schemaLower = schemaName.toLowerCase();
|
|
3333
|
+
const oldLower = oldName.toLowerCase();
|
|
3334
|
+
let changed = false;
|
|
3335
|
+
const next = fks.map(fk => {
|
|
3336
|
+
if (!isSelfForeignKey(fk, schemaLower, oldLower))
|
|
3337
|
+
return fk;
|
|
3338
|
+
changed = true;
|
|
3339
|
+
return { ...fk, referencedTable: newName };
|
|
3340
|
+
});
|
|
3341
|
+
return changed ? Object.freeze(next) : fks;
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* Rename a column inside a self-referencing foreign key's `referencedColumnNames`
|
|
3345
|
+
* (the names the FK resolves to parent-column indices at enforcement time). Pure
|
|
3346
|
+
* copy, as in {@link retargetSelfForeignKeys}. The child-side `columns` are
|
|
3347
|
+
* indices, unaffected by a rename.
|
|
3348
|
+
*/
|
|
3349
|
+
function renameColumnInSelfForeignKeys(fks, schemaName, tableName, oldCol, newCol) {
|
|
3350
|
+
if (!fks)
|
|
3351
|
+
return fks;
|
|
3352
|
+
const schemaLower = schemaName.toLowerCase();
|
|
3353
|
+
const tableLower = tableName.toLowerCase();
|
|
3354
|
+
const oldColLower = oldCol.toLowerCase();
|
|
3355
|
+
let changed = false;
|
|
3356
|
+
const next = fks.map(fk => {
|
|
3357
|
+
if (!isSelfForeignKey(fk, schemaLower, tableLower))
|
|
3358
|
+
return fk;
|
|
3359
|
+
if (!fk.referencedColumnNames?.some(n => n.toLowerCase() === oldColLower))
|
|
3360
|
+
return fk;
|
|
3361
|
+
changed = true;
|
|
3362
|
+
return {
|
|
3363
|
+
...fk,
|
|
3364
|
+
referencedColumnNames: Object.freeze(fk.referencedColumnNames.map(n => n.toLowerCase() === oldColLower ? newCol : n)),
|
|
3365
|
+
};
|
|
3366
|
+
});
|
|
3367
|
+
return changed ? Object.freeze(next) : fks;
|
|
3368
|
+
}
|
|
2438
3369
|
//# sourceMappingURL=store-module.js.map
|