|
| 1 | +const _ = require('lodash'); |
1 | 2 | const Analyser = require('./Analyser'); |
| 3 | +const sql = require('./sql'); |
2 | 4 | const { |
3 | 5 | D1_SCHEMA_SNAPSHOT_SQL, |
4 | 6 | applyD1SnapshotContentHashes, |
5 | 7 | buildD1SchemaSnapshot, |
6 | 8 | } = require('./cloudflare/d1SchemaSnapshot'); |
| 9 | +const { |
| 10 | + collectD1IndexNames, |
| 11 | + filterD1InternalRows, |
| 12 | + foreignKeyListKey, |
| 13 | + indexInfoKey, |
| 14 | + indexListKey, |
| 15 | + loadD1Pragmas, |
| 16 | + loadMissingD1IndexInfo, |
| 17 | + queryD1Batch, |
| 18 | + sortIndexColumns, |
| 19 | + tableInfoKey, |
| 20 | +} = require('./cloudflare/d1SchemaLoader'); |
7 | 21 |
|
8 | 22 | /** |
9 | | - * D1-specific analyser which checks the schema with one REST query. The standard SQLite snapshot |
10 | | - * uses table-valued index PRAGMAs, which D1 does not support and would otherwise expand into many |
11 | | - * HTTP requests. |
| 23 | + * D1-specific analyser. |
| 24 | + * |
| 25 | + * The inherited SQLite analyser is written for a local file, where one query costs nothing. On D1 |
| 26 | + * every query is an HTTPS request, so it would spend `4 + 3 * tables + indexes + views` serial |
| 27 | + * round trips on a single analysis. This class asks for the same information, but plans all |
| 28 | + * queries up front and sends them as D1 batch requests, which brings a full analysis down to two |
| 29 | + * requests for a normally sized database. The standard SQLite snapshot additionally uses |
| 30 | + * table-valued index PRAGMAs, which D1 does not support at all. |
12 | 31 | */ |
13 | 32 | class D1Analyser extends Analyser { |
| 33 | + constructor(dbhan, driver, version) { |
| 34 | + super(dbhan, driver, version); |
| 35 | + /** sqlite_master rows, shared by the fast snapshot and the full analysis */ |
| 36 | + this.schemaRows = null; |
| 37 | + } |
| 38 | + |
| 39 | + get client() { |
| 40 | + return this.dbhan.client; |
| 41 | + } |
| 42 | + |
14 | 43 | async _getFastSnapshot() { |
15 | | - const result = await this.driver.query(this.dbhan, D1_SCHEMA_SNAPSHOT_SQL); |
16 | | - return buildD1SchemaSnapshot(result.rows); |
| 44 | + if (!this.schemaRows) { |
| 45 | + const [result] = await queryD1Batch(this.client, [D1_SCHEMA_SNAPSHOT_SQL]); |
| 46 | + this.schemaRows = result?.rows ?? []; |
| 47 | + } |
| 48 | + return buildD1SchemaSnapshot(this.schemaRows); |
17 | 49 | } |
18 | 50 |
|
19 | 51 | async _runAnalysis() { |
20 | | - const structure = await super._runAnalysis(); |
21 | | - const snapshot = await this._getFastSnapshot(); |
22 | | - return applyD1SnapshotContentHashes(structure, snapshot); |
| 52 | + // Request 1: the object lists. sqlite_master is skipped when the incremental analysis already |
| 53 | + // loaded it for the modification check. |
| 54 | + const [snapshotResult, objectsResult, triggersResult] = await queryD1Batch(this.client, [ |
| 55 | + this.schemaRows ? null : D1_SCHEMA_SNAPSHOT_SQL, |
| 56 | + this.createQuery(sql.objectsConditioned, ['tables', 'views']), |
| 57 | + sql.triggers, |
| 58 | + ]); |
| 59 | + if (snapshotResult) { |
| 60 | + this.schemaRows = snapshotResult.rows ?? []; |
| 61 | + } |
| 62 | + |
| 63 | + const objects = filterD1InternalRows(objectsResult?.rows ?? []); |
| 64 | + const tables = objects.filter((x) => x.type == 'table'); |
| 65 | + const views = objects.filter((x) => x.type == 'view'); |
| 66 | + |
| 67 | + const tableSqls = _.zipObject( |
| 68 | + tables.map((x) => x.name), |
| 69 | + tables.map((x) => x.sql) |
| 70 | + ); |
| 71 | + |
| 72 | + const tableList = tables.map((x) => ({ |
| 73 | + pureName: x.name, |
| 74 | + objectId: x.name, |
| 75 | + contentHash: x.sql, |
| 76 | + })); |
| 77 | + |
| 78 | + const viewList = views.map((x) => ({ |
| 79 | + pureName: x.name, |
| 80 | + objectId: x.name, |
| 81 | + contentHash: x.sql, |
| 82 | + createSql: x.sql, |
| 83 | + })); |
| 84 | + |
| 85 | + const tableNames = this.getRequestedObjectPureNames( |
| 86 | + 'tables', |
| 87 | + tables.map((x) => x.name) |
| 88 | + ).filter((name) => tableList.some((x) => x.pureName == name)); |
| 89 | + const viewNames = this.getRequestedObjectPureNames( |
| 90 | + 'views', |
| 91 | + views.map((x) => x.name) |
| 92 | + ).filter((name) => viewList.some((x) => x.pureName == name)); |
| 93 | + |
| 94 | + // Request 2: all column, foreign key and index PRAGMAs of the requested objects at once. |
| 95 | + const pragmas = await loadD1Pragmas(this.client, { |
| 96 | + tableNames, |
| 97 | + viewNames, |
| 98 | + indexNames: collectD1IndexNames(filterD1InternalRows(this.schemaRows), tableNames), |
| 99 | + }); |
| 100 | + await loadMissingD1IndexInfo(this.client, pragmas, tableNames); |
| 101 | + |
| 102 | + for (const tableName of tableNames) { |
| 103 | + const tableObj = tableList.find((x) => x.pureName == tableName); |
| 104 | + const columnRows = pragmas.get(tableInfoKey(tableName)) ?? []; |
| 105 | + |
| 106 | + tableObj.columns = columnRows.map((col) => ({ |
| 107 | + columnName: col.name, |
| 108 | + dataType: col.type, |
| 109 | + notNull: !!col.notnull, |
| 110 | + defaultValue: col.dflt_value == null ? undefined : col.dflt_value, |
| 111 | + autoIncrement: !!tableSqls[tableName]?.toLowerCase().includes('autoincrement') && !!col.pk, |
| 112 | + })); |
| 113 | + |
| 114 | + // `pragma index_list` returns the newest index first; sort by name so that repeated |
| 115 | + // analyses (and structure comparisons) see a stable order. |
| 116 | + const indexList = _.sortBy(pragmas.get(indexListKey(tableName)) ?? [], (index) => index.name); |
| 117 | + const indexColumns = (index) => |
| 118 | + sortIndexColumns(pragmas.get(indexInfoKey(index.name))).map((col) => ({ columnName: col.name })); |
| 119 | + |
| 120 | + tableObj.indexes = indexList |
| 121 | + .filter((index) => index.origin == 'c') |
| 122 | + .map((index) => ({ |
| 123 | + constraintName: index.name, |
| 124 | + isUnique: !!index.unique, |
| 125 | + columns: indexColumns(index), |
| 126 | + })); |
| 127 | + |
| 128 | + tableObj.uniques = indexList |
| 129 | + .filter((index) => index.origin == 'u') |
| 130 | + .map((index) => ({ |
| 131 | + constraintName: index.name, |
| 132 | + columns: indexColumns(index), |
| 133 | + })); |
| 134 | + |
| 135 | + const pkColumns = columnRows.filter((x) => x.pk).map((col) => ({ columnName: col.name })); |
| 136 | + if (pkColumns.length > 0) { |
| 137 | + tableObj.primaryKey = { columns: pkColumns }; |
| 138 | + } |
| 139 | + |
| 140 | + const fkRows = pragmas.get(foreignKeyListKey(tableName)) ?? []; |
| 141 | + tableObj.foreignKeys = _.values(_.groupBy(fkRows, 'id')).map((fkcols) => { |
| 142 | + const fkcol = fkcols[0]; |
| 143 | + return { |
| 144 | + pureName: tableName, |
| 145 | + refTableName: fkcol.table, |
| 146 | + columns: fkcols.map((col) => ({ |
| 147 | + columnName: col.from, |
| 148 | + refColumnName: col.to, |
| 149 | + })), |
| 150 | + updateAction: fkcol.on_update, |
| 151 | + deleteAction: fkcol.on_delete, |
| 152 | + constraintName: `FK_${tableName}_${fkcol.id}`, |
| 153 | + constraintType: 'foreignKey', |
| 154 | + }; |
| 155 | + }); |
| 156 | + } |
| 157 | + |
| 158 | + for (const viewName of viewNames) { |
| 159 | + const viewObj = viewList.find((x) => x.pureName == viewName); |
| 160 | + viewObj.columns = (pragmas.get(tableInfoKey(viewName)) ?? []).map((col) => ({ |
| 161 | + columnName: col.name, |
| 162 | + dataType: col.type, |
| 163 | + notNull: !!col.notnull, |
| 164 | + })); |
| 165 | + } |
| 166 | + |
| 167 | + const structure = { |
| 168 | + tables: tableList, |
| 169 | + views: viewList, |
| 170 | + triggers: filterD1InternalRows(triggersResult?.rows ?? []), |
| 171 | + }; |
| 172 | + |
| 173 | + return applyD1SnapshotContentHashes(structure, buildD1SchemaSnapshot(this.schemaRows)); |
23 | 174 | } |
24 | 175 | } |
25 | 176 |
|
|
0 commit comments