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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- MCP server runs only while Enable MCP Server is on in Settings > MCP. Clients and pairing no longer start it while it is off.
- Edit > Data shown only in data file windows.
- Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB.
- Several statements run together on PostgreSQL from iPhone and iPad commit one at a time, and a failure keeps the ones before it. (#3399)

### Removed

Expand Down Expand Up @@ -79,6 +80,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background.
- GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed.
- Settings > AI reading sign-in tokens and running command line tools for providers that were never added.
- Only the first statement running on iPhone and iPad when the query holds several. (#3399)
- Query on iPhone and iPad appearing to do nothing after CREATE, DROP or a write that changed no rows, including every Oracle write. (#3399)
- SQLite on iPhone and iPad reporting a CHECK or UNIQUE violation as a saved row in Add Row, the row editor and Shortcuts. (#3399)
- Previous write's row count shown after a SQLite CREATE, DROP or ALTER. (#3399)
- SQLite, PostgreSQL, MySQL and DuckDB query on iPhone and iPad hiding an error raised after the first rows. (#3399)
- Stop on iPhone and iPad letting a running SQLite or PostgreSQL statement finish and commit, and running a DuckDB query again from the start. (#3399)
- Query run right after Stop on iPhone and iPad showing no rows. (#3399)
- PostgreSQL query on iPhone and iPad downloading the whole table before showing the first 10,000 rows. (#3399)
- DuckDB result on iPhone and iPad cut at 10,000 rows without the notice that more rows exist. (#3399)
- MySQL connection failing every query after calling a procedure that returns rows, and a procedure that failed after its first rows reported as a success. (#3399)
- Redis command on iPhone and iPad reading a line break as part of a value, and dropping an empty quoted value. (#3399)
- View DDL shown as a made-up CREATE TABLE on SQL Server, Oracle, Dameng, DuckDB, Redshift and Cassandra. (#3402)
- Show DDL and Copy DDL failing on every view on SQLite, libSQL, Cloudflare D1, SAP HANA, Spanner, Trino and Teradata.
- SQL Server view definitions naming the view's old name after a rename, and blank for an encrypted view or a name with a period.
Expand Down
2 changes: 1 addition & 1 deletion Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ let package = Package(
),
.target(
name: "TableProDatabase",
dependencies: ["TableProModels", "TableProCoreTypes", "TableProPluginKit"],
dependencies: ["TableProModels", "TableProCoreTypes", "TableProPluginKit", "TableProSQLGrammar"],
path: "Sources/TableProDatabase"
),
.target(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import TableProModels
import TableProPluginKit
import TableProSQLGrammar

public protocol DatabaseDriver: AnyObject, Sendable {
func connect() async throws
Expand All @@ -12,6 +13,13 @@ public protocol DatabaseDriver: AnyObject, Sendable {
func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream<StreamElement, Error>
func cancelCurrentQuery() async throws

/// Whether one request may carry a whole `GO` batch, so a script keeps its batch-scoped variables.
var sendsBatchesWhole: Bool { get }

/// How this session reads a string literal, where a server setting decides it, so a script is split the way the
/// server will read it. Nil when the engine has no such setting or the session cannot say.
func sessionLexicalFacts() async -> SQLSessionLexicalFacts?

func fetchTables(schema: String?) async throws -> [TableInfo]
func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo]
func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo]
Expand Down Expand Up @@ -39,6 +47,10 @@ public protocol DatabaseDriver: AnyObject, Sendable {
public extension DatabaseDriver {
var holdsSuspensionBlockingResource: Bool { false }

var sendsBatchesWhole: Bool { false }

func sessionLexicalFacts() async -> SQLSessionLexicalFacts? { nil }

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
try await beginTransaction()
}
Expand Down
120 changes: 120 additions & 0 deletions Packages/TableProCore/Sources/TableProQuery/QueryScript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import TableProModels
import TableProSQLGrammar

/// The requests one run of a query text makes, in the order it makes them.
///
/// A text sent whole runs one statement or reports one result on every engine the iOS app speaks: SQLite and DuckDB
/// compile one statement per prepare, MySQL is connected without multi-statement support, and libpq answers with one
/// result. So the text is split here, by the grammar the engine's own lexer follows, as the Mac splits it. A driver
/// that sends batches whole (SQL Server) gets its `GO` batches instead, because T-SQL scopes a variable to its batch.
public struct QueryScript: Sendable, Equatable {
public enum UnitKind: Sendable, Equatable {
case statement
case batch
}

public struct Unit: Sendable, Equatable {
public let sql: String

/// How many times the script asks for the unit to run: `GO 5` runs its batch five times.
public let repeatCount: Int

public init(sql: String, repeatCount: Int = 1) {
self.sql = sql
self.repeatCount = repeatCount
}
}

public let units: [Unit]
public let unitKind: UnitKind

/// How the session read literals when the text was split.
public let session: SQLSessionLexicalFacts?

/// The text that was split, where each unit ends in it, and its engine, so the rest can be split again.
private let source: String?
private let unitEnds: [Int]
private let databaseType: DatabaseType?

public init(units: [Unit], unitKind: UnitKind) {
self.init(units: units, unitKind: unitKind, session: nil, source: nil, unitEnds: [], databaseType: nil)
}

private init(
units: [Unit],
unitKind: UnitKind,
session: SQLSessionLexicalFacts?,
source: String?,
unitEnds: [Int],
databaseType: DatabaseType?
) {
self.units = units
self.unitKind = unitKind
self.session = session
self.source = source
self.unitEnds = unitEnds
self.databaseType = databaseType
}

public var isEmpty: Bool { units.isEmpty }

/// Every request the run sends, a `GO n` batch counted `n` times.
public var requestCount: Int {
units.reduce(0) { $0 + max($1.repeatCount, 1) }
}

/// Redis speaks commands, not SQL, and its driver reads the whole text as one command.
///
/// `session` is what the connection says about its own literals, such as PostgreSQL's
/// `standard_conforming_strings`: with it off, `'a\';b'` is one literal, and the default reading would cut it.
public static func plan(
_ text: String,
databaseType: DatabaseType,
sendsBatchesWhole: Bool,
session: SQLSessionLexicalFacts? = nil
) -> QueryScript {
if databaseType == .redis {
let command = text.trimmingCharacters(in: .whitespacesAndNewlines)
return QueryScript(units: command.isEmpty ? [] : [Unit(sql: command)], unitKind: .statement)
}

let grammar = SQLLexicalReadings.resolve(
databaseTypeId: databaseType.rawValue,
declared: nil,
session: session
).execution
let statements = SQLStatementScanner.executableStatements(in: text, grammar: grammar)

guard sendsBatchesWhole else {
return QueryScript(
units: statements.map { Unit(sql: $0.sql) },
unitKind: .statement,
session: session,
source: text,
unitEnds: statements.map { $0.range.location + $0.range.length },
databaseType: databaseType
)
}
let batches = SQLStatementScanner.batches(
in: text,
statements: statements,
separators: SQLStatementScanner.batchSeparators(in: text, grammar: grammar)
)
return QueryScript(
units: batches.map { Unit(sql: $0.sql, repeatCount: $0.repeatCount) },
unitKind: .batch
)
}

/// The text after unit `index`, split again under `session`, for a statement that changed how the session reads
/// literals: after `SET sql_mode = 'NO_BACKSLASH_ESCAPES'` a backslash no longer escapes a quote, so the split
/// made before it ran no longer matches what the server will read. Nil when the script cannot be split again.
public func replanned(after index: Int, session: SQLSessionLexicalFacts?) -> QueryScript? {
guard unitKind == .statement, let source, let databaseType, unitEnds.indices.contains(index) else {
return nil
}
let rest = (source as NSString).substring(from: unitEnds[index])
return QueryScript.plan(rest, databaseType: databaseType, sendsBatchesWhole: false, session: session)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import TableProSQLGrammar
/// taught, or anything hidden behind a leading comment, runs unguarded.
///
/// The batch is split into statements the way its engine lexes it, by the grammar the Mac reads
/// too, and under every reading the engine could be using: iOS drivers send the whole text in one
/// call, so a `DELETE` hidden behind a quote only one reading closes still runs. On an engine that
/// needs no `;`, every statement that begins inside one is read as well, because SQL Server runs
/// `SELECT 1` followed by `DROP TABLE t` as two statements.
/// too, and under every reading the engine could be using: the run splits by one reading, and a
/// `DELETE` hidden behind a quote that only another reading closes still reaches the server. On an
/// engine that needs no `;`, every statement that begins inside one is read as well, because SQL
/// Server runs `SELECT 1` followed by `DROP TABLE t` as two statements.
public enum SQLWriteClassifier {
/// `EXPLAIN` and `PRAGMA` are deliberately absent: `EXPLAIN ANALYZE DELETE …` runs the delete on
/// PostgreSQL, and `PRAGMA journal_mode = WAL` writes on SQLite and DuckDB.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation

/// The statements the server receives in one request, and the text they are sent as.
///
/// SQL Server scopes a local variable, a table variable and a `TRY...CATCH` to one batch, and its own tools cut a script
/// into batches only at a line holding `GO`. A text with no such line is one batch, and only a driver that sends
/// batches whole runs it as one: every other run takes its statements one by one.
public struct SQLStatementBatch: Sendable {
/// The source between the first statement's start and the last one's end, inner `;` and comments included.
public let sql: String

/// The statements inside, in the caller's coordinates, as the statement scanner found them.
public let statements: [SQLStatementScanner.ExecutableStatement]

/// How many times the script asks for the batch to run: `GO 5` runs it five times.
public let repeatCount: Int

public init(sql: String, statements: [SQLStatementScanner.ExecutableStatement], repeatCount: Int) {
self.sql = sql
self.statements = statements
self.repeatCount = repeatCount
}

/// The batch's span in the caller's coordinates.
public var range: NSRange {
guard let first = statements.first, let last = statements.last else {
return NSRange(location: NSNotFound, length: 0)
}
return NSRange(location: first.range.location, length: last.range.upperBound - first.range.location)
}
}

public extension SQLStatementScanner {
/// Groups a scanned text's statements into the batches its engine runs.
///
/// `statements` and `separators` are in `text`'s coordinates; the batches come back shifted by `sourceOffset`, the
/// way a run started from a selection already shifts its statements onto the whole document.
static func batches(
in text: String,
statements: [ExecutableStatement],
separators: [SQLBatchSeparator],
sourceOffset: Int = 0
) -> [SQLStatementBatch] {
let source = text as NSString
var batches: [SQLStatementBatch] = []
var pending: [ExecutableStatement] = []
var remaining = statements[...]

func close(repeatCount: Int) {
guard let first = pending.first, let last = pending.last else { return }
let span = NSRange(location: first.range.location, length: last.range.upperBound - first.range.location)
batches.append(
SQLStatementBatch(
sql: source.substring(with: span),
statements: pending.map { $0.offset(by: sourceOffset) },
repeatCount: repeatCount
)
)
pending = []
}

for separator in separators {
while let next = remaining.first, next.range.location < separator.range.location {
pending.append(next)
remaining = remaining.dropFirst()
}
close(repeatCount: separator.repeatCount)
}
pending.append(contentsOf: remaining)
close(repeatCount: 1)
return batches
}
}
Loading
Loading