@proteinjs/db-driver-spanner 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,49 +18,24 @@ export class SpannerSchemaOperations implements SchemaOperations {
18
18
 
19
19
  constructor(private spannerDriver: SpannerDriver) {}
20
20
 
21
- async createTable(table: Table<any>) {
22
- const indexes: { name?: string; columns: string[]; unique?: boolean }[] = [];
23
- for (const { name, columns, unique } of table.indexes) {
24
- indexes.push({ name, columns: columns.map((x) => table.columns[x as string]!.name), unique });
21
+ /**
22
+ * Create every table in `tables` each table's `CREATE TABLE` plus all of its `CREATE INDEX`
23
+ * statements, concatenated across tables in the given order — as ONE schema-update operation.
24
+ * `UpdateDatabaseDdl` applies the batch in order, so a table whose foreign keys reference an
25
+ * earlier table in the list resolves against it, exactly as it did when the statements ran
26
+ * serially.
27
+ */
28
+ async createTables(tables: Table<any>[]) {
29
+ const statements: string[] = [];
30
+ for (const table of tables) {
31
+ statements.push(...this.createTableStatements(table));
25
32
  }
26
33
 
27
- const serializedColumns: { name: string; type: string; nullable?: boolean }[] = [];
28
- const foreignKeys: { table: string; column: string; referencedByColumn: string }[] = [];
29
- for (const columnPropertyName in table.columns) {
30
- const column = table.columns[columnPropertyName];
31
- const columnType = new SpannerColumnTypeFactory().getType(column);
32
- serializedColumns.push({ name: column.name, type: columnType, nullable: column.options?.nullable });
33
- this.logger.info({ message: `[${table.name}] Creating column: ${column.name} (${column.constructor.name})` });
34
- if (column.options?.unique?.unique) {
35
- indexes.push({
36
- name: column.options.unique.indexName,
37
- columns: [table.columns[column.name]!.name],
38
- unique: true,
39
- });
40
- this.logger.info({ message: `[${table.name}.${column.name}] Adding unique constraint` });
41
- }
42
-
43
- if (column.options?.references) {
44
- foreignKeys.push({ table: column.options.references.table, column: 'id', referencedByColumn: column.name });
45
- this.logger.info({
46
- message: `[${table.name}.${column.name}] Adding foreign key -> ${column.options.references.table}.id`,
47
- });
48
- }
34
+ if (statements.length === 0) {
35
+ return;
49
36
  }
50
- const createTableSql = new StatementFactory().createTable(table.name, serializedColumns, 'id', foreignKeys).sql;
51
- await this.spannerDriver.runUpdateSchema(createTableSql);
52
37
 
53
- for (const index of indexes) {
54
- const createIndexSql = new StatementFactory().createIndex(index, table.name).sql;
55
- const indexName = StatementUtil.getIndexName(table.name, index);
56
- this.logger.info({
57
- message: `[${table.name}] Creating index: ${indexName} (${index.columns.join(', ')})`,
58
- });
59
- await this.spannerDriver.runUpdateSchema(createIndexSql);
60
- this.logger.info({
61
- message: `[${table.name}] Created index: ${indexName} (${index.columns.join(', ')})`,
62
- });
63
- }
38
+ await this.spannerDriver.runUpdateSchema(statements);
64
39
  }
65
40
 
66
41
  async alterTable(table: Table<any>, tableChanges: TableChanges) {
@@ -134,37 +109,76 @@ export class SpannerSchemaOperations implements SchemaOperations {
134
109
  throw new Error(errorMessage);
135
110
  }
136
111
 
137
- const alterStatements = new StatementFactory().alterTable(alterParams);
138
- for (const alterStatement of alterStatements) {
139
- await this.spannerDriver.runUpdateSchema(alterStatement.sql);
140
- }
141
-
142
- for (const wideningStatement of wideningStatements) {
143
- await this.spannerDriver.runUpdateSchema(wideningStatement);
144
- }
112
+ // One schema-update operation for the whole alter pass, preserving the statement order the
113
+ // serial version applied: alters (add column / drop+add FK), STRING widenings, index drops,
114
+ // index creates.
115
+ const statements: string[] = new StatementFactory()
116
+ .alterTable(alterParams)
117
+ .map((alterStatement) => alterStatement.sql);
118
+ statements.push(...wideningStatements);
145
119
 
146
120
  for (const index of tableChanges.indexesToDrop) {
147
- const dropIndexSql = new StatementFactory().dropIndex(index, table.name).sql;
148
121
  this.logger.info({
149
122
  message: `[${table.name}] Dropping index: ${index.name} (${typeof index.columns === 'string' ? index.columns : index.columns.join(', ')})`,
150
123
  });
151
- await this.spannerDriver.runUpdateSchema(dropIndexSql);
152
- this.logger.info({
153
- message: `[${table.name}] Dropped index: ${index.name} (${typeof index.columns === 'string' ? index.columns : index.columns.join(', ')})`,
154
- });
124
+ statements.push(new StatementFactory().dropIndex(index, table.name).sql);
155
125
  }
156
126
 
157
127
  for (const index of tableChanges.indexesToCreate) {
158
- const createIndexSql = new StatementFactory().createIndex(index, table.name).sql;
159
128
  const indexName = StatementUtil.getIndexName(table.name, index);
160
129
  this.logger.info({
161
130
  message: `[${table.name}] Creating index: ${indexName} (${typeof index.columns === 'string' ? index.columns : index.columns.join(', ')})`,
162
131
  });
163
- await this.spannerDriver.runUpdateSchema(createIndexSql);
132
+ statements.push(new StatementFactory().createIndex(index, table.name).sql);
133
+ }
134
+
135
+ if (statements.length === 0) {
136
+ return;
137
+ }
138
+
139
+ await this.spannerDriver.runUpdateSchema(statements);
140
+ }
141
+
142
+ private createTableStatements(table: Table<any>): string[] {
143
+ const indexes: { name?: string; columns: string[]; unique?: boolean }[] = [];
144
+ for (const { name, columns, unique } of table.indexes) {
145
+ indexes.push({ name, columns: columns.map((x) => table.columns[x as string]!.name), unique });
146
+ }
147
+
148
+ const serializedColumns: { name: string; type: string; nullable?: boolean }[] = [];
149
+ const foreignKeys: { table: string; column: string; referencedByColumn: string }[] = [];
150
+ for (const columnPropertyName in table.columns) {
151
+ const column = table.columns[columnPropertyName];
152
+ const columnType = new SpannerColumnTypeFactory().getType(column);
153
+ serializedColumns.push({ name: column.name, type: columnType, nullable: column.options?.nullable });
154
+ this.logger.info({ message: `[${table.name}] Creating column: ${column.name} (${column.constructor.name})` });
155
+ if (column.options?.unique?.unique) {
156
+ indexes.push({
157
+ name: column.options.unique.indexName,
158
+ columns: [table.columns[column.name]!.name],
159
+ unique: true,
160
+ });
161
+ this.logger.info({ message: `[${table.name}.${column.name}] Adding unique constraint` });
162
+ }
163
+
164
+ if (column.options?.references) {
165
+ foreignKeys.push({ table: column.options.references.table, column: 'id', referencedByColumn: column.name });
166
+ this.logger.info({
167
+ message: `[${table.name}.${column.name}] Adding foreign key -> ${column.options.references.table}.id`,
168
+ });
169
+ }
170
+ }
171
+
172
+ const statements = [new StatementFactory().createTable(table.name, serializedColumns, 'id', foreignKeys).sql];
173
+ for (const index of indexes) {
174
+ const indexName = StatementUtil.getIndexName(table.name, index);
164
175
  this.logger.info({
165
- message: `[${table.name}] Created index: ${indexName} (${typeof index.columns === 'string' ? index.columns : index.columns.join(', ')})`,
176
+ message: `[${table.name}] Creating index: ${indexName} (${index.columns.join(', ')})`,
166
177
  });
178
+ statements.push(new StatementFactory().createIndex(index, table.name).sql);
167
179
  }
180
+
181
+ return statements;
168
182
  }
169
183
 
170
184
  /**
@@ -0,0 +1,293 @@
1
+ import { Database, Spanner } from '@google-cloud/spanner';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
4
+ import { getTables, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
5
+ import { getDropTestTable } from './util/getDropTestTable';
6
+ import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
7
+ import '../generated/test/index';
8
+
9
+ const spannerConfig = {
10
+ projectId: 'proteinjs-test',
11
+ instanceName: 'proteinjs-test',
12
+ databaseName: 'test',
13
+ };
14
+
15
+ const spannerDriver = new SpannerDriver(spannerConfig);
16
+
17
+ interface BatchDdlParent extends Record {
18
+ name?: string;
19
+ }
20
+
21
+ interface BatchDdlChild extends Record {
22
+ label?: string;
23
+ parentId?: string;
24
+ }
25
+
26
+ /** Parent with a declared index — its create is CREATE TABLE + CREATE INDEX. */
27
+ const parentTable = (): Table<BatchDdlParent> => {
28
+ return new (class extends Table<BatchDdlParent> {
29
+ name = 'db_test_batchddl_parent';
30
+ columns = withRecordColumns<BatchDdlParent>({
31
+ name: new StringColumn('name'),
32
+ });
33
+ indexes = [{ name: 'db_test_batchddl_parent_name_index', columns: ['name'] as (keyof BatchDdlParent)[] }];
34
+ })();
35
+ };
36
+
37
+ /** Child whose FK references the parent — creation order across the two tables is load-bearing. */
38
+ const childTable = (): Table<BatchDdlChild> => {
39
+ return new (class extends Table<BatchDdlChild> {
40
+ name = 'db_test_batchddl_child';
41
+ columns = withRecordColumns<BatchDdlChild>({
42
+ label: new StringColumn('label'),
43
+ parentId: new StringColumn('parent_id', { references: { table: 'db_test_batchddl_parent' } }),
44
+ });
45
+ indexes = [{ name: 'db_test_batchddl_child_label_index', columns: ['label'] as (keyof BatchDdlChild)[] }];
46
+ })();
47
+ };
48
+
49
+ /** Parent redeclared with an extra column + extra index — the alter pass under test. */
50
+ const grownParentTable = (): Table<BatchDdlParent & { nickname?: string }> => {
51
+ return new (class extends Table<BatchDdlParent & { nickname?: string }> {
52
+ name = 'db_test_batchddl_parent';
53
+ columns = withRecordColumns<BatchDdlParent & { nickname?: string }>({
54
+ name: new StringColumn('name'),
55
+ nickname: new StringColumn('nickname'),
56
+ });
57
+ indexes = [
58
+ { name: 'db_test_batchddl_parent_name_index', columns: ['name'] as (keyof BatchDdlParent)[] },
59
+ { name: 'db_test_batchddl_parent_nickname_index', columns: ['nickname'] as any },
60
+ ];
61
+ })();
62
+ };
63
+
64
+ describe('Batched DDL', () => {
65
+ const dropTable = getDropTestTable(spannerDriver);
66
+ const tableManager = spannerDriver.getTableManager();
67
+
68
+ beforeAll(async () => {
69
+ await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
70
+ await dropTable(childTable());
71
+ await dropTable(parentTable());
72
+ }, 60000);
73
+
74
+ afterAll(async () => {
75
+ await dropTable(childTable());
76
+ await dropTable(parentTable());
77
+ SpannerEmulatorProvisioner.release();
78
+ }, 60000);
79
+
80
+ afterEach(() => {
81
+ jest.restoreAllMocks();
82
+ });
83
+
84
+ test('a new table lands via one runUpdateSchema call (CREATE TABLE + its indexes in one batch)', async () => {
85
+ const parent = parentTable();
86
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
87
+
88
+ await tableManager.loadTable(parent);
89
+
90
+ // Call-shape at the driver seam: the table's whole schema rides ONE schema-update operation.
91
+ expect(spy).toHaveBeenCalledTimes(1);
92
+
93
+ // Outcome: the schema actually landed (INFORMATION_SCHEMA-backed metadata).
94
+ expect(await tableManager.tableExists(parent)).toBe(true);
95
+ const indexes = await tableManager.schemaMetadata.getIndexes(parent);
96
+ expect(indexes['db_test_batchddl_parent_name_index']).toEqual(['name']);
97
+
98
+ await dropTable(parent);
99
+ }, 60000);
100
+
101
+ test('an alter pass (add column + add index) lands via one runUpdateSchema call', async () => {
102
+ await tableManager.loadTable(parentTable());
103
+ const grown = grownParentTable();
104
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
105
+
106
+ await tableManager.loadTable(grown);
107
+
108
+ expect(spy).toHaveBeenCalledTimes(1);
109
+
110
+ const columnMetadata = await tableManager.schemaMetadata.getColumnMetadata(grown);
111
+ expect(columnMetadata['nickname']).toBeDefined();
112
+ const indexes = await tableManager.schemaMetadata.getIndexes(grown);
113
+ expect(indexes['db_test_batchddl_parent_nickname_index']).toEqual(['nickname']);
114
+
115
+ await dropTable(grown);
116
+ }, 60000);
117
+
118
+ test('parent and FK child land in one ordered batch, and the FK is live', async () => {
119
+ const parent = parentTable();
120
+ const child = childTable();
121
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
122
+
123
+ // The one-LRO claim is pinned at the CLIENT seam, not just the driver seam: a
124
+ // runUpdateSchema that quietly looped per-statement operations would still count 1 on the
125
+ // driver spy but N here.
126
+ const updateSchemaSpy = jest.spyOn(Database.prototype, 'updateSchema');
127
+
128
+ await tableManager.schemaOperations.createTables([parent, child]);
129
+
130
+ // One schema-update operation carrying the whole set: 2 CREATE TABLE + 2 CREATE INDEX,
131
+ // parent's CREATE before the child's (the child's inline FK resolves against it in-batch).
132
+ expect(spy).toHaveBeenCalledTimes(1);
133
+ expect(updateSchemaSpy).toHaveBeenCalledTimes(1);
134
+ expect(updateSchemaSpy.mock.calls[0][0]).toHaveLength(4);
135
+ const statements = spy.mock.calls[0][0] as string[];
136
+ expect(Array.isArray(statements)).toBe(true);
137
+ expect(statements).toHaveLength(4);
138
+ const parentCreateIndex = statements.findIndex((sql) => sql.includes('CREATE TABLE `db_test_batchddl_parent`'));
139
+ const childCreateIndex = statements.findIndex((sql) => sql.includes('CREATE TABLE `db_test_batchddl_child`'));
140
+ expect(parentCreateIndex).toBeGreaterThanOrEqual(0);
141
+ expect(childCreateIndex).toBeGreaterThan(parentCreateIndex);
142
+
143
+ // Outcome: both tables live, the child's foreign key and index actually exist.
144
+ expect(await tableManager.tableExists(parent)).toBe(true);
145
+ expect(await tableManager.tableExists(child)).toBe(true);
146
+ const foreignKeys = await tableManager.schemaMetadata.getForeignKeys(child);
147
+ expect(foreignKeys['parent_id']).toEqual({
148
+ referencedTableName: 'db_test_batchddl_parent',
149
+ referencedColumnName: 'id',
150
+ });
151
+ const childIndexes = await tableManager.schemaMetadata.getIndexes(child);
152
+ expect(childIndexes['db_test_batchddl_child_label_index']).toEqual(['label']);
153
+
154
+ await dropTable(child);
155
+ await dropTable(parent);
156
+ }, 60000);
157
+
158
+ test('a wrong-ordered batch (FK child before parent) is REJECTED by the backend — ordering is load-bearing', async () => {
159
+ const parent = parentTable();
160
+ const child = childTable();
161
+ // House-style access to the statement assembler: typed cast on the instance, not a public method.
162
+ const ops = tableManager.schemaOperations as unknown as { createTableStatements(table: Table<any>): string[] };
163
+ const childFirst = [...ops.createTableStatements(child), ...ops.createTableStatements(parent)];
164
+
165
+ // The child's CREATE TABLE carries an inline FK to a parent that is not yet in the
166
+ // projected schema at its position in the batch — validation rejects the batch and NOTHING
167
+ // applies. This is the failure our ordered assembly exists to prevent.
168
+ await expect(spannerDriver.runUpdateSchema(childFirst)).rejects.toThrow();
169
+ expect(await tableManager.tableExists(child)).toBe(false);
170
+ expect(await tableManager.tableExists(parent)).toBe(false);
171
+ }, 60000);
172
+
173
+ test('a schema-invalid statement mid-batch rejects the WHOLE batch upfront — nothing applied (validation phase)', async () => {
174
+ const parent = parentTable();
175
+ const ops = tableManager.schemaOperations as unknown as { createTableStatements(table: Table<any>): string[] };
176
+ const [createParentSql, createNameIndexSql] = ops.createTableStatements(parent);
177
+ // Statement 2 parses but is schema-invalid (index on a column the table does not have).
178
+ // Schema-shape errors are caught in the batch's upfront VALIDATION pass — run in order
179
+ // against the projected schema (the error names the missing column, so statement 1's table
180
+ // WAS in the validation context) — and reject the batch before anything applies. Strictly
181
+ // SAFER than the old serial path, which would have left statement 1's table behind.
182
+ const badIndexSql = 'CREATE INDEX db_test_batchddl_parent_bogus_index ON db_test_batchddl_parent(no_such_column)';
183
+
184
+ await expect(spannerDriver.runUpdateSchema([createParentSql, badIndexSql, createNameIndexSql])).rejects.toThrow(
185
+ /no_such_column/
186
+ );
187
+
188
+ expect(await tableManager.tableExists(parent)).toBe(false);
189
+ }, 60000);
190
+
191
+ test('a data-dependent failure mid-batch leaves EARLIER statements applied, LATER unapplied (apply phase)', async () => {
192
+ const parent = parentTable();
193
+ await tableManager.loadTable(parent);
194
+ // Two rows with the same `name`: schema validation cannot see this — only the APPLY phase
195
+ // (index backfill) can fail on it.
196
+ const client = new Spanner({ projectId: spannerConfig.projectId });
197
+ const database = client.instance(spannerConfig.instanceName).database(spannerConfig.databaseName);
198
+ database.on('error', () => undefined);
199
+ try {
200
+ await database.table(parent.name).insert([
201
+ { id: 'dup-1', name: 'dup', created: new Date(), updated: new Date() },
202
+ { id: 'dup-2', name: 'dup', created: new Date(), updated: new Date() },
203
+ ]);
204
+ } finally {
205
+ await database.close().catch(() => undefined);
206
+ client.close();
207
+ }
208
+
209
+ const statements = [
210
+ 'CREATE INDEX db_test_batchddl_parent_pre_index ON db_test_batchddl_parent(name, id)',
211
+ 'CREATE UNIQUE INDEX db_test_batchddl_parent_dup_unique ON db_test_batchddl_parent(name)',
212
+ 'CREATE INDEX db_test_batchddl_parent_post_index ON db_test_batchddl_parent(id, name)',
213
+ ];
214
+ const logErrorSpy = jest.spyOn(Logger.prototype, 'error');
215
+ await expect(spannerDriver.runUpdateSchema(statements)).rejects.toThrow(/uniqueness violation/);
216
+
217
+ // The failure LOG must carry the backend's reason too. Apply-phase LRO errors put it in
218
+ // `error.message` and leave `error.details` UNDEFINED — logging details alone records an
219
+ // empty reason for exactly the failure class that leaves partial schema state behind.
220
+ const failureLog = logErrorSpy.mock.calls.find(
221
+ ([entry]) => entry.message === 'Failed when executing schema update'
222
+ );
223
+ expect(failureLog).toBeDefined();
224
+ expect(String((failureLog![0].obj as { errorDetails?: unknown }).errorDetails)).toMatch(/uniqueness violation/);
225
+
226
+ // Honest partial-failure semantics of the apply phase: NOT atomic. Statement 1 stays
227
+ // applied; statement 3, ordered after the failure, is cancelled.
228
+ const indexes = await tableManager.schemaMetadata.getIndexes(parent);
229
+ expect(indexes['db_test_batchddl_parent_pre_index']).toEqual(['name', 'id']);
230
+ expect(indexes['db_test_batchddl_parent_dup_unique']).toBeUndefined();
231
+ expect(indexes['db_test_batchddl_parent_post_index']).toBeUndefined();
232
+
233
+ await dropTable(parent);
234
+ }, 60000);
235
+
236
+ test('loadTables creates the whole absent set via one runUpdateSchema call; a second pass issues none', async () => {
237
+ const registeredTables = getTables();
238
+ // This package registers the migration table (@proteinjs/db) and the service-verbs doc table.
239
+ expect(registeredTables.length).toBeGreaterThanOrEqual(2);
240
+ for (const table of [...registeredTables].reverse()) {
241
+ await dropTable(table);
242
+ }
243
+
244
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
245
+ await tableManager.loadTables();
246
+ expect(spy).toHaveBeenCalledTimes(1);
247
+ for (const table of registeredTables) {
248
+ expect(await tableManager.tableExists(table)).toBe(true);
249
+ }
250
+
251
+ // Reconcile pass on an up-to-date schema issues zero DDL.
252
+ spy.mockClear();
253
+ await tableManager.loadTables();
254
+ expect(spy).not.toHaveBeenCalled();
255
+ }, 60000);
256
+
257
+ test('createDb with ddl births a queryable database; dropDb removes it', async () => {
258
+ const databaseName = 'batchddl-born';
259
+ if (await spannerDriver.dbExists(databaseName)) {
260
+ await spannerDriver.dropDb(databaseName);
261
+ }
262
+
263
+ await spannerDriver.createDb(databaseName, {
264
+ ddl: [
265
+ 'CREATE TABLE born_row (id STRING(36) NOT NULL, label STRING(MAX)) PRIMARY KEY (id)',
266
+ 'CREATE INDEX born_row_label_index ON born_row(label)',
267
+ ],
268
+ });
269
+ expect(await spannerDriver.dbExists(databaseName)).toBe(true);
270
+
271
+ // Queryability check rides a dedicated client: SpannerDriver's process-wide Database handle
272
+ // is pinned to the suite's database, and this test is about the NEW database.
273
+ const client = new Spanner({ projectId: spannerConfig.projectId });
274
+ const database = client.instance(spannerConfig.instanceName).database(databaseName);
275
+ database.on('error', () => undefined);
276
+ try {
277
+ await database.table('born_row').insert({ id: 'r1', label: 'born' });
278
+ const [rows] = await database.run({ sql: 'SELECT id, label FROM born_row', json: true });
279
+ expect(rows).toEqual([{ id: 'r1', label: 'born' }]);
280
+ const [indexRows] = await database.run({
281
+ sql: `SELECT i.INDEX_NAME FROM INFORMATION_SCHEMA.INDEXES i WHERE i.TABLE_NAME = 'born_row' AND i.INDEX_NAME = 'born_row_label_index'`,
282
+ json: true,
283
+ });
284
+ expect(indexRows).toHaveLength(1);
285
+ } finally {
286
+ await database.close().catch(() => undefined);
287
+ client.close();
288
+ }
289
+
290
+ await spannerDriver.dropDb(databaseName);
291
+ expect(await spannerDriver.dbExists(databaseName)).toBe(false);
292
+ }, 60000);
293
+ });