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
27 changes: 27 additions & 0 deletions Sources/Simpleton/Panels/SQL/SQLDatabasePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,30 @@ struct SQLDatabasePicker: View {
)
}
}

/// The active-schema switcher (Postgres `search_path`), shown only for engines that expose a schema
/// layer distinct from the database — MySQL/SQLite report no schemas, so it stays hidden. Selecting a
/// schema calls `model.selectSchema`, which switches live and reloads the schema tree.
struct SQLSchemaPicker: View {
@ObservedObject var model: SQLPanelModel

var body: some View {
if model.isConnected && model.schemas.count > 1 {
Picker("", selection: selection) {
ForEach(model.schemas, id: \.self) { s in Text(s).tag(s) }
}
.labelsHidden().fixedSize().help("Active schema")
.disabled(model.isConnecting)
}
}

private var selection: Binding<String> {
Binding(
get: {
let current = model.selectedSchema ?? ""
return model.schemas.contains(current) ? current : (model.schemas.first ?? "")
},
set: { newValue in Task { await model.selectSchema(newValue) } }
)
}
}
26 changes: 26 additions & 0 deletions Sources/Simpleton/Panels/SQL/SQLPanelModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ final class SQLPanelModel: ObservableObject {
/// The active database. Seeded from the connection's `database` param on connect, updated by
/// `selectDatabase`. nil when unknown.
@Published var selectedDatabase: String?
/// The schemas in the current database, for engines with a schema layer (Postgres). Empty for
/// MySQL/SQLite, which hides the schema switcher. Reloaded on connect and on a database switch.
@Published var schemas: [String] = []
/// The active schema (Postgres `search_path`). nil when the engine has no schema layer.
@Published var selectedSchema: String?
/// Non-nil exactly when the current result is a single-table SELECT with a primary key we can
/// UPDATE by. The grid shows the edit affordance only while this is set. Recomputed on every
/// `runQuery`; cleared on disconnect or any non-editable result.
Expand Down Expand Up @@ -174,9 +179,28 @@ final class SQLPanelModel: ObservableObject {
savedQueries = await savedStore.saved(for: connectionID)
databases = (try? await d.databases()) ?? []
selectedDatabase = activeDatabase ?? databases.first
schemas = (try? await d.schemas()) ?? []
// The driver starts at its default schema ("public" for Postgres); preselect it when present.
selectedSchema = schemas.isEmpty ? nil : (schemas.contains("public") ? "public" : schemas.first)
await loadSchema()
}

/// Switch the active schema on the live connection (Postgres `search_path`) and reload the schema
/// tree. A no-op for engines without a schema layer, when the target is already active, or with no
/// live driver.
func selectSchema(_ name: String) async {
guard let driver, name != selectedSchema else { return }
errorMessage = nil
do {
if try await driver.useSchema(name) {
selectedSchema = name
await loadSchema()
}
} catch {
errorMessage = Self.describe(error)
}
}

/// Switch the active database. Engines that can switch a live connection (MySQL `USE`) do so and
/// reload the schema; engines that cannot (Postgres — isolated databases) reconnect to `name`. A
/// no-op when the target is already active or there is no live driver.
Expand Down Expand Up @@ -229,6 +253,8 @@ final class SQLPanelModel: ObservableObject {
savedQueries = []
databases = []
selectedDatabase = nil
schemas = []
selectedSchema = nil
tables = []
columnsByTable = [:]
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Simpleton/Panels/SQL/SQLPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ struct SQLPanelView: View {
.labelsHidden()
.onChange(of: model.selectedID) { Task { await model.connectSelected() } }
SQLDatabasePicker(model: model)
SQLSchemaPicker(model: model)
Button(model.isConnected ? "Disconnect" : "Connect") {
Task { model.isConnected ? await model.disconnect() : await model.connect() }
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ struct SQLWorkspaceView: View {
.onChange(of: model.selectedID) { Task { await model.connectSelected() } }

SQLDatabasePicker(model: model)
SQLSchemaPicker(model: model)

Button(model.isConnected ? "Disconnect" : "Connect") {
Task { model.isConnected ? await model.disconnect() : await model.connect() }
Expand Down
5 changes: 5 additions & 0 deletions Sources/SimpletonSQL/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ public final class MySQLDriver: SQLDriver, @unchecked Sendable {
}
}

/// In MySQL a schema *is* a database, so the database switcher already covers it — there is no
/// separate schema axis to expose.
public func schemas() async throws -> [String] { [] }
public func useSchema(_ name: String) async throws -> Bool { false }

public func tables(in database: String?) async throws -> [TableInfo] {
guard case .rows(_, let rows) = try await run("SHOW FULL TABLES") else { return [] }
return rows.compactMap { row in
Expand Down
63 changes: 50 additions & 13 deletions Sources/SimpletonSQL/PostgresDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable {
private let config: PostgresConnection.Configuration
private let logger = Logger(label: "com.simpleton.sql.postgres")
private var connection: PostgresConnection?
/// The active schema, driving `search_path` (set on switch) and the schema-scoped introspection
/// below. Defaults to "public" — the connection's own default — until `useSchema` changes it.
private var currentSchema = "public"

public init(connection c: Connection, secret: ConnectionSecret?) throws {
let tls: PostgresConnection.Configuration.TLS =
Expand Down Expand Up @@ -101,30 +104,63 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable {
/// caller reconnects with the target `database` param, so this always returns `false`.
public func useDatabase(_ name: String) async throws -> Bool { false }

public func tables(in database: String?) async throws -> [TableInfo] {
/// User-visible schemas in the current database (system `pg_*` schemas excluded). `information_schema`
/// is kept so it can be browsed like any other schema.
public func schemas() async throws -> [String] {
guard
case .rows(_, let rows) = try await run(
"SELECT table_name, table_type FROM information_schema.tables "
+ "WHERE table_schema = 'public' ORDER BY table_name")
"SELECT schema_name FROM information_schema.schemata "
+ "WHERE schema_name NOT LIKE 'pg\\_%' ORDER BY schema_name")
else { return [] }
return rows.compactMap { row in
guard row.count >= 2 else { return nil }
let kind: TableKind = row[1].displayString.uppercased().contains("VIEW") ? .view : .table
return TableInfo(name: row[0].displayString, kind: kind)
return rows.compactMap { $0.first?.displayString }
}

/// Switch the active schema live via `search_path`. `SET` takes no bind parameters, so the schema
/// identifier is quoted (internal quotes doubled) — the name comes from `schemas()` (the catalog),
/// never user free-text. This redirects both raw editor queries and the introspection below.
public func useSchema(_ name: String) async throws -> Bool {
_ = try await run("SET search_path TO " + dialect.quoteIdentifier(name))
currentSchema = name
return true
}

public func tables(in database: String?) async throws -> [TableInfo] {
guard let connection else { throw SQLDriverError.notConnected }
do {
// The active schema is bound as a typed parameter ($1), never interpolated into SQL.
let schema = currentSchema
let seq = try await connection.query(
"""
SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = \(schema) ORDER BY table_name
""", logger: logger)
var out: [TableInfo] = []
for try await row in seq {
let cells = Array(row)
guard cells.count >= 2 else { continue }
let name = (try? cells[0].decode(String.self)) ?? ""
let type = (try? cells[1].decode(String.self)) ?? ""
out.append(TableInfo(name: name, kind: type.uppercased().contains("VIEW") ? .view : .table))
}
return out
} catch {
throw SQLDriverError.queryFailed("\(error)")
}
}

public func columns(of table: String, in database: String?) async throws -> [ColumnInfo] {
guard let connection else { throw SQLDriverError.notConnected }
do {
let primaryKeys = try await primaryKeyColumns(of: table, on: connection)
// `table` is bound as a parameter ($1) via PostgresQuery string interpolation — NOT raw
// SQL — so no manual escaping and no injection surface (contrast `run`'s unsafeSQL path,
// which intentionally executes the user's own IDE queries).
// `table` and the active `schema` are bound as parameters ($1/$2) via PostgresQuery string
// interpolation — NOT raw SQL — so no manual escaping and no injection surface (contrast
// `run`'s unsafeSQL path, which intentionally executes the user's own IDE queries). Scoping
// by schema stops a same-named table in another schema from merging its columns in.
let schema = currentSchema
let seq = try await connection.query(
"""
SELECT column_name, data_type, is_nullable FROM information_schema.columns \
WHERE table_name = \(table) ORDER BY ordinal_position
WHERE table_name = \(table) AND table_schema = \(schema) ORDER BY ordinal_position
""", logger: logger)
var out: [ColumnInfo] = []
for try await row in seq {
Expand All @@ -150,7 +186,8 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable {
// Join the three information_schema views that describe a foreign-key constraint: the
// owning column (key_column_usage), the referenced column (constraint_column_usage), and
// the constraint metadata (referential_constraints ties the two together by name).
// `table` is bound as `$1` (a typed parameter), never interpolated into SQL.
// `table` and the active `schema` are bound as typed parameters, never interpolated.
let schema = currentSchema
let seq = try await connection.query(
"""
SELECT kcu.column_name, ccu.table_name AS referenced_table, \
Expand All @@ -160,7 +197,7 @@ public final class PostgresDriver: SQLDriver, @unchecked Sendable {
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema \
JOIN information_schema.constraint_column_usage ccu \
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema \
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public' \
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = \(schema) \
AND tc.table_name = \(table) ORDER BY kcu.ordinal_position
""", logger: logger)
var out: [ForeignKeyInfo] = []
Expand Down
8 changes: 8 additions & 0 deletions Sources/SimpletonSQL/SQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ public protocol SQLDriver: AnyObject, Sendable {
/// single file) return `false`, signalling the caller to reconnect to reach `name` instead. `name`
/// comes from `databases()` (the engine's own catalog), and is identifier-quoted, not interpolated.
func useDatabase(_ name: String) async throws -> Bool
/// The schemas within the current database, for engines with a schema layer distinct from the
/// database (Postgres). Engines where a schema *is* the database (MySQL) or has none (SQLite)
/// return `[]`, which hides the schema switcher.
func schemas() async throws -> [String]
/// Switch the active schema on the *live* connection when the engine has a schema layer (Postgres
/// `SET search_path`), returning `true`; others return `false`. `name` comes from `schemas()` and
/// is identifier-quoted, not interpolated as a value. Introspection then follows the new schema.
func useSchema(_ name: String) async throws -> Bool
/// The placeholder dialect for `execute`, so callers build `UPDATE … WHERE …` with the correct
/// placeholder syntax and identifier quoting for this engine.
var dialect: SQLDialect { get }
Expand Down
4 changes: 4 additions & 0 deletions Sources/SimpletonSQL/SQLiteDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public final class SQLiteDriver: SQLDriver, @unchecked Sendable {
/// by opening a different connection, so this always returns `false`.
public func useDatabase(_ name: String) async throws -> Bool { false }

/// SQLite has no schema layer distinct from the database, so there is nothing to switch.
public func schemas() async throws -> [String] { [] }
public func useSchema(_ name: String) async throws -> Bool { false }

public func tables(in database: String?) async throws -> [TableInfo] {
let result = try await run(
"SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY name")
Expand Down
21 changes: 21 additions & 0 deletions Tests/CoreChecks/SQLDriverChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ func runSQLDriverChecks(_ t: TestRunner) async {
// the caller would reconnect to reach another database.
let switched = try await driver.useDatabase("main")
t.expect(!switched, "SQLite cannot switch a live connection")
// SQLite has no schema layer, so the schema switcher is inert.
t.expect(try await driver.schemas().isEmpty, "SQLite exposes no schemas")
t.expect(try await driver.useSchema("anything") == false, "SQLite cannot switch schema")
await driver.close()
} catch {
t.expect(false, "unexpected error: \(error)")
Expand Down Expand Up @@ -250,6 +253,24 @@ func runSQLDriverChecks(_ t: TestRunner) async {
}
await d2.close()
}
// Schema switch (search_path): seed a table in `public` and one in a new schema, then
// prove useSchema redirects introspection to the active schema and back.
_ = try await driver.run("CREATE SCHEMA IF NOT EXISTS simp_test")
_ = try await driver.run("CREATE TABLE IF NOT EXISTS public.simp_pub (id int)")
_ = try await driver.run("CREATE TABLE IF NOT EXISTS simp_test.simp_sales (id int)")
let schemaList = try await driver.schemas()
t.expect(schemaList.contains("simp_test"), "new schema listed")
t.expect(schemaList.contains("public"), "public schema listed")
let pubTables = try await driver.tables(in: nil)
t.expect(pubTables.contains { $0.name == "simp_pub" }, "public table visible before switch")
t.expect(!pubTables.contains { $0.name == "simp_sales" }, "other-schema table hidden in public")
t.expect(try await driver.useSchema("simp_test"), "useSchema returns true (live search_path)")
let salesTables = try await driver.tables(in: nil)
t.expect(salesTables.contains { $0.name == "simp_sales" }, "schema table visible after switch")
t.expect(!salesTables.contains { $0.name == "simp_pub" }, "public table hidden after switch")
_ = try await driver.run("DROP TABLE IF EXISTS simp_test.simp_sales")
_ = try await driver.run("DROP SCHEMA IF EXISTS simp_test CASCADE")
_ = try await driver.run("DROP TABLE IF EXISTS public.simp_pub")
await driver.close()
} catch {
t.expect(false, "unexpected error: \(error)")
Expand Down
Loading