Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
65 changes: 9 additions & 56 deletions Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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? {
Expand All @@ -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? {
Expand Down
43 changes: 43 additions & 0 deletions Plugins/DuckDBDriverPlugin/DuckDBIndexClauses.swift
Original file line number Diff line number Diff line change
@@ -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))"
}
}
58 changes: 9 additions & 49 deletions Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
}
}
65 changes: 9 additions & 56 deletions Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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? {
Expand All @@ -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? {
Expand Down
Loading
Loading