Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5d0521e
feat: extend ConnectionField with number, toggle, and stepper field t…
datlechin Mar 12, 2026
d324444
fix: address PR review feedback for ConnectionField types
datlechin Mar 12, 2026
4197e24
Merge branch 'worktree-agent-a588ec25' into feat/connection-field-types
datlechin Mar 12, 2026
abf00ca
feat: add SQLDialectDescriptor to PluginKit for plugin-provided SQL d…
datlechin Mar 12, 2026
b3ec79d
feat: add ParameterStyle to PluginKit and DML generation in ClickHous…
datlechin Mar 12, 2026
663d850
feat: add DDL schema generation methods to PluginDatabaseDriver protocol
datlechin Mar 12, 2026
bb7c903
feat: add table operation methods to PluginDatabaseDriver protocol
datlechin Mar 12, 2026
5e382b8
feat: move MSSQL/Oracle pagination to plugin query building hooks
datlechin Mar 12, 2026
0157cf9
feat: add buildExplainQuery to PluginDatabaseDriver protocol
datlechin Mar 12, 2026
efd1fbb
Merge branch 'worktree-agent-ad9e1aa8' into feat/connection-field-types
datlechin Mar 12, 2026
459c8c6
Merge branch 'worktree-agent-a9fd3533' into feat/connection-field-types
datlechin Mar 12, 2026
19daec9
Merge Phase 3.2: DDL schema generation
datlechin Mar 12, 2026
9792c8d
remove accidentally staged embedded repos
datlechin Mar 12, 2026
a348321
Merge Phase 3.3: table operations in plugins
datlechin Mar 12, 2026
2274af3
Merge Phase 3.4: MSSQL/Oracle pagination in plugins
datlechin Mar 12, 2026
a12b8ce
remove accidentally staged embedded repos
datlechin Mar 12, 2026
e8b56e9
merge: integrate Phase 3.5 (EXPLAIN query building in plugins)
datlechin Mar 12, 2026
08ed63f
chore: remove .md files not intended for this PR
datlechin Mar 12, 2026
38bae23
refactor: remove database-specific DML switches from SQLStatementGene…
datlechin Mar 12, 2026
3e90bd0
refactor: remove database-specific DML switches from SQLStatementGene…
datlechin Mar 12, 2026
d949d08
refactor: remove all DatabaseType fallback switches for clean plugin-…
datlechin Mar 12, 2026
935f9e2
fix: address PR review feedback for plugin extensibility
datlechin Mar 12, 2026
3295bee
fix: address PR review feedback for plugin extensibility
datlechin Mar 12, 2026
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `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
- 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
- Plugin-provided table operations: `truncateTableStatements`, `dropObjectStatement`, `foreignKeyDisableStatements`, `foreignKeyEnableStatements` in `PluginDatabaseDriver` protocol, allowing plugins to override TRUNCATE, DROP, and FK handling SQL
- `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
- `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
- 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
- Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel
Expand All @@ -21,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support
- `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form
- Pre-connect script: run a shell command before each connection (e.g., to refresh credentials or update ~/.pgpass)
- `ParameterStyle` enum in TableProPluginKit: plugins declare `?` or `$1` placeholder style via `parameterStyle` property on `PluginDatabaseDriver`
- DML statement generation in ClickHouse, MSSQL, and Oracle plugins via `generateStatements()` for database-specific UPDATE/DELETE syntax

### Changed

- Moved MSSQL and Oracle pagination query building (`OFFSET...FETCH NEXT`) from `TableQueryBuilder` into their respective plugin drivers via `buildBrowseQuery`/`buildFilteredQuery`/`buildQuickSearchQuery`/`buildCombinedQuery` hooks

### Fixed

Expand Down
173 changes: 173 additions & 0 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,51 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin {
"Geo": ["Point", "Ring", "Polygon", "MultiPolygon"]
]

static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
identifierQuote: "`",
keywords: [
"SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL",
"ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS",
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET",
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
"CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT",
"ADD", "MODIFY", "COLUMN", "RENAME",
"NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME",
"CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE",
"UNION", "INTERSECT", "EXCEPT",
"FINAL", "SAMPLE", "PREWHERE", "GLOBAL", "FORMAT", "SETTINGS",
"OPTIMIZE", "SYSTEM", "PARTITION", "TTL", "ENGINE", "CODEC",
"MATERIALIZED", "WITH"
],
functions: [
"COUNT", "SUM", "AVG", "MAX", "MIN",
"CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER",
"TRIM", "LTRIM", "RTRIM", "REPLACE",
"NOW", "TODAY", "YESTERDAY",
"CAST",
"UNIQ", "UNIQEXACT", "ARGMIN", "ARGMAX", "GROUPARRAY",
"TOSTRING", "TOINT32", "FORMATDATETIME",
"IF", "MULTIIF",
"ARRAYMAP", "ARRAYJOIN",
"MATCH", "CURRENTDATABASE", "VERSION",
"QUANTILE", "TOPK"
],
dataTypes: [
"INT8", "INT16", "INT32", "INT64", "INT128", "INT256",
"UINT8", "UINT16", "UINT32", "UINT64", "UINT128", "UINT256",
"FLOAT32", "FLOAT64",
"DECIMAL", "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256",
"STRING", "FIXEDSTRING", "UUID",
"DATE", "DATE32", "DATETIME", "DATETIME64",
"ARRAY", "TUPLE", "MAP",
"NULLABLE", "LOWCARDINALITY",
"ENUM8", "ENUM16",
"IPV4", "IPV6",
"JSON", "BOOL"
]
)

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
ClickHousePluginDriver(config: config)
}
Expand Down Expand Up @@ -499,6 +544,128 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
_ = try await execute(query: "CREATE DATABASE `\(escapedName)`")
}

// MARK: - DML Statement Generation

func generateStatements(
table: String,
columns: [String],
changes: [PluginRowChange],
insertedRowData: [Int: [String?]],
deletedRowIndices: Set<Int>,
insertedRowIndices: Set<Int>
) -> [(statement: String, parameters: [String?])]? {
var statements: [(statement: String, parameters: [String?])] = []

for change in changes {
switch change.type {
case .insert:
guard insertedRowIndices.contains(change.rowIndex) else { continue }
if let values = insertedRowData[change.rowIndex] {
if let stmt = generateClickHouseInsert(table: table, columns: columns, values: values) {
statements.append(stmt)
}
}
case .update:
if let stmt = generateClickHouseUpdate(table: table, columns: columns, change: change) {
statements.append(stmt)
}
case .delete:
guard deletedRowIndices.contains(change.rowIndex) else { continue }
if let stmt = generateClickHouseDelete(table: table, columns: columns, change: change) {
statements.append(stmt)
}
}
}

return statements.isEmpty ? nil : statements
}

private func generateClickHouseInsert(
table: String,
columns: [String],
values: [String?]
) -> (statement: String, parameters: [String?])? {
var nonDefaultColumns: [String] = []
var parameters: [String?] = []

for (index, value) in values.enumerated() {
if value == "__DEFAULT__" { continue }
guard index < columns.count else { continue }
nonDefaultColumns.append("`\(columns[index].replacingOccurrences(of: "`", with: "``"))`")
parameters.append(value)
}

guard !nonDefaultColumns.isEmpty else { return nil }

let columnList = nonDefaultColumns.joined(separator: ", ")
let placeholders = parameters.map { _ in "?" }.joined(separator: ", ")
let sql = "INSERT INTO `\(table.replacingOccurrences(of: "`", with: "``"))` (\(columnList)) VALUES (\(placeholders))"
return (statement: sql, parameters: parameters)
}

private func generateClickHouseUpdate(
table: String,
columns: [String],
change: PluginRowChange
) -> (statement: String, parameters: [String?])? {
guard !change.cellChanges.isEmpty else { return nil }

let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`"
var parameters: [String?] = []

let setClauses = change.cellChanges.map { cellChange -> String in
let col = "`\(cellChange.columnName.replacingOccurrences(of: "`", with: "``"))`"
parameters.append(cellChange.newValue)
return "\(col) = ?"
}.joined(separator: ", ")

guard let whereClause = buildWhereClause(
columns: columns, change: change, parameters: &parameters
) else { return nil }

let sql = "ALTER TABLE \(escapedTable) UPDATE \(setClauses) WHERE \(whereClause)"
return (statement: sql, parameters: parameters)
}

private func generateClickHouseDelete(
table: String,
columns: [String],
change: PluginRowChange
) -> (statement: String, parameters: [String?])? {
let escapedTable = "`\(table.replacingOccurrences(of: "`", with: "``"))`"
var parameters: [String?] = []

guard let whereClause = buildWhereClause(
columns: columns, change: change, parameters: &parameters
) else { return nil }

let sql = "ALTER TABLE \(escapedTable) DELETE WHERE \(whereClause)"
return (statement: sql, parameters: parameters)
}

private func buildWhereClause(
columns: [String],
change: PluginRowChange,
parameters: inout [String?]
) -> String? {
guard let originalRow = change.originalRow else { return nil }

var conditions: [String] = []
for (index, columnName) in columns.enumerated() {
guard index < originalRow.count else { continue }
let col = "`\(columnName.replacingOccurrences(of: "`", with: "``"))`"
if let value = originalRow[index] {
parameters.append(value)
conditions.append("\(col) = ?")
} else {
conditions.append("\(col) IS NULL")
}
}

guard !conditions.isEmpty else { return nil }
return conditions.joined(separator: " AND ")
}

func cancelQuery() throws {
let queryId: String?
lock.lock()
Expand All @@ -525,6 +692,12 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
lock.unlock()
}

// MARK: - EXPLAIN

func buildExplainQuery(_ sql: String) -> String? {
"EXPLAIN \(sql)"
}

// MARK: - Kill Query

private func killQuery(queryId: String) {
Expand Down
53 changes: 53 additions & 0 deletions Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,52 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin {
"Enum": ["ENUM"]
]

static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
identifierQuote: "\"",
keywords: [
"SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL",
"ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS",
"ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY",
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
"CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT",
"ADD", "MODIFY", "COLUMN", "RENAME",
"NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME",
"CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF",
"UNION", "INTERSECT", "EXCEPT",
"COPY", "PRAGMA", "DESCRIBE", "SUMMARIZE", "PIVOT", "UNPIVOT",
"QUALIFY", "SAMPLE", "TABLESAMPLE", "RETURNING",
"INSTALL", "LOAD", "FORCE", "ATTACH", "DETACH",
"EXPORT", "IMPORT",
"WITH", "RECURSIVE", "MATERIALIZED",
"EXPLAIN", "ANALYZE",
"WINDOW", "OVER", "PARTITION"
],
functions: [
"COUNT", "SUM", "AVG", "MAX", "MIN",
"LIST_AGG", "STRING_AGG", "ARRAY_AGG",
"CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER",
"TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART",
"NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP",
"DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE",
"EPOCH_MS",
"ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT",
"CAST",
"REGEXP_MATCHES", "READ_CSV", "READ_PARQUET", "READ_JSON",
"GLOB", "STRUCT_PACK", "LIST_VALUE", "MAP", "UNNEST",
"GENERATE_SERIES", "RANGE"
],
dataTypes: [
"INTEGER", "BIGINT", "HUGEINT", "UHUGEINT",
"DOUBLE", "FLOAT", "DECIMAL",
"VARCHAR", "TEXT", "BLOB",
"BOOLEAN",
"DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "INTERVAL",
"UUID", "JSON",
"LIST", "MAP", "STRUCT", "UNION", "ENUM", "BIT"
]
)

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
DuckDBPluginDriver(config: config)
}
Expand Down Expand Up @@ -324,6 +370,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
var serverVersion: String? { String(cString: duckdb_library_version()) }
var supportsSchemas: Bool { true }
var supportsTransactions: Bool { true }
var parameterStyle: ParameterStyle { .dollar }

init(config: DriverConnectionConfig) {
self.config = config
Expand Down Expand Up @@ -764,6 +811,12 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
throw DuckDBPluginError.unsupportedOperation
}

// MARK: - EXPLAIN

func buildExplainQuery(_ sql: String) -> String? {
"EXPLAIN \(sql)"
}

// MARK: - Private Helpers

nonisolated private func setInterruptHandle(_ handle: duckdb_connection?) {
Expand Down
Loading
Loading