import { Utils } from '@mikro-orm/core'; import { SchemaHelper } from '../../schema/SchemaHelper.js'; /** SpatiaLite system views that should be automatically ignored */ const SPATIALITE_VIEWS = [ 'geometry_columns', 'spatial_ref_sys', 'views_geometry_columns', 'virts_geometry_columns', 'geom_cols_ref_sys', 'spatial_ref_sys_aux', 'vector_layers', 'vector_layers_auth', 'vector_layers_field_infos', 'vector_layers_statistics', 'ElementaryGeometries', ]; export class SqliteSchemaHelper extends SchemaHelper { disableForeignKeysSQL() { return 'pragma foreign_keys = off;'; } enableForeignKeysSQL() { return 'pragma foreign_keys = on;'; } supportsSchemaConstraints() { return false; } getCreateNamespaceSQL(name) { return ''; } getDropNamespaceSQL(name) { return ''; } getListTablesSQL() { return ( `select name as table_name from sqlite_master where type = 'table' and name != 'sqlite_sequence' and name != 'geometry_columns' and name != 'spatial_ref_sys' ` + `union all select name as table_name from sqlite_temp_master where type = 'table' order by name` ); } async getAllTables(connection, schemas) { const databases = await this.getDatabaseList(connection); const hasAttachedDbs = databases.length > 1; // More than just 'main' // If no attached databases, use original behavior if (!hasAttachedDbs && !schemas?.length) { return connection.execute(this.getListTablesSQL()); } // With attached databases, query each one const targetSchemas = schemas?.length ? schemas : databases; const allTables = []; for (const dbName of targetSchemas) { const prefix = this.getSchemaPrefix(dbName); const tables = await connection.execute( `select name from ${prefix}sqlite_master where type = 'table' ` + `and name != 'sqlite_sequence' and name != 'geometry_columns' and name != 'spatial_ref_sys'`, ); for (const t of tables) { allTables.push({ table_name: t.name, schema_name: dbName }); } } return allTables; } async getNamespaces(connection) { return this.getDatabaseList(connection); } getIgnoredViewsCondition() { return SPATIALITE_VIEWS.map(v => `name != '${v}'`).join(' and '); } getListViewsSQL() { return `select name as view_name, sql as view_definition from sqlite_master where type = 'view' and ${this.getIgnoredViewsCondition()} order by name`; } async loadViews(schema, connection, schemaName) { const databases = await this.getDatabaseList(connection); const hasAttachedDbs = databases.length > 1; // More than just 'main' // If no attached databases and no specific schema, use original behavior if (!hasAttachedDbs && !schemaName) { const views = await connection.execute(this.getListViewsSQL()); for (const view of views) { schema.addView(view.view_name, schemaName, this.extractViewDefinition(view.view_definition)); } return; } // With attached databases, query each one /* v8 ignore next - schemaName branch not commonly used */ const targetDbs = schemaName ? [schemaName] : databases; for (const dbName of targetDbs) { const prefix = this.getSchemaPrefix(dbName); const views = await connection.execute( `select name as view_name, sql as view_definition from ${prefix}sqlite_master where type = 'view' and ${this.getIgnoredViewsCondition()} order by name`, ); for (const view of views) { schema.addView(view.view_name, dbName, this.extractViewDefinition(view.view_definition)); } } } getDropDatabaseSQL(name) { if (name === ':memory:') { return ''; } /* v8 ignore next */ return `drop database if exists ${this.quote(name)}`; } async loadInformationSchema(schema, connection, tables, schemas) { for (const t of tables) { const table = schema.addTable(t.table_name, t.schema_name, t.table_comment); const cols = await this.getColumns(connection, table.name, table.schema); const indexes = await this.getIndexes(connection, table.name, table.schema); const checks = await this.getChecks(connection, table.name, table.schema); const pks = await this.getPrimaryKeys(connection, indexes, table.name, table.schema); const fks = await this.getForeignKeys(connection, table.name, table.schema); const enums = await this.getEnumDefinitions(connection, table.name, table.schema); table.init(cols, indexes, checks, pks, fks, enums); } } createTable(table, alter) { let sql = `create table ${table.getQuotedName()} (`; const columns = table.getColumns(); const lastColumn = columns[columns.length - 1].name; for (const column of columns) { const col = this.createTableColumn(column, table); if (col) { const comma = column.name === lastColumn ? '' : ', '; sql += col + comma; } } const primaryKey = table.getPrimaryKey(); const createPrimary = primaryKey?.composite; if (createPrimary && primaryKey) { sql += `, primary key (${primaryKey.columnNames.map(c => this.quote(c)).join(', ')})`; } const parts = []; for (const fk of Object.values(table.getForeignKeys())) { parts.push(this.createForeignKey(table, fk, false)); } for (const check of table.getChecks()) { const sql = `constraint ${this.quote(check.name)} check (${check.expression})`; parts.push(sql); } if (parts.length > 0) { sql += ', ' + parts.join(', '); } sql += ')'; if (table.comment) { sql += ` /* ${table.comment} */`; } const ret = []; this.append(ret, sql); for (const index of table.getIndexes()) { this.append(ret, this.createIndex(index, table)); } return ret; } createTableColumn(column, table, _changedProperties) { const col = [this.quote(column.name)]; const checks = table.getChecks(); const check = checks.findIndex(check => check.columnName === column.name); const useDefault = column.default != null && column.default !== 'null'; let columnType = column.type; if (column.autoincrement) { columnType = 'integer'; } if (column.generated) { columnType += ` generated always as ${column.generated}`; } col.push(columnType); if (check !== -1) { col.push(`check (${checks[check].expression})`); checks.splice(check, 1); } Utils.runIfNotEmpty(() => col.push('null'), column.nullable); Utils.runIfNotEmpty(() => col.push('not null'), !column.nullable && !column.generated); Utils.runIfNotEmpty(() => col.push('primary key'), column.primary); Utils.runIfNotEmpty(() => col.push('autoincrement'), column.autoincrement); Utils.runIfNotEmpty(() => col.push(`default ${column.default}`), useDefault); return col.join(' '); } getAddColumnsSQL(table, columns, diff) { return columns.map(column => { let sql = `alter table ${table.getQuotedName()} add column ${this.createTableColumn(column, table)}`; const foreignKey = Object.values(diff.addedForeignKeys).find( fk => fk.columnNames.length === 1 && fk.columnNames[0] === column.name, ); if (foreignKey && this.options.createForeignKeyConstraints) { delete diff.addedForeignKeys[foreignKey.constraintName]; sql += ' ' + this.createForeignKey(diff.toTable, foreignKey, false, true); } return sql; }); } dropForeignKey(tableName, constraintName) { return ''; } getDropColumnsSQL(tableName, columns, schemaName) { /* v8 ignore next */ const name = this.quote( (schemaName && schemaName !== this.platform.getDefaultSchemaName() ? schemaName + '.' : '') + tableName, ); return columns .map(column => { return `alter table ${name} drop column ${this.quote(column.name)}`; }) .join(';\n'); } getCreateIndexSQL(tableName, index) { /* v8 ignore next */ if (index.expression) { return index.expression; } // SQLite requires: CREATE INDEX schema.index_name ON table_name (columns) // NOT: CREATE INDEX index_name ON schema.table_name (columns) const [schemaName, rawTableName] = this.splitTableName(tableName); const quotedTableName = this.quote(rawTableName); // If there's a schema, prefix the index name with it let keyName; if (schemaName && schemaName !== 'main') { keyName = `${this.quote(schemaName)}.${this.quote(index.keyName)}`; } else { keyName = this.quote(index.keyName); } const sqlPrefix = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${quotedTableName}`; /* v8 ignore next 4 */ if (index.columnNames.some(column => column.includes('.'))) { // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them const columns = this.platform.getJsonIndexDefinition(index); return `${sqlPrefix} (${columns.join(', ')})`; } // Use getIndexColumns to support advanced options like sort order and collation return `${sqlPrefix} (${this.getIndexColumns(index)})`; } parseTableDefinition(sql, cols) { const columns = {}; const constraints = []; // extract all columns definitions let columnsDef = new RegExp(`create table [\`"']?.*?[\`"']? \\((.*)\\)`, 'i').exec(sql.replaceAll('\n', ''))?.[1]; /* v8 ignore next */ if (columnsDef) { if (columnsDef.includes(', constraint ')) { constraints.push(...columnsDef.substring(columnsDef.indexOf(', constraint') + 2).split(', ')); columnsDef = columnsDef.substring(0, columnsDef.indexOf(', constraint')); } for (let i = cols.length - 1; i >= 0; i--) { const col = cols[i]; const re = ` *, *[\`"']?${col.name}[\`"']? (.*)`; const columnDef = new RegExp(re, 'i').exec(columnsDef); if (columnDef) { columns[col.name] = { name: col.name, definition: columnDef[1] }; columnsDef = columnsDef.substring(0, columnDef.index); } } } return { columns, constraints }; } /** * Returns schema prefix for pragma and sqlite_master queries. * Returns empty string for main database (no prefix needed). */ getSchemaPrefix(schemaName) { if (!schemaName || schemaName === 'main') { return ''; } return `${this.platform.quoteIdentifier(schemaName)}.`; } /** * Returns all database names excluding 'temp'. */ async getDatabaseList(connection) { const databases = await connection.execute('pragma database_list'); return databases.filter(d => d.name !== 'temp').map(d => d.name); } /** * Extracts the SELECT part from a CREATE VIEW statement. */ extractViewDefinition(viewDefinition) { const match = /create\s+view\s+[`"']?\w+[`"']?\s+as\s+(.*)/is.exec(viewDefinition); /* v8 ignore next - fallback for non-standard view definitions */ return match ? match[1] : viewDefinition; } async getColumns(connection, tableName, schemaName) { const prefix = this.getSchemaPrefix(schemaName); const columns = await connection.execute(`pragma ${prefix}table_xinfo('${tableName}')`); const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`; const tableDefinition = await connection.execute(sql, ['table', tableName], 'get'); const composite = columns.reduce((count, col) => count + (col.pk ? 1 : 0), 0) > 1; // there can be only one, so naive check like this should be enough const hasAutoincrement = tableDefinition.sql.toLowerCase().includes('autoincrement'); const { columns: columnDefinitions } = this.parseTableDefinition(tableDefinition.sql, columns); return columns.map(col => { const mappedType = connection.getPlatform().getMappedType(col.type); let generated; if (col.hidden > 1) { /* v8 ignore next */ const storage = col.hidden === 2 ? 'virtual' : 'stored'; const re = new RegExp(`(generated always)? as \\((.*)\\)( ${storage})?$`, 'i'); const match = columnDefinitions[col.name].definition.match(re); if (match) { generated = `${match[2]} ${storage}`; } } return { name: col.name, type: col.type, default: this.wrapExpressionDefault(col.dflt_value), nullable: !col.notnull, primary: !!col.pk, mappedType, unsigned: false, autoincrement: !composite && col.pk && this.platform.isNumericColumn(mappedType) && hasAutoincrement, generated, }; }); } /** * SQLite strips outer parentheses from expression defaults (`DEFAULT (expr)` → `expr` in pragma). * We need to add them back so they match what we generate in DDL. */ wrapExpressionDefault(value) { if (value == null) { return null; } // simple values that are returned as-is from pragma (no wrapping needed) if ( /^-?\d/.test(value) || /^[xX]'/.test(value) || value.startsWith("'") || value.startsWith('"') || value.startsWith('(') ) { return value; } const lower = value.toLowerCase(); if (['null', 'true', 'false', 'current_timestamp', 'current_date', 'current_time'].includes(lower)) { return value; } // everything else is an expression that had its outer parens stripped return `(${value})`; } async getEnumDefinitions(connection, tableName, schemaName) { const prefix = this.getSchemaPrefix(schemaName); const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`; const tableDefinition = await connection.execute(sql, ['table', tableName], 'get'); const checkConstraints = [...(tableDefinition.sql.match(/[`["'][^`\]"']+[`\]"'] text check \(.*?\)/gi) ?? [])]; return checkConstraints.reduce((o, item) => { // check constraints are defined as (note that last closing paren is missing): // `type` text check (`type` in ('local', 'global') const match = /[`["']([^`\]"']+)[`\]"'] text check \(.* \((.*)\)/i.exec(item); /* v8 ignore next */ if (match) { o[match[1]] = match[2].split(',').map(item => /^\(?'(.*)'/.exec(item.trim())[1]); } return o; }, {}); } async getPrimaryKeys(connection, indexes, tableName, schemaName) { const prefix = this.getSchemaPrefix(schemaName); const sql = `pragma ${prefix}table_info(\`${tableName}\`)`; const cols = await connection.execute(sql); return cols.filter(col => !!col.pk).map(col => col.name); } async getIndexes(connection, tableName, schemaName) { const prefix = this.getSchemaPrefix(schemaName); const sql = `pragma ${prefix}table_info(\`${tableName}\`)`; const cols = await connection.execute(sql); const indexes = await connection.execute(`pragma ${prefix}index_list(\`${tableName}\`)`); const ret = []; for (const col of cols.filter(c => c.pk)) { ret.push({ columnNames: [col.name], keyName: 'primary', constraint: true, unique: true, primary: true, }); } for (const index of indexes.filter(index => !this.isImplicitIndex(index.name))) { const res = await connection.execute(`pragma ${prefix}index_info(\`${index.name}\`)`); ret.push( ...res.map(row => ({ columnNames: [row.name], keyName: index.name, unique: !!index.unique, constraint: !!index.unique, primary: false, })), ); } return this.mapIndexes(ret); } async getChecks(connection, tableName, schemaName) { const { columns, constraints } = await this.getColumnDefinitions(connection, tableName, schemaName); const checks = []; for (const key of Object.keys(columns)) { const column = columns[key]; const expression = / (check \((.*)\))/i.exec(column.definition); if (expression) { checks.push({ name: this.platform.getConfig().getNamingStrategy().indexName(tableName, [column.name], 'check'), definition: expression[1], expression: expression[2], columnName: column.name, }); } } for (const constraint of constraints) { const expression = /constraint *[`"']?(.*?)[`"']? * (check \((.*)\))/i.exec(constraint); if (expression) { checks.push({ name: expression[1], definition: expression[2], expression: expression[3], }); } } return checks; } async getColumnDefinitions(connection, tableName, schemaName) { const prefix = this.getSchemaPrefix(schemaName); const columns = await connection.execute(`pragma ${prefix}table_xinfo('${tableName}')`); const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`; const tableDefinition = await connection.execute(sql, ['table', tableName], 'get'); return this.parseTableDefinition(tableDefinition.sql, columns); } async getForeignKeys(connection, tableName, schemaName) { const { constraints } = await this.getColumnDefinitions(connection, tableName, schemaName); const prefix = this.getSchemaPrefix(schemaName); const fks = await connection.execute(`pragma ${prefix}foreign_key_list(\`${tableName}\`)`); const qualifiedTableName = schemaName ? `${schemaName}.${tableName}` : tableName; return fks.reduce((ret, fk) => { const constraintName = this.platform.getIndexName(tableName, [fk.from], 'foreign'); const constraint = constraints?.find(c => c.includes(constraintName)); ret[constraintName] = { constraintName, columnName: fk.from, columnNames: [fk.from], localTableName: qualifiedTableName, referencedTableName: fk.table, referencedColumnName: fk.to, referencedColumnNames: [fk.to], updateRule: fk.on_update.toLowerCase(), deleteRule: fk.on_delete.toLowerCase(), deferMode: constraint?.match(/ deferrable initially (deferred|immediate)/i)?.[1].toLowerCase(), }; return ret; }, {}); } getManagementDbName() { return ''; } getCreateDatabaseSQL(name) { return ''; } async databaseExists(connection, name) { const tables = await connection.execute(this.getListTablesSQL()); return tables.length > 0; } /** * Implicit indexes will be ignored when diffing */ isImplicitIndex(name) { // Ignore indexes with reserved names, e.g. autoindexes return name.startsWith('sqlite_'); } dropIndex(table, index, oldIndexName = index.keyName) { return `drop index ${this.quote(oldIndexName)}`; } /** * SQLite does not support schema-qualified table names in REFERENCES clauses. * Foreign key references can only point to tables in the same database. */ getReferencedTableName(referencedTableName, schema) { const [schemaName, tableName] = this.splitTableName(referencedTableName); // Strip any schema prefix - SQLite REFERENCES clause doesn't support it return tableName; } alterTable(diff, safe) { const ret = []; const [schemaName, tableName] = this.splitTableName(diff.name); if ( Utils.hasObjectKeys(diff.removedChecks) || Utils.hasObjectKeys(diff.changedChecks) || Utils.hasObjectKeys(diff.changedForeignKeys) || Utils.hasObjectKeys(diff.changedColumns) ) { return this.getAlterTempTableSQL(diff); } for (const index of Object.values(diff.removedIndexes)) { this.append(ret, this.dropIndex(diff.name, index)); } for (const index of Object.values(diff.changedIndexes)) { this.append(ret, this.dropIndex(diff.name, index)); } /* v8 ignore next */ if (!safe && Object.values(diff.removedColumns).length > 0) { this.append(ret, this.getDropColumnsSQL(tableName, Object.values(diff.removedColumns), schemaName)); } if (Object.values(diff.addedColumns).length > 0) { this.append(ret, this.getAddColumnsSQL(diff.toTable, Object.values(diff.addedColumns), diff)); } if (Utils.hasObjectKeys(diff.addedForeignKeys) || Utils.hasObjectKeys(diff.addedChecks)) { return this.getAlterTempTableSQL(diff); } for (const [oldColumnName, column] of Object.entries(diff.renamedColumns)) { this.append(ret, this.getRenameColumnSQL(tableName, oldColumnName, column, schemaName)); } for (const index of Object.values(diff.addedIndexes)) { ret.push(this.createIndex(index, diff.toTable)); } for (const index of Object.values(diff.changedIndexes)) { ret.push(this.createIndex(index, diff.toTable, true)); } for (const [oldIndexName, index] of Object.entries(diff.renamedIndexes)) { if (index.unique) { this.append(ret, this.dropIndex(diff.name, index, oldIndexName)); this.append(ret, this.createIndex(index, diff.toTable)); } else { this.append(ret, this.getRenameIndexSQL(diff.name, index, oldIndexName)); } } return ret; } getAlterTempTableSQL(changedTable) { const tempName = `${changedTable.toTable.name}__temp_alter`; const quotedName = this.quote(changedTable.toTable.name); const quotedTempName = this.quote(tempName); const [first, ...rest] = this.createTable(changedTable.toTable); const sql = [ 'pragma foreign_keys = off;', first.replace(`create table ${quotedName}`, `create table ${quotedTempName}`), ]; const columns = []; for (const column of changedTable.toTable.getColumns()) { const fromColumn = changedTable.fromTable.getColumn(column.name); if (fromColumn) { columns.push(this.quote(column.name)); } else { columns.push(`null as ${this.quote(column.name)}`); } } sql.push(`insert into ${quotedTempName} select ${columns.join(', ')} from ${quotedName};`); sql.push(`drop table ${quotedName};`); sql.push(`alter table ${quotedTempName} rename to ${quotedName};`); sql.push(...rest); sql.push('pragma foreign_keys = on;'); return sql; } }