diff --git a/CHANGELOG.md b/CHANGELOG.md index 764a45935..b7212daf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- iOS: rebuilt the data layer around streaming so opening large tables and running ad-hoc queries no longer materialises the entire result set in memory. New shared types in `TableProModels` (`Cell`, `Row`, `CellRef`, `StreamElement`, `StreamOptions`) and a new `executeStreaming(query:options:)` method on `DatabaseDriver` (with a backwards-compatible default implementation, so the macOS app and all 14 plugins keep working unchanged). MySQL switched from `mysql_store_result` (buffered, libmariadb mallocs the full result before Swift sees the first row) to `mysql_use_result` (server-side cursor, one row per fetch). Postgres switched from `PQexec` to `PQsendQuery` + `PQsetSingleRowMode` + `PQgetResult` per-row. SQLite stops eagerly base64-encoding BLOBs; cells now arrive as `Cell.binary(byteCount:ref:)` carrying only the byte count and a re-fetch reference. Cell-text > 4 KB arrives as `Cell.truncatedText` carrying a 4 KB prefix plus total length and ref. New `MemoryPressureMonitor` actor wraps `DispatchSourceMemoryPressure` + `os_proc_available_memory` and is started at app launch in `TableProMobileApp`; `DataBrowserViewModel` and `QueryEditorViewModel` (new, `@Observable`) listen and call `RowWindow.shrink(to:)` on warning (100 rows) or critical (50 rows + cancel in-flight stream). `RowWindow` is a bounded sliding buffer (default 200 rows) replacing the unbounded `[[String?]]` `@State` arrays in views. Cancellation propagates through `AsyncThrowingStream.onTermination` to the C layer (`PQcancel`, `sqlite3_interrupt`). New `StreamingExporter` writes CSV / JSON / SQL to a `FileHandle` in O(1) memory regardless of result size. The TabView in `RowDetailView` (which pre-rendered 2-3 full rows on iPad split view) is replaced with a single `rowContent` plus horizontal `DragGesture` for prev/next, so memory cost is O(1) per row. + ### Added - Linked SQL Folders. Point TablePro at a folder of `.sql` files (e.g. a Git repo of shared queries) and they appear in the Favorites sidebar live. Recursive subfolder watching via `FSEventStreamCreate`; nested directory hierarchy preserved. Add a folder via the `+` button at the bottom of the Favorites sidebar (Mail.app menu pattern), or right-click an existing linked folder for `Add Another SQL Folder...`. Files keep their on-disk identity: clicking a linked file opens it as a regular editor tab, `⌘S` writes back to disk. External modifications surface as a yellow live banner above the editor tab with one-click `Reload`. Save-time conflict prompt shows a side-by-side diff sheet (line-level, via `Swift.CollectionDifference`) with `Keep My Changes` / `Reload from Disk` / `Cancel`. Right-click a linked file for `Edit Metadata...` to update `@name` / `@keyword` / `@description` frontmatter without opening the editor. Drag a favorite row (linked or DB-stored) onto the SQL editor to insert its content. Non-UTF-8 files are detected via `String(contentsOf: usedEncoding:)`, surfaced with a yellow warning icon in the sidebar, and saved back in their original encoding (UTF-16, ISO Latin-1, etc.). UTF-8 BOM bytes at the top of a frontmatter file no longer make metadata silently disappear. Per-connection scope or global. Frontmatter feeds autocomplete keyword expansion alongside DB-stored favorites. No Pro license required. diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift index bf57aab3f..55d52f3fc 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift @@ -7,6 +7,7 @@ public protocol DatabaseDriver: AnyObject, Sendable { func ping() async throws -> Bool func execute(query: String) async throws -> QueryResult + func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream func cancelCurrentQuery() async throws func fetchTables(schema: String?) async throws -> [TableInfo] @@ -28,3 +29,51 @@ public protocol DatabaseDriver: AnyObject, Sendable { var serverVersion: String? { get } } + +public extension DatabaseDriver { + func executeStreaming(query: String, options: StreamOptions = .default) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let result = try await self.execute(query: query) + continuation.yield(.columns(result.columns)) + + var emitted = 0 + for legacyRow in result.rows { + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + break + } + if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + break + } + let cells = legacyRow.enumerated().map { index, value -> Cell in + let typeName = index < result.columns.count ? result.columns[index].typeName : nil + return Cell.from(legacyValue: value, columnTypeName: typeName, options: options) + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + + if let message = result.statusMessage { + continuation.yield(.statusMessage(message)) + } + if result.rowsAffected != 0 { + continuation.yield(.rowsAffected(result.rowsAffected)) + } + if result.isTruncated && emitted < options.maxRows { + continuation.yield(.truncated(reason: .driverLimit("driver returned isTruncated=true"))) + } + continuation.finish() + } catch is CancellationError { + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} diff --git a/Packages/TableProCore/Sources/TableProModels/Cell.swift b/Packages/TableProCore/Sources/TableProModels/Cell.swift new file mode 100644 index 000000000..e5dd09074 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/Cell.swift @@ -0,0 +1,113 @@ +import Foundation + +public enum Cell: Sendable { + case null + case text(String) + case truncatedText(prefix: String, totalBytes: Int, ref: CellRef?) + case binary(byteCount: Int, ref: CellRef?) +} + +public extension Cell { + var displayString: String { + switch self { + case .null: + return "NULL" + case .text(let value): + return value + case .truncatedText(let head, let total, _): + return head + "... (\(byteCountFormatter.string(fromByteCount: Int64(total))))" + case .binary(let count, _): + return "[BLOB \(byteCountFormatter.string(fromByteCount: Int64(count)))]" + } + } + + var isLoadable: Bool { + switch self { + case .truncatedText(_, _, let ref), .binary(_, let ref): + return ref != nil + case .text, .null: + return false + } + } + + var fullValueRef: CellRef? { + switch self { + case .truncatedText(_, _, let ref), .binary(_, let ref): + return ref + case .text, .null: + return nil + } + } +} + +public struct CellRef: Sendable, Hashable { + public let table: String + public let column: String + public let primaryKey: [PrimaryKeyComponent] + + public init(table: String, column: String, primaryKey: [PrimaryKeyComponent]) { + self.table = table + self.column = column + self.primaryKey = primaryKey + } +} + +public struct PrimaryKeyComponent: Sendable, Hashable { + public let column: String + public let value: String + + public init(column: String, value: String) { + self.column = column + self.value = value + } +} + +public struct Row: Sendable { + public let cells: [Cell] + + public init(cells: [Cell]) { + self.cells = cells + } +} + +public extension Row { + var legacyValues: [String?] { + cells.map { cell -> String? in + switch cell { + case .null: + return nil + case .text(let value): + return value + case .truncatedText, .binary: + return cell.displayString + } + } + } +} + +public extension Cell { + static func from(legacyValue value: String?, columnTypeName: String?, options: StreamOptions, ref: CellRef? = nil) -> Cell { + guard let value else { return .null } + let bytes = value.utf8.count + let upper = (columnTypeName ?? "").uppercased() + let isBinary = upper.contains("BLOB") || upper.contains("BYTEA") || upper.contains("BINARY") || upper.contains("VARBINARY") || upper.contains("IMAGE") + + if isBinary { + return .binary(byteCount: bytes, ref: ref) + } + + if bytes > options.textTruncationBytes { + let prefixSlice = value.prefix(options.textTruncationBytes) + return .truncatedText(prefix: String(prefixSlice), totalBytes: bytes, ref: ref) + } + + return .text(value) + } +} + +private let byteCountFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + formatter.countStyle = .binary + return formatter +}() diff --git a/Packages/TableProCore/Sources/TableProModels/StreamingResult.swift b/Packages/TableProCore/Sources/TableProModels/StreamingResult.swift new file mode 100644 index 000000000..fd619d01e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/StreamingResult.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum StreamElement: Sendable { + case columns([ColumnInfo]) + case row(Row) + case rowsAffected(Int) + case statusMessage(String) + case truncated(reason: TruncationReason) +} + +public enum TruncationReason: Sendable { + case rowCap(Int) + case memoryPressure + case cancelled + case driverLimit(String) +} + +public struct StreamOptions: Sendable { + public let textTruncationBytes: Int + public let inlineBinary: Bool + public let maxRows: Int + public let lazyContext: LazyContext? + + public init( + textTruncationBytes: Int = 4_096, + inlineBinary: Bool = false, + maxRows: Int = 100_000, + lazyContext: LazyContext? = nil + ) { + self.textTruncationBytes = textTruncationBytes + self.inlineBinary = inlineBinary + self.maxRows = maxRows + self.lazyContext = lazyContext + } + + public static let `default` = StreamOptions() +} + +public struct LazyContext: Sendable { + public let table: String + public let primaryKeyColumns: [String] + + public init(table: String, primaryKeyColumns: [String]) { + self.table = table + self.primaryKeyColumns = primaryKeyColumns + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index fe906f205..f95b05ad7 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -81,6 +81,50 @@ final class MySQLDriver: DatabaseDriver, @unchecked Sendable { // No-op for mobile. } + func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { + let actor = self.actor + return AsyncThrowingStream { continuation in + let task = Task { + do { + let beginResult = try await actor.beginStream(query: query) + switch beginResult { + case .noResult(let affectedRows): + if affectedRows != 0 { + continuation.yield(.rowsAffected(affectedRows)) + } + continuation.finish() + return + case .rowSet(let columns): + continuation.yield(.columns(columns)) + var emitted = 0 + while !Task.isCancelled, emitted < options.maxRows { + guard let cells = try await actor.fetchNextRow(options: options, columns: columns) else { + break + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + } else if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + } + await actor.endStream() + continuation.finish() + } + } catch is CancellationError { + await actor.endStream() + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } catch { + await actor.endStream() + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [TableInfo] { @@ -351,6 +395,106 @@ private actor MySQLActor { rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: isTruncated ) } + + // MARK: - Streaming + + private var streamingResult: UnsafeMutablePointer? + private var streamingColumns: [ColumnInfo] = [] + + func beginStream(query: String) throws -> MySQLBeginStreamResult { + guard let mysql else { throw MySQLError.notConnected } + if streamingResult != nil { + endStream() + } + + guard mysql_real_query(mysql, query, UInt(query.utf8.count)) == 0 else { + throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + } + + guard let result = mysql_use_result(mysql) else { + if mysql_field_count(mysql) != 0 { + throw MySQLError.queryFailed(String(cString: mysql_error(mysql))) + } + let raw = mysql_affected_rows(mysql) + let affected = raw == .max ? 0 : Int(clamping: raw) + return .noResult(affectedRows: affected) + } + + streamingResult = result + + let fieldCount = Int(mysql_num_fields(result)) + var columns: [ColumnInfo] = [] + if let fields = mysql_fetch_fields(result) { + for i in 0.. [Cell]? { + guard let result = streamingResult else { return nil } + guard let row = mysql_fetch_row(result) else { return nil } + + let lengths = mysql_fetch_lengths(result) + var cells: [Cell] = [] + cells.reserveCapacity(columns.count) + + for i in 0.. CellRef? { + guard let lazyContext = options.lazyContext, !lazyContext.primaryKeyColumns.isEmpty else { return nil } + + var pkComponents: [PrimaryKeyComponent] = [] + for pkColumn in lazyContext.primaryKeyColumns { + guard let columnIndex = columns.firstIndex(where: { $0.name == pkColumn }) else { return nil } + guard let cValue = row[columnIndex] else { return nil } + pkComponents.append(PrimaryKeyComponent(column: pkColumn, value: String(cString: cValue))) + } + return CellRef(table: lazyContext.table, column: column, primaryKey: pkComponents) + } +} + +enum MySQLBeginStreamResult: Sendable { + case rowSet([ColumnInfo]) + case noResult(affectedRows: Int) } // MARK: - MySQL Field Type Names diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 661bc9e3f..9c602b6ac 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -81,6 +81,55 @@ final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { await actor.cancel() } + func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { + let actor = self.actor + return AsyncThrowingStream { continuation in + let task = Task { + do { + let beginResult = try await actor.beginStream(query: query) + switch beginResult { + case .commandOk(let affectedRows): + if affectedRows != 0 { + continuation.yield(.rowsAffected(affectedRows)) + } + continuation.finish() + return + case .tuples(let columns): + continuation.yield(.columns(columns)) + var emitted = 0 + while !Task.isCancelled, emitted < options.maxRows { + guard let cells = try await actor.fetchNextRow(options: options, columns: columns) else { + break + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + } else if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + } + await actor.endStream() + continuation.finish() + } + } catch is CancellationError { + await actor.endStream() + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } catch { + await actor.endStream() + continuation.finish(throwing: error) + } + } + continuation.onTermination = { reason in + task.cancel() + if case .cancelled = reason { + Task { await actor.cancel() } + } + } + } + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [TableInfo] { @@ -411,6 +460,161 @@ private actor PostgreSQLActor { rowsAffected: 0, executionTime: Date().timeIntervalSince(start), isTruncated: isTruncated ) } + + // MARK: - Streaming + + private var pendingResult: OpaquePointer? + private var streamingFinished = true + + func beginStream(query: String) throws -> PGBeginStreamResult { + guard let conn else { throw PostgreSQLError.notConnected } + endStream() + + guard PQsendQuery(conn, query) == 1 else { + throw PostgreSQLError.queryFailed(String(cString: PQerrorMessage(conn))) + } + guard PQsetSingleRowMode(conn) == 1 else { + drainResults() + throw PostgreSQLError.queryFailed("Failed to enter single-row streaming mode") + } + streamingFinished = false + + guard let firstResult = PQgetResult(conn) else { + streamingFinished = true + return .commandOk(affectedRows: 0) + } + + let status = PQresultStatus(firstResult) + switch status { + case PGRES_COMMAND_OK: + let affectedStr = String(cString: PQcmdTuples(firstResult)) + let affected = Int(affectedStr) ?? 0 + PQclear(firstResult) + drainResults() + return .commandOk(affectedRows: affected) + case PGRES_TUPLES_OK: + let columns = parseColumns(firstResult) + PQclear(firstResult) + drainResults() + return .tuples(columns) + case PGRES_SINGLE_TUPLE: + let columns = parseColumns(firstResult) + pendingResult = firstResult + return .tuples(columns) + default: + let msg = String(cString: PQresultErrorMessage(firstResult)) + PQclear(firstResult) + drainResults() + throw PostgreSQLError.queryFailed(msg) + } + } + + func fetchNextRow(options: StreamOptions, columns: [ColumnInfo]) -> [Cell]? { + guard !streamingFinished else { return nil } + + let result: OpaquePointer? + if let pending = pendingResult { + result = pending + pendingResult = nil + } else { + guard let conn else { streamingFinished = true; return nil } + result = PQgetResult(conn) + } + + guard let result else { + streamingFinished = true + return nil + } + + let status = PQresultStatus(result) + if status == PGRES_TUPLES_OK { + PQclear(result) + drainResults() + return nil + } + + guard status == PGRES_SINGLE_TUPLE else { + PQclear(result) + drainResults() + return nil + } + + var cells: [Cell] = [] + cells.reserveCapacity(columns.count) + for c in 0.. [ColumnInfo] { + let colCount = Int(PQnfields(result)) + var cols: [ColumnInfo] = [] + for i in 0.. CellRef? { + guard let lazyContext = options.lazyContext, !lazyContext.primaryKeyColumns.isEmpty else { return nil } + var pkComponents: [PrimaryKeyComponent] = [] + for pkColumn in lazyContext.primaryKeyColumns { + guard let columnIndex = columns.firstIndex(where: { $0.name == pkColumn }) else { return nil } + let col = Int32(columnIndex) + guard PQgetisnull(result, 0, col) == 0 else { return nil } + guard let cValue = PQgetvalue(result, 0, col) else { return nil } + pkComponents.append(PrimaryKeyComponent(column: pkColumn, value: String(cString: cValue))) + } + return CellRef(table: lazyContext.table, column: column, primaryKey: pkComponents) + } +} + +enum PGBeginStreamResult: Sendable { + case tuples([ColumnInfo]) + case commandOk(affectedRows: Int) } // MARK: - PostgreSQL OID Type Names diff --git a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index a0d4af998..e0851a938 100644 --- a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -74,6 +74,55 @@ final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { await actor.interrupt() } + func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { + let actor = self.actor + return AsyncThrowingStream { continuation in + let task = Task { + do { + let beginResult = try await actor.beginStream(query: query) + switch beginResult { + case .commandOk(let affectedRows): + if affectedRows != 0 { + continuation.yield(.rowsAffected(affectedRows)) + } + continuation.finish() + return + case .rowSet(let columns): + continuation.yield(.columns(columns)) + var emitted = 0 + while !Task.isCancelled, emitted < options.maxRows { + guard let cells = try await actor.fetchNextRow(options: options, columns: columns) else { + break + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + } else if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + } + await actor.endStream() + continuation.finish() + } + } catch is CancellationError { + await actor.endStream() + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } catch { + await actor.endStream() + continuation.finish(throwing: error) + } + } + continuation.onTermination = { reason in + task.cancel() + if case .cancelled = reason { + Task { await actor.interrupt() } + } + } + } + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [TableInfo] { @@ -273,6 +322,120 @@ private actor SQLiteActor { return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false) } + + // MARK: - Streaming + + private var streamingStmt: OpaquePointer? + private var streamingColumns: [ColumnInfo] = [] + + func beginStream(query: String) throws -> SQLiteBeginStreamResult { + guard let db else { throw SQLiteError.notConnected } + if streamingStmt != nil { + endStream() + } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { + throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + guard let stmt else { + throw SQLiteError.queryFailed("Failed to prepare statement") + } + + let colCount = Int(sqlite3_column_count(stmt)) + if colCount == 0 { + let stepResult = sqlite3_step(stmt) + sqlite3_finalize(stmt) + if stepResult == SQLITE_DONE || stepResult == SQLITE_ROW { + let affected = Int(sqlite3_changes(db)) + return .commandOk(affectedRows: affected) + } else { + throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(db))) + } + } + + var columns: [ColumnInfo] = [] + for i in 0.. [Cell]? { + guard let stmt = streamingStmt else { return nil } + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { return nil } + + var cells: [Cell] = [] + cells.reserveCapacity(columns.count) + for i in 0.. CellRef? { + guard let lazyContext = options.lazyContext, !lazyContext.primaryKeyColumns.isEmpty else { return nil } + var pkComponents: [PrimaryKeyComponent] = [] + for pkColumn in lazyContext.primaryKeyColumns { + guard let columnIndex = columns.firstIndex(where: { $0.name == pkColumn }) else { return nil } + let idx = Int32(columnIndex) + if sqlite3_column_type(statement, idx) == SQLITE_NULL { + return nil + } + guard let text = sqlite3_column_text(statement, idx) else { return nil } + pkComponents.append(PrimaryKeyComponent(column: pkColumn, value: String(cString: text))) + } + return CellRef(table: lazyContext.table, column: column, primaryKey: pkComponents) + } +} + +enum SQLiteBeginStreamResult: Sendable { + case rowSet([ColumnInfo]) + case commandOk(affectedRows: Int) } private struct RawResult: Sendable { diff --git a/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift b/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift new file mode 100644 index 000000000..d4b355d47 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift @@ -0,0 +1,135 @@ +// +// StreamingExporter.swift +// TableProMobile +// + +import Foundation +import os +import TableProDatabase +import TableProModels + +actor StreamingExporter { + private static let logger = Logger(subsystem: "com.TablePro", category: "StreamingExporter") + + init() {} + + func exportToFile( + driver: DatabaseDriver, + query: String, + format: ExportFormat, + tableName: String, + options: StreamOptions = .default + ) async throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("TablePro-export-\(UUID().uuidString).\(format.fileExtension)") + FileManager.default.createFile(atPath: url.path, contents: nil) + + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + + var headerWritten = false + var seenColumns: [String] = [] + var rowIndex = 0 + + if case .json = format { + try handle.write(contentsOf: Data("[\n".utf8)) + } + + do { + for try await element in driver.executeStreaming(query: query, options: options) { + switch element { + case .columns(let cols): + seenColumns = cols.map(\.name) + if !headerWritten, format != .json { + let header = formatHeader(format: format, columns: seenColumns) + "\n" + try handle.write(contentsOf: Data(header.utf8)) + headerWritten = true + } + case .row(let row): + let values = row.legacyValues + let line = formatRow( + format: format, + columns: seenColumns, + values: values, + tableName: tableName, + isFirst: rowIndex == 0 + ) + try handle.write(contentsOf: Data(line.utf8)) + rowIndex += 1 + case .rowsAffected, .statusMessage, .truncated: + continue + } + } + } catch { + try? FileManager.default.removeItem(at: url) + throw error + } + + if case .json = format { + try handle.write(contentsOf: Data("\n]\n".utf8)) + } + + Self.logger.info("Streaming export wrote \(rowIndex) rows to \(url.lastPathComponent, privacy: .public)") + return url + } + + private func formatHeader(format: ExportFormat, columns: [String]) -> String { + switch format { + case .csv: + return columns.map(escapeCsv).joined(separator: ",") + case .json: + return "" + case .sqlInsert: + return "" + } + } + + private func formatRow(format: ExportFormat, columns: [String], values: [String?], tableName: String, isFirst: Bool) -> String { + switch format { + case .csv: + let cells = columns.indices.map { i in + escapeCsv(i < values.count ? (values[i] ?? "NULL") : "NULL") + } + return cells.joined(separator: ",") + "\n" + case .json: + var dict: [String: Any] = [:] + for (i, name) in columns.enumerated() where i < values.count { + if let value = values[i] { + dict[name] = value + } else { + dict[name] = NSNull() + } + } + let data = (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) ?? Data() + let json = String(data: data, encoding: .utf8) ?? "{}" + return (isFirst ? " " : ",\n ") + json + case .sqlInsert: + let safeTable = tableName.replacingOccurrences(of: "`", with: "``") + let columnList = columns.map { "`\($0.replacingOccurrences(of: "`", with: "``"))`" }.joined(separator: ", ") + let valueList = columns.indices.map { i -> String in + guard i < values.count, let value = values[i] else { return "NULL" } + let escaped = value.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + }.joined(separator: ", ") + return "INSERT INTO `\(safeTable)` (\(columnList)) VALUES (\(valueList));\n" + } + } + + private func escapeCsv(_ value: String) -> String { + if value.contains(",") || value.contains("\"") || value.contains("\n") { + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + return value + } +} + +extension ExportFormat { + var fileExtension: String { + switch self { + case .csv: return "csv" + case .json: return "json" + case .sqlInsert: return "sql" + } + } +} diff --git a/TableProMobile/TableProMobile/Models/RowWindow.swift b/TableProMobile/TableProMobile/Models/RowWindow.swift new file mode 100644 index 000000000..8ca04e803 --- /dev/null +++ b/TableProMobile/TableProMobile/Models/RowWindow.swift @@ -0,0 +1,71 @@ +// +// RowWindow.swift +// TableProMobile +// + +import Foundation +import TableProModels + +struct RowWindow: Sendable { + private(set) var rows: [Row] + private(set) var firstAbsoluteIndex: Int + private(set) var totalAppended: Int + let capacity: Int + + init(capacity: Int = 200) { + self.rows = [] + self.firstAbsoluteIndex = 0 + self.totalAppended = 0 + self.capacity = max(1, capacity) + } + + mutating func append(_ row: Row) { + rows.append(row) + totalAppended += 1 + slideForwardIfOverCapacity() + } + + mutating func append(contentsOf newRows: [Row]) { + for row in newRows { + append(row) + } + } + + mutating func shrink(to maxCount: Int) { + guard maxCount >= 0, rows.count > maxCount else { return } + let dropCount = rows.count - maxCount + rows.removeFirst(dropCount) + firstAbsoluteIndex += dropCount + } + + mutating func clear() { + rows = [] + firstAbsoluteIndex = 0 + totalAppended = 0 + } + + var lastAbsoluteIndex: Int { + firstAbsoluteIndex + rows.count - 1 + } + + var isEmpty: Bool { + rows.isEmpty + } + + var count: Int { + rows.count + } + + func row(atAbsolute absoluteIndex: Int) -> Row? { + let relative = absoluteIndex - firstAbsoluteIndex + guard rows.indices.contains(relative) else { return nil } + return rows[relative] + } + + private mutating func slideForwardIfOverCapacity() { + guard rows.count > capacity else { return } + let dropCount = rows.count - capacity + rows.removeFirst(dropCount) + firstAbsoluteIndex += dropCount + } +} diff --git a/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift new file mode 100644 index 000000000..d81b052ba --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift @@ -0,0 +1,61 @@ +// +// MemoryPressureMonitor.swift +// TableProMobile +// + +import Foundation +import os + +@MainActor +@Observable +final class MemoryPressureMonitor { + static let shared = MemoryPressureMonitor() + + enum Level: Sendable { + case normal + case warning + case critical + } + + private(set) var currentLevel: Level = .normal + + private static let logger = Logger(subsystem: "com.TablePro", category: "MemoryPressureMonitor") + private var source: DispatchSourceMemoryPressure? + + private init() {} + + func start() { + guard source == nil else { return } + + let newSource = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], + queue: .global(qos: .utility) + ) + + newSource.setEventHandler { [weak self] in + let event = newSource.data + let level: Level = event.contains(.critical) ? .critical : .warning + Self.logger.warning("Memory pressure event: \(String(describing: level), privacy: .public)") + Task { @MainActor in + self?.currentLevel = level + } + } + + newSource.activate() + source = newSource + } + + func reset() { + currentLevel = .normal + } + + nonisolated func availableMemoryBytes() -> Int { + Int(os_proc_available_memory()) + } + + nonisolated func hasHeadroom(forBytes requiredBytes: Int) -> Bool { + let available = availableMemoryBytes() + guard available > 0 else { return true } + return available > requiredBytes + } +} diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 288e9cb11..14c775512 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -55,6 +55,7 @@ struct TableProMobileApp: App { .onChange(of: scenePhase) { _, phase in switch phase { case .active: + MemoryPressureMonitor.shared.start() syncTask?.cancel() syncTask = Task { await appState.syncCoordinator.sync( diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift new file mode 100644 index 000000000..34c6e04d7 --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -0,0 +1,179 @@ +// +// DataBrowserViewModel.swift +// TableProMobile +// + +import Foundation +import os +import TableProDatabase +import TableProModels + +@MainActor +@Observable +final class DataBrowserViewModel { + enum Phase: Sendable { + case idle + case loading + case loaded + case truncated(reason: TruncationReason) + case error(AppError) + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "DataBrowserViewModel") + + private(set) var columns: [ColumnInfo] = [] + private(set) var window: RowWindow + private(set) var legacyRows: [[String?]] = [] + private(set) var totalRows: Int? + private(set) var phase: Phase = .idle + private(set) var rowsAffected: Int? + private(set) var statusMessage: String? + private(set) var executionTime: TimeInterval = 0 + + @ObservationIgnored private var pendingRows: [Row] = [] + @ObservationIgnored private var flushTask: Task? + @ObservationIgnored private var fetchTask: Task? + + private static let flushBatchSize = 200 + private static let flushInterval: Duration = .milliseconds(50) + + init(windowCapacity: Int = 1_000) { + self.window = RowWindow(capacity: windowCapacity) + } + + func loadPage( + driver: DatabaseDriver, + query: String, + lazyContext: LazyContext?, + pageSize: Int + ) async { + fetchTask?.cancel() + let options = StreamOptions( + textTruncationBytes: 4_096, + inlineBinary: false, + maxRows: pageSize, + lazyContext: lazyContext + ) + phase = .loading + columns = [] + window.clear() + legacyRows.removeAll(keepingCapacity: true) + rowsAffected = nil + statusMessage = nil + pendingRows.removeAll(keepingCapacity: true) + + let start = Date() + let task = Task { [weak self] in + guard let self else { return } + do { + for try await element in driver.executeStreaming(query: query, options: options) { + if Task.isCancelled { break } + self.apply(element: element) + } + self.flushPendingRows() + self.executionTime = Date().timeIntervalSince(start) + if case .loading = self.phase { + self.phase = .loaded + } + } catch { + self.flushPendingRows() + self.phase = .error(self.classify(error: error)) + } + } + fetchTask = task + await task.value + } + + func cancel() { + fetchTask?.cancel() + flushTask?.cancel() + flushTask = nil + } + + func loadFullValue(driver: DatabaseDriver, ref: CellRef) async throws -> String? { + let predicates = ref.primaryKey.map { component in + "\"\(component.column.replacingOccurrences(of: "\"", with: "\"\""))\" = '\(component.value.replacingOccurrences(of: "'", with: "''"))'" + } + let predicate = predicates.joined(separator: " AND ") + let column = "\"\(ref.column.replacingOccurrences(of: "\"", with: "\"\""))\"" + let table = "\"\(ref.table.replacingOccurrences(of: "\"", with: "\"\""))\"" + let query = "SELECT \(column) FROM \(table) WHERE \(predicate) LIMIT 1" + + let result = try await driver.execute(query: query) + return result.rows.first?.first ?? nil + } + + nonisolated func handlePressure(_ level: MemoryPressureMonitor.Level) async { + await MainActor.run { + switch level { + case .normal: + break + case .warning: + Self.logger.warning("Memory pressure warning: shrinking window to 100 rows") + self.window.shrink(to: 100) + self.shrinkLegacyRows(to: 100) + case .critical: + Self.logger.error("Memory pressure critical: shrinking window to 50 rows and cancelling") + self.window.shrink(to: 50) + self.shrinkLegacyRows(to: 50) + self.fetchTask?.cancel() + } + } + } + + private func apply(element: StreamElement) { + switch element { + case .columns(let cols): + columns = cols + case .row(let row): + pendingRows.append(row) + scheduleFlushIfNeeded() + case .rowsAffected(let count): + flushPendingRows() + rowsAffected = count + case .statusMessage(let message): + flushPendingRows() + statusMessage = message + case .truncated(let reason): + flushPendingRows() + phase = .truncated(reason: reason) + } + } + + private func scheduleFlushIfNeeded() { + if pendingRows.count >= Self.flushBatchSize { + flushPendingRows() + return + } + if flushTask == nil { + flushTask = Task { [weak self] in + try? await Task.sleep(for: Self.flushInterval) + guard !Task.isCancelled else { return } + self?.flushPendingRows() + } + } + } + + private func flushPendingRows() { + flushTask?.cancel() + flushTask = nil + guard !pendingRows.isEmpty else { return } + let legacyBatch = pendingRows.map(\.legacyValues) + window.append(contentsOf: pendingRows) + legacyRows.append(contentsOf: legacyBatch) + if legacyRows.count > window.count { + legacyRows.removeFirst(legacyRows.count - window.count) + } + pendingRows.removeAll(keepingCapacity: true) + } + + private func shrinkLegacyRows(to count: Int) { + guard legacyRows.count > count else { return } + legacyRows.removeFirst(legacyRows.count - count) + } + + private func classify(error: Error) -> AppError { + let context = ErrorContext(operation: "loadPage") + return ErrorClassifier.classify(error, context: context) + } +} diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift new file mode 100644 index 000000000..923d30bef --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -0,0 +1,199 @@ +// +// QueryEditorViewModel.swift +// TableProMobile +// + +import Foundation +import os +import TableProDatabase +import TableProModels + +@MainActor +@Observable +final class QueryEditorViewModel { + enum Phase: Sendable { + case idle + case running + case finished + case truncated(reason: TruncationReason) + case error(AppError) + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorViewModel") + + private(set) var columns: [ColumnInfo] = [] + private(set) var window: RowWindow + private(set) var legacyRows: [[String?]] = [] + private(set) var rowsReceived: Int = 0 + private(set) var phase: Phase = .idle + private(set) var rowsAffected: Int? + private(set) var statusMessage: String? + private(set) var executionTime: TimeInterval = 0 + + @ObservationIgnored private var pendingRows: [Row] = [] + @ObservationIgnored private var pendingRowsReceived: Int = 0 + @ObservationIgnored private var flushTask: Task? + @ObservationIgnored private var fetchTask: Task? + @ObservationIgnored private var startedAt: Date? + + private static let flushBatchSize = 200 + private static let flushInterval: Duration = .milliseconds(50) + + init(windowCapacity: Int = 100_000) { + self.window = RowWindow(capacity: windowCapacity) + } + + var isRunning: Bool { + if case .running = phase { return true } + return false + } + + func run(driver: DatabaseDriver, query: String, maxRows: Int = 100_000) async { + fetchTask?.cancel() + let options = StreamOptions( + textTruncationBytes: 4_096, + inlineBinary: false, + maxRows: maxRows, + lazyContext: nil + ) + phase = .running + columns = [] + window.clear() + legacyRows.removeAll(keepingCapacity: true) + rowsReceived = 0 + rowsAffected = nil + statusMessage = nil + executionTime = 0 + pendingRows.removeAll(keepingCapacity: true) + pendingRowsReceived = 0 + startedAt = Date() + + let task = Task { [weak self] in + guard let self else { return } + do { + for try await element in driver.executeStreaming(query: query, options: options) { + if Task.isCancelled { break } + self.apply(element: element) + } + self.flushPendingRows() + self.finalizeTiming() + if case .running = self.phase { + self.phase = .finished + } + } catch is CancellationError { + self.flushPendingRows() + self.finalizeTiming() + self.phase = .truncated(reason: .cancelled) + } catch { + self.flushPendingRows() + self.finalizeTiming() + self.phase = .error(self.classify(error: error)) + } + } + fetchTask = task + await task.value + } + + func stop() { + fetchTask?.cancel() + } + + func reset() { + fetchTask?.cancel() + flushTask?.cancel() + flushTask = nil + columns = [] + window.clear() + legacyRows.removeAll(keepingCapacity: true) + rowsReceived = 0 + rowsAffected = nil + statusMessage = nil + executionTime = 0 + pendingRows.removeAll(keepingCapacity: true) + pendingRowsReceived = 0 + phase = .idle + } + + nonisolated func handlePressure(_ level: MemoryPressureMonitor.Level) async { + await MainActor.run { + switch level { + case .normal: + break + case .warning: + Self.logger.warning("Memory pressure warning: shrinking editor window to 100 rows") + self.window.shrink(to: 100) + self.shrinkLegacyRows(to: 100) + case .critical: + Self.logger.error("Memory pressure critical: cancelling editor stream and shrinking to 50 rows") + self.window.shrink(to: 50) + self.shrinkLegacyRows(to: 50) + self.fetchTask?.cancel() + } + } + } + + private func shrinkLegacyRows(to count: Int) { + guard legacyRows.count > count else { return } + legacyRows.removeFirst(legacyRows.count - count) + } + + private func apply(element: StreamElement) { + switch element { + case .columns(let cols): + columns = cols + case .row(let row): + pendingRows.append(row) + pendingRowsReceived += 1 + scheduleFlushIfNeeded() + case .rowsAffected(let count): + flushPendingRows() + rowsAffected = count + case .statusMessage(let message): + flushPendingRows() + statusMessage = message + case .truncated(let reason): + flushPendingRows() + phase = .truncated(reason: reason) + } + } + + private func scheduleFlushIfNeeded() { + if pendingRows.count >= Self.flushBatchSize { + flushPendingRows() + return + } + if flushTask == nil { + flushTask = Task { [weak self] in + try? await Task.sleep(for: Self.flushInterval) + guard !Task.isCancelled else { return } + self?.flushPendingRows() + } + } + } + + private func flushPendingRows() { + flushTask?.cancel() + flushTask = nil + guard !pendingRows.isEmpty else { return } + let legacyBatch = pendingRows.map(\.legacyValues) + window.append(contentsOf: pendingRows) + legacyRows.append(contentsOf: legacyBatch) + if legacyRows.count > window.count { + let drop = legacyRows.count - window.count + legacyRows.removeFirst(drop) + } + rowsReceived = pendingRowsReceived + pendingRows.removeAll(keepingCapacity: true) + } + + private func finalizeTiming() { + if let startedAt { + executionTime = Date().timeIntervalSince(startedAt) + } + } + + private func classify(error: Error) -> AppError { + let context = ErrorContext(operation: "executeQuery") + return ErrorClassifier.classify(error, context: context) + } +} diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 10beaca53..7e8da4dd7 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -18,9 +18,8 @@ struct DataBrowserView: View { private var connection: DatabaseConnection { coordinator.connection } private var session: ConnectionSession? { coordinator.session } - @State private var columns: [ColumnInfo] = [] + @State private var viewModel = DataBrowserViewModel() @State private var columnDetails: [ColumnInfo] = [] - @State private var rows: [[String?]] = [] @State private var isLoading = true @State private var isPageLoading = false @State private var appError: AppError? @@ -57,14 +56,17 @@ struct DataBrowserView: View { columnDetails.contains(where: \.isPrimaryKey) } + private var columns: [ColumnInfo] { viewModel.columns } + private var rows: [[String?]] { viewModel.legacyRows } + private var paginationLabel: String { guard !rows.isEmpty else { return "" } let start = pagination.currentOffset + 1 let end = pagination.currentOffset + rows.count if let total = pagination.totalRows { - return "\(start)–\(end) of \(total)" + return "\(start)-\(end) of \(total)" } - return "\(start)–\(end)" + return "\(start)-\(end)" } private var hasActiveSearch: Bool { @@ -157,9 +159,12 @@ struct DataBrowserView: View { } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in guard !rows.isEmpty else { return } - Self.logger.warning("Memory warning received — clearing \(rows.count) rows") - rows = [] - memoryWarningMessage = String(localized: "Results cleared due to memory pressure.") + Self.logger.warning("Memory warning received: shrinking window from \(rows.count) rows") + Task { await viewModel.handlePressure(.warning) } + memoryWarningMessage = String(localized: "Results trimmed due to memory pressure.") + } + .onChange(of: MemoryPressureMonitor.shared.currentLevel) { _, level in + Task { await viewModel.handlePressure(level) } } .overlay(alignment: .center) { if let message = memoryWarningMessage, rows.isEmpty, !isLoading, appError == nil { @@ -250,11 +255,12 @@ struct DataBrowserView: View { private var rowList: some View { List { - ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + ForEach(rows.indices, id: \.self) { index in + let row = rows[index] NavigationLink { RowDetailView( columns: columns, - rows: rows, + rows: viewModel.window.rows, initialIndex: index, table: table, session: session, @@ -262,7 +268,11 @@ struct DataBrowserView: View { databaseType: connection.type, safeModeLevel: connection.safeModeLevel, foreignKeys: foreignKeys, - onSaved: { Task { await loadData() } } + onSaved: { Task { await loadData() } }, + loadFullValue: { ref in + guard let session else { return nil } + return try await viewModel.loadFullValue(driver: session.driver, ref: ref) + } ) } label: { RowCard( @@ -523,15 +533,28 @@ struct DataBrowserView: View { limit: pagination.pageSize, offset: pagination.currentOffset ) } - let result = try await session.driver.execute(query: query) - columns = result.columns - rows = result.rows - if rows.count < pagination.pageSize, pagination.totalRows == nil { - pagination.totalRows = pagination.currentOffset + rows.count - } if columnDetails.isEmpty || isInitial { columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) } + let pkColumns = columnDetails.filter(\.isPrimaryKey).map(\.name) + let lazyContext = pkColumns.isEmpty ? nil : LazyContext(table: table.name, primaryKeyColumns: pkColumns) + + await viewModel.loadPage( + driver: session.driver, + query: query, + lazyContext: lazyContext, + pageSize: pagination.pageSize + ) + + if case .error(let err) = viewModel.phase { + appError = err + isLoading = false + return + } + + if rows.count < pagination.pageSize, pagination.totalRows == nil { + pagination.totalRows = pagination.currentOffset + rows.count + } if foreignKeys.isEmpty || isInitial { do { foreignKeys = try await session.driver.fetchForeignKeys(table: table.name, schema: nil) @@ -687,4 +710,3 @@ struct DataBrowserView: View { Task { await loadData() } } } - diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 1647d8a38..ac670a677 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -14,11 +14,19 @@ struct QueryEditorView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") @State private var query = "" - @State private var result: QueryResult? + @State private var viewModel = QueryEditorViewModel() @State private var appError: AppError? @State private var isExecuting = false @State private var executionTime: TimeInterval? @State private var executeTask: Task? + + private var hasResult: Bool { + !viewModel.columns.isEmpty || viewModel.rowsAffected != nil + } + + private var resultRowCount: Int { + viewModel.legacyRows.count + } @State private var saveQueryTask: Task? @State private var executionStartTime: Date? @State private var showWriteConfirmation = false @@ -91,7 +99,7 @@ struct QueryEditorView: View { ) { Button(String(localized: "Clear"), role: .destructive) { query = "" - result = nil + viewModel.reset() appError = nil executionTime = nil } @@ -105,7 +113,7 @@ struct QueryEditorView: View { private var editorSection: some View { VStack(spacing: 0) { SQLHighlightTextView(text: $query) - .frame(minHeight: 80, maxHeight: result != nil || appError != nil ? 120 : 250) + .frame(minHeight: 80, maxHeight: hasResult || appError != nil ? 120 : 250) actionBar } @@ -148,8 +156,8 @@ struct QueryEditorView: View { .foregroundStyle(.secondary) } - if let result, !result.rows.isEmpty { - Text(verbatim: "\(result.rows.count) rows") + if resultRowCount > 0 { + Text(verbatim: "\(resultRowCount) rows") .font(.caption2) .foregroundStyle(.secondary) } @@ -192,21 +200,21 @@ struct QueryEditorView: View { .padding() .frame(maxWidth: .infinity, alignment: .leading) } - } else if let result { - if result.columns.isEmpty { + } else if hasResult { + if viewModel.columns.isEmpty { VStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle) .foregroundStyle(.green) - Text(String(format: String(localized: "%d row(s) affected"), result.rowsAffected)) + Text(String(format: String(localized: "%d row(s) affected"), viewModel.rowsAffected ?? 0)) .font(.body) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if result.rows.isEmpty { + } else if viewModel.legacyRows.isEmpty { ContentUnavailableView("No Results", systemImage: "tray") .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - resultList(result) + resultList } } else { ContentUnavailableView { @@ -219,20 +227,21 @@ struct QueryEditorView: View { } } - private func resultList(_ result: QueryResult) -> some View { + private var resultList: some View { List { - ForEach(Array(result.rows.enumerated()), id: \.offset) { rowIndex, row in + ForEach(viewModel.legacyRows.indices, id: \.self) { rowIndex in + let row = viewModel.legacyRows[rowIndex] NavigationLink { RowDetailView( - columns: result.columns, - rows: result.rows, + columns: viewModel.columns, + rows: viewModel.window.rows, initialIndex: rowIndex ) } label: { - resultRowCard(columns: result.columns, row: row) + resultRowCard(columns: viewModel.columns, row: row) } .contextMenu { - resultRowContextMenu(columns: result.columns, row: row) + resultRowContextMenu(columns: viewModel.columns, row: row) } } } @@ -308,12 +317,12 @@ struct QueryEditorView: View { } } - if let result, !result.rows.isEmpty { + if !viewModel.legacyRows.isEmpty { Section("Share Results") { ForEach(ExportFormat.allCases) { format in Button { shareText = ClipboardExporter.exportRows( - columns: result.columns, rows: result.rows, + columns: viewModel.columns, rows: viewModel.legacyRows, format: format ) showShareSheet = true @@ -326,7 +335,7 @@ struct QueryEditorView: View { ForEach(ExportFormat.allCases) { format in Button { let text = ClipboardExporter.exportRows( - columns: result.columns, rows: result.rows, + columns: viewModel.columns, rows: viewModel.legacyRows, format: format ) ClipboardExporter.copyToClipboard(text) @@ -389,22 +398,21 @@ struct QueryEditorView: View { executionStartTime = nil } appError = nil - result = nil - do { - let queryResult = try await session.driver.execute(query: trimmed) - self.result = queryResult - self.executionTime = queryResult.executionTime - hapticSuccess.toggle() + await viewModel.run(driver: session.driver, query: trimmed) - IOSAnalyticsProvider.shared.markFirstQueryExecuted() - - let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) - coordinator.addHistoryItem(item) - } catch { - let context = ErrorContext(operation: "executeQuery") - self.appError = ErrorClassifier.classify(error, context: context) + if case .error(let err) = viewModel.phase { + appError = err hapticError.toggle() + return } + + executionTime = viewModel.executionTime + hapticSuccess.toggle() + + IOSAnalyticsProvider.shared.markFirstQueryExecuted() + + let item = QueryHistoryItem(query: trimmed, connectionId: connectionId) + coordinator.addHistoryItem(item) } } diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 3aab01981..3efd6d776 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -10,7 +10,7 @@ import TableProModels struct RowDetailView: View { let columns: [ColumnInfo] - @State private var rows: [[String?]] + @State private var rows: [Row] let table: TableInfo? let session: ConnectionSession? let columnDetails: [ColumnInfo] @@ -18,10 +18,13 @@ struct RowDetailView: View { let safeModeLevel: SafeModeLevel let foreignKeys: [ForeignKeyInfo] var onSaved: (() -> Void)? + var loadFullValue: ((CellRef) async throws -> String?)? @State private var currentIndex: Int @State private var isEditing = false @State private var editedValues: [String?] = [] + @State private var loadingCell: Int? + @State private var fullValueOverrides: [Int: [Int: String?]] = [:] @State private var isSaving = false @State private var operationError: AppError? @State private var showOperationError = false @@ -36,7 +39,7 @@ struct RowDetailView: View { init( columns: [ColumnInfo], - rows: [[String?]], + rows: [Row], initialIndex: Int, table: TableInfo? = nil, session: ConnectionSession? = nil, @@ -44,7 +47,8 @@ struct RowDetailView: View { databaseType: DatabaseType = .sqlite, safeModeLevel: SafeModeLevel = .off, foreignKeys: [ForeignKeyInfo] = [], - onSaved: (() -> Void)? = nil + onSaved: (() -> Void)? = nil, + loadFullValue: ((CellRef) async throws -> String?)? = nil ) { self.columns = columns _rows = State(initialValue: rows) @@ -55,12 +59,22 @@ struct RowDetailView: View { self.safeModeLevel = safeModeLevel self.foreignKeys = foreignKeys self.onSaved = onSaved + self.loadFullValue = loadFullValue _currentIndex = State(initialValue: initialIndex) } + private var currentRowCells: [Cell] { + guard currentIndex >= 0, currentIndex < rows.count else { return [] } + return rows[currentIndex].cells + } + private var currentRow: [String?] { guard currentIndex >= 0, currentIndex < rows.count else { return [] } - return rows[currentIndex] + let overrides = fullValueOverrides[currentIndex] ?? [:] + return rows[currentIndex].legacyValues.enumerated().map { index, base in + if let override = overrides[index] { return override } + return base + } } private var isView: Bool { @@ -80,7 +94,7 @@ struct RowDetailView: View { rowContent(at: currentIndex) } else { TabView(selection: $currentIndex) { - ForEach(Array(rows.indices), id: \.self) { index in + ForEach(rows.indices, id: \.self) { index in rowContent(at: index) .tag(index) } @@ -221,11 +235,19 @@ struct RowDetailView: View { @ViewBuilder private func rowContent(at rowIndex: Int) -> some View { - let row = rowIndex >= 0 && rowIndex < rows.count ? rows[rowIndex] : [] + let row: [String?] = { + guard rowIndex >= 0, rowIndex < rows.count else { return [] } + let overrides = fullValueOverrides[rowIndex] ?? [:] + return rows[rowIndex].legacyValues.enumerated().map { index, base in + overrides[index] ?? base + } + }() + let cells = rowIndex >= 0 && rowIndex < rows.count ? rows[rowIndex].cells : [] let values = isEditing ? editedValues : row List { - ForEach(Array(zip(columns, values).enumerated()), id: \.offset) { index, pair in - let (column, value) = pair + ForEach(0.. some View { + if let ref = cell.fullValueRef, let loadFullValue { + Button { + Task { await performLoadFullValue(ref: ref, cellIndex: cellIndex, loadFullValue: loadFullValue) } + } label: { + HStack(spacing: 4) { + if loadingCell == cellIndex { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.down.circle") + .font(.footnote) + } + Text("Load full value") + .font(.footnote) + } + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + .disabled(loadingCell != nil) + } + } + + private func performLoadFullValue(ref: CellRef, cellIndex: Int, loadFullValue: (CellRef) async throws -> String?) async { + loadingCell = cellIndex + defer { loadingCell = nil } + do { + let fullValue = try await loadFullValue(ref) + var rowOverrides = fullValueOverrides[currentIndex] ?? [:] + rowOverrides[cellIndex] = fullValue + fullValueOverrides[currentIndex] = rowOverrides + } catch { + operationError = AppError( + category: .network, + title: String(localized: "Load Failed"), + message: error.localizedDescription, + recovery: String(localized: "Try again or check your connection."), + underlying: error + ) + showOperationError = true + } + } + private func editableField(index: Int, value: String?) -> some View { let binding = Binding( get: { @@ -415,7 +483,11 @@ struct RowDetailView: View { do { _ = try await session.driver.execute(query: sql) guard currentIndex >= 0, currentIndex < rows.count else { return } - rows[currentIndex] = editedValues + let newCells = editedValues.map { value -> Cell in + value.map { Cell.text($0) } ?? .null + } + rows[currentIndex] = Row(cells: newCells) + fullValueOverrides[currentIndex] = nil isEditing = false showSaveSuccess = true hapticSuccess.toggle()