diff --git a/CHANGELOG.md b/CHANGELOG.md index d004679b3c..0928c0716e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Git status letters, history and **Discard Changes…** for files in a linked SQL folder. (#2505) - Whether a materialized view can be refreshed concurrently, on its **Indexes** tab. (#2522) - Invalid PostgreSQL indexes named on the table's **Indexes** tab. +- Expression keys typed into an index's **Columns** cell, such as `lower(email)`. ### Changed @@ -377,6 +378,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PostgreSQL exclusion constraints missing from exports and the DDL tab. - PostgreSQL column reorder script dropping an index named like one of the table's check constraints. - Invalid PostgreSQL index recreated on the target by Compare & Sync and **Copy To**. +- Expression key parts missing from SQLite, libSQL, Cloudflare D1, MySQL and DuckDB indexes. +- Condition missing from SQLite, libSQL and Cloudflare D1 partial indexes. +- Descending MySQL index keys recreated ascending by a rename. +- MySQL index dropped when the index replacing it failed to create. - Indent and Outdent named the wrong way round for Command-[ and Command-] in Settings > Keyboard. - Table, routine or type missing from the sidebar or Open Quickly when a period in its quoted name matched another's. - Show Previous Tab and Show Next Tab listed twice in the Window menu. diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index a5f92e67d6..eae9b0271a 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -359,50 +359,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let safeTable = escapeStringLiteral(table) - let query = """ - SELECT il.name, il."unique", il.origin, ii.name AS col_name - FROM pragma_index_list('\(safeTable)') il - LEFT JOIN pragma_index_info(il.name) ii ON 1=1 - ORDER BY il.seq, ii.seqno - """ - let result = try await execute(query: query) - - var indexMap: [(name: String, isUnique: Bool, isPrimary: Bool, columns: [String])] = [] - var indexLookup: [String: Int] = [:] - - for row in result.rows { - guard row.count >= 4, - let indexName = row[0].asText else { continue } - - let isUnique = row[1].asText == "1" - let origin = row[2].asText ?? "c" - - if let idx = indexLookup[indexName] { - if let colName = row[3].asText { - indexMap[idx].columns.append(colName) - } - } else { - let columns: [String] = row[3].asText.map { [$0] } ?? [] - indexLookup[indexName] = indexMap.count - indexMap.append(( - name: indexName, - isUnique: isUnique, - isPrimary: origin == "pk", - columns: columns - )) - } - } - - return indexMap.map { entry in - PluginIndexInfo( - name: entry.name, - columns: entry.columns, - isUnique: entry.isUnique, - isPrimary: entry.isPrimary, - type: "BTREE" - ) - }.sorted { $0.isPrimary && !$1.isPrimary } + let result = try await execute(query: SQLiteIndexCatalog.indexesQuery(table: table)) + return SQLiteIndexCatalog.indexes(fromRows: result.rows) } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { @@ -782,14 +740,7 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable } func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { - let uniqueStr = index.isUnique ? "UNIQUE " : "" - let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") - var statement = "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name)) " - + "ON \(quoteIdentifier(table)) (\(cols))" - if let predicate = index.whereClause?.nilIfEmpty { - statement += " WHERE \(predicate)" - } - return statement + SQLiteIndexCatalog.createStatement(for: index, table: table, quote: quoteIdentifier) } func generateDropIndexSQL(table: String, indexName: String) -> String? { @@ -801,10 +752,12 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable } func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { - let uniqueStr = index.isUnique ? "UNIQUE " : "" - let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") - let onClause = tableName.map { " ON \(quoteIdentifier($0))" } ?? "" - return "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name))\(onClause) (\(cols))" + guard let tableName else { + let unique = index.isUnique ? "UNIQUE " : "" + let keys = SQLiteIndexCatalog.keyList(for: index, quote: quoteIdentifier) + return "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) \(keys)" + } + return SQLiteIndexCatalog.createStatement(for: index, table: tableName, quote: quoteIdentifier) } func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { diff --git a/Plugins/DuckDBDriverPlugin/DuckDBIndexClauses.swift b/Plugins/DuckDBDriverPlugin/DuckDBIndexClauses.swift new file mode 100644 index 0000000000..d660720a3e --- /dev/null +++ b/Plugins/DuckDBDriverPlugin/DuckDBIndexClauses.swift @@ -0,0 +1,43 @@ +// +// DuckDBIndexClauses.swift +// DuckDBDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum DuckDBIndexClauses { + struct KeyParts: Equatable { + let columns: [String] + let expressions: [String] + } + + static func keyParts(ofCreateIndex sql: String?) -> KeyParts { + let features = DuckDBLexicalFeatures.features + guard let sql, let statement = SQLIndexKeyList.statement(sql, lexicalFeatures: features) else { + return KeyParts(columns: [], expressions: []) + } + var expressions: [String] = [] + let columns = statement.keyParts.map { part -> String in + if let expression = SQLIndexKeyList.unwrapped(part, lexicalFeatures: features) { + expressions.append(expression) + return expression + } + return SQLIndexKeyList.quotedIdentifier(part, lexicalFeatures: features) ?? part + } + return KeyParts(columns: columns, expressions: expressions) + } + + static func createStatement( + for index: PluginIndexDefinition, + qualifiedTable: String, + quote: (String) -> String + ) -> String { + let expressions = Set(index.expressions ?? []) + let keys = index.columns + .map { expressions.contains($0) ? "(\($0))" : quote($0) } + .joined(separator: ", ") + let unique = index.isUnique ? "UNIQUE " : "" + return "CREATE \(unique)INDEX \(quote(index.name)) ON \(qualifiedTable) (\(keys))" + } +} diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 2f3848c6a0..bfe5b9f0d4 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -713,16 +713,22 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { guard let name = row[safe: 0]?.asText else { return nil } let sql = row[safe: 2]?.asText + let keys = DuckDBIndexClauses.keyParts(ofCreateIndex: sql) /// `duckdb_indexes()` lists user indexes only, so nothing here backs a primary key. /// Reading one out of the name matched any index called something like /// `idx_primary_contact`, which then reported as the table's primary key and as /// unique. return PluginIndexInfo( name: name, - columns: extractIndexColumns(from: sql), + columns: keys.columns, isUnique: (row[safe: 1]?.asText) == "true", isPrimary: false, - type: "ART" + type: "ART", + expressions: keys.expressions.isEmpty ? nil : keys.expressions, + includedColumns: nil, + ddlMethodAndKeys: nil, + ddlWhereClause: nil, + isValid: nil ) } } catch { @@ -1042,9 +1048,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func duckdbIndexDefinition(_ index: PluginIndexDefinition, qualifiedTable: String) -> String { - let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") - let unique = index.isUnique ? "UNIQUE " : "" - return "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable) (\(cols))" + DuckDBIndexClauses.createStatement(for: index, qualifiedTable: qualifiedTable, quote: quoteIdentifier) } private func duckdbForeignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String { @@ -1138,48 +1142,4 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } return stmts.isEmpty ? nil : stmts } - - private static let indexColumnsRegex = try? NSRegularExpression( - pattern: #"ON\s+(?:(?:"[^"]*"|[^\s(]+)\s*\.\s*)*(?:"[^"]*"|[^\s(]+)\s*\("#, - options: .caseInsensitive - ) - - /// Splits an index's key list on the commas that separate its keys. - /// - /// A key can be an expression, so both the opening parenthesis and the commas inside it belong - /// to the key rather than to the list: `(lower(email))` is one key and `(coalesce(a, b))` is - /// one key with a comma in it. Matching the list with a regex that stops at the first closing - /// parenthesis produced `(lower(email` and `[(COALESCE(a, b]`, which the DDL then quoted as - /// column names. - private func extractIndexColumns(from sql: String?) -> [String] { - guard let sql, let regex = Self.indexColumnsRegex else { return [] } - - let range = NSRange(sql.startIndex..., in: sql) - guard let match = regex.firstMatch(in: sql, range: range), - let openParen = Range(match.range, in: sql) else { - return [] - } - - var depth = 1 - var current = "" - var keys: [String] = [] - for character in sql[openParen.upperBound...] { - if character == "(" { - depth += 1 - } else if character == ")" { - depth -= 1 - if depth == 0 { break } - } else if character == ",", depth == 1 { - keys.append(current) - current = "" - continue - } - current.append(character) - } - keys.append(current) - - return keys - .map { $0.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") } - .filter { !$0.isEmpty } - } } diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index f05f4944a5..0399b11659 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -434,50 +434,8 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let safeTable = escapeStringLiteral(table) - let query = """ - SELECT il.name, il."unique", il.origin, ii.name AS col_name - FROM pragma_index_list('\(safeTable)') il - LEFT JOIN pragma_index_info(il.name) ii ON 1=1 - ORDER BY il.seq, ii.seqno - """ - let result = try await execute(query: query) - - var indexMap: [(name: String, isUnique: Bool, isPrimary: Bool, columns: [String])] = [] - var indexLookup: [String: Int] = [:] - - for row in result.rows { - guard row.count >= 4, - let indexName = row[0].asText else { continue } - - let isUnique = row[1].asText == "1" - let origin = row[2].asText ?? "c" - - if let idx = indexLookup[indexName] { - if let colName = row[3].asText { - indexMap[idx].columns.append(colName) - } - } else { - let columns: [String] = row[3].asText.map { [$0] } ?? [] - indexLookup[indexName] = indexMap.count - indexMap.append(( - name: indexName, - isUnique: isUnique, - isPrimary: origin == "pk", - columns: columns - )) - } - } - - return indexMap.map { entry in - PluginIndexInfo( - name: entry.name, - columns: entry.columns, - isUnique: entry.isUnique, - isPrimary: entry.isPrimary, - type: "BTREE" - ) - }.sorted { $0.isPrimary && !$1.isPrimary } + let result = try await execute(query: SQLiteIndexCatalog.indexesQuery(table: table)) + return SQLiteIndexCatalog.indexes(fromRows: result.rows) } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { @@ -803,14 +761,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { - let uniqueStr = index.isUnique ? "UNIQUE " : "" - let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") - var statement = "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name)) " - + "ON \(quoteIdentifier(table)) (\(cols))" - if let predicate = index.whereClause?.nilIfEmpty { - statement += " WHERE \(predicate)" - } - return statement + SQLiteIndexCatalog.createStatement(for: index, table: table, quote: quoteIdentifier) } func generateDropIndexSQL(table: String, indexName: String) -> String? { @@ -822,10 +773,12 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { - let uniqueStr = index.isUnique ? "UNIQUE " : "" - let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") - let onClause = tableName.map { " ON \(quoteIdentifier($0))" } ?? "" - return "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name))\(onClause) (\(cols))" + guard let tableName else { + let unique = index.isUnique ? "UNIQUE " : "" + let keys = SQLiteIndexCatalog.keyList(for: index, quote: quoteIdentifier) + return "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) \(keys)" + } + return SQLiteIndexCatalog.createStatement(for: index, table: tableName, quote: quoteIdentifier) } func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { diff --git a/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift b/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift index bb4c976861..be602eeb2b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift @@ -56,30 +56,76 @@ internal func mysqlCreateTableSQL( return sql + ";" } -internal func mysqlIndexDefinitionSQL(_ index: PluginIndexDefinition) -> String { - let columns = index.columns.map { column -> String in - let quoted = mysqlQuoteIdentifier(column) - if let prefixes = index.columnPrefixes, let prefix = prefixes[column] { - return "\(quoted)(\(prefix))" +internal enum MySQLIndexKeyPart: Equatable { + case column(String, prefixLength: Int?) + case expression(String) + + var text: String { + switch self { + case .column(let name, _): return name + case .expression(let expression): return expression } - return quoted - }.joined(separator: ", ") + } - let upperType = index.indexType?.uppercased() ?? "" - var definition: String - switch upperType { - case "FULLTEXT": definition = "FULLTEXT INDEX" - case "SPATIAL": definition = "SPATIAL INDEX" - default: definition = index.isUnique ? "UNIQUE INDEX" : "INDEX" + var sql: String { + switch self { + case .column(let name, let prefixLength?): return "\(mysqlQuoteIdentifier(name))(\(prefixLength))" + case .column(let name, nil): return mysqlQuoteIdentifier(name) + case .expression(let expression): return "(\(expression))" + } } +} - definition += " \(mysqlQuoteIdentifier(index.name)) (\(columns))" +internal struct MySQLIndexKey: Equatable { + let part: MySQLIndexKeyPart + let isDescending: Bool + var sql: String { + isDescending ? "\(part.sql) DESC" : part.sql + } +} + +internal func mysqlIndexKeyClause(_ keys: [MySQLIndexKey], type: String?) -> String { + var clause = "(\(keys.map(\.sql).joined(separator: ", ")))" + let upperType = type?.uppercased() ?? "" if upperType == "BTREE" || upperType == "HASH" { - definition += " USING \(upperType)" + clause += " USING \(upperType)" } + return clause +} - return definition +internal func mysqlIndexKeys(of index: PluginIndexDefinition) -> [MySQLIndexKey] { + let expressions = Set(index.expressions ?? []) + return index.columns.map { column in + let part: MySQLIndexKeyPart = expressions.contains(column) + ? .expression(column) + : .column(column, prefixLength: index.columnPrefixes?[column]) + return MySQLIndexKey(part: part, isDescending: false) + } +} + +internal func mysqlIndexDefinitionSQL(_ index: PluginIndexDefinition) -> String { + let upperType = index.indexType?.uppercased() ?? "" + let kind: String + switch upperType { + case "FULLTEXT": kind = "FULLTEXT INDEX" + case "SPATIAL": kind = "SPATIAL INDEX" + default: kind = index.isUnique ? "UNIQUE INDEX" : "INDEX" + } + let keys = index.ddlMethodAndKeys?.nilIfEmpty + ?? mysqlIndexKeyClause(mysqlIndexKeys(of: index), type: upperType) + return "\(kind) \(mysqlQuoteIdentifier(index.name)) \(keys)" +} + +internal func mysqlModifyIndexSQL( + table: String, + oldIndexName: String, + newIndex: PluginIndexDefinition, + flavor: MySQLServerFlavor +) -> String? { + guard flavor == .mysql || flavor == .mariadb else { return nil } + return "ALTER TABLE \(mysqlQuoteIdentifier(table)) DROP INDEX \(mysqlQuoteIdentifier(oldIndexName)), " + + "ADD \(mysqlIndexDefinitionSQL(newIndex))" } /// `CONSTRAINT name` is optional in MySQL's grammar and the server invents one when it is left out, diff --git a/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift b/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift new file mode 100644 index 0000000000..ac86ab6b13 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift @@ -0,0 +1,45 @@ +// +// MySQLFunctionalKeyParts.swift +// MySQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +internal enum MySQLFunctionalKeyParts { + private static let firstRelease = (8, 0, 13) + + static func catalogReportsExpressions(banner: String?, flavor: MySQLServerFlavor) -> Bool { + flavor == .mysql && MySQLServerVersion.isAtLeast(firstRelease, banner: banner) + } + + static func refusal(for index: PluginIndexDefinition, banner: String?, flavor: MySQLServerFlavor) -> String? { + guard let expressions = index.expressions, !expressions.isEmpty else { return nil } + switch flavor { + case .mariadb: + return String( + format: String(localized: "%@ cannot index an expression. Index a generated column instead."), + "MariaDB" + ) + case .mysql: + guard MySQLServerVersion.isKnownBelow(firstRelease, banner: banner) else { return nil } + return String(localized: "Indexing an expression needs MySQL 8.0.13 or later.") + case .tidb, .oceanbase, .databend: + return nil + } + } + + static func unescaped(_ catalogExpression: String) -> String { + var result = "" + var escaping = false + for character in catalogExpression { + if !escaping, character == "\\" { + escaping = true + continue + } + escaping = false + result.append(character) + } + return result + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLIndexGrouping.swift b/Plugins/MySQLDriverPlugin/MySQLIndexGrouping.swift index ba64c6d0ed..06e16f688f 100644 --- a/Plugins/MySQLDriverPlugin/MySQLIndexGrouping.swift +++ b/Plugins/MySQLDriverPlugin/MySQLIndexGrouping.swift @@ -10,13 +10,53 @@ import TableProPluginKit struct MySQLIndexRow { let table: String let index: String - let column: String + let key: MySQLIndexKey let isNonUnique: Bool let type: String - let prefixLength: Int? + + init(table: String, index: String, key: MySQLIndexKey, isNonUnique: Bool, type: String) { + self.table = table + self.index = index + self.key = key + self.isNonUnique = isNonUnique + self.type = type + } + + init?( + table: String, + index: String, + column: String?, + catalogExpression: String?, + prefixLength: Int?, + collation: String?, + isNonUnique: Bool, + type: String + ) { + let part: MySQLIndexKeyPart + if let column { + part = .column(column, prefixLength: prefixLength) + } else if let catalogExpression { + part = .expression(MySQLFunctionalKeyParts.unescaped(catalogExpression)) + } else { + return nil + } + self.init( + table: table, + index: index, + key: MySQLIndexKey(part: part, isDescending: collation == "D"), + isNonUnique: isNonUnique, + type: type + ) + } } enum MySQLIndexGrouping { + private struct Entry { + let isUnique: Bool + let type: String + var keys: [MySQLIndexKey] + } + /// Rows must arrive in index-position order: a composite index takes its column order from the /// order they are appended, which is what the caller's `ORDER BY … SEQ_IN_INDEX` provides. /// @@ -25,26 +65,15 @@ enum MySQLIndexGrouping { /// unchanged table disagree. static func group(_ rows: [MySQLIndexRow]) -> [String: [PluginIndexInfo]] { var order: [String: [String]] = [:] - var byTable: [String: [String: (columns: [String], isUnique: Bool, type: String, prefixes: [String: Int])]] = [:] + var byTable: [String: [String: Entry]] = [:] for row in rows { var indexes = byTable[row.table] ?? [:] - if var existing = indexes[row.index] { - existing.columns.append(row.column) - if let prefix = row.prefixLength { - existing.prefixes[row.column] = prefix - } - indexes[row.index] = existing - } else { - var prefixes: [String: Int] = [:] - if let prefix = row.prefixLength { - prefixes[row.column] = prefix - } - indexes[row.index] = ( - columns: [row.column], isUnique: !row.isNonUnique, type: row.type, prefixes: prefixes - ) + if indexes[row.index] == nil { + indexes[row.index] = Entry(isUnique: !row.isNonUnique, type: row.type, keys: []) order[row.table, default: []].append(row.index) } + indexes[row.index]?.keys.append(row.key) byTable[row.table] = indexes } @@ -53,15 +82,42 @@ enum MySQLIndexGrouping { let indexes = byTable[table] ?? [:] grouped[table] = names .compactMap { name -> PluginIndexInfo? in - guard let info = indexes[name] else { return nil } - return PluginIndexInfo( - name: name, columns: info.columns, isUnique: info.isUnique, - isPrimary: name == "PRIMARY", type: info.type, - columnPrefixes: info.prefixes.isEmpty ? nil : info.prefixes - ) + guard let entry = indexes[name] else { return nil } + return info(name: name, entry: entry) } .sorted { $0.isPrimary && !$1.isPrimary } } return grouped } + + private static func info(name: String, entry: Entry) -> PluginIndexInfo { + var prefixes: [String: Int] = [:] + var expressions: [String] = [] + for key in entry.keys { + switch key.part { + case .column(let column, let prefixLength?): + prefixes[column] = prefixLength + case .expression(let expression): + expressions.append(expression) + case .column: + break + } + } + let spelling = entry.keys.contains(where: \.isDescending) + ? mysqlIndexKeyClause(entry.keys, type: entry.type) + : nil + return PluginIndexInfo( + name: name, + columns: entry.keys.map(\.part.text), + isUnique: entry.isUnique, + isPrimary: name == "PRIMARY", + type: entry.type, + columnPrefixes: prefixes.isEmpty ? nil : prefixes, + expressions: expressions.isEmpty ? nil : expressions, + includedColumns: nil, + ddlMethodAndKeys: spelling, + ddlWhereClause: nil, + isValid: nil + ) + } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift index 6432f50835..ada4a0a507 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift @@ -38,10 +38,15 @@ extension MySQLPluginDriver { private func catalogIndexes(database: String) async throws -> [String: [PluginIndexInfo]] { let escapedDb = mysqlEscapeStringLiteral(database) + let identity = serverIdentity + let expression = MySQLFunctionalKeyParts.catalogReportsExpressions( + banner: identity.banner, flavor: identity.flavor + ) ? "EXPRESSION" : "NULL" let query = """ SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, - CAST(NON_UNIQUE AS CHAR), INDEX_TYPE, CAST(SUB_PART AS CHAR) + CAST(NON_UNIQUE AS CHAR), INDEX_TYPE, CAST(SUB_PART AS CHAR), + COLLATION, \(expression) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = '\(escapedDb)' ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX @@ -50,16 +55,17 @@ extension MySQLPluginDriver { let result = try await execute(ownStatement: query) let rows = result.rows.compactMap { row -> MySQLIndexRow? in guard let table = row[safe: 0]?.asText, - let index = row[safe: 1]?.asText, - let column = row[safe: 2]?.asText + let index = row[safe: 1]?.asText else { return nil } return MySQLIndexRow( table: table, index: index, - column: column, + column: row[safe: 2]?.asText, + catalogExpression: row[safe: 7]?.asText, + prefixLength: (row[safe: 5]?.asText).flatMap { Int($0) }, + collation: row[safe: 6]?.asText, isNonUnique: (row[safe: 3]?.asText) == "1", - type: (row[safe: 4]?.asText) ?? "BTREE", - prefixLength: (row[safe: 5]?.asText).flatMap { Int($0) } + type: (row[safe: 4]?.asText) ?? "BTREE" ) } return MySQLIndexGrouping.group(rows) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 5107e9478e..b37391dc15 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -649,18 +649,19 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { guard !flavor.isDatabend else { return [] } let result = try await execute(query: "SHOW INDEX FROM \(qualifiedName(table, schema: schema))") + let expressionColumn = result.columns.firstIndex(of: "Expression") let rows = result.rows.compactMap { row -> MySQLIndexRow? in - guard let indexName = row[safe: 2]?.asText, - let columnName = row[safe: 4]?.asText - else { return nil } + guard let indexName = row[safe: 2]?.asText else { return nil } return MySQLIndexRow( table: table, index: indexName, - column: columnName, + column: row[safe: 4]?.asText, + catalogExpression: expressionColumn.flatMap { row[safe: $0]?.asText }, + prefixLength: (row[safe: 7]?.asText).flatMap { Int($0) }, + collation: row[safe: 5]?.asText, isNonUnique: (row[safe: 1]?.asText) == "1", - type: (row[safe: 10]?.asText) ?? "BTREE", - prefixLength: (row[safe: 7]?.asText).flatMap { Int($0) } + type: (row[safe: 10]?.asText) ?? "BTREE" ) } return MySQLIndexGrouping.group(rows)[table] ?? [] @@ -1049,6 +1050,16 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return "ALTER TABLE \(quoteIdentifier(table)) DROP INDEX \(quoteIdentifier(indexName))" } + func generateModifyIndexSQL(table: String, oldIndexName: String, newIndex: PluginIndexDefinition) -> String? { + mysqlModifyIndexSQL(table: table, oldIndexName: oldIndexName, newIndex: newIndex, flavor: flavor) + } + + func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { + guard case .addIndex(let index) = operation else { return nil } + let identity = serverIdentity + return MySQLFunctionalKeyParts.refusal(for: index, banner: identity.banner, flavor: identity.flavor) + } + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { guard !flavor.isDatabend else { return nil } return "ALTER TABLE \(quoteIdentifier(table)) ADD \(mysqlForeignKeyDefinitionSQL(fk))" diff --git a/Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift b/Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift index d9166bd736..778231435f 100644 --- a/Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift +++ b/Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift @@ -94,16 +94,3 @@ internal func sqliteForeignKeyDefinitionSQL(_ foreignKey: PluginForeignKeyDefini } return definition } - -/// The `WHERE` predicate is written, because a partial index without it is a different index: a -/// unique partial index that loses its condition rejects the rows the user meant to exclude. -internal func sqliteAddIndexSQL(table: String, index: PluginIndexDefinition) -> String { - let columns = index.columns.map(sqliteQuoteIdentifier).joined(separator: ", ") - let unique = index.isUnique ? "UNIQUE " : "" - var statement = "CREATE \(unique)INDEX \(sqliteQuoteIdentifier(index.name)) " - + "ON \(sqliteQuoteIdentifier(table)) (\(columns))" - if let predicate = index.whereClause?.nilIfEmpty { - statement += " WHERE \(predicate)" - } - return statement -} diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index cfa68b5654..09e7e66041 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -504,26 +504,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let safeTable = escapeStringLiteral(table) - let query = """ - SELECT il.name, il."unique", il.origin, ii.name AS col_name - FROM pragma_index_list('\(safeTable)') il - LEFT JOIN pragma_index_info(il.name) ii ON 1=1 - ORDER BY il.seq, ii.seqno - """ - let result = try await execute(query: query) - - let rows = result.rows.compactMap { row -> SQLiteIndexRow? in - guard row.count >= 4, let indexName = row[0].asText else { return nil } - return SQLiteIndexRow( - table: table, - index: indexName, - column: row[3].asText, - isUnique: row[1].asText == "1", - origin: row[2].asText ?? "c" - ) - } - return SQLiteIndexGrouping.group(rows)[table] ?? [] + let result = try await execute(query: SQLiteIndexCatalog.indexesQuery(table: table)) + return SQLiteIndexCatalog.indexes(fromRows: result.rows) } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { @@ -809,7 +791,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { - sqliteAddIndexSQL(table: table, index: index) + SQLiteIndexCatalog.createStatement(for: index, table: table, quote: sqliteQuoteIdentifier) } func generateDropIndexSQL(table: String, indexName: String) -> String? { diff --git a/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+BulkMetadata.swift b/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+BulkMetadata.swift index 5d347e5fa2..be64b86a8e 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+BulkMetadata.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+BulkMetadata.swift @@ -15,86 +15,12 @@ import Foundation import TableProPluginKit -/// One row of `pragma_index_list` joined to `pragma_index_info`, in the fields both forms carry. -struct SQLiteIndexRow { - let table: String - let index: String - let column: String? - let isUnique: Bool - let origin: String -} - -enum SQLiteIndexGrouping { - /// Rows must arrive in index-position order, which is what the caller's - /// `ORDER BY il.seq, ii.seqno` provides: a composite index takes its column order from the - /// order they are appended. - static func group(_ rows: [SQLiteIndexRow]) -> [String: [PluginIndexInfo]] { - var order: [String: [String]] = [:] - var entries: [String: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])]] = [:] - - for row in rows { - var tableEntries = entries[row.table] ?? [:] - if var existing = tableEntries[row.index] { - if let column = row.column { - existing.columns.append(column) - } - tableEntries[row.index] = existing - } else { - tableEntries[row.index] = ( - isUnique: row.isUnique, - isPrimary: row.origin == "pk", - columns: row.column.map { [$0] } ?? [] - ) - order[row.table, default: []].append(row.index) - } - entries[row.table] = tableEntries - } - - var result: [String: [PluginIndexInfo]] = [:] - for (table, names) in order { - result[table] = names.compactMap { name -> PluginIndexInfo? in - guard let entry = entries[table]?[name] else { return nil } - return PluginIndexInfo( - name: name, - columns: entry.columns, - isUnique: entry.isUnique, - isPrimary: entry.isPrimary, - type: "BTREE" - ) - } - .sorted { $0.isPrimary && !$1.isPrimary } - } - return result - } -} - extension SQLitePluginDriver { var providesBulkIndexFetch: Bool { true } func fetchAllIndexes(schema: String?) async throws -> [String: [PluginIndexInfo]] { - let query = """ - SELECT m.name AS tbl, il.name, il."unique", il.origin, ii.name AS col_name - FROM sqlite_master m - JOIN pragma_index_list(m.name) il - LEFT JOIN pragma_index_info(il.name) ii ON 1=1 - WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' - ORDER BY m.name, il.seq, ii.seqno - """ - let result = try await execute(query: query) - - let rows = result.rows.compactMap { row -> SQLiteIndexRow? in - guard row.count >= 5, - let table = row[0].asText, - let index = row[1].asText else { return nil } - return SQLiteIndexRow( - table: table, - index: index, - column: row[4].asText, - isUnique: row[2].asText == "1", - origin: row[3].asText ?? "c" - ) - } - return SQLiteIndexGrouping.group(rows) + let result = try await execute(query: SQLiteIndexCatalog.schemaIndexesQuery) + return SQLiteIndexCatalog.indexesByTable(fromRows: result.rows) } var providesBulkTableMetadataFetch: Bool { true } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index dc6ce2314a..5c5e9a7f9d 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -300,6 +300,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func generateDropColumnSQL(table: String, columnName: String) -> String? func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? func generateDropIndexSQL(table: String, indexName: String) -> String? + func generateModifyIndexSQL(table: String, oldIndexName: String, newIndex: PluginIndexDefinition) -> String? func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? func generateDropForeignKeySQL(table: String, constraintName: String) -> String? func generateAddCheckConstraintSQL(table: String, constraint: PluginCheckConstraintDefinition) -> String? @@ -922,6 +923,7 @@ public extension PluginDatabaseDriver { func generateDropColumnSQL(table: String, columnName: String) -> String? { nil } func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { nil } func generateDropIndexSQL(table: String, indexName: String) -> String? { nil } + func generateModifyIndexSQL(table: String, oldIndexName: String, newIndex: PluginIndexDefinition) -> String? { nil } func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { nil } func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil } func generateAddCheckConstraintSQL(table: String, constraint: PluginCheckConstraintDefinition) -> String? { nil } diff --git a/Plugins/TableProPluginKit/SQLFeatureLexer.swift b/Plugins/TableProPluginKit/SQLFeatureLexer.swift index 85ae597597..461999a892 100644 --- a/Plugins/TableProPluginKit/SQLFeatureLexer.swift +++ b/Plugins/TableProPluginKit/SQLFeatureLexer.swift @@ -330,7 +330,7 @@ struct SQLFeatureLexer { || unit == 0x5F } - private func isWordUnit(_ unit: UInt16) -> Bool { + func isWordUnit(_ unit: UInt16) -> Bool { unit < 0x80 ? isASCIIIdentifierPart(unit) : !isSeparating(unit) } diff --git a/Plugins/TableProPluginKit/SQLIndexKeyList.swift b/Plugins/TableProPluginKit/SQLIndexKeyList.swift new file mode 100644 index 0000000000..e6b2899b0d --- /dev/null +++ b/Plugins/TableProPluginKit/SQLIndexKeyList.swift @@ -0,0 +1,214 @@ +// +// SQLIndexKeyList.swift +// TableProPluginKit +// + +import Foundation + +public enum SQLIndexKeyList { + public struct Statement: Equatable, Sendable { + public let keyList: String + public let keyParts: [String] + public let predicate: String? + } + + private static let openParen = UInt16(UnicodeScalar("(").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + private static let comma = UInt16(UnicodeScalar(",").value) + private static let semicolon = UInt16(UnicodeScalar(";").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) + private static let sortOrderWords: Set = ["ASC", "DESC"] + + public static func statement(_ createIndex: String, lexicalFeatures: SQLLexicalFeatures) -> Statement? { + let lexer = SQLFeatureLexer(createIndex, features: lexicalFeatures) + guard let open = firstOpenParen(in: lexer), + let close = matchingCloseParen(in: lexer, openingAt: open) else { return nil } + let keyList = text(of: lexer, from: open + 1, to: close) + return Statement( + keyList: keyList, + keyParts: parts(of: keyList, lexicalFeatures: lexicalFeatures), + predicate: predicate(in: lexer, after: close + 1) + ) + } + + public static func parts(of keyList: String, lexicalFeatures: SQLLexicalFeatures) -> [String] { + let lexer = SQLFeatureLexer(keyList, features: lexicalFeatures) + var parts: [String] = [] + var start = 0 + var depth = 0 + var index = 0 + while index < lexer.count { + if let span = lexer.span(at: index) { + index = max(span.end, index + 1) + continue + } + let unit = lexer.units[index] + if unit == openParen { + depth += 1 + } else if unit == closeParen { + depth = max(0, depth - 1) + } else if unit == comma, depth == 0 { + parts.append(text(of: lexer, from: start, to: index)) + start = index + 1 + } + index += 1 + } + parts.append(text(of: lexer, from: start, to: lexer.count)) + return parts.map(trimmed).filter { !$0.isEmpty } + } + + public static func withoutSortOrder(_ part: String, lexicalFeatures: SQLLexicalFeatures) -> String { + let lexer = SQLFeatureLexer(part, features: lexicalFeatures) + guard let word = lastCodeWord(in: lexer), sortOrderWords.contains(word.text.uppercased()) else { return part } + let head = trimmed(text(of: lexer, from: 0, to: word.start)) + return head.isEmpty ? part : head + } + + public static func unwrapped(_ part: String, lexicalFeatures: SQLLexicalFeatures) -> String? { + let body = trimmed(part) + let lexer = SQLFeatureLexer(body, features: lexicalFeatures) + guard lexer.count > 2, lexer.units[0] == openParen, + let close = matchingCloseParen(in: lexer, openingAt: 0), close == lexer.count - 1 else { return nil } + return trimmed(text(of: lexer, from: 1, to: close)) + } + + public static func quotedIdentifier(_ part: String, lexicalFeatures: SQLLexicalFeatures) -> String? { + let body = trimmed(part) + let lexer = SQLFeatureLexer(body, features: lexicalFeatures) + guard lexer.count >= 2, + let closer = identifierCloser(for: lexer.units[0], lexicalFeatures: lexicalFeatures), + let span = lexer.span(at: 0), span.end == lexer.count, + lexer.units[lexer.count - 1] == closer else { return nil } + let name = text(of: lexer, from: 1, to: lexer.count - 1) + guard closer != closeBracket else { return name } + let quote = String(decoding: [closer], as: UTF16.self) + return name.replacingOccurrences(of: quote + quote, with: quote) + } + + private static func identifierCloser(for opener: UInt16, lexicalFeatures: SQLLexicalFeatures) -> UInt16? { + if opener == doubleQuote { return doubleQuote } + if opener == backtick, lexicalFeatures.contains(.backtickQuotes) { return backtick } + if opener == openBracket, lexicalFeatures.contains(.bracketQuotedIdentifiers) { return closeBracket } + return nil + } + + private static func firstOpenParen(in lexer: SQLFeatureLexer) -> Int? { + var index = 0 + while index < lexer.count { + if let span = lexer.span(at: index) { + index = max(span.end, index + 1) + continue + } + if lexer.units[index] == openParen { return index } + index += 1 + } + return nil + } + + private static func matchingCloseParen(in lexer: SQLFeatureLexer, openingAt open: Int) -> Int? { + var depth = 0 + var index = open + while index < lexer.count { + if let span = lexer.span(at: index) { + index = max(span.end, index + 1) + continue + } + let unit = lexer.units[index] + if unit == openParen { + depth += 1 + } else if unit == closeParen { + depth -= 1 + if depth == 0 { return index } + } + index += 1 + } + return nil + } + + private static func predicate(in lexer: SQLFeatureLexer, after start: Int) -> String? { + guard let word = firstCodeWord(in: lexer, from: start), word.text.uppercased() == "WHERE" else { return nil } + var body = trimmed(text(of: lexer, from: word.end, to: lexer.count)) + while body.utf16.last == semicolon { + body = trimmed(String(body.dropLast())) + } + return body.isEmpty ? nil : body + } + + private struct Word { + let text: String + let start: Int + let end: Int + } + + private static func firstCodeWord(in lexer: SQLFeatureLexer, from start: Int) -> Word? { + var index = start + while index < lexer.count { + if let span = lexer.span(at: index) { + guard span.kind == .comment else { return nil } + index = max(span.end, index + 1) + continue + } + let unit = lexer.units[index] + if isBlank(unit) { + index += 1 + continue + } + guard lexer.isWordUnit(unit) else { return nil } + return word(in: lexer, startingAt: index) + } + return nil + } + + private static func lastCodeWord(in lexer: SQLFeatureLexer) -> Word? { + var last: Word? + var depth = 0 + var index = 0 + while index < lexer.count { + if let span = lexer.span(at: index) { + last = nil + index = max(span.end, index + 1) + continue + } + let unit = lexer.units[index] + if isBlank(unit) { + index += 1 + continue + } + if lexer.isWordUnit(unit) { + let found = word(in: lexer, startingAt: index) + last = depth == 0 ? found : nil + index = found.end + continue + } + if unit == openParen { depth += 1 } + if unit == closeParen { depth = max(0, depth - 1) } + last = nil + index += 1 + } + return last + } + + private static func word(in lexer: SQLFeatureLexer, startingAt start: Int) -> Word { + var end = start + while end < lexer.count, lexer.isWordUnit(lexer.units[end]) { + end += 1 + } + return Word(text: text(of: lexer, from: start, to: end), start: start, end: end) + } + + private static func isBlank(_ unit: UInt16) -> Bool { + unit == 0x20 || unit == 0x09 || unit == 0x0A || unit == 0x0D || unit == 0x0C || unit == 0x0B + } + + private static func text(of lexer: SQLFeatureLexer, from start: Int, to end: Int) -> String { + guard start < end else { return "" } + return String(decoding: lexer.units[start.. String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Plugins/TableProPluginKit/SQLiteIndexCatalog.swift b/Plugins/TableProPluginKit/SQLiteIndexCatalog.swift new file mode 100644 index 0000000000..acf3d8d851 --- /dev/null +++ b/Plugins/TableProPluginKit/SQLiteIndexCatalog.swift @@ -0,0 +1,149 @@ +// +// SQLiteIndexCatalog.swift +// TableProPluginKit +// + +import Foundation + +public enum SQLiteIndexCatalog { + public static let lexicalFeatures: SQLLexicalFeatures = [ + .backtickQuotes, .bracketQuotedIdentifiers, .parenthesizedParameterNames, + ] + + private static let expressionColumnId = -2 + + public static func indexesQuery(table: String) -> String { + """ + SELECT il.name, il."unique", il.origin, ix.cid, ix.name, m.sql + FROM pragma_index_list('\(SQLiteMasterQueries.escapeLiteral(table))') il + LEFT JOIN pragma_index_xinfo(il.name) ix ON ix.key = 1 + LEFT JOIN sqlite_master m ON m.type = 'index' AND m.name = il.name + ORDER BY il.seq, ix.seqno + """ + } + + public static let schemaIndexesQuery = """ + SELECT t.name, il.name, il."unique", il.origin, ix.cid, ix.name, m.sql + FROM sqlite_master t + JOIN pragma_index_list(t.name) il + LEFT JOIN pragma_index_xinfo(il.name) ix ON ix.key = 1 + LEFT JOIN sqlite_master m ON m.type = 'index' AND m.name = il.name + WHERE t.type = 'table' AND t.name NOT LIKE 'sqlite_%' + ORDER BY t.name, il.seq, ix.seqno + """ + + public static func indexes(fromRows rows: [[PluginCellValue]]) -> [PluginIndexInfo] { + infos(grouping: rows.compactMap { Row($0, offset: 0) }) + } + + public static func indexesByTable(fromRows rows: [[PluginCellValue]]) -> [String: [PluginIndexInfo]] { + var order: [String] = [] + var rowsByTable: [String: [Row]] = [:] + for cells in rows { + guard let table = cells.first?.asText, let row = Row(cells, offset: 1) else { continue } + if rowsByTable[table] == nil { order.append(table) } + rowsByTable[table, default: []].append(row) + } + return order.reduce(into: [:]) { result, table in + result[table] = infos(grouping: rowsByTable[table] ?? []) + } + } + + public static func keyList(for index: PluginIndexDefinition, quote: (String) -> String) -> String { + if let spelling = index.ddlMethodAndKeys?.nilIfEmpty { + return spelling + } + let expressions = Set(index.expressions ?? []) + let keys = index.columns.map { expressions.contains($0) ? $0 : quote($0) } + return "(\(keys.joined(separator: ", ")))" + } + + public static func createStatement( + for index: PluginIndexDefinition, + table: String, + quote: (String) -> String + ) -> String { + let unique = index.isUnique ? "UNIQUE " : "" + var statement = "CREATE \(unique)INDEX \(quote(index.name)) ON \(quote(table)) " + + keyList(for: index, quote: quote) + if let predicate = index.whereClause?.nilIfEmpty { + statement += " WHERE \(predicate)" + } + return statement + } + + private struct Row { + let index: String + let isUnique: Bool + let origin: String + let columnId: Int? + let column: String? + let sql: String? + + init?(_ cells: [PluginCellValue], offset: Int) { + guard cells.count >= offset + 6, let index = cells[offset].asText else { return nil } + self.index = index + self.isUnique = cells[offset + 1].asText == "1" + self.origin = cells[offset + 2].asText ?? "c" + self.columnId = cells[offset + 3].asText.flatMap { Int($0) } + self.column = cells[offset + 4].asText + self.sql = cells[offset + 5].asText + } + } + + private struct Entry { + let name: String + let isUnique: Bool + let isPrimary: Bool + let sql: String? + var keys: [(columnId: Int?, column: String?)] + } + + private static func infos(grouping rows: [Row]) -> [PluginIndexInfo] { + var entries: [Entry] = [] + var positions: [String: Int] = [:] + for row in rows { + let key = (columnId: row.columnId, column: row.column) + let hasKey = row.columnId != nil + if let position = positions[row.index] { + if hasKey { entries[position].keys.append(key) } + continue + } + positions[row.index] = entries.count + entries.append(Entry( + name: row.index, + isUnique: row.isUnique, + isPrimary: row.origin == "pk", + sql: row.sql, + keys: hasKey ? [key] : [] + )) + } + return entries.map(info).sorted { $0.isPrimary && !$1.isPrimary } + } + + private static func info(for entry: Entry) -> PluginIndexInfo { + let statement = entry.sql.flatMap { SQLIndexKeyList.statement($0, lexicalFeatures: lexicalFeatures) } + let parts = statement.flatMap { $0.keyParts.count == entry.keys.count ? $0.keyParts : nil } + var expressions: [String] = [] + let columns = entry.keys.enumerated().compactMap { offset, key -> String? in + if let column = key.column { return column } + guard key.columnId == expressionColumnId, let parts else { return nil } + let expression = SQLIndexKeyList.withoutSortOrder(parts[offset], lexicalFeatures: lexicalFeatures) + expressions.append(expression) + return expression + } + return PluginIndexInfo( + name: entry.name, + columns: columns, + isUnique: entry.isUnique, + isPrimary: entry.isPrimary, + type: "BTREE", + whereClause: statement?.predicate, + expressions: expressions.isEmpty ? nil : expressions, + includedColumns: nil, + ddlMethodAndKeys: statement.map { "(\($0.keyList))" }, + ddlWhereClause: nil, + isValid: nil + ) + } +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 5cffbe0e61..38e01c04be 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -86,6 +86,13 @@ final class PluginManager: ObservableObject { /// /// 33 also adds `isValid` to `PluginIndexInfo`, through an added initializer with the previous /// full one disfavoured; nil means the driver does not report it. + /// + /// 33 also adds `generateModifyIndexSQL(table:oldIndexName:newIndex:)`, which replaces an index + /// in one statement where the engine's DDL is not transactional, the public `SQLiteIndexCatalog` + /// that SQLite, libSQL and Cloudflare D1 read and write indexes through, and the public + /// `SQLIndexKeyList` it and DuckDB read a stored `CREATE INDEX` with. The requirement defaults + /// to nil, so an already-built plugin keeps loading and the app splits the change into a drop + /// and an add as before. nonisolated static let currentPluginKitVersion = 33 /// Still 19, so every plugin already published for the previous release keeps loading. diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift index 3e9d8191d2..c2cdf64cb8 100644 --- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift +++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift @@ -73,7 +73,8 @@ struct SchemaStatementGenerator { private func sortByDependency(_ changes: [SchemaChange]) -> [SchemaChange] { // Execution order for safety: // 1. Drop foreign keys first (includes modify FK, which requires drop+recreate) - // 2. Drop indexes (includes modify index, which requires drop+recreate) + // 2. Drop indexes (a modified index drops here and is added at 6, unless the driver + // replaces it in one statement and no column changes in the same save) // 3. Drop/modify columns // 4. Add columns // 5. Modify primary key @@ -91,6 +92,7 @@ struct SchemaStatementGenerator { var indexAdds: [SchemaChange] = [] var fkAdds: [SchemaChange] = [] var constraintAdds: [SchemaChange] = [] + let keepsIndexModifiesWhole = !changes.contains(where: Self.changesColumns) for change in changes { switch change { @@ -121,8 +123,12 @@ struct SchemaStatementGenerator { case .deleteIndex: indexDeletes.append(change) case .modifyIndex(let old, let new): - indexDeletes.append(.deleteIndex(old)) - indexAdds.append(.addIndex(new)) + if keepsIndexModifiesWhole, modifyIndexSQL(old: old, new: new) != nil { + indexAdds.append(change) + } else { + indexDeletes.append(.deleteIndex(old)) + indexAdds.append(.addIndex(new)) + } case .deleteColumn: columnDeletes.append(change) case .modifyColumn: @@ -142,6 +148,16 @@ struct SchemaStatementGenerator { + columnModifies + columnAdds + pkChanges + indexAdds + fkAdds + constraintAdds } + private static func changesColumns(_ change: SchemaChange) -> Bool { + switch change { + case .addColumn, .modifyColumn, .deleteColumn, .modifyPrimaryKey: + return true + case .addIndex, .modifyIndex, .deleteIndex, .addForeignKey, .modifyForeignKey, .deleteForeignKey, + .addCheckConstraint, .modifyCheckConstraint, .deleteCheckConstraint: + return false + } + } + // MARK: - Statement Generation private func generateStatements(for change: SchemaChange) throws -> [SchemaStatement] { @@ -216,14 +232,12 @@ struct SchemaStatementGenerator { } private func generateModifyIndex(old: EditableIndexDefinition, new: EditableIndexDefinition) -> [SchemaStatement] { - guard let dropSql = pluginDriver.generateDropIndexSQL(table: tableName, indexName: old.name), - let addSql = pluginDriver.generateAddIndexSQL(table: tableName, index: new.toPlugin()) else { - return [] - } - return [ - SchemaStatement(sql: dropSql, description: "Drop index '\(old.name)'", isDestructive: false), - SchemaStatement(sql: addSql, description: "Add index '\(new.name)'", isDestructive: false) - ] + guard let sql = modifyIndexSQL(old: old, new: new) else { return [] } + return [SchemaStatement(sql: sql, description: "Replace index '\(old.name)'", isDestructive: false)] + } + + private func modifyIndexSQL(old: EditableIndexDefinition, new: EditableIndexDefinition) -> String? { + pluginDriver.generateModifyIndexSQL(table: tableName, oldIndexName: old.name, newIndex: new.toPlugin()) } private func generateDeleteIndex(_ index: EditableIndexDefinition) -> SchemaStatement? { diff --git a/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift b/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift index cad161f52d..9e1319b97b 100644 --- a/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift +++ b/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift @@ -46,6 +46,8 @@ internal struct SQLTokenCursor { internal private(set) var parenDepth = 0 + internal var location: Int { index } + internal init(_ text: NSString, grammar: SQLLexicalGrammar) { self.text = text self.grammar = grammar diff --git a/TablePro/Models/Schema/IndexDefinition.swift b/TablePro/Models/Schema/IndexDefinition.swift index fb4f8df2a9..e3af900195 100644 --- a/TablePro/Models/Schema/IndexDefinition.swift +++ b/TablePro/Models/Schema/IndexDefinition.swift @@ -27,11 +27,12 @@ struct EditableIndexDefinition: Hashable, Codable, Identifiable { /// The server's own spellings of the method and key list and of `whereClause` for a /// `CREATE INDEX`, carried from the catalog read. /// - /// The method and key spelling applies only while `type`, `columns`, `expressions` and - /// `includedColumns` still hold what they were read with, and the predicate spelling only while - /// `whereClause` does. So a rename keeps both, an edit to the condition keeps the keys, and an - /// edit to the columns or the type writes the index from its fields. The pairs are stored rather - /// than cleared on edit, so changing a field and changing it back restores the spelling. + /// The method and key spelling applies only while `type`, `columns`, `columnPrefixes`, + /// `expressions` and `includedColumns` still hold what they were read with, and the predicate + /// spelling only while `whereClause` does. So a rename keeps both, an edit to the condition keeps + /// the keys, and an edit to the columns or the type writes the index from its fields. The pairs + /// are stored rather than cleared on edit, so changing a field and changing it back restores the + /// spelling. /// /// Not encoded. An index pasted from the clipboard can come from another connection, where /// `public.gin_trgm_ops` names a schema this one may not have. @@ -44,12 +45,19 @@ struct EditableIndexDefinition: Hashable, Codable, Identifiable { private struct KeyShape: Hashable { let type: IndexType let columns: [String] + let columnPrefixes: [String: Int] let expressions: [String] let includedColumns: [String] } private var keyShape: KeyShape { - KeyShape(type: type, columns: columns, expressions: expressions, includedColumns: includedColumns) + KeyShape( + type: type, + columns: columns, + columnPrefixes: columnPrefixes, + expressions: expressions, + includedColumns: includedColumns + ) } private enum CodingKeys: String, CodingKey { @@ -128,7 +136,13 @@ struct EditableIndexDefinition: Hashable, Codable, Identifiable { self.whereClause = whereClause self.expressions = expressions self.includedColumns = includedColumns - let shape = KeyShape(type: type, columns: columns, expressions: expressions, includedColumns: includedColumns) + let shape = KeyShape( + type: type, + columns: columns, + columnPrefixes: columnPrefixes, + expressions: expressions, + includedColumns: includedColumns + ) self.catalogKeys = ddlMethodAndKeys.map { CatalogSpelling(value: shape, spelling: $0) } if let whereClause, let ddlWhereClause { self.catalogPredicate = CatalogSpelling(value: whereClause, spelling: ddlWhereClause) diff --git a/TablePro/Models/Schema/IndexKeyDialect.swift b/TablePro/Models/Schema/IndexKeyDialect.swift new file mode 100644 index 0000000000..26bd85e841 --- /dev/null +++ b/TablePro/Models/Schema/IndexKeyDialect.swift @@ -0,0 +1,26 @@ +// +// IndexKeyDialect.swift +// TablePro +// + +import Foundation + +struct IndexKeyDialect: Equatable, Sendable { + let takesPrefixLengths: Bool + let takesExpressions: Bool + + static let columnsOnly = IndexKeyDialect(takesPrefixLengths: false, takesExpressions: false) + + static func forType(_ databaseType: DatabaseType) -> IndexKeyDialect { + switch databaseType { + case .mysql: + return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true) + case .mariadb, .tidb, .oceanbase: + return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: false) + case .postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: + return IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true) + default: + return .columnsOnly + } + } +} diff --git a/TablePro/Models/Schema/IndexKeyList.swift b/TablePro/Models/Schema/IndexKeyList.swift new file mode 100644 index 0000000000..4179783898 --- /dev/null +++ b/TablePro/Models/Schema/IndexKeyList.swift @@ -0,0 +1,189 @@ +// +// IndexKeyList.swift +// TablePro +// + +import Foundation +import TableProSQLGrammar + +struct IndexKeyContext { + let columnNames: [String] + let dialect: IndexKeyDialect + let grammar: SQLLexicalGrammar + + func column(named name: String) -> String? { + columnNames.first { $0 == name } + ?? columnNames.first { $0.compare(name, options: .caseInsensitive) == .orderedSame } + } +} + +enum IndexKeyPart: Equatable { + case column(String) + case prefixedColumn(String, length: Int) + case expression(String) + + var entry: String { + switch self { + case .column(let name), .prefixedColumn(let name, _): + return name + case .expression(let text): + return text + } + } +} + +enum IndexKeyList { + private static let sortOrderWords: Set = ["ASC", "DESC"] + private static let nullsPlacementWords: Set = ["FIRST", "LAST"] + + static func parts(of text: String, keeping expressions: [String], in context: IndexKeyContext) -> [IndexKeyPart] { + entries(of: text, keeping: expressions, in: context).map { + classify($0, keeping: expressions, in: context) + } + } + + static func entries(of text: String, keeping expressions: [String], in context: IndexKeyContext) -> [String] { + let knownNames = self.knownNames(expressions: expressions, columns: context.columnNames) + var entries: [String] = [] + var remaining = text as NSString + while remaining.length > 0 { + let start = remaining.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.inverted).location + guard start != NSNotFound else { break } + remaining = remaining.substring(from: start) as NSString + let length = knownEntryLength(in: remaining, knownNames: knownNames) + ?? codeEntryLength(in: remaining, grammar: context.grammar) + entries.append(remaining.substring(to: length).trimmingCharacters(in: .whitespacesAndNewlines)) + remaining = remaining.substring(from: min(length + 1, remaining.length)) as NSString + } + return entries.filter { !$0.isEmpty } + } + + private struct KnownName { + let text: String + let ignoresCase: Bool + } + + private static func knownNames(expressions: [String], columns: [String]) -> [KnownName] { + let names = expressions.filter { !$0.isEmpty }.map { KnownName(text: $0, ignoresCase: false) } + + columns.filter { !$0.isEmpty }.map { KnownName(text: $0, ignoresCase: true) } + return names.sorted { ($0.text as NSString).length > ($1.text as NSString).length } + } + + private static func knownEntryLength(in text: NSString, knownNames: [KnownName]) -> Int? { + for name in knownNames { + let options: NSString.CompareOptions = name.ignoresCase ? [.anchored, .caseInsensitive] : [.anchored] + let match = text.range(of: name.text, options: options) + guard match.location == 0 else { continue } + let tail = text.substring(from: match.length) as NSString + let next = tail.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.inverted) + guard next.location != NSNotFound else { return text.length } + if tail.character(at: next.location) == SQLTokenCursor.comma { + return match.length + next.location + } + } + return nil + } + + private static func codeEntryLength(in text: NSString, grammar: SQLLexicalGrammar) -> Int { + var cursor = SQLTokenCursor(text, grammar: grammar) + while let token = cursor.next() { + if token.isSymbol(SQLTokenCursor.comma), cursor.parenDepth == 0 { + return cursor.location - 1 + } + } + return text.length + } + + private static func classify( + _ entry: String, + keeping expressions: [String], + in context: IndexKeyContext + ) -> IndexKeyPart { + if expressions.contains(entry) { return .expression(entry) } + if let column = context.column(named: entry) { return .column(column) } + let scan = Scan(entry, grammar: context.grammar) + if let name = scan.singleName { return .column(context.column(named: name) ?? name) } + if let name = scan.parenthesizedName(in: entry) { return .column(context.column(named: name) ?? name) } + if context.dialect.takesPrefixLengths, let prefix = scan.prefix(of: entry) { + return .prefixedColumn(context.column(named: prefix.name) ?? prefix.name, length: prefix.length) + } + guard context.dialect.takesExpressions, scan.isWritableExpression, + isPlainCode(entry, grammar: context.grammar) else { return .column(entry) } + return .expression(entry) + } + + private static func isPlainCode(_ entry: String, grammar: SQLLexicalGrammar) -> Bool { + let text = entry as NSString + var index = 0 + while index < text.length { + guard let span = SQLNonCodeSpan.span(at: index, in: text, grammar: grammar) else { + index += 1 + continue + } + guard span.kind == .quoted, span.isTerminated else { return false } + index = max(span.end, index + 1) + } + return true + } + + private struct Scan { + private(set) var tokens: [SQLTokenCursor.Token] = [] + private(set) var isBalanced = true + + init(_ entry: String, grammar: SQLLexicalGrammar) { + let text = entry as NSString + var cursor = SQLTokenCursor(text, grammar: grammar) + while true { + let depth = cursor.parenDepth + guard let token = cursor.next() else { break } + if token.isSymbol(SQLTokenCursor.closeParen), depth == 0 { isBalanced = false } + tokens.append(token) + } + isBalanced = isBalanced && cursor.parenDepth == 0 && cursor.location >= text.length + } + + var singleName: String? { + guard tokens.count == 1, case .quotedIdentifier(let name) = tokens[0] else { return nil } + return name + } + + func parenthesizedName(in entry: String) -> String? { + guard tokens.count == 3, tokens[0].isSymbol(SQLTokenCursor.openParen), + tokens[2].isSymbol(SQLTokenCursor.closeParen) else { return nil } + switch tokens[1] { + case .word: + return String(entry.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + case .quotedIdentifier(let name): + return name + case .literal, .symbol: + return nil + } + } + + func prefix(of entry: String) -> (name: String, length: Int)? { + guard tokens.count == 4, tokens[1].isSymbol(SQLTokenCursor.openParen), + let digits = tokens[2].word, let length = Int(digits), length > 0, + tokens[3].isSymbol(SQLTokenCursor.closeParen) else { return nil } + switch tokens[0] { + case .quotedIdentifier(let name): + return (name, length) + case .word: + let name = entry.prefix { $0 != "(" }.trimmingCharacters(in: .whitespacesAndNewlines) + return (name, length) + case .literal, .symbol: + return nil + } + } + + var isWritableExpression: Bool { + isBalanced && tokens.count > 1 && !endsWithSortOrder + } + + private var endsWithSortOrder: Bool { + guard let last = tokens.last?.word else { return false } + if IndexKeyList.sortOrderWords.contains(last) { return true } + guard IndexKeyList.nullsPlacementWords.contains(last), tokens.count >= 2 else { return false } + return tokens[tokens.count - 2].word == "NULLS" + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a203816316..33650de240 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6365,6 +6365,9 @@ }, "%@ cannot be scripted, because its definition is not a statement that recreates it." : { + }, + "%@ cannot index an expression. Index a generated column instead." : { + }, "%@ does not allow NULL, so its default cannot be NULL" : { @@ -74857,6 +74860,9 @@ } } } + }, + "Indexing an expression needs MySQL 8.0.13 or later." : { + }, "Its indexes are left out, because the target is not known to take indexes on this kind of object." : { diff --git a/TablePro/Views/Structure/CreateTableGridDelegate.swift b/TablePro/Views/Structure/CreateTableGridDelegate.swift index 90443321ed..32c9344c22 100644 --- a/TablePro/Views/Structure/CreateTableGridDelegate.swift +++ b/TablePro/Views/Structure/CreateTableGridDelegate.swift @@ -71,7 +71,12 @@ final class CreateTableGridDelegate: DataGridViewDelegate { case .indexes: guard row < structureChangeManager.workingIndexes.count else { return } var idx = structureChangeManager.workingIndexes[row] - StructureEditingSupport.updateIndex(&idx, at: column, with: newValue ?? "") + StructureEditingSupport.updateIndex( + &idx, + at: column, + with: newValue ?? "", + keys: StructureEditingSupport.indexKeyContext(for: structureChangeManager, on: connection) + ) structureChangeManager.updateIndex(id: idx.id, with: idx) case .foreignKeys: diff --git a/TablePro/Views/Structure/StructureEditingSupport.swift b/TablePro/Views/Structure/StructureEditingSupport.swift index 0b001e020c..c6c189ae41 100644 --- a/TablePro/Views/Structure/StructureEditingSupport.swift +++ b/TablePro/Views/Structure/StructureEditingSupport.swift @@ -57,29 +57,15 @@ enum StructureEditingSupport { } } - static func updateIndex(_ index: inout EditableIndexDefinition, at colIndex: Int, with value: String) { + static func updateIndex( + _ index: inout EditableIndexDefinition, + at colIndex: Int, + with value: String, + keys: IndexKeyContext + ) { switch colIndex { case 0: index.name = value - case 1: - let previousExpressions = Set(index.expressions) - var prefixes: [String: Int] = [:] - var expressions: [String] = [] - index.columns = indexKeyParts(value, expressions: index.expressions).map { trimmed in - if previousExpressions.contains(trimmed) { - expressions.append(trimmed) - return trimmed - } - if let parenStart = trimmed.firstIndex(of: "("), - let parenEnd = trimmed.firstIndex(of: ")"), - let prefix = Int(trimmed[trimmed.index(after: parenStart).. [String] { - let longestFirst = expressions.filter { !$0.isEmpty }.sorted { $0.count > $1.count } - var parts: [String] = [] - var remaining = value[...] - while !remaining.isEmpty { - remaining = remaining.drop(while: isBlank) - if let expression = longestFirst.first(where: { entry(in: remaining, isWhole: $0) }) { - parts.append(expression) - remaining = remaining.dropFirst(expression.count).drop(while: isBlank).dropFirst() - continue - } - let entryEnd = remaining.firstIndex(of: ",") ?? remaining.endIndex - parts.append(remaining[.. Bool { - guard text.hasPrefix(expression) else { return false } - let rest = text.dropFirst(expression.count).drop(while: isBlank) - return rest.isEmpty || rest.first == "," + static func indexKeyContext( + for changeManager: StructureChangeManager, + on connection: DatabaseConnection + ) -> IndexKeyContext { + IndexKeyContext( + columnNames: changeManager.workingColumns.map(\.name), + dialect: .forType(connection.type), + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + ) } - nonisolated private static func isBlank(_ character: Character) -> Bool { - character.unicodeScalars.allSatisfy { CharacterSet.whitespaces.contains($0) } + private static func applyKeyParts(_ parts: [IndexKeyPart], to index: inout EditableIndexDefinition) { + index.columns = parts.map(\.entry) + index.columnPrefixes = parts.reduce(into: [:]) { prefixes, part in + if case .prefixedColumn(let name, let length) = part { prefixes[name] = length } + } + index.expressions = parts.compactMap { part in + guard case .expression(let text) = part else { return nil } + return text + } } static func updateForeignKey(_ fk: inout EditableForeignKeyDefinition, at index: Int, with value: String) { @@ -171,8 +140,8 @@ enum StructureEditingSupport { } /// Grid columns: 0 Name, 1 Columns, 2 Type, 3 Unique, 4 Condition. Index 1 - /// covers `columns` and `columnPrefixes` together because prefixes render - /// inline with the column list (`email(10)`). `isPrimary` and `comment` are + /// covers `columns`, `columnPrefixes` and `expressions` together because all three + /// render in the one column list (`email(10), lower(name)`). `isPrimary` and `comment` are /// intentionally excluded; neither has a grid column on the Indexes tab, /// so changes to them produce no tint. Matches the data-tab convention of /// only tinting fields the user can actually see. @@ -182,7 +151,10 @@ enum StructureEditingSupport { ) -> Set { var indices: Set = [] if old.name != new.name { indices.insert(0) } - if old.columns != new.columns || old.columnPrefixes != new.columnPrefixes { indices.insert(1) } + if old.columns != new.columns || old.columnPrefixes != new.columnPrefixes + || old.expressions != new.expressions { + indices.insert(1) + } if old.type != new.type { indices.insert(2) } if old.isUnique != new.isUnique { indices.insert(3) } if old.whereClause != new.whereClause { indices.insert(4) } diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index 1b1f1da630..5c722c05cc 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -179,7 +179,12 @@ final class StructureGridDelegate: DataGridViewDelegate { case .indexes: guard sourceRowIndex < structureChangeManager.workingIndexes.count else { return } var idx = structureChangeManager.workingIndexes[sourceRowIndex] - StructureEditingSupport.updateIndex(&idx, at: column, with: newValue ?? "") + StructureEditingSupport.updateIndex( + &idx, + at: column, + with: newValue ?? "", + keys: StructureEditingSupport.indexKeyContext(for: structureChangeManager, on: connection) + ) structureChangeManager.updateIndex(id: idx.id, with: idx) case .foreignKeys: diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift index 2e6cf4aba1..024cc0514c 100644 --- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift +++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift @@ -18,6 +18,7 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable var dropColumnHandler: ((String, String) -> String?)? var addIndexHandler: ((String, PluginIndexDefinition) -> String?)? var dropIndexHandler: ((String, String) -> String?)? + var modifyIndexHandler: ((String, String, PluginIndexDefinition) -> String?)? var addForeignKeyHandler: ((String, PluginForeignKeyDefinition) -> String?)? var dropForeignKeyHandler: ((String, String) -> String?)? var modifyPrimaryKeyHandler: ((String, [String], [String]) -> [String]?)? @@ -44,6 +45,10 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable dropIndexHandler?(table, indexName) } + func generateModifyIndexSQL(table: String, oldIndexName: String, newIndex: PluginIndexDefinition) -> String? { + modifyIndexHandler?(table, oldIndexName, newIndex) + } + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { addForeignKeyHandler?(table, fk) } @@ -363,6 +368,50 @@ struct SchemaStatementGeneratorPluginTests { } } + @Test("A modified index the driver replaces in one statement is written as that one statement") + func modifyIndexInOneStatement() throws { + let mock = MockPluginDriver() + mock.dropIndexHandler = { _, name in "DROP INDEX \(name)" } + mock.addIndexHandler = { table, idx in "CREATE INDEX \(idx.name) ON \(table)" } + mock.modifyIndexHandler = { table, oldName, idx in + "ALTER TABLE \(table) DROP INDEX \(oldName), ADD INDEX \(idx.name)" + } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let stmts = try generator.generate(changes: [ + .modifyIndex(old: makeIndex(name: "idx_email"), new: makeIndex(name: "idx_email_name")), + .addIndex(makeIndex(name: "idx_other")) + ]) + + let sql = stmts.map { $0.sql } + #expect(sql == [ + "ALTER TABLE users DROP INDEX idx_email, ADD INDEX idx_email_name;", + "CREATE INDEX idx_other ON users;" + ]) + } + + @Test("A modified index is split around column changes even when the driver can replace it whole") + func modifyIndexSplitsAroundColumnWork() throws { + let mock = MockPluginDriver() + mock.addColumnHandler = { table, col in "ALTER TABLE \(table) ADD COLUMN \(col.name)" } + mock.dropIndexHandler = { _, name in "DROP INDEX \(name)" } + mock.addIndexHandler = { table, idx in "CREATE INDEX \(idx.name) ON \(table)" } + mock.modifyIndexHandler = { table, oldName, _ in "ALTER TABLE \(table) REPLACE \(oldName)" } + + let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock) + let stmts = try generator.generate(changes: [ + .modifyIndex(old: makeIndex(name: "idx_name"), new: makeIndex(name: "idx_name", columns: ["name", "email"])), + .addColumn(makeColumn(name: "email")) + ]) + + let sql = stmts.map { $0.sql } + #expect(sql == [ + "DROP INDEX idx_name;", + "ALTER TABLE users ADD COLUMN email;", + "CREATE INDEX idx_name ON users;" + ]) + } + @Test("Modify foreign key generates drop and create via plugin") func modifyForeignKeyViaPlugin() throws { let mock = MockPluginDriver() diff --git a/TableProTests/Core/SchemaTracking/StructureChangeManagerIndexExpressionTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerIndexExpressionTests.swift index 388169d945..a105778320 100644 --- a/TableProTests/Core/SchemaTracking/StructureChangeManagerIndexExpressionTests.swift +++ b/TableProTests/Core/SchemaTracking/StructureChangeManagerIndexExpressionTests.swift @@ -46,6 +46,10 @@ struct StructureChangeManagerIndexExpressionTests { return new } + private func keys(_ manager: StructureChangeManager) -> IndexKeyContext { + .testing(.postgresql, columns: manager.workingColumns.map(\.name)) + } + @Test("Renaming an expression index raises no missing-column error and recreates it from its spelling") func renameKeepsTheExpression() throws { let manager = loadedManager() @@ -64,10 +68,10 @@ struct StructureChangeManagerIndexExpressionTests { func reenteredColumnsKeepTheExpression() throws { let manager = loadedManager() var edited = manager.workingIndexes[0] - StructureEditingSupport.updateIndex(&edited, at: 1, with: "tenant_id, lower(email)") + StructureEditingSupport.updateIndex(&edited, at: 1, with: "tenant_id, lower(email)", keys: keys(manager)) #expect(edited == manager.workingIndexes[0]) - StructureEditingSupport.updateIndex(&edited, at: 1, with: "lower(email), tenant_id") + StructureEditingSupport.updateIndex(&edited, at: 1, with: "lower(email), tenant_id", keys: keys(manager)) manager.updateIndex(id: edited.id, with: edited) #expect(manager.validationErrors.isEmpty) let new = try #require(stagedIndex(manager)) @@ -76,6 +80,39 @@ struct StructureChangeManagerIndexExpressionTests { #expect(sql == #"CREATE UNIQUE INDEX "users_tenant_lower_email" ON "public"."users" USING btree ((lower(email)), "tenant_id") INCLUDE ("name")"#) } + @Test("A new index typed with an expression validates and writes the expression in parentheses") + func typedExpressionOnANewIndex() throws { + let manager = loadedManager() + manager.addNewIndex() + var added = try #require(manager.workingIndexes.last) + StructureEditingSupport.updateIndex(&added, at: 0, with: "ix", keys: keys(manager)) + StructureEditingSupport.updateIndex(&added, at: 1, with: "tenant_id, lower(email)", keys: keys(manager)) + manager.updateIndex(id: added.id, with: added) + + #expect(manager.validationErrors.isEmpty) + #expect(manager.canCommit) + guard case .addIndex(let staged)? = manager.getChangesArray().last else { + Issue.record("Expected a staged index add") + return + } + let sql = PostgreSQLIndexClauses.createStatement(for: staged.toPlugin(), qualifiedTable: #""public"."users""#) + #expect(sql == #"CREATE INDEX "ix" ON "public"."users" USING btree ("tenant_id", (lower(email)))"#) + } + + @Test("An expression typed with a sort order is refused before anything runs") + func typedSortOrderIsRefused() throws { + let manager = loadedManager() + manager.addNewIndex() + var added = try #require(manager.workingIndexes.last) + StructureEditingSupport.updateIndex(&added, at: 0, with: "ix", keys: keys(manager)) + StructureEditingSupport.updateIndex(&added, at: 1, with: "lower(email) DESC", keys: keys(manager)) + manager.updateIndex(id: added.id, with: added) + + #expect(manager.validationErrors[.index(added.id)] + == "Index references a column that does not exist: lower(email) DESC") + #expect(!manager.canCommit) + } + @Test("An INCLUDE column the table does not have is reported") func missingIncludedColumnIsReported() { let manager = loadedManager() diff --git a/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift b/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift index 617c824549..5ad3cccffa 100644 --- a/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift +++ b/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift @@ -133,4 +133,15 @@ struct SQLTokenCursorTests { let tokens = Self.tokens("SELECT q'[it's; ok]' FROM dual", grammar: TestGrammar.oracle) #expect(tokens == [.word("SELECT"), .literal, .word("FROM"), .word("DUAL")]) } + + @Test("The location is just past the last token read, and stays on a depth-0 semicolon") + func locationFollowsTheTokens() { + var cursor = SQLTokenCursor("f(a, 'b,c'), d; e", grammar: TestGrammar.postgres) + var commas: [Int] = [] + while let token = cursor.next() { + if token.isSymbol(SQLTokenCursor.comma), cursor.parenDepth == 0 { commas.append(cursor.location - 1) } + } + #expect(commas == [11]) + #expect(cursor.location == 14) + } } diff --git a/TableProTests/Helpers/IndexKeyContext+Testing.swift b/TableProTests/Helpers/IndexKeyContext+Testing.swift new file mode 100644 index 0000000000..0a217eaa40 --- /dev/null +++ b/TableProTests/Helpers/IndexKeyContext+Testing.swift @@ -0,0 +1,17 @@ +// +// IndexKeyContext+Testing.swift +// TableProTests +// + +import Foundation +@testable import TablePro + +extension IndexKeyContext { + static func testing(_ databaseType: DatabaseType, columns: [String] = []) -> IndexKeyContext { + IndexKeyContext( + columnNames: columns, + dialect: .forType(databaseType), + grammar: databaseType.lexicalGrammar + ) + } +} diff --git a/TableProTests/Models/Schema/IndexDefinitionCatalogSpellingTests.swift b/TableProTests/Models/Schema/IndexDefinitionCatalogSpellingTests.swift index 4743e9af37..3a0caff547 100644 --- a/TableProTests/Models/Schema/IndexDefinitionCatalogSpellingTests.swift +++ b/TableProTests/Models/Schema/IndexDefinitionCatalogSpellingTests.swift @@ -77,6 +77,20 @@ struct IndexDefinitionCatalogSpellingTests { #expect(type.ddlMethodAndKeys == nil) } + @Test("A changed key prefix retires the key spelling") + func prefixEditRetiresTheKeySpelling() { + let keys = "(`v` DESC, `email`(20)) USING BTREE" + var index = EditableIndexDefinition.from(IndexInfo( + name: "i_desc", columns: ["v", "email"], isUnique: false, isPrimary: false, type: "BTREE", + columnPrefixes: ["email": 20], ddlMethodAndKeys: keys + )) + index.name = "i_desc_renamed" + #expect(index.ddlMethodAndKeys == keys) + + index.columnPrefixes = ["email": 30] + #expect(index.ddlMethodAndKeys == nil) + } + @Test("Changing a field and changing it back restores the spelling") func revertRestores() { var index = Self.loaded() diff --git a/TableProTests/Models/Schema/IndexKeyListTests.swift b/TableProTests/Models/Schema/IndexKeyListTests.swift new file mode 100644 index 0000000000..62c5d5d37b --- /dev/null +++ b/TableProTests/Models/Schema/IndexKeyListTests.swift @@ -0,0 +1,145 @@ +// +// IndexKeyListTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Index key list") +struct IndexKeyListTests { + private static let columns = ["id", "tenant_id", "email", "a", "b", "v", "Weird, Name", "owner's_id", "lower(v)"] + + private func parts( + _ text: String, + on databaseType: DatabaseType = .postgresql, + keeping expressions: [String] = [] + ) -> [IndexKeyPart] { + IndexKeyList.parts( + of: text, + keeping: expressions, + in: .testing(databaseType, columns: Self.columns) + ) + } + + @Test("A typed expression keeps the comma inside its call") + func typedExpressionKeepsItsComma() { + #expect(parts("id, coalesce(a, b)") == [.column("id"), .expression("coalesce(a, b)")]) + } + + @Test("PostgreSQL's own deparse splits at the commas between keys only") + func postgresDeparseSplits() { + #expect( + parts("((a || ', '::text) || b), id") + == [.expression("((a || ', '::text) || b)"), .column("id")] + ) + #expect(parts("(id + 1), (v)::character varying(10)") == [ + .expression("(id + 1)"), .expression("(v)::character varying(10)") + ]) + } + + @Test("A CASE spread over several lines is one key") + func multiLineCaseIsOneKey() { + let text = "CASE\n WHEN a IS NULL THEN b\n ELSE a\nEND, id" + #expect(parts(text) == [.expression("CASE\n WHEN a IS NULL THEN b\n ELSE a\nEND"), .column("id")]) + } + + @Test("A column whose name holds a comma or an apostrophe is matched whole") + func knownColumnsAreMatchedWhole() { + #expect(parts("Weird, Name, owner's_id") == [.column("Weird, Name"), .column("owner's_id")]) + #expect(parts("owner's_id, tenant_id") == [.column("owner's_id"), .column("tenant_id")]) + } + + @Test("A column name is found whatever its case, alone or in one pair of parentheses") + func columnsResolveToTheirSpelling() { + #expect(parts("EMAIL, (v), ( Tenant_ID )") == [.column("email"), .column("v"), .column("tenant_id")]) + #expect(parts("(nickname)") == [.column("nickname")]) + } + + @Test("A column named like an expression stays a column") + func columnNamedLikeAnExpression() { + #expect(parts("lower(v)") == [.column("lower(v)")]) + } + + @Test("A quoted identifier is a column, known or not") + func quotedIdentifierIsAColumn() { + #expect(parts(#""Weird, Name", "we ird""#) == [.column("Weird, Name"), .column("we ird")]) + #expect(parts("`email`", on: .mysql) == [.column("email")]) + } + + @Test("name(N) is a key prefix on MySQL and a function call on PostgreSQL") + func prefixDependsOnTheEngine() { + #expect(parts("email(20), id", on: .mysql) == [.prefixedColumn("email", length: 20), .column("id")]) + #expect(parts("EMAIL(20)", on: .mariadb) == [.prefixedColumn("email", length: 20)]) + #expect(parts("email(20)") == [.expression("email(20)")]) + } + + @Test("An engine that indexes no expression reads one as a column") + func columnsOnlyEngine() { + #expect(parts("lower(email)", on: .mssql) == [.column("lower(email)")]) + #expect(parts("email(20)", on: .oracle) == [.column("email(20)")]) + #expect(parts("lower(email)", on: .mariadb) == [.column("lower(email)")]) + } + + @Test("An expression the index already has stays an expression on any engine") + func existingExpressionIsKept() { + #expect(parts("f(20)", on: .mysql, keeping: ["f(20)"]) == [.expression("f(20)")]) + #expect(parts("UPPER([a]), id", on: .mssql, keeping: ["UPPER([a])"]) == [.expression("UPPER([a])"), .column("id")]) + } + + @Test("MySQL reads a backslash-escaped quote inside a string as part of it") + func mysqlBackslashStrings() { + #expect( + parts(#"concat(`a`,_utf8mb4'it\'s, ok'), id"#, on: .mysql) + == [.expression(#"concat(`a`,_utf8mb4'it\'s, ok')"#), .column("id")] + ) + } + + @Test("PostgreSQL's literal backslash, escape strings and dollar quotes end where the server ends them") + func postgresStrings() { + #expect(parts(#"concat(a, 'x\'), id"#) == [.expression(#"concat(a, 'x\')"#), .column("id")]) + #expect(parts(#"concat(a, E'x\', y'), id"#) == [.expression(#"concat(a, E'x\', y')"#), .column("id")]) + #expect(parts("concat(a, $$x, y$$), id") == [.expression("concat(a, $$x, y$$)"), .column("id")]) + } + + @Test("A sort order cannot be typed into the key") + func sortOrderIsNotAnExpression() { + #expect(parts("lower(v) DESC") == [.column("lower(v) DESC")]) + #expect(parts("v asc, id") == [.column("v asc"), .column("id")]) + #expect(parts("lower(v) NULLS LAST") == [.column("lower(v) NULLS LAST")]) + #expect(parts("lower(v) COLLATE NOCASE DESC", on: .sqlite) == [.column("lower(v) COLLATE NOCASE DESC")]) + #expect(parts("(lower(v)) DESC", on: .mysql) == [.column("(lower(v)) DESC")]) + } + + @Test("Text that is not whole SQL is read as a column, so the column check names it") + func brokenTextIsAColumn() { + #expect(parts("lower(v") == [.column("lower(v")]) + #expect(parts("v)") == [.column("v)")]) + #expect(parts("lower('v)") == [.column("lower('v)")]) + #expect(parts("lower(v) -- note") == [.column("lower(v) -- note")]) + #expect(parts("lower(v); DROP TABLE t") == [.column("lower(v); DROP TABLE t")]) + #expect(parts("emial, id") == [.column("emial"), .column("id")]) + } + + @Test("Empty entries are dropped") + func emptyEntriesAreDropped() { + #expect(parts("").isEmpty) + #expect(parts("a,,b,") == [.column("a"), .column("b")]) + #expect(parts(" , a , ") == [.column("a")]) + } + + @Test("Engines that write an expression key take one; the rest take column names") + func dialectPerEngine() { + for type in [DatabaseType.postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb] { + #expect(IndexKeyDialect.forType(type) == IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true)) + } + #expect(IndexKeyDialect.forType(.mysql) == IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true)) + for type in [DatabaseType.mariadb, .tidb, .oceanbase] { + #expect(IndexKeyDialect.forType(type) == IndexKeyDialect(takesPrefixLengths: true, takesExpressions: false)) + } + for type in [DatabaseType.cockroachdb, .redshift, .mssql, .oracle, DatabaseType(rawValue: "FutureDB")] { + #expect(IndexKeyDialect.forType(type) == .columnsOnly) + } + } +} diff --git a/TableProTests/Models/Schema/SQLiteIndexCatalogTests.swift b/TableProTests/Models/Schema/SQLiteIndexCatalogTests.swift new file mode 100644 index 0000000000..5ad9e8baae --- /dev/null +++ b/TableProTests/Models/Schema/SQLiteIndexCatalogTests.swift @@ -0,0 +1,221 @@ +// +// SQLiteIndexCatalogTests.swift +// TableProTests +// + +import Foundation +import SQLite3 +@testable import TablePro +import TableProPluginKit +import Testing + +private final class InMemorySQLite { + private var handle: OpaquePointer? + + init() throws { + guard sqlite3_open(":memory:", &handle) == SQLITE_OK else { throw SQLiteTestError(message: "open") } + } + + deinit { + sqlite3_close_v2(handle) + } + + func run(_ sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(handle, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? "exec" + sqlite3_free(error) + throw SQLiteTestError(message: message) + } + } + + func rows(_ sql: String) throws -> [[PluginCellValue]] { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK else { + throw SQLiteTestError(message: String(cString: sqlite3_errmsg(handle))) + } + defer { sqlite3_finalize(statement) } + var rows: [[PluginCellValue]] = [] + while sqlite3_step(statement) == SQLITE_ROW { + rows.append((0.. InMemorySQLite { + let database = try InMemorySQLite() + try database.run(Self.table) + for statement in Self.indexes { + try database.run(statement) + } + return database + } + + private func read(_ database: InMemorySQLite) throws -> [String: PluginIndexInfo] { + let indexes = SQLiteIndexCatalog.indexes(fromRows: try database.rows(SQLiteIndexCatalog.indexesQuery(table: "t"))) + return Dictionary(uniqueKeysWithValues: indexes.map { ($0.name, $0) }) + } + + private func quote(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + @Test("The measured rows give every key part, expression, predicate and spelling") + func measuredRowsGroup() throws { + let indexes = Dictionary( + uniqueKeysWithValues: SQLiteIndexCatalog.indexes(fromRows: Self.measuredRows).map { ($0.name, $0) } + ) + + let mixed = try #require(indexes["i_mix"]) + #expect(mixed.columns == ["id", "coalesce(a, b)"]) + #expect(mixed.expressions == ["coalesce(a, b)"]) + #expect(mixed.ddlMethodAndKeys == "(id, coalesce(a, b))") + + #expect(indexes["i_fn"]?.columns == ["lower(v)"]) + #expect(indexes["i_fn"]?.expressions == ["lower(v)"]) + + let descending = try #require(indexes["i_desc"]) + #expect(descending.columns == ["lower(v) COLLATE NOCASE", "v"]) + #expect(descending.expressions == ["lower(v) COLLATE NOCASE"]) + #expect(descending.ddlMethodAndKeys == "(lower(v) COLLATE NOCASE DESC, v DESC)") + + #expect(indexes["i_partial"]?.columns == ["a"]) + #expect(indexes["i_partial"]?.whereClause == "b IS NOT NULL") + + #expect(indexes["i_colnamed"]?.columns == ["lower(v)", "Weird, Name"]) + #expect(indexes["i_colnamed"]?.expressions == nil) + + #expect(indexes["i \"q\""]?.expressions == ["(lower(v))"]) + + let constraint = try #require(indexes["sqlite_autoindex_t_1"]) + #expect(constraint.columns == ["email"]) + #expect(constraint.isUnique) + #expect(constraint.ddlMethodAndKeys == nil) + } + + @Test("The catalog query against SQLite returns what was measured") + func liveQueryMatchesTheMeasurement() throws { + let live = try read(try database()) + let measured = SQLiteIndexCatalog.indexes(fromRows: Self.measuredRows) + for index in measured { + let read = try #require(live[index.name]) + #expect(read.columns == index.columns, "\(index.name)") + #expect(read.expressions == index.expressions, "\(index.name)") + #expect(read.whereClause == index.whereClause, "\(index.name)") + #expect(read.ddlMethodAndKeys == index.ddlMethodAndKeys, "\(index.name)") + } + } + + @Test("The schema-wide query groups each table's indexes") + func schemaWideQuery() throws { + let database = try database() + try database.run("CREATE TABLE u (x TEXT)") + try database.run("CREATE INDEX u_upper ON u (upper(x))") + + let byTable = SQLiteIndexCatalog.indexesByTable(fromRows: try database.rows(SQLiteIndexCatalog.schemaIndexesQuery)) + #expect(byTable["u"]?.map(\.columns) == [["upper(x)"]]) + #expect(byTable["t"]?.count == 7) + } + + @Test("Renaming an index recreates its sort order and collation") + func renameKeepsSortOrderAndCollation() throws { + let database = try database() + var index = EditableIndexDefinition.from(IndexInfo(try #require(try read(database)["i_desc"]))) + index.name = "i_desc_renamed" + + try database.run("DROP INDEX i_desc") + try database.run(SQLiteIndexCatalog.createStatement(for: index.toPlugin(), table: "t", quote: quote)) + + let renamed = try #require(try read(database)["i_desc_renamed"]) + #expect(renamed.columns == ["lower(v) COLLATE NOCASE", "v"]) + #expect(renamed.ddlMethodAndKeys == "(lower(v) COLLATE NOCASE DESC, v DESC)") + } + + @Test("Renaming a partial index keeps its condition") + func renameKeepsTheCondition() throws { + let database = try database() + var index = EditableIndexDefinition.from(IndexInfo(try #require(try read(database)["i_partial"]))) + index.name = "i_partial_renamed" + + try database.run("DROP INDEX i_partial") + try database.run(SQLiteIndexCatalog.createStatement(for: index.toPlugin(), table: "t", quote: quote)) + + #expect(try read(database)["i_partial_renamed"]?.whereClause == "b IS NOT NULL") + } + + @Test("A typed expression is written as typed and read back as an expression") + func typedExpressionRoundTrips() throws { + let database = try database() + var index = EditableIndexDefinition.placeholder() + let keys = IndexKeyContext.testing(.sqlite, columns: ["id", "v", "a", "b"]) + StructureEditingSupport.updateIndex(&index, at: 0, with: "i_typed", keys: keys) + StructureEditingSupport.updateIndex(&index, at: 1, with: "a || ', ' || b, id", keys: keys) + + let statement = SQLiteIndexCatalog.createStatement(for: index.toPlugin(), table: "t", quote: quote) + #expect(statement == #"CREATE INDEX "i_typed" ON "t" (a || ', ' || b, "id")"#) + try database.run(statement) + + let read = try #require(try read(database)["i_typed"]) + #expect(read.columns == ["a || ', ' || b", "id"]) + #expect(read.expressions == ["a || ', ' || b"]) + } + + @Test("A partial index keeps its WHERE predicate") + func partialIndexStatement() { + let index = PluginIndexDefinition( + name: "idx_open", columns: ["parent_id"], isUnique: true, whereClause: "deleted_at IS NULL" + ) + #expect( + SQLiteIndexCatalog.createStatement(for: index, table: "child", quote: { "`\($0)`" }) + == "CREATE UNIQUE INDEX `idx_open` ON `child` (`parent_id`) WHERE deleted_at IS NULL" + ) + } + + @Test("An index is its own statement") + func indexStatement() { + let index = PluginIndexDefinition(name: "idx_parent", columns: ["parent_id"], isUnique: true) + #expect( + SQLiteIndexCatalog.createStatement(for: index, table: "child", quote: { "`\($0)`" }) + == "CREATE UNIQUE INDEX `idx_parent` ON `child` (`parent_id`)" + ) + } +} diff --git a/TableProTests/Plugins/DuckDBIndexClausesTests.swift b/TableProTests/Plugins/DuckDBIndexClausesTests.swift new file mode 100644 index 0000000000..18208e434c --- /dev/null +++ b/TableProTests/Plugins/DuckDBIndexClausesTests.swift @@ -0,0 +1,61 @@ +// +// DuckDBIndexClausesTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("DuckDB index clauses") +struct DuckDBIndexClausesTests { + private func keys(_ sql: String?) -> DuckDBIndexClauses.KeyParts { + DuckDBIndexClauses.keyParts(ofCreateIndex: sql) + } + + @Test("A parenthesized key in duckdb_indexes().sql is an expression, the rest are columns") + func expressionKeysAreRead() { + #expect(keys("CREATE INDEX i_mix ON t(id, (COALESCE(a, b)));") == DuckDBIndexClauses.KeyParts( + columns: ["id", "COALESCE(a, b)"], expressions: ["COALESCE(a, b)"] + )) + #expect(keys("CREATE INDEX i_fn ON t((lower(v)));").expressions == ["lower(v)"]) + } + + @Test("DuckDB's own deparse keeps its inner parentheses and doubled quotes") + func deparsedExpressionsAreKept() { + #expect(keys("CREATE INDEX i_concat ON t((((a || ', ') || b)));").columns == ["((a || ', ') || b)"]) + #expect(keys("CREATE INDEX i_str ON t(((a || 'it''s')));").columns == ["(a || 'it''s')"]) + #expect( + keys("CREATE INDEX i_case ON t((CASE WHEN ((a IS NULL)) THEN (b) ELSE a END));").columns + == ["CASE WHEN ((a IS NULL)) THEN (b) ELSE a END"] + ) + } + + @Test("A quoted column name is read without its quotes, commas included") + func quotedColumnsAreUnquoted() { + #expect(keys(#"CREATE INDEX i_quoted ON t("we ird", "Weird, Name", "say ""hi""");"#) == DuckDBIndexClauses.KeyParts( + columns: ["we ird", "Weird, Name", #"say "hi""#], expressions: [] + )) + #expect(keys(#"CREATE INDEX "Mixed (Name)" ON main.t(id);"#).columns == ["id"]) + } + + @Test("An index with no statement has no key parts to read") + func missingStatement() { + #expect(keys(nil) == DuckDBIndexClauses.KeyParts(columns: [], expressions: [])) + } + + @Test("The writer parenthesizes an expression and quotes a column") + func writer() { + let index = PluginIndexDefinition( + name: "i_mix", + columns: ["id", "COALESCE(a, b)"], + isUnique: true, + expressions: ["COALESCE(a, b)"], + includedColumns: nil, + ddlMethodAndKeys: nil, + ddlWhereClause: nil + ) + let sql = DuckDBIndexClauses.createStatement(for: index, qualifiedTable: #""main"."t""#) { "\"\($0)\"" } + #expect(sql == #"CREATE UNIQUE INDEX "i_mix" ON "main"."t" ("id", (COALESCE(a, b)))"#) + } +} diff --git a/TableProTests/Plugins/MySQLFunctionalKeyPartsTests.swift b/TableProTests/Plugins/MySQLFunctionalKeyPartsTests.swift new file mode 100644 index 0000000000..03f524326d --- /dev/null +++ b/TableProTests/Plugins/MySQLFunctionalKeyPartsTests.swift @@ -0,0 +1,70 @@ +// +// MySQLFunctionalKeyPartsTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("MySQL functional key parts") +struct MySQLFunctionalKeyPartsTests { + private static let expressionIndex = PluginIndexDefinition( + name: "ix", + columns: ["lower(v)"], + expressions: ["lower(v)"], + includedColumns: nil, + ddlMethodAndKeys: nil, + ddlWhereClause: nil + ) + + private static let columnIndex = PluginIndexDefinition(name: "ix", columns: ["v"]) + + private func refusal(_ index: PluginIndexDefinition, banner: String?, flavor: MySQLServerFlavor) -> String? { + MySQLFunctionalKeyParts.refusal(for: index, banner: banner, flavor: flavor) + } + + @Test("MySQL before 8.0.13 is refused an expression key") + func oldMySQLIsRefused() { + #expect(refusal(Self.expressionIndex, banner: "8.0.12", flavor: .mysql) + == "Indexing an expression needs MySQL 8.0.13 or later.") + #expect(refusal(Self.expressionIndex, banner: "5.7.44-log", flavor: .mysql) != nil) + } + + @Test("MariaDB is refused an expression key") + func mariaDBIsRefused() { + #expect(refusal(Self.expressionIndex, banner: "13.0.2-MariaDB", flavor: .mariadb) + == "MariaDB cannot index an expression. Index a generated column instead.") + } + + @Test("MySQL 8.0.13 and later, an unknown version and the other engines are refused nothing") + func nothingElseIsRefused() { + #expect(refusal(Self.expressionIndex, banner: "8.0.13", flavor: .mysql) == nil) + #expect(refusal(Self.expressionIndex, banner: "8.4.11", flavor: .mysql) == nil) + #expect(refusal(Self.expressionIndex, banner: nil, flavor: .mysql) == nil) + #expect(refusal(Self.expressionIndex, banner: nil, flavor: .tidb(version: nil)) == nil) + #expect(refusal(Self.expressionIndex, banner: nil, flavor: .oceanbase(version: nil)) == nil) + } + + @Test("An index of plain columns is never refused") + func columnIndexIsNeverRefused() { + #expect(refusal(Self.columnIndex, banner: "8.0.12", flavor: .mysql) == nil) + #expect(refusal(Self.columnIndex, banner: "10.6.16-MariaDB", flavor: .mariadb) == nil) + } + + @Test("Only MySQL 8.0.13 and later has an EXPRESSION column in its statistics catalog") + func catalogExpressionColumn() { + #expect(MySQLFunctionalKeyParts.catalogReportsExpressions(banner: "8.0.13", flavor: .mysql)) + #expect(MySQLFunctionalKeyParts.catalogReportsExpressions(banner: "8.4.11", flavor: .mysql)) + #expect(!MySQLFunctionalKeyParts.catalogReportsExpressions(banner: "8.0.12", flavor: .mysql)) + #expect(!MySQLFunctionalKeyParts.catalogReportsExpressions(banner: nil, flavor: .mysql)) + #expect(!MySQLFunctionalKeyParts.catalogReportsExpressions(banner: "13.0.2-MariaDB", flavor: .mariadb)) + } + + @Test("One level of backslash escaping comes off a catalog expression") + func unescaping() { + #expect(MySQLFunctionalKeyParts.unescaped("lower(`v`)") == "lower(`v`)") + #expect(MySQLFunctionalKeyParts.unescaped(#"_utf8mb4\'x\\ny\'"#) == #"_utf8mb4'x\ny'"#) + #expect(MySQLFunctionalKeyParts.unescaped(#"_utf8mb4\', \'"#) == "_utf8mb4', '") + } +} diff --git a/TableProTests/Plugins/MySQLIndexGroupingTests.swift b/TableProTests/Plugins/MySQLIndexGroupingTests.swift index 1ba430c823..19d5db80b2 100644 --- a/TableProTests/Plugins/MySQLIndexGroupingTests.swift +++ b/TableProTests/Plugins/MySQLIndexGroupingTests.swift @@ -19,13 +19,36 @@ struct MySQLIndexGroupingTests { MySQLIndexRow( table: table, index: index, - column: column, + key: MySQLIndexKey(part: .column(column, prefixLength: prefixLength), isDescending: false), isNonUnique: isNonUnique, - type: "BTREE", - prefixLength: prefixLength + type: "BTREE" ) } + private func catalogRow( + _ index: String, + column: String?, + expression: String? = nil, + collation: String? = "A", + type: String = "BTREE" + ) -> MySQLIndexRow? { + MySQLIndexRow( + table: "t", + index: index, + column: column, + catalogExpression: expression, + prefixLength: nil, + collation: collation, + isNonUnique: index != "PRIMARY", + type: type + ) + } + + private func grouped(_ rows: [MySQLIndexRow?]) -> [String: PluginIndexInfo] { + let indexes = MySQLIndexGrouping.group(rows.compactMap { $0 })["t"] ?? [] + return Dictionary(uniqueKeysWithValues: indexes.map { ($0.name, $0) }) + } + /// Compare & Sync reads a table twice, once to compare and once before writing the script, and /// refuses the script when the two reads differ. A read that listed the same indexes in a new /// order every time refused a table nobody had touched. @@ -76,4 +99,49 @@ struct MySQLIndexGroupingTests { #expect(!index.isPrimary) #expect(grouped["regions"]?.map(\.name) == ["regions_name_idx"]) } + + @Test("A functional key part is read from its expression, beside the plain columns") + func functionalKeyPartsAreRead() throws { + let indexes = grouped([ + catalogRow("PRIMARY", column: "id"), + catalogRow("i_fn", column: nil, expression: "lower(`v`)", collation: "D"), + catalogRow("i_mix", column: "id"), + catalogRow("i_mix", column: nil, expression: "coalesce(`a`,`b`)") + ]) + + let function = try #require(indexes["i_fn"]) + #expect(function.columns == ["lower(`v`)"]) + #expect(function.expressions == ["lower(`v`)"]) + + let mixed = try #require(indexes["i_mix"]) + #expect(mixed.columns == ["id", "coalesce(`a`,`b`)"]) + #expect(mixed.expressions == ["coalesce(`a`,`b`)"]) + #expect(mixed.ddlMethodAndKeys == nil) + } + + @Test("The catalog's escaping is taken off an expression, which then reads as SHOW CREATE TABLE writes it") + func catalogEscapingIsRemoved() throws { + let raw = #"concat(`a`,_utf8mb4\'it\\\'s\',_utf8mb4\'\\\\n\',_utf8mb4\'x\\ny\')"# + let index = try #require(grouped([catalogRow("i_q", column: nil, expression: raw)])["i_q"]) + #expect(index.expressions == [#"concat(`a`,_utf8mb4'it\'s',_utf8mb4'\\n',_utf8mb4'x\ny')"#]) + } + + @Test("A row with neither a column nor an expression is not a key part") + func unreadableRowIsDropped() { + #expect(catalogRow("i_fn", column: nil, expression: nil) == nil) + } + + @Test("A descending key part is kept in the server's own key spelling") + func descendingKeysAreSpelled() throws { + let indexes = grouped([ + catalogRow("i_desc", column: "v", collation: "D"), + catalogRow("i_desc", column: "id"), + catalogRow("i_fn", column: nil, expression: "lower(`v`)", collation: "D"), + catalogRow("i_ft", column: "body", collation: nil, type: "FULLTEXT") + ]) + + #expect(indexes["i_desc"]?.ddlMethodAndKeys == "(`v` DESC, `id`) USING BTREE") + #expect(indexes["i_fn"]?.ddlMethodAndKeys == "((lower(`v`)) DESC) USING BTREE") + #expect(indexes["i_ft"]?.ddlMethodAndKeys == nil) + } } diff --git a/TableProTests/Plugins/MySQLIndexKeyWriterTests.swift b/TableProTests/Plugins/MySQLIndexKeyWriterTests.swift new file mode 100644 index 0000000000..c5858b65b9 --- /dev/null +++ b/TableProTests/Plugins/MySQLIndexKeyWriterTests.swift @@ -0,0 +1,112 @@ +// +// MySQLIndexKeyWriterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("MySQL index key writer") +@MainActor +struct MySQLIndexKeyWriterTests { + private func catalogRow( + _ index: String, + column: String?, + expression: String? = nil, + collation: String? = "A" + ) -> MySQLIndexRow? { + MySQLIndexRow( + table: "t", + index: index, + column: column, + catalogExpression: expression, + prefixLength: nil, + collation: collation, + isNonUnique: true, + type: "BTREE" + ) + } + + private func read(_ rows: [MySQLIndexRow?], named name: String) throws -> EditableIndexDefinition { + let indexes = MySQLIndexGrouping.group(rows.compactMap { $0 })["t"] ?? [] + let info = try #require(indexes.first { $0.name == name }) + return EditableIndexDefinition.from(IndexInfo(info)) + } + + @Test("Expression keys are written in parentheses beside quoted and prefixed columns") + func expressionKeysAreParenthesized() { + let index = PluginIndexDefinition( + name: "ix", + columns: ["id", "coalesce(a, b)", "email"], + indexType: "BTREE", + columnPrefixes: ["email": 20], + expressions: ["coalesce(a, b)"], + includedColumns: nil, + ddlMethodAndKeys: nil, + ddlWhereClause: nil + ) + #expect(mysqlIndexDefinitionSQL(index) == "INDEX `ix` (`id`, (coalesce(a, b)), `email`(20)) USING BTREE") + } + + @Test("Renaming a descending functional index recreates it descending") + func renameKeepsFunctionalDescending() throws { + var index = try read([catalogRow("i_fn", column: nil, expression: "lower(`v`)", collation: "D")], named: "i_fn") + index.name = "i_fn_lower" + + #expect( + mysqlModifyIndexSQL(table: "t", oldIndexName: "i_fn", newIndex: index.toPlugin(), flavor: .mysql) + == "ALTER TABLE `t` DROP INDEX `i_fn`, ADD INDEX `i_fn_lower` ((lower(`v`)) DESC) USING BTREE" + ) + } + + @Test("Renaming a descending column index recreates it descending") + func renameKeepsColumnDescending() throws { + var index = try read( + [catalogRow("i_desc", column: "v", collation: "D"), catalogRow("i_desc", column: "id")], + named: "i_desc" + ) + index.name = "i_v_desc" + + #expect( + mysqlModifyIndexSQL(table: "t", oldIndexName: "i_desc", newIndex: index.toPlugin(), flavor: .mysql) + == "ALTER TABLE `t` DROP INDEX `i_desc`, ADD INDEX `i_v_desc` (`v` DESC, `id`) USING BTREE" + ) + } + + @Test("Changing the key of a descending index writes it from the fields") + func keyEditWritesFromTheFields() throws { + var index = try read([catalogRow("i_fn", column: nil, expression: "lower(`v`)", collation: "D")], named: "i_fn") + StructureEditingSupport.updateIndex( + &index, at: 1, with: "lower(`v`), id", keys: .testing(.mysql, columns: ["id", "v"]) + ) + + #expect(index.expressions == ["lower(`v`)"]) + #expect(mysqlIndexDefinitionSQL(index.toPlugin()) == "INDEX `i_fn` ((lower(`v`)), `id`) USING BTREE") + } + + @Test("A typed expression reaches the statement as an expression") + func typedExpressionIsWritten() { + var index = EditableIndexDefinition.placeholder() + let keys = IndexKeyContext.testing(.mysql, columns: ["id", "a", "b"]) + StructureEditingSupport.updateIndex(&index, at: 0, with: "ix", keys: keys) + StructureEditingSupport.updateIndex(&index, at: 1, with: "id, coalesce(a, b)", keys: keys) + + #expect(mysqlIndexDefinitionSQL(index.toPlugin()) == "INDEX `ix` (`id`, (coalesce(a, b))) USING BTREE") + } + + @Test("Only MySQL and MariaDB replace an index in one ALTER TABLE") + func oneStatementModifyByFlavor() { + let index = PluginIndexDefinition(name: "ix", columns: ["a"], indexType: "BTREE") + #expect( + mysqlModifyIndexSQL(table: "t", oldIndexName: "ix", newIndex: index, flavor: .mariadb) + == "ALTER TABLE `t` DROP INDEX `ix`, ADD INDEX `ix` (`a`) USING BTREE" + ) + #expect(mysqlModifyIndexSQL(table: "t", oldIndexName: "ix", newIndex: index, flavor: .tidb(version: nil)) == nil) + #expect( + mysqlModifyIndexSQL(table: "t", oldIndexName: "ix", newIndex: index, flavor: .oceanbase(version: nil)) == nil + ) + #expect(mysqlModifyIndexSQL(table: "t", oldIndexName: "ix", newIndex: index, flavor: .databend) == nil) + } +} diff --git a/TableProTests/Plugins/SQLIndexKeyListTests.swift b/TableProTests/Plugins/SQLIndexKeyListTests.swift new file mode 100644 index 0000000000..9b38e89d94 --- /dev/null +++ b/TableProTests/Plugins/SQLIndexKeyListTests.swift @@ -0,0 +1,67 @@ +// +// SQLIndexKeyListTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("SQL index key list") +struct SQLIndexKeyListTests { + private static let sqlite = SQLiteIndexCatalog.lexicalFeatures + + @Test("A stored CREATE INDEX gives its key list, its keys and its predicate") + func statementParts() throws { + let statement = try #require(SQLIndexKeyList.statement( + "CREATE UNIQUE INDEX [i (x)] ON \"t (y)\" (a, coalesce(b, ')'), [c,d]) WHERE a > ',' ;", + lexicalFeatures: Self.sqlite + )) + #expect(statement.keyList == "a, coalesce(b, ')'), [c,d]") + #expect(statement.keyParts == ["a", "coalesce(b, ')')", "[c,d]"]) + #expect(statement.predicate == "a > ','") + } + + @Test("A statement with no key list reads as nothing") + func noKeyList() { + #expect(SQLIndexKeyList.statement("CREATE INDEX i ON t", lexicalFeatures: Self.sqlite) == nil) + #expect(SQLIndexKeyList.statement("CREATE INDEX i ON t (a", lexicalFeatures: Self.sqlite) == nil) + } + + @Test("Comments and strings never end a key") + func commentsAndStrings() { + #expect( + SQLIndexKeyList.parts(of: "a /* x, y */, 'p,q' || b, -- c, d\n e", lexicalFeatures: Self.sqlite) + == ["a /* x, y */", "'p,q' || b", "-- c, d\n e"] + ) + } + + @Test("A trailing sort order comes off a key; one inside a string or a call does not") + func sortOrder() { + let strip = { SQLIndexKeyList.withoutSortOrder($0, lexicalFeatures: Self.sqlite) } + #expect(strip("lower(v) COLLATE NOCASE DESC") == "lower(v) COLLATE NOCASE") + #expect(strip("v asc") == "v") + #expect(strip("'DESC'") == "'DESC'") + #expect(strip("f(DESC)") == "f(DESC)") + #expect(strip("DESC") == "DESC") + } + + @Test("One pair of wrapping parentheses comes off, and only a pair that wraps the whole key") + func unwrapping() { + let unwrap = { SQLIndexKeyList.unwrapped($0, lexicalFeatures: Self.sqlite) } + #expect(unwrap("((a || b))") == "(a || b)") + #expect(unwrap("(a) + (b)") == nil) + #expect(unwrap("a") == nil) + #expect(unwrap("(')')") == "')'") + } + + @Test("A key that is one quoted identifier is read as its name") + func quotedIdentifiers() { + let name = { SQLIndexKeyList.quotedIdentifier($0, lexicalFeatures: Self.sqlite) } + #expect(name(#""a ""b""""#) == #"a "b""#) + #expect(name("`a``b`") == "a`b") + #expect(name("[a b]") == "a b") + #expect(name("'a'") == nil) + #expect(name(#""a" || b"#) == nil) + } +} diff --git a/TableProTests/Plugins/SQLiteCreateTableDDLTests.swift b/TableProTests/Plugins/SQLiteCreateTableDDLTests.swift index d83d63bf73..05624eb711 100644 --- a/TableProTests/Plugins/SQLiteCreateTableDDLTests.swift +++ b/TableProTests/Plugins/SQLiteCreateTableDDLTests.swift @@ -123,26 +123,4 @@ struct SQLiteCreateTableDDLTests { let sql = try #require(sqliteCreateTableSQL(definition: definition(columns: columns))) #expect(sql.contains("`we``ird`")) } - - /// A unique partial index that loses its predicate rejects rows the user meant to exclude, so - /// the condition is part of the index rather than decoration. - @Test("a partial index keeps its WHERE predicate") - func partialIndex() { - let index = PluginIndexDefinition( - name: "idx_open", columns: ["parent_id"], isUnique: true, whereClause: "deleted_at IS NULL" - ) - #expect( - sqliteAddIndexSQL(table: "child", index: index) - == "CREATE UNIQUE INDEX `idx_open` ON `child` (`parent_id`) WHERE deleted_at IS NULL" - ) - } - - @Test("an index is its own statement") - func addIndex() { - let index = PluginIndexDefinition(name: "idx_parent", columns: ["parent_id"], isUnique: true) - #expect( - sqliteAddIndexSQL(table: "child", index: index) - == "CREATE UNIQUE INDEX `idx_parent` ON `child` (`parent_id`)" - ) - } } diff --git a/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift b/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift index 5eb81465b6..9151dfbe7d 100644 --- a/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift +++ b/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift @@ -199,7 +199,7 @@ struct StructureEditingSupportBooleanParsingTests { var definition = EditableIndexDefinition.placeholder() definition.name = "idx" definition.columns = ["id"] - StructureEditingSupport.updateIndex(&definition, at: 3, with: token) + StructureEditingSupport.updateIndex(&definition, at: 3, with: token, keys: .testing(.postgresql)) #expect(definition.isUnique) } } diff --git a/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift b/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift index d243e75b1e..050a02671f 100644 --- a/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift +++ b/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift @@ -174,6 +174,16 @@ struct StructureEditingSupportFieldDiffTests { #expect(result == [1]) } + @Test("A key that turns from a column into an expression flags the columns index") + func indexExpressionsChanged() { + var original = makeIndex() + original.columns = ["lower(email)"] + var changed = original + changed.expressions = ["lower(email)"] + + #expect(StructureEditingSupport.indexModifiedIndices(old: original, new: changed) == [1]) + } + @Test("Toggling unique flags only the unique index") func indexUniqueChanged() { let original = makeIndex() diff --git a/TableProTests/Views/Structure/StructureEditingSupportIndexKeyTests.swift b/TableProTests/Views/Structure/StructureEditingSupportIndexKeyTests.swift index 4f8eaa485a..bcf2a509ac 100644 --- a/TableProTests/Views/Structure/StructureEditingSupportIndexKeyTests.swift +++ b/TableProTests/Views/Structure/StructureEditingSupportIndexKeyTests.swift @@ -12,6 +12,8 @@ import Testing @Suite("Structure editing index key parts") @MainActor struct StructureEditingSupportIndexKeyTests { + private static let columns = ["owner's_id", "created_at", "tenant_id", "a", "b", "c", "email", "name", "created"] + private func index(columns: [String], expressions: [String] = []) -> EditableIndexDefinition { EditableIndexDefinition( id: UUID(), name: "ix", columns: columns, type: .btree, isUnique: false, isPrimary: false, @@ -19,58 +21,20 @@ struct StructureEditingSupportIndexKeyTests { ) } - @Test("An expression from the index is taken whole, commas and quotes included") - func expressionIsTakenWhole() { - #expect( - StructureEditingSupport.indexKeyParts("tenant_id, coalesce(a, b)", expressions: ["coalesce(a, b)"]) - == ["tenant_id", "coalesce(a, b)"] - ) - #expect( - StructureEditingSupport.indexKeyParts("(a || ', ' || b), c", expressions: ["(a || ', ' || b)"]) - == ["(a || ', ' || b)", "c"] - ) - #expect( - StructureEditingSupport.indexKeyParts(" lower(email) , b ", expressions: ["lower(email)"]) - == ["lower(email)", "b"] - ) - } - - @Test("A column name holding a quote or a parenthesis is split at every comma") - func columnNamesAreSplitAtEveryComma() { - #expect( - StructureEditingSupport.indexKeyParts("owner's_id, created_at", expressions: []) - == ["owner's_id", "created_at"] + private func edit( + _ index: inout EditableIndexDefinition, + to text: String, + on databaseType: DatabaseType = .postgresql + ) { + StructureEditingSupport.updateIndex( + &index, at: 1, with: text, keys: .testing(databaseType, columns: Self.columns) ) - #expect(StructureEditingSupport.indexKeyParts(#"size"in, id"#, expressions: []) == [#"size"in"#, "id"]) - #expect( - StructureEditingSupport.indexKeyParts("O'Brien, lower(email)", expressions: ["lower(email)"]) - == ["O'Brien", "lower(email)"] - ) - } - - @Test("Text that only begins with an expression, or edits one, is read as column names") - func textThatIsNotTheExpressionIsColumnNames() { - #expect( - StructureEditingSupport.indexKeyParts("lower(email)x, b", expressions: ["lower(email)"]) - == ["lower(email)x", "b"] - ) - #expect( - StructureEditingSupport.indexKeyParts("coalesce(a, c)", expressions: ["coalesce(a, b)"]) - == ["coalesce(a", "c)"] - ) - } - - @Test("Empty entries are dropped") - func emptyEntriesAreDropped() { - #expect(StructureEditingSupport.indexKeyParts("", expressions: []).isEmpty) - #expect(StructureEditingSupport.indexKeyParts("a,,b", expressions: []) == ["a", "b"]) - #expect(StructureEditingSupport.indexKeyParts("a, ,b,", expressions: []) == ["a", "b"]) } @Test("Adding a column to an index over a name with an apostrophe keeps every column") func apostropheColumnSurvivesTheEdit() { var edited = index(columns: ["owner's_id", "created_at"]) - StructureEditingSupport.updateIndex(&edited, at: 1, with: "owner's_id, created_at, tenant_id") + edit(&edited, to: "owner's_id, created_at, tenant_id", on: .mysql) #expect(edited.columns == ["owner's_id", "created_at", "tenant_id"]) #expect(edited.expressions.isEmpty) #expect(edited.columnPrefixes.isEmpty) @@ -79,16 +43,32 @@ struct StructureEditingSupportIndexKeyTests { @Test("An expression keeps its commas and stays an expression across the edit") func expressionSurvivesTheEdit() { var edited = index(columns: ["tenant_id", "coalesce(a, b)"], expressions: ["coalesce(a, b)"]) - StructureEditingSupport.updateIndex(&edited, at: 1, with: "tenant_id, coalesce(a, b), created") + edit(&edited, to: "tenant_id, coalesce(a, b), created") #expect(edited.columns == ["tenant_id", "coalesce(a, b)", "created"]) #expect(edited.expressions == ["coalesce(a, b)"]) #expect(edited.columnPrefixes.isEmpty) } + @Test("An expression typed into the cell is an expression, commas and all") + func typedExpressionIsAnExpression() { + var edited = index(columns: ["tenant_id"]) + edit(&edited, to: "tenant_id, coalesce(a, c)") + #expect(edited.columns == ["tenant_id", "coalesce(a, c)"]) + #expect(edited.expressions == ["coalesce(a, c)"]) + } + + @Test("An expression edited by hand replaces the one the index had") + func editedExpressionReplacesTheOld() { + var edited = index(columns: ["coalesce(a, b)"], expressions: ["coalesce(a, b)"]) + edit(&edited, to: "coalesce(a, c)") + #expect(edited.columns == ["coalesce(a, c)"]) + #expect(edited.expressions == ["coalesce(a, c)"]) + } + @Test("An expression removed from the cell leaves the expression list") func removedExpressionLeavesTheList() { var edited = index(columns: ["tenant_id", "lower(email)"], expressions: ["lower(email)"]) - StructureEditingSupport.updateIndex(&edited, at: 1, with: "tenant_id") + edit(&edited, to: "tenant_id") #expect(edited.columns == ["tenant_id"]) #expect(edited.expressions.isEmpty) } @@ -96,7 +76,7 @@ struct StructureEditingSupportIndexKeyTests { @Test("A MySQL key prefix is still read as a prefix") func mysqlPrefixIsStillAPrefix() { var edited = index(columns: ["email"]) - StructureEditingSupport.updateIndex(&edited, at: 1, with: "email(20), name") + edit(&edited, to: "email(20), name", on: .mysql) #expect(edited.columns == ["email", "name"]) #expect(edited.columnPrefixes == ["email": 20]) #expect(edited.expressions.isEmpty) @@ -105,9 +85,28 @@ struct StructureEditingSupportIndexKeyTests { @Test("An expression that looks like a prefix is not read as one") func expressionShapedLikeAPrefix() { var edited = index(columns: ["f(20)"], expressions: ["f(20)"]) - StructureEditingSupport.updateIndex(&edited, at: 1, with: "f(20)") + edit(&edited, to: "f(20)", on: .mysql) #expect(edited.columns == ["f(20)"]) #expect(edited.columnPrefixes.isEmpty) #expect(edited.expressions == ["f(20)"]) } + + @Test("An engine without expression keys reads a call as a column name, which the column check names") + func columnsOnlyEngineKeepsTheText() { + var edited = index(columns: ["email"]) + edit(&edited, to: "lower(email)", on: .mssql) + #expect(edited.columns == ["lower(email)"]) + #expect(edited.expressions.isEmpty) + #expect(edited.referencedColumnNames == ["lower(email)"]) + } + + @Test("Only the Columns cell reads key parts") + func otherCellsIgnoreTheKeyContext() { + var edited = index(columns: ["email"]) + StructureEditingSupport.updateIndex(&edited, at: 0, with: "ix_email", keys: .testing(.postgresql)) + StructureEditingSupport.updateIndex(&edited, at: 4, with: "email IS NOT NULL", keys: .testing(.postgresql)) + #expect(edited.name == "ix_email") + #expect(edited.whereClause == "email IS NOT NULL") + #expect(edited.columns == ["email"]) + } } diff --git a/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift b/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift index 7b5a9cf5d6..c174641206 100644 --- a/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift +++ b/TableProTests/Views/Structure/StructureIndexTypeMenuTests.swift @@ -88,9 +88,9 @@ struct StructureIndexTypeMenuTests { )) let typeColumn = StructureRowProvider.indexTypeColumn - StructureEditingSupport.updateIndex(&index, at: typeColumn, with: "hnsw USING btree") + StructureEditingSupport.updateIndex(&index, at: typeColumn, with: "hnsw USING btree", keys: .testing(.postgresql)) #expect(index.type.rawValue == "BLOOM") - StructureEditingSupport.updateIndex(&index, at: typeColumn, with: "spgist") + StructureEditingSupport.updateIndex(&index, at: typeColumn, with: "spgist", keys: .testing(.postgresql)) #expect(index.type == .spgist) } } diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 52aaf37b50..3e73d58aaf 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -141,6 +141,7 @@ New connections default to **Preferred**: TLS first, dropping to plain text only - No Unix socket connections. Give the connection a host and a port, and leave networking on in the server. - `LOAD DATA LOCAL INFILE` is refused by the driver. Load the file with **File > Import > Import Data…** instead. +- An index key over an expression, such as `lower(email)`, needs MySQL 8.0.13 or later. MariaDB has none, so index a generated column instead. The **Indexes** tab stops the save on either server before anything runs. ## Troubleshooting diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 8aa2caf2f1..563d328d4a 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -106,11 +106,23 @@ Everything that runs goes to query history rather than the change queue. | Field | Description | |-------|-------------| -| **Columns** | Key columns and expressions in key order, such as `tenant_id, lower(email)`. MySQL prefix lengths are written as `email(20)`. A PostgreSQL index's `INCLUDE` columns are not listed, and editing the index keeps them. An expression typed here is read as a column name, so create a new expression index in the SQL editor. Pasted into a MySQL, SQLite or other non-PostgreSQL table, an index leaves its `INCLUDE` columns behind and each expression becomes a column name the row flags until you replace it | +| **Columns** | Key columns and expressions in key order, such as `tenant_id, lower(email)`. MySQL prefix lengths are written as `email(20)`. A PostgreSQL index's `INCLUDE` columns are not listed, and editing the index keeps them. Pasted into a table on another engine, an index leaves its `INCLUDE` columns behind and each expression becomes a column name the row flags until you replace it | | **Type** | The type the server reports, such as `HNSW` from pgvector or `CLUSTERED` on SQL Server. The menu lists that type beside BTREE, HASH, FULLTEXT and SPATIAL (MySQL), GIN, GIST, SPGIST (PostgreSQL 9.2 and later) and BRIN (PostgreSQL 9.5 and later). On SQL Server, a duplicated or pasted `CLUSTERED` index becomes `NONCLUSTERED` when the table already has a clustered index. Pasted into a table on another database type, a FULLTEXT or SPATIAL index is refused by PostgreSQL before anything runs, and any other type that engine has no index of is written as its default index | | **Unique** | Whether the index enforces uniqueness | | **Condition** | `WHERE` predicate for partial indexes (PostgreSQL, SQLite, libSQL, Cloudflare D1) | +### Expression keys + +Type an expression into **Columns** as `CREATE INDEX` writes it, such as `lower(email)` or `coalesce(first_name, last_name)`. A comma inside parentheses or a string stays part of its key. Text that names one of the table's columns is that column, in any case and with or without parentheses around it. + +| Engine | Typed expression | +|--------|------------------| +| PostgreSQL, PGlite, SQLite, libSQL, Turso, Cloudflare D1, DuckDB | Indexed as written | +| MySQL | Needs 8.0.13 or later. On an older server, or a MariaDB server, the save stops before anything runs | +| MariaDB, TiDB, OceanBase, CockroachDB and every other engine | Read as a column name, which the row flags | + +A sort order is not typed here: `lower(email) DESC` is flagged as a column that does not exist. A descending key, a collation or an operator class the index already has stays through a rename or a new condition, and goes when you edit **Columns** or **Type**. On MySQL and MariaDB, a changed index in a save with no column changes is replaced in one `ALTER TABLE`, so a replacement the server rejects leaves the original index in place. + ### Invalid indexes A PostgreSQL `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY` that fails or is cancelled leaves an invalid index behind. It keeps its row in the list, and a line under the list names it. Queries skip it, but every write still updates it, and an invalid unique index still refuses duplicates. Delete the row and save to drop it, or run `REINDEX INDEX` on it in the SQL editor to build it again. diff --git a/project.yml b/project.yml index 1b3fa67f0b..5ec50a2d2e 100644 --- a/project.yml +++ b/project.yml @@ -436,6 +436,7 @@ targets: - Plugins/DamengDriverPlugin/DamengSystemSchemas.swift - Plugins/DuckDBDriverPlugin/DuckDBAccessMode.swift - Plugins/DuckDBDriverPlugin/DuckDBFileKinds.swift + - Plugins/DuckDBDriverPlugin/DuckDBIndexClauses.swift - Plugins/DuckDBDriverPlugin/DuckDBIdleRelease.swift - Plugins/DuckDBDriverPlugin/DuckDBLexicalFeatures.swift - Plugins/DuckDBDriverPlugin/DuckDBLockConflict.swift @@ -548,6 +549,7 @@ targets: - Plugins/MySQLDriverPlugin/MySQLForeignKeyCatalog.swift - Plugins/MySQLDriverPlugin/MySQLForeignKeyClause.swift - Plugins/MySQLDriverPlugin/MySQLIndexGrouping.swift + - Plugins/MySQLDriverPlugin/MySQLFunctionalKeyParts.swift - Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift - Plugins/SQLiteDriverPlugin/SQLiteCheckConstraintParser.swift - Plugins/SQLiteDriverPlugin/SQLiteCreateTableDDL.swift