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 @@ -16,12 +16,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The iOS Shortcuts documentation says which app build each action needs and where to find the full action list, so a Mac release note no longer reads as though the iPhone app already has them.
- `Cmd+F` on a table tab used to toggle the filter panel, which meant it closed the panel when it was already open and never searched anything. The filter panel keeps `Cmd+Option+F` and its funnel button in the status bar.
- Find Next and Find Previous work on the data grid when its find bar is open, instead of staying dimmed on a table tab.
- The PHP serialized viewer's tree filter now behaves like the JSON one. Both ignore accents, so `cafe` finds `café`. (#2204)

### Fixed

- iOS: a value containing a backslash, a newline or a carriage return is stored as you typed it on PostgreSQL, Redshift, SQL Server, SQLite, DuckDB and Oracle. Every insert, edit and delete used MySQL's escaping rules, so `C:\Users\dat` was saved with doubled backslashes, a line break was saved as the two characters `\n`, and editing a row that already held a backslash matched nothing and silently changed no rows. MySQL and MariaDB are unaffected.
- iOS: PostgreSQL sessions now set `standard_conforming_strings` on, so a backslash in a value is always data and never an escape character. Servers that do not have the setting, Redshift among them, already behave that way and are left alone.
- iOS: Add Row to Table and Add Rows to Table write to the database or schema you picked in Shortcuts. The rows went to the connection's default schema instead, while the column names were checked against the schema you chose, so a row could land in the wrong table or fail against a table the picker had just offered.
- iOS: the three Shortcuts actions are found by searching "TablePro" in the Shortcuts action list, and are grouped under Database. Only Open Connection matched before, because the two insert actions carried no keywords.
- Filtering a JSON or PHP tree now reveals nested key and value matches instead of leaving them behind collapsed parent rows. (#2204)
- A tree filter that matches a key now lets you open that key and read what is inside it. (#2204)
- Expanding or collapsing rows while a tree filter is active no longer springs back on the next keystroke. (#2204)
Expand Down
2 changes: 1 addition & 1 deletion Configs/Version-iOS.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
// The app and its widget extension must ship the same values, so both read this file.

MARKETING_VERSION = 1.0
CURRENT_PROJECT_VERSION = 16
CURRENT_PROJECT_VERSION = 17
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ public protocol DatabaseDriver: AnyObject, Sendable {
func rollbackTransaction() async throws

var serverVersion: String? { get }

func escapeStringLiteral(_ value: String) -> String
}

public extension DatabaseDriver {
var holdsSuspensionBlockingResource: Bool { false }

func escapeStringLiteral(_ value: String) -> String {
SQLEscaping.ansiStringLiteral(value)
}

func executeStreaming(query: String, options: StreamOptions = .default) -> AsyncThrowingStream<StreamElement, Error> {
AsyncThrowingStream { continuation in
let task = Task {
Expand Down
19 changes: 19 additions & 0 deletions Packages/TableProCore/Sources/TableProDatabase/SQLEscaping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

public enum SQLEscaping {
public static func ansiStringLiteral(_ value: String) -> String {
value
.replacingOccurrences(of: "\0", with: "")
.replacingOccurrences(of: "'", with: "''")
}

public static func backslashStringLiteral(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\0", with: "\\0")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\u{1a}", with: "\\Z")
.replacingOccurrences(of: "'", with: "''")
}
}
4 changes: 4 additions & 0 deletions TableProMobile/TableProMobile/Drivers/MySQLDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable {
var currentSchema: String? { nil }
var supportsTransactions: Bool { true }

func escapeStringLiteral(_ value: String) -> String {
SQLEscaping.backslashStringLiteral(value)
}

// Set once during connect() before the driver is shared — safe for concurrent reads
nonisolated(unsafe) private(set) var serverVersion: String?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
func connect() async throws {
try await LocalNetworkPermission.shared.ensureAccess(for: host)
try await actor.connect(host: host, port: port, user: user, password: password, database: database, ssl: ssl)
_ = try? await actor.execute("SET standard_conforming_strings = on")
serverVersion = await actor.serverVersion()
}

Expand Down Expand Up @@ -326,7 +327,7 @@
private actor PostgreSQLActor {
private var conn: OpaquePointer?

func connect(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) throws {

Check warning on line 330 in TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift

View workflow job for this annotation

GitHub Actions / Run iOS Tests

main actor-isolated static property 'disabled' can not be referenced from a nonisolated context

Check warning on line 330 in TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift

View workflow job for this annotation

GitHub Actions / Run iOS Tests

main actor-isolated static property 'disabled' can not be referenced from a nonisolated context
guard (1...65_535).contains(port) else {
throw PostgreSQLError.connectionFailed(
"Port \(port) is out of range. Use a value between 1 and 65535."
Expand All @@ -335,7 +336,7 @@
// Close existing connection if reconnecting
if let conn { PQfinish(conn); self.conn = nil }

let connStr = PostgreSQLConnectionString.build(

Check warning on line 339 in TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift

View workflow job for this annotation

GitHub Actions / Run iOS Tests

call to main actor-isolated static method 'build(host:port:database:user:password:ssl:)' in a synchronous actor-isolated context
host: host,
port: port,
database: database,
Expand Down
43 changes: 22 additions & 21 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import TableProDatabase
import TableProModels
import TableProPluginKit
import TableProQuery
Expand Down Expand Up @@ -31,16 +32,6 @@ enum SQLBuilder {
}
}

static func escapeString(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\0", with: "\\0")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\u{1a}", with: "\\Z")
.replacingOccurrences(of: "'", with: "''")
}

static func buildCount(table: String, type: DatabaseType) -> String {
let quoted = quoteIdentifier(table, for: type)
return "SELECT COUNT(*) FROM \(quoted)"
Expand All @@ -55,46 +46,56 @@ enum SQLBuilder {
static func buildDelete(
table: String,
type: DatabaseType,
driver: any DatabaseDriver,
primaryKeys: [(column: String, value: String)]
) -> String {
let quotedTable = quoteIdentifier(table, for: type)
let where_ = primaryKeys.map {
"\(quoteIdentifier($0.column, for: type)) = '\(escapeString($0.value))'"
let predicate = primaryKeys.map {
"\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'"
}.joined(separator: " AND ")
return "DELETE FROM \(quotedTable) WHERE \(where_)"
return "DELETE FROM \(quotedTable) WHERE \(predicate)"
}

static func buildUpdate(
table: String,
type: DatabaseType,
driver: any DatabaseDriver,
changes: [(column: String, value: String?)],
primaryKeys: [(column: String, value: String)]
) -> String {
let quotedTable = quoteIdentifier(table, for: type)
let set_ = changes.map { col, val in
let assignments = changes.map { col, val in
let qcol = quoteIdentifier(col, for: type)
if let val { return "\(qcol) = '\(escapeString(val))'" }
if let val { return "\(qcol) = '\(driver.escapeStringLiteral(val))'" }
return "\(qcol) = NULL"
}.joined(separator: ", ")
let where_ = primaryKeys.map {
"\(quoteIdentifier($0.column, for: type)) = '\(escapeString($0.value))'"
let predicate = primaryKeys.map {
"\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'"
}.joined(separator: " AND ")
return "UPDATE \(quotedTable) SET \(set_) WHERE \(where_)"
return "UPDATE \(quotedTable) SET \(assignments) WHERE \(predicate)"
}

static func buildInsert(
table: String,
schema: String?,
type: DatabaseType,
driver: any DatabaseDriver,
columns: [String],
values: [String?]
) -> String {
let quotedTable = quoteIdentifier(table, for: type)
let qualifiedTable = qualifiedIdentifier(table: table, schema: schema, for: type)
let cols = columns.map { quoteIdentifier($0, for: type) }.joined(separator: ", ")
let vals = values.map { val in
if let val { return "'\(escapeString(val))'" }
if let val { return "'\(driver.escapeStringLiteral(val))'" }
return "NULL"
}.joined(separator: ", ")
return "INSERT INTO \(quotedTable) (\(cols)) VALUES (\(vals))"
return "INSERT INTO \(qualifiedTable) (\(cols)) VALUES (\(vals))"
}

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 }
return "\(quoteIdentifier(schema, for: type)).\(quotedTable)"
}

static func buildSelect(
Expand Down
8 changes: 6 additions & 2 deletions TableProMobile/TableProMobile/Intents/AddRowIntents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ extension RowInsertingIntent {
struct AddRowToTableIntent: RowInsertingIntent {
static var title: LocalizedStringResource = "Add Row to Table"
static var description = IntentDescription(
"Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row."
"Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row.",
categoryName: "Database",
searchKeywords: ["TablePro", "database", "SQL", "insert", "row", "table"]
)
static var openAppWhenRun = false
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
Expand Down Expand Up @@ -70,7 +72,9 @@ struct AddRowToTableIntent: RowInsertingIntent {
struct AddRowsToTableIntent: RowInsertingIntent {
static var title: LocalizedStringResource = "Add Rows to Table"
static var description = IntentDescription(
"Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file."
"Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file.",
categoryName: "Database",
searchKeywords: ["TablePro", "database", "SQL", "insert", "rows", "table", "import"]
)
static var openAppWhenRun = false
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,16 @@ struct IntentDatabaseSession {
table: table,
type: connection.type,
schema: schema,
qualifier: pickedSchema(namespace: namespace),
rows: rows
)
}

private func pickedSchema(namespace: String?) -> String? {
guard let namespace, !namespace.isEmpty, session.driver.supportsSchemas else { return nil }
return namespace
}

private func resolveSchema(namespace: String?) async throws -> String? {
let driver = session.driver
guard let namespace, !namespace.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import UIKit

struct OpenConnectionIntent: AppIntent {
static var title: LocalizedStringResource = "Open Connection"
static var description = IntentDescription("Opens a database connection in TablePro")
static var description = IntentDescription(
"Opens a database connection in TablePro",
categoryName: "Database",
searchKeywords: ["TablePro", "database", "connection", "SQL", "open"]
)
static var openAppWhenRun = true

@Parameter(title: "Connection")
Expand Down
14 changes: 13 additions & 1 deletion TableProMobile/TableProMobile/Intents/RowInserter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import TableProModels
enum RowInsertPlanner {
static func statements(
table: String,
schema: String?,
type: DatabaseType,
driver: any DatabaseDriver,
columns: [ColumnInfo],
rows: [PayloadRow]
) throws -> [String] {
Expand All @@ -28,7 +30,9 @@ enum RowInsertPlanner {
guard !insertColumns.isEmpty else { return nil }
return SQLBuilder.buildInsert(
table: table,
schema: schema,
type: type,
driver: driver,
columns: insertColumns,
values: insertValues
)
Expand All @@ -42,10 +46,18 @@ enum RowInserter {
table: String,
type: DatabaseType,
schema: String?,
qualifier: String?,
rows: [PayloadRow]
) async throws -> Int {
let columns = try await driver.fetchColumns(table: table, schema: schema)
let statements = try RowInsertPlanner.statements(table: table, type: type, columns: columns, rows: rows)
let statements = try RowInsertPlanner.statements(
table: table,
schema: qualifier,
type: type,
driver: driver,
columns: columns,
rows: rows
)
guard !statements.isEmpty else { throw IntentDataError.noInsertableValues(table) }

if driver.supportsTransactions, statements.count > 1 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,12 @@ final class DataBrowserViewModel {
guard let session, let table, !pkValues.isEmpty else { return false }
do {
_ = try await session.driver.execute(
query: SQLBuilder.buildDelete(table: table.name, type: databaseType, primaryKeys: pkValues)
query: SQLBuilder.buildDelete(
table: table.name,
type: databaseType,
driver: session.driver,
primaryKeys: pkValues
)
)
await load()
return true
Expand All @@ -348,7 +353,7 @@ final class DataBrowserViewModel {

func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType) async throws -> String? {
let predicates = ref.primaryKey.map { component in
"\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(component.value.replacingOccurrences(of: "'", with: "''"))'"
"\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(driver.escapeStringLiteral(component.value))'"
}
let predicate = predicates.joined(separator: " AND ")
let column = SQLBuilder.quoteIdentifier(ref.column, for: databaseType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ final class RowDetailViewModel {
let sql = SQLBuilder.buildUpdate(
table: table.name,
type: databaseType,
driver: session.driver,
changes: changes,
primaryKeys: pkValues
)
Expand Down
6 changes: 4 additions & 2 deletions TableProMobile/TableProMobile/Views/InsertRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ struct InsertRowView: View {
private func insertRow() async {
guard let session else { return }

let sql = buildInsertSQL()
let sql = buildInsertSQL(driver: session.driver)

switch safeModeLevel.writePermission {
case .blocked:
Expand All @@ -197,7 +197,7 @@ struct InsertRowView: View {
await executeInsert(sql: sql, session: session)
}

private func buildInsertSQL() -> String {
private func buildInsertSQL(driver: any DatabaseDriver) -> String {
var insertColumns: [String] = []
var insertValues: [String?] = []

Expand All @@ -219,7 +219,9 @@ struct InsertRowView: View {

return SQLBuilder.buildInsert(
table: table.name,
schema: nil,
type: databaseType,
driver: driver,
columns: insertColumns,
values: insertValues
)
Expand Down
Loading
Loading