Skip to content

Commit 5d5113b

Browse files
authored
feat: plugin extensibility (#294)
* feat: extend ConnectionField with number, toggle, and stepper field types * fix: address PR review feedback for ConnectionField types * feat: add SQLDialectDescriptor to PluginKit for plugin-provided SQL dialects * feat: add ParameterStyle to PluginKit and DML generation in ClickHouse/MSSQL/Oracle plugins * feat: add DDL schema generation methods to PluginDatabaseDriver protocol * feat: add table operation methods to PluginDatabaseDriver protocol * feat: move MSSQL/Oracle pagination to plugin query building hooks * feat: add buildExplainQuery to PluginDatabaseDriver protocol * remove accidentally staged embedded repos * remove accidentally staged embedded repos * chore: remove .md files not intended for this PR * refactor: remove database-specific DML switches from SQLStatementGenerator * refactor: remove all DatabaseType fallback switches for clean plugin-only architecture * fix: address PR review feedback for plugin extensibility * fix: address PR review feedback for plugin extensibility
1 parent e95f919 commit 5d5113b

42 files changed

Lines changed: 2973 additions & 2575 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `SQLDialectDescriptor` in TableProPluginKit: plugins can now self-describe their SQL dialect (keywords, functions, data types, identifier quoting), with `SQLDialectFactory` preferring plugin-provided dialect info over built-in structs
13+
- DDL schema generation protocol in TableProPluginKit: plugins can now optionally provide database-specific ALTER TABLE syntax (ADD/MODIFY/DROP COLUMN, ADD/DROP INDEX, ADD/DROP FK, MODIFY PK) via `PluginDatabaseDriver`, with `SchemaStatementGenerator` trying plugin methods first before falling back to built-in logic
14+
- Plugin-provided table operations: `truncateTableStatements`, `dropObjectStatement`, `foreignKeyDisableStatements`, `foreignKeyEnableStatements` in `PluginDatabaseDriver` protocol, allowing plugins to override TRUNCATE, DROP, and FK handling SQL
15+
- `buildExplainQuery` method in `PluginDatabaseDriver` protocol: plugins can now provide database-specific EXPLAIN syntax, with coordinator falling back to built-in logic when plugin returns nil
1216
- `SettablePlugin` protocol in TableProPluginKit SDK: unified settings pattern for all plugins with automatic persistence via `loadSettings()`/`saveSettings()`, replacing duplicated boilerplate across export/import/driver plugins
1317
- Plugin UI/capability metadata: each driver plugin now self-declares brand color, connection mode, supported features, column types, URL schemes, and grouping strategy via the `DriverPlugin` protocol
1418
- Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel
@@ -21,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2125
- MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support
2226
- `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form
2327
- Pre-connect script: run a shell command before each connection (e.g., to refresh credentials or update ~/.pgpass)
28+
- `ParameterStyle` enum in TableProPluginKit: plugins declare `?` or `$1` placeholder style via `parameterStyle` property on `PluginDatabaseDriver`
29+
- DML statement generation in ClickHouse, MSSQL, and Oracle plugins via `generateStatements()` for database-specific UPDATE/DELETE syntax
30+
31+
### Changed
32+
33+
- Moved MSSQL and Oracle pagination query building (`OFFSET...FETCH NEXT`) from `TableQueryBuilder` into their respective plugin drivers via `buildBrowseQuery`/`buildFilteredQuery`/`buildQuickSearchQuery`/`buildCombinedQuery` hooks
2434

2535
### Fixed
2636

Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,51 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {
4242
"Geo": ["Point", "Ring", "Polygon", "MultiPolygon"]
4343
]
4444

45+
static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
46+
identifierQuote: "`",
47+
keywords: [
48+
"SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL",
49+
"ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS",
50+
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET",
51+
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
52+
"CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA",
53+
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT",
54+
"ADD", "MODIFY", "COLUMN", "RENAME",
55+
"NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME",
56+
"CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE",
57+
"UNION", "INTERSECT", "EXCEPT",
58+
"FINAL", "SAMPLE", "PREWHERE", "GLOBAL", "FORMAT", "SETTINGS",
59+
"OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC",
60+
"MATERIALIZED", "WITH"
61+
],
62+
functions: [
63+
"COUNT", "SUM", "AVG", "MAX", "MIN",
64+
"CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER",
65+
"TRIM", "LTRIM", "RTRIM", "REPLACE",
66+
"NOW", "TODAY", "YESTERDAY",
67+
"CAST",
68+
"UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY",
69+
"TOSTRING", "TOINT32", "FORMATDATETIME",
70+
"IF", "MULTIIF",
71+
"ARRAYMAP", "ARRAYJOIN",
72+
"MATCH", "CURRENTDATABASE", "VERSION",
73+
"QUANTILE", "TOPK"
74+
],
75+
dataTypes: [
76+
"INT8", "INT16", "INT32", "INT64", "INT128", "INT256",
77+
"UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256",
78+
"FLOAT32", "FLOAT64",
79+
"DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256",
80+
"STRING", "FIXEDSTRING", "UUID",
81+
"DATE", "DATE32", "DATETIME", "DATETIME64",
82+
"ARRAY", "TUPLE", "MAP",
83+
"NULLABLE", "LOWCARDINALITY",
84+
"ENUM8", "ENUM16",
85+
"IPV4", "IPV6",
86+
"JSON", "BOOL"
87+
]
88+
)
89+
4590
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
4691
ClickHousePluginDriver(config: config)
4792
}
@@ -499,6 +544,128 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
499544
_ = try await execute(query: "CREATE DATABASE `\(escapedName)`")
500545
}
501546

547+
// MARK: - DML Statement Generation
548+
549+
func generateStatements(
550+
table: String,
551+
columns: [String],
552+
changes: [PluginRowChange],
553+
insertedRowData: [Int: [String?]],
554+
deletedRowIndices: Set<Int>,
555+
insertedRowIndices: Set<Int>
556+
) -> [(statement: String, parameters: [String?])]? {
557+
var statements: [(statement: String, parameters: [String?])] = []
558+
559+
for change in changes {
560+
switch change.type {
561+
case .insert:
562+
guard insertedRowIndices.contains(change.rowIndex) else { continue }
563+
if let values = insertedRowData[change.rowIndex] {
564+
if let stmt = generateClickHouseInsert(table: table, columns: columns, values: values) {
565+
statements.append(stmt)
566+
}
567+
}
568+
case .update:
569+
if let stmt = generateClickHouseUpdate(table: table, columns: columns, change: change) {
570+
statements.append(stmt)
571+
}
572+
case .delete:
573+
guard deletedRowIndices.contains(change.rowIndex) else { continue }
574+
if let stmt = generateClickHouseDelete(table: table, columns: columns, change: change) {
575+
statements.append(stmt)
576+
}
577+
}
578+
}
579+
580+
return statements.isEmpty ? nil : statements
581+
}
582+
583+
private func generateClickHouseInsert(
584+
table: String,
585+
columns: [String],
586+
values: [String?]
587+
) -> (statement: String, parameters: [String?])? {
588+
var nonDefaultColumns: [String] = []
589+
var parameters: [String?] = []
590+
591+
for (index, value) in values.enumerated() {
592+
if value == "__DEFAULT__" { continue }
593+
guard index < columns.count else { continue }
594+
nonDefaultColumns.append("`\(columns[index].replacingOccurrences(of: "`", with: "``"))`")
595+
parameters.append(value)
596+
}
597+
598+
guard !nonDefaultColumns.isEmpty else { return nil }
599+
600+
let columnList = nonDefaultColumns.joined(separator: ", ")
601+
let placeholders = parameters.map { _ in "?" }.joined(separator: ", ")
602+
let sql = "INSERT INTO `\(table.replacingOccurrences(of: "`", with: "``"))` (\(columnList)) VALUES (\(placeholders))"
603+
return (statement: sql, parameters: parameters)
604+
}
605+
606+
private func generateClickHouseUpdate(
607+
table: String,
608+
columns: [String],
609+
change: PluginRowChange
610+
) -> (statement: String, parameters: [String?])? {
611+
guard !change.cellChanges.isEmpty else { return nil }
612+
613+
let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`"
614+
var parameters: [String?] = []
615+
616+
let setClauses = change.cellChanges.map { cellChange -> String in
617+
let col = "`\(cellChange.columnName.replacingOccurrences(of: "`", with: "``"))`"
618+
parameters.append(cellChange.newValue)
619+
return "\(col) = ?"
620+
}.joined(separator: ", ")
621+
622+
guard let whereClause = buildWhereClause(
623+
columns: columns, change: change, parameters: &parameters
624+
) else { return nil }
625+
626+
let sql = "ALTER TABLE \(escapedTable) UPDATE \(setClauses) WHERE \(whereClause)"
627+
return (statement: sql, parameters: parameters)
628+
}
629+
630+
private func generateClickHouseDelete(
631+
table: String,
632+
columns: [String],
633+
change: PluginRowChange
634+
) -> (statement: String, parameters: [String?])? {
635+
let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`"
636+
var parameters: [String?] = []
637+
638+
guard let whereClause = buildWhereClause(
639+
columns: columns, change: change, parameters: &parameters
640+
) else { return nil }
641+
642+
let sql = "ALTER TABLE \(escapedTable) DELETE WHERE \(whereClause)"
643+
return (statement: sql, parameters: parameters)
644+
}
645+
646+
private func buildWhereClause(
647+
columns: [String],
648+
change: PluginRowChange,
649+
parameters: inout [String?]
650+
) -> String? {
651+
guard let originalRow = change.originalRow else { return nil }
652+
653+
var conditions: [String] = []
654+
for (index, columnName) in columns.enumerated() {
655+
guard index < originalRow.count else { continue }
656+
let col = "`\(columnName.replacingOccurrences(of: "`", with: "``"))`"
657+
if let value = originalRow[index] {
658+
parameters.append(value)
659+
conditions.append("\(col) = ?")
660+
} else {
661+
conditions.append("\(col) IS NULL")
662+
}
663+
}
664+
665+
guard !conditions.isEmpty else { return nil }
666+
return conditions.joined(separator: " AND ")
667+
}
668+
502669
func cancelQuery() throws {
503670
let queryId: String?
504671
lock.lock()
@@ -525,6 +692,12 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
525692
lock.unlock()
526693
}
527694

695+
// MARK: - EXPLAIN
696+
697+
func buildExplainQuery(_ sql: String) -> String? {
698+
"EXPLAIN \(sql)"
699+
}
700+
528701
// MARK: - Kill Query
529702

530703
private func killQuery(queryId: String) {

Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,52 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin {
4545
"Enum": ["ENUM"]
4646
]
4747

48+
static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
49+
identifierQuote: "\"",
50+
keywords: [
51+
"SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL",
52+
"ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS",
53+
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY",
54+
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
55+
"CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA",
56+
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT",
57+
"ADD", "MODIFY", "COLUMN", "RENAME",
58+
"NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME",
59+
"CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF",
60+
"UNION", "INTERSECT", "EXCEPT",
61+
"COPY", "PRAGMA", "DESCRIBE", "SUMMARIZE", "PIVOT", "UNPIVOT",
62+
"QUALIFY", "SAMPLE", "TABLESAMPLE", "RETURNING",
63+
"INSTALL", "LOAD", "FORCE", "ATTACH", "DETACH",
64+
"EXPORT", "IMPORT",
65+
"WITH", "RECURSIVE", "MATERIALIZED",
66+
"EXPLAIN", "ANALYZE",
67+
"WINDOW", "OVER", "PARTITION"
68+
],
69+
functions: [
70+
"COUNT", "SUM", "AVG", "MAX", "MIN",
71+
"LIST_AGG", "STRING_AGG", "ARRAY_AGG",
72+
"CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER",
73+
"TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART",
74+
"NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP",
75+
"DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE",
76+
"EPOCH_MS",
77+
"ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT",
78+
"CAST",
79+
"REGEXP_MATCHES", "READ_CSV", "READ_PARQUET", "READ_JSON",
80+
"GLOB", "STRUCT_PACK", "LIST_VALUE", "MAP", "UNNEST",
81+
"GENERATE_SERIES", "RANGE"
82+
],
83+
dataTypes: [
84+
"INTEGER", "BIGINT", "HUGEINT", "UHUGEINT",
85+
"DOUBLE", "FLOAT", "DECIMAL",
86+
"VARCHAR", "TEXT", "BLOB",
87+
"BOOLEAN",
88+
"DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL",
89+
"UUID", "JSON",
90+
"LIST", "MAP", "STRUCT", "UNION", "ENUM", "BIT"
91+
]
92+
)
93+
4894
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
4995
DuckDBPluginDriver(config: config)
5096
}
@@ -324,6 +370,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
324370
var serverVersion: String? { String(cString: duckdb_library_version()) }
325371
var supportsSchemas: Bool { true }
326372
var supportsTransactions: Bool { true }
373+
var parameterStyle: ParameterStyle { .dollar }
327374

328375
init(config: DriverConnectionConfig) {
329376
self.config = config
@@ -764,6 +811,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
764811
throw DuckDBPluginError.unsupportedOperation
765812
}
766813

814+
// MARK: - EXPLAIN
815+
816+
func buildExplainQuery(_ sql: String) -> String? {
817+
"EXPLAIN \(sql)"
818+
}
819+
767820
// MARK: - Private Helpers
768821

769822
nonisolated private func setInterruptHandle(_ handle: duckdb_connection?) {

0 commit comments

Comments
 (0)