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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Editor tabs drawn as a segmented tab picker rather than in Liquid Glass, on every macOS version. (#2439)
- A connection opens full screen on iPhone and iPad, with its four sections in a sidebar on iPad. (#2544)

### Fixed

Expand Down Expand Up @@ -84,6 +85,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Snowflake reporting no rows changed for an `UPDATE` or `MERGE`, and a `SELECT`'s own result for a column named `number of rows`.
- Two saved Snowflake connections to one account sharing a session, so switching database in one window moved the other.
- Stop on a Snowflake query cancelling every other query on the same connection, including a sidebar refresh or a save.
- Insert Row on iPhone and iPad writing every column, so a `NOT NULL` column with a default could not be inserted. (#2543)
- NULL offered on a `NOT NULL` column in Insert Row and the row editor on iPhone and iPad.
- Insert Row on iPhone and iPad writing a generated column, which every engine refuses.
- Both halves of a composite integer primary key left out of the insert on iPhone and iPad.
- Typed values discarded when Insert Row opened before the column list had loaded on iPhone and iPad.
- Insert Row offered on Redis connections on iPhone and iPad, where it can only fail.
- Missing search field in a connection's Tables tab on iPhone and iPad. (#2544)
- A search from one connection or table still filtering another's list on iPhone and iPad.

### Security

Expand Down
12 changes: 10 additions & 2 deletions Packages/TableProCore/Sources/TableProModels/QueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public struct ColumnInfo: Sendable, Identifiable {
public let comment: String?
public let characterMaxLength: Int?
public let ordinalPosition: Int
public let isAutoIncrement: Bool
public let isGenerated: Bool

public init(
name: String,
Expand All @@ -47,7 +49,9 @@ public struct ColumnInfo: Sendable, Identifiable {
defaultValue: String? = nil,
comment: String? = nil,
characterMaxLength: Int? = nil,
ordinalPosition: Int = 0
ordinalPosition: Int = 0,
isAutoIncrement: Bool = false,
isGenerated: Bool = false
) {
self.name = name
self.typeName = typeName
Expand All @@ -57,6 +61,8 @@ public struct ColumnInfo: Sendable, Identifiable {
self.comment = comment
self.characterMaxLength = characterMaxLength
self.ordinalPosition = ordinalPosition
self.isAutoIncrement = isAutoIncrement
self.isGenerated = isGenerated
}
}

Expand Down Expand Up @@ -227,7 +233,9 @@ public extension ColumnInfo {
isNullable: plugin.isNullable,
defaultValue: plugin.defaultValue,
comment: plugin.comment,
ordinalPosition: ordinalPosition
ordinalPosition: ordinalPosition,
isAutoIncrement: plugin.isIdentity,
isGenerated: plugin.isGenerated
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable {
isNullable: parsed.isNullable,
defaultValue: parsed.defaultValue,
characterMaxLength: parsed.characterMaxLength,
ordinalPosition: idx
ordinalPosition: idx,
isAutoIncrement: parsed.isIdentity
)
}
}
Expand Down
5 changes: 4 additions & 1 deletion TableProMobile/TableProMobile/Drivers/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable {
guard row.count >= 9, let name = row[0], let dataType = row[1] else { return nil }
let isPK = row[4]?.uppercased().contains("PRI") == true
let isNullable = row[3]?.uppercased() == "YES"
let extra = row[6]
return ColumnInfo(
name: name,
typeName: dataType,
Expand All @@ -150,7 +151,9 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable {
defaultValue: row[5],
comment: row[8],
characterMaxLength: nil,
ordinalPosition: index
ordinalPosition: index,
isAutoIncrement: ColumnMetadataRules.mySQLIsAutoIncrement(extra: extra),
isGenerated: ColumnMetadataRules.mySQLIsGenerated(extra: extra)
)
}
}
Expand Down
73 changes: 52 additions & 21 deletions TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable {
nonisolated(unsafe) private(set) var currentSchema: String? = "public"
nonisolated(unsafe) private(set) var serverVersion: String?

/// Nil until the first column fetch answers it. Redshift shares this driver and its
/// `information_schema` predates `is_identity`, so the answer is remembered rather than
/// re-probed for every table.
nonisolated(unsafe) private var reportsIdentityColumns: Bool?

init(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) {
self.host = host
self.port = port
Expand Down Expand Up @@ -154,14 +159,55 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable {
let safeTbl = table.replacingOccurrences(of: "'", with: "''")
let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''")

let raw = try await actor.execute("""
let result: RawPGResult
if reportsIdentityColumns == false {
result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: false))
} else {
do {
result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: true))
reportsIdentityColumns = true
} catch is CancellationError {
throw CancellationError()
} catch {
reportsIdentityColumns = false
result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: false))
}
}

return result.rows.enumerated().compactMap { index, row in
guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil }
let maxLen = row[4].flatMap { Int($0) }
return ColumnInfo(
name: name,
typeName: dataType,
isPrimaryKey: row[5] == "YES",
isNullable: row[2]?.uppercased() == "YES",
defaultValue: row[3],
comment: nil,
characterMaxLength: maxLen,
ordinalPosition: index,
isAutoIncrement: ColumnMetadataRules.postgresIsAutoIncrement(
isIdentity: row.count > 6 ? row[6] : nil, columnDefault: row[3]
),
isGenerated: ColumnMetadataRules.postgresIsGenerated(
isGenerated: row.count > 7 ? row[7] : nil
)
)
}
}

/// The plain form drops `is_identity` (PostgreSQL 10) and `is_generated` (PostgreSQL 12) for a
/// server that has neither. A serial default still reports auto-increment through `nextval`.
private func columnsQuery(schema: String, table: String, identity: Bool) -> String {
let identityColumns = identity ? ",\n c.is_identity,\n c.is_generated" : ""
return """
SELECT
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
c.character_maximum_length,
CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk
CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk\(identityColumns)
FROM information_schema.columns c
LEFT JOIN (
SELECT kcu.column_name
Expand All @@ -170,27 +216,12 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable {
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = '\(safeSchema)'
AND tc.table_name = '\(safeTbl)'
AND tc.table_schema = '\(schema)'
AND tc.table_name = '\(table)'
) pk ON c.column_name = pk.column_name
WHERE c.table_schema = '\(safeSchema)' AND c.table_name = '\(safeTbl)'
WHERE c.table_schema = '\(schema)' AND c.table_name = '\(table)'
ORDER BY c.ordinal_position
""")

return raw.rows.enumerated().compactMap { index, row in
guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil }
let maxLen = row[4].flatMap { Int($0) }
return ColumnInfo(
name: name,
typeName: dataType,
isPrimaryKey: row[5] == "YES",
isNullable: row[2]?.uppercased() == "YES",
defaultValue: row[3],
comment: nil,
characterMaxLength: maxLen,
ordinalPosition: index
)
}
"""
}

func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] {
Expand Down
23 changes: 21 additions & 2 deletions TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,22 +135,41 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable {
func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] {
let safe = table.replacingOccurrences(of: "'", with: "''")
let raw = try await actor.execute("PRAGMA table_info('\(safe)')")
let createStatement = try await fetchCreateStatement(table: safe)

let primaryKeyCount = raw.rows.filter { row in
row.count >= 6 && ColumnMetadataRules.sqliteIsPrimaryKey(pk: row[5])
}.count

return raw.rows.enumerated().compactMap { index, row in
guard row.count >= 6, let name = row[1], let dataType = row[2] else { return nil }
let isPrimaryKey = ColumnMetadataRules.sqliteIsPrimaryKey(pk: row[5])
return ColumnInfo(
name: name,
typeName: dataType,
isPrimaryKey: row[5] == "1",
isPrimaryKey: isPrimaryKey,
isNullable: row[3] == "0",
defaultValue: row[4],
comment: nil,
characterMaxLength: nil,
ordinalPosition: index
ordinalPosition: index,
isAutoIncrement: ColumnMetadataRules.sqliteIsRowIdAlias(
typeName: dataType,
isPrimaryKey: isPrimaryKey,
primaryKeyCount: primaryKeyCount,
createStatement: createStatement
)
)
}
}

private func fetchCreateStatement(table safeTable: String) async throws -> String? {
let raw = try await actor.execute(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = '\(safeTable)'"
)
return raw.rows.first?.first ?? nil
}

func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] {
let safe = table.replacingOccurrences(of: "'", with: "''")
let raw = try await actor.execute("""
Expand Down
45 changes: 45 additions & 0 deletions TableProMobile/TableProMobile/Helpers/ColumnMetadataRules.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

nonisolated enum ColumnMetadataRules {
static func mySQLIsAutoIncrement(extra: String?) -> Bool {
normalized(extra).contains("AUTO_INCREMENT")
}

static func mySQLIsGenerated(extra: String?) -> Bool {
let value = normalized(extra)
return value.contains("VIRTUAL GENERATED") || value.contains("STORED GENERATED")
}

static func postgresIsAutoIncrement(isIdentity: String?, columnDefault: String?) -> Bool {
if normalized(isIdentity) == "YES" { return true }
return normalized(columnDefault).hasPrefix("NEXTVAL(")
}

static func postgresIsGenerated(isGenerated: String?) -> Bool {
normalized(isGenerated) == "ALWAYS"
}

/// `PRAGMA table_info` reports `pk` as the column's 1-based rank inside the primary key, not as
/// a flag, so a composite key's second column comes back as 2.
static func sqliteIsPrimaryKey(pk: String?) -> Bool {
guard let pk, !pk.isEmpty else { return false }
return pk != "0"
}

/// Only a lone `INTEGER PRIMARY KEY` on a rowid table aliases the rowid and is filled in when
/// omitted. The same declaration on a `WITHOUT ROWID` table is an ordinary NOT NULL column.
static func sqliteIsRowIdAlias(
typeName: String?,
isPrimaryKey: Bool,
primaryKeyCount: Int,
createStatement: String?
) -> Bool {
guard isPrimaryKey, primaryKeyCount == 1 else { return false }
guard normalized(typeName) == "INTEGER" else { return false }
return !normalized(createStatement).contains("WITHOUT ROWID")
}

private static func normalized(_ value: String?) -> String {
(value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import TableProDatabase
import TableProModels

nonisolated enum RowInsertPlanner {
/// A column absent from a row is left out of the statement so the database applies its default.
///
/// `allowAllDefaults` and `dropsEmptyPrimaryKey` both default to the behaviour the Shortcuts
/// path has always had, where a payload cannot say "leave this out" any other way. A caller that
/// distinguishes omission from an empty value, as the insert form does, turns them off.
static func statements(
table: String,
schema: String?,
type: DatabaseType,
driver: any DatabaseDriver,
columns: [ColumnInfo],
rows: [PayloadRow]
rows: [PayloadRow],
allowAllDefaults: Bool = false,
dropsEmptyPrimaryKey: Bool = true
) throws -> [String] {
guard !columns.isEmpty else { throw IntentDataError.noColumns(table) }
let columnNames = Set(columns.map(\.name))
Expand All @@ -22,12 +29,13 @@ nonisolated enum RowInsertPlanner {
var insertColumns: [String] = []
var insertValues: [String?] = []
for column in columns {
guard !column.isGenerated else { continue }
guard let value = row.value(for: column.name) else { continue }
if primaryKeys.contains(column.name), value.isEmptyOrNull { continue }
if dropsEmptyPrimaryKey, primaryKeys.contains(column.name), value.isEmptyOrNull { continue }
insertColumns.append(column.name)
insertValues.append(value.sqlValue)
}
guard !insertColumns.isEmpty else { return nil }
guard !insertColumns.isEmpty || allowAllDefaults else { return nil }
return SQLBuilder.buildInsert(
table: table,
schema: schema,
Expand Down
23 changes: 22 additions & 1 deletion TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ nonisolated enum SQLBuilder {
driver: any DatabaseDriver,
columns: [String],
values: [String?]
) -> String {
) -> String? {
let qualifiedTable = qualifiedIdentifier(table: table, schema: schema, for: type)
guard !columns.isEmpty else {
return buildAllDefaultsInsert(qualifiedTable: qualifiedTable, for: type)
}
let cols = columns.map { quoteIdentifier($0, for: type) }.joined(separator: ", ")
let vals = values.map { val in
if let val { return "'\(driver.escapeStringLiteral(val))'" }
Expand All @@ -92,6 +95,24 @@ nonisolated enum SQLBuilder {
return "INSERT INTO \(qualifiedTable) (\(cols)) VALUES (\(vals))"
}

/// Every column left on its database default. MySQL and MariaDB take an empty column list;
/// the PostgreSQL family, SQLite and SQL Server take `DEFAULT VALUES`. Oracle accepts
/// neither, so it gets no statement.
static func buildAllDefaultsInsert(qualifiedTable: String, for type: DatabaseType) -> String? {
switch type {
case .mysql, .mariadb:
return "INSERT INTO \(qualifiedTable) () VALUES ()"
case .postgresql, .redshift, .sqlite, .mssql, .duckdb:
return "INSERT INTO \(qualifiedTable) DEFAULT VALUES"
default:
return nil
}
}

static func supportsAllDefaultsInsert(_ type: DatabaseType) -> Bool {
buildAllDefaultsInsert(qualifiedTable: "t", for: type) != nil
}

static func qualifiedIdentifier(table: String, schema: String?, for type: DatabaseType) -> String {
let quotedTable = quoteIdentifier(table, for: type)
guard let schema, !schema.isEmpty else { return quotedTable }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ final class RowDetailViewModel {
return columnDetail(for: column.name)?.isPrimaryKey ?? column.isPrimaryKey
}

func isNullable(at index: Int) -> Bool {
guard index >= 0, index < columns.count else { return true }
let column = columns[index]
return columnDetail(for: column.name)?.isNullable ?? column.isNullable
}

// MARK: - Edit Lifecycle

func startEditing() {
Expand Down
Loading
Loading