From 864b63622a6d52b5f2e482378012a2886b48518b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:18:45 +0700 Subject: [PATCH 01/10] refactor(ios): streaming data architecture for large tables and queries --- CHANGELOG.md | 4 + .../TableProDatabase/DatabaseDriver.swift | 49 +++++ .../Sources/TableProModels/Cell.swift | 115 ++++++++++ .../TableProModels/StreamingResult.swift | 47 ++++ .../TableProMobile/Drivers/MySQLDriver.swift | 144 +++++++++++++ .../Drivers/PostgreSQLDriver.swift | 204 ++++++++++++++++++ .../TableProMobile/Drivers/SQLiteDriver.swift | 163 ++++++++++++++ .../Helpers/StreamingExporter.swift | 135 ++++++++++++ .../TableProMobile/Models/RowWindow.swift | 71 ++++++ .../Platform/MemoryPressureMonitor.swift | 61 ++++++ .../TableProMobile/TableProMobileApp.swift | 1 + .../ViewModels/DataBrowserViewModel.swift | 129 +++++++++++ .../ViewModels/QueryEditorViewModel.swift | 130 +++++++++++ .../Views/DataBrowserView.swift | 45 ++-- .../Views/QueryEditorView.swift | 43 ++-- .../TableProMobile/Views/RowDetailView.swift | 23 +- 16 files changed, 1324 insertions(+), 40 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProModels/Cell.swift create mode 100644 Packages/TableProCore/Sources/TableProModels/StreamingResult.swift create mode 100644 TableProMobile/TableProMobile/Helpers/StreamingExporter.swift create mode 100644 TableProMobile/TableProMobile/Models/RowWindow.swift create mode 100644 TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift create mode 100644 TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift create mode 100644 TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift 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..f7c2a4cd0 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProModels/Cell.swift @@ -0,0 +1,115 @@ +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 prefix, let total, _): + return prefix + "… (\(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(let prefix, _, _): + return prefix + case .binary: + return nil + } + } + } +} + +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..35a854b0a 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: OpaquePointer? + 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..cb38cf2bc --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift @@ -0,0 +1,135 @@ +// +// StreamingExporter.swift +// TableProMobile +// + +import Foundation +import os +import TableProDatabase +import TableProModels + +public actor StreamingExporter { + private static let logger = Logger(subsystem: "com.TablePro", category: "StreamingExporter") + + public init() {} + + public 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 + } +} + +public 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..865f5459a --- /dev/null +++ b/TableProMobile/TableProMobile/Models/RowWindow.swift @@ -0,0 +1,71 @@ +// +// RowWindow.swift +// TableProMobile +// + +import Foundation +import TableProModels + +public struct RowWindow: Sendable { + public private(set) var rows: [Row] + public private(set) var firstAbsoluteIndex: Int + public private(set) var totalAppended: Int + public let capacity: Int + + public init(capacity: Int = 200) { + self.rows = [] + self.firstAbsoluteIndex = 0 + self.totalAppended = 0 + self.capacity = max(1, capacity) + } + + public mutating func append(_ row: Row) { + rows.append(row) + totalAppended += 1 + slideForwardIfOverCapacity() + } + + public mutating func append(contentsOf newRows: [Row]) { + for row in newRows { + append(row) + } + } + + public mutating func shrink(to maxCount: Int) { + guard maxCount >= 0, rows.count > maxCount else { return } + let dropCount = rows.count - maxCount + rows.removeFirst(dropCount) + firstAbsoluteIndex += dropCount + } + + public mutating func clear() { + rows = [] + firstAbsoluteIndex = 0 + totalAppended = 0 + } + + public var lastAbsoluteIndex: Int { + firstAbsoluteIndex + rows.count - 1 + } + + public var isEmpty: Bool { + rows.isEmpty + } + + public var count: Int { + rows.count + } + + public 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..2b29b8b44 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift @@ -0,0 +1,61 @@ +// +// MemoryPressureMonitor.swift +// TableProMobile +// + +import Foundation +import os + +@MainActor +@Observable +public final class MemoryPressureMonitor { + public static let shared = MemoryPressureMonitor() + + public enum Level: Sendable { + case normal + case warning + case critical + } + + public private(set) var currentLevel: Level = .normal + + private static let logger = Logger(subsystem: "com.TablePro", category: "MemoryPressureMonitor") + private var source: DispatchSourceMemoryPressure? + + private init() {} + + public 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 + } + + public func reset() { + currentLevel = .normal + } + + nonisolated public func availableMemoryBytes() -> Int { + Int(os_proc_available_memory()) + } + + nonisolated public 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..97db0e5c3 --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -0,0 +1,129 @@ +// +// 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 totalRows: Int? + private(set) var phase: Phase = .idle + private(set) var rowsAffected: Int? + private(set) var statusMessage: String? + private(set) var executionTime: TimeInterval = 0 + + private var fetchTask: Task? + + init(windowCapacity: Int = 200) { + 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() + rowsAffected = nil + statusMessage = nil + + 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.executionTime = Date().timeIntervalSince(start) + if case .loading = self.phase { + self.phase = .loaded + } + } catch { + self.phase = .error(self.classify(error: error)) + } + } + fetchTask = task + await task.value + } + + func cancel() { + fetchTask?.cancel() + } + + 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) + case .critical: + Self.logger.error("Memory pressure critical: shrinking window to 50 rows and cancelling") + self.window.shrink(to: 50) + self.fetchTask?.cancel() + } + } + } + + private func apply(element: StreamElement) { + switch element { + case .columns(let cols): + columns = cols + case .row(let row): + window.append(row) + case .rowsAffected(let count): + rowsAffected = count + case .statusMessage(let message): + statusMessage = message + case .truncated(let reason): + phase = .truncated(reason: reason) + } + } + + 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..663f35a44 --- /dev/null +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -0,0 +1,130 @@ +// +// 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 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 + + private var fetchTask: Task? + private var startedAt: Date? + + init(windowCapacity: Int = 200) { + 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() + rowsReceived = 0 + rowsAffected = nil + statusMessage = nil + executionTime = 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.finalizeTiming() + if case .running = self.phase { + self.phase = .finished + } + } catch is CancellationError { + self.finalizeTiming() + self.phase = .truncated(reason: .cancelled) + } catch { + self.finalizeTiming() + self.phase = .error(self.classify(error: error)) + } + } + fetchTask = task + await task.value + } + + func stop() { + fetchTask?.cancel() + } + + 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) + case .critical: + Self.logger.error("Memory pressure critical: cancelling editor stream and shrinking to 50 rows") + self.window.shrink(to: 50) + self.fetchTask?.cancel() + } + } + } + + private func apply(element: StreamElement) { + switch element { + case .columns(let cols): + columns = cols + case .row(let row): + window.append(row) + rowsReceived += 1 + case .rowsAffected(let count): + rowsAffected = count + case .statusMessage(let message): + statusMessage = message + case .truncated(let reason): + phase = .truncated(reason: reason) + } + } + + 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..f8fa1cc43 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.window.rows.map(\.legacyValues) } + 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 { @@ -523,15 +528,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 +705,3 @@ struct DataBrowserView: View { Task { await loadData() } } } - diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 1647d8a38..fc4e93153 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -14,11 +14,27 @@ 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 result: QueryResult? { + guard !viewModel.columns.isEmpty || viewModel.rowsAffected != nil else { return nil } + let isTruncated: Bool = { + if case .truncated = viewModel.phase { return true } + return false + }() + return QueryResult( + columns: viewModel.columns, + rows: viewModel.window.rows.map(\.legacyValues), + rowsAffected: viewModel.rowsAffected ?? 0, + executionTime: viewModel.executionTime, + isTruncated: isTruncated, + statusMessage: viewModel.statusMessage + ) + } @State private var saveQueryTask: Task? @State private var executionStartTime: Date? @State private var showWriteConfirmation = false @@ -389,22 +405,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() - IOSAnalyticsProvider.shared.markFirstQueryExecuted() + await viewModel.run(driver: session.driver, query: trimmed) - 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..14d1b2ff6 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -75,19 +75,18 @@ struct RowDetailView: View { } var body: some View { - Group { - if isEditing { - rowContent(at: currentIndex) - } else { - TabView(selection: $currentIndex) { - ForEach(Array(rows.indices), id: \.self) { index in - rowContent(at: index) - .tag(index) + rowContent(at: currentIndex) + .gesture( + DragGesture(minimumDistance: 30) + .onEnded { value in + guard !isEditing else { return } + if value.translation.width < -50, currentIndex < rows.count - 1 { + currentIndex += 1 + } else if value.translation.width > 50, currentIndex > 0 { + currentIndex -= 1 + } } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - } - } + ) .background(Color(.systemGroupedBackground)) .onDisappear { dismissSuccessTask?.cancel() From 66e789b3bdfc15fe7f51d6b60b6802c2153cf4a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:23:40 +0700 Subject: [PATCH 02/10] fix: rename Cell pattern binding to avoid String.prefix overload ambiguity --- Packages/TableProCore/Sources/TableProModels/Cell.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/Cell.swift b/Packages/TableProCore/Sources/TableProModels/Cell.swift index f7c2a4cd0..195f46686 100644 --- a/Packages/TableProCore/Sources/TableProModels/Cell.swift +++ b/Packages/TableProCore/Sources/TableProModels/Cell.swift @@ -14,8 +14,8 @@ public extension Cell { return "NULL" case .text(let value): return value - case .truncatedText(let prefix, let total, _): - return prefix + "… (\(byteCountFormatter.string(fromByteCount: Int64(total))))" + case .truncatedText(let head, let total, _): + return head + "... (\(byteCountFormatter.string(fromByteCount: Int64(total))))" case .binary(let count, _): return "[BLOB \(byteCountFormatter.string(fromByteCount: Int64(count)))]" } @@ -78,8 +78,8 @@ public extension Row { return nil case .text(let value): return value - case .truncatedText(let prefix, _, _): - return prefix + case .truncatedText(let head, _, _): + return head case .binary: return nil } From c094e883315eb39b76d07f16d0fe95d8e19ace84 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:26:50 +0700 Subject: [PATCH 03/10] fix: drop public modifiers from iOS-app internal types --- .../Helpers/StreamingExporter.swift | 8 +++--- .../TableProMobile/Models/RowWindow.swift | 28 +++++++++---------- .../Platform/MemoryPressureMonitor.swift | 16 +++++------ 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift b/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift index cb38cf2bc..d4b355d47 100644 --- a/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift +++ b/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift @@ -8,12 +8,12 @@ import os import TableProDatabase import TableProModels -public actor StreamingExporter { +actor StreamingExporter { private static let logger = Logger(subsystem: "com.TablePro", category: "StreamingExporter") - public init() {} + init() {} - public func exportToFile( + func exportToFile( driver: DatabaseDriver, query: String, format: ExportFormat, @@ -124,7 +124,7 @@ public actor StreamingExporter { } } -public extension ExportFormat { +extension ExportFormat { var fileExtension: String { switch self { case .csv: return "csv" diff --git a/TableProMobile/TableProMobile/Models/RowWindow.swift b/TableProMobile/TableProMobile/Models/RowWindow.swift index 865f5459a..8ca04e803 100644 --- a/TableProMobile/TableProMobile/Models/RowWindow.swift +++ b/TableProMobile/TableProMobile/Models/RowWindow.swift @@ -6,57 +6,57 @@ import Foundation import TableProModels -public struct RowWindow: Sendable { - public private(set) var rows: [Row] - public private(set) var firstAbsoluteIndex: Int - public private(set) var totalAppended: Int - public let capacity: Int +struct RowWindow: Sendable { + private(set) var rows: [Row] + private(set) var firstAbsoluteIndex: Int + private(set) var totalAppended: Int + let capacity: Int - public init(capacity: Int = 200) { + init(capacity: Int = 200) { self.rows = [] self.firstAbsoluteIndex = 0 self.totalAppended = 0 self.capacity = max(1, capacity) } - public mutating func append(_ row: Row) { + mutating func append(_ row: Row) { rows.append(row) totalAppended += 1 slideForwardIfOverCapacity() } - public mutating func append(contentsOf newRows: [Row]) { + mutating func append(contentsOf newRows: [Row]) { for row in newRows { append(row) } } - public mutating func shrink(to maxCount: Int) { + mutating func shrink(to maxCount: Int) { guard maxCount >= 0, rows.count > maxCount else { return } let dropCount = rows.count - maxCount rows.removeFirst(dropCount) firstAbsoluteIndex += dropCount } - public mutating func clear() { + mutating func clear() { rows = [] firstAbsoluteIndex = 0 totalAppended = 0 } - public var lastAbsoluteIndex: Int { + var lastAbsoluteIndex: Int { firstAbsoluteIndex + rows.count - 1 } - public var isEmpty: Bool { + var isEmpty: Bool { rows.isEmpty } - public var count: Int { + var count: Int { rows.count } - public func row(atAbsolute absoluteIndex: Int) -> Row? { + func row(atAbsolute absoluteIndex: Int) -> Row? { let relative = absoluteIndex - firstAbsoluteIndex guard rows.indices.contains(relative) else { return nil } return rows[relative] diff --git a/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift index 2b29b8b44..d81b052ba 100644 --- a/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift +++ b/TableProMobile/TableProMobile/Platform/MemoryPressureMonitor.swift @@ -8,23 +8,23 @@ import os @MainActor @Observable -public final class MemoryPressureMonitor { - public static let shared = MemoryPressureMonitor() +final class MemoryPressureMonitor { + static let shared = MemoryPressureMonitor() - public enum Level: Sendable { + enum Level: Sendable { case normal case warning case critical } - public private(set) var currentLevel: Level = .normal + private(set) var currentLevel: Level = .normal private static let logger = Logger(subsystem: "com.TablePro", category: "MemoryPressureMonitor") private var source: DispatchSourceMemoryPressure? private init() {} - public func start() { + func start() { guard source == nil else { return } let newSource = DispatchSource.makeMemoryPressureSource( @@ -45,15 +45,15 @@ public final class MemoryPressureMonitor { source = newSource } - public func reset() { + func reset() { currentLevel = .normal } - nonisolated public func availableMemoryBytes() -> Int { + nonisolated func availableMemoryBytes() -> Int { Int(os_proc_available_memory()) } - nonisolated public func hasHeadroom(forBytes requiredBytes: Int) -> Bool { + nonisolated func hasHeadroom(forBytes requiredBytes: Int) -> Bool { let available = availableMemoryBytes() guard available > 0 else { return true } return available > requiredBytes From 7950ccf48a95f53892b28fc012bf858aa0767eb0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:28:45 +0700 Subject: [PATCH 04/10] fix(mysql): use UnsafeMutablePointer and Data bridge for streaming --- TableProMobile/TableProMobile/Drivers/MySQLDriver.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 35a854b0a..f95b05ad7 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -398,7 +398,7 @@ private actor MySQLActor { // MARK: - Streaming - private var streamingResult: OpaquePointer? + private var streamingResult: UnsafeMutablePointer? private var streamingColumns: [ColumnInfo] = [] func beginStream(query: String) throws -> MySQLBeginStreamResult { @@ -455,8 +455,8 @@ private actor MySQLActor { for i in 0.. Date: Wed, 6 May 2026 19:32:27 +0700 Subject: [PATCH 05/10] fix: route QueryEditorView Clear through QueryEditorViewModel.reset() --- .../ViewModels/QueryEditorViewModel.swift | 11 +++++++++++ .../TableProMobile/Views/QueryEditorView.swift | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index 663f35a44..3f8e94139 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -85,6 +85,17 @@ final class QueryEditorViewModel { fetchTask?.cancel() } + func reset() { + fetchTask?.cancel() + columns = [] + window.clear() + rowsReceived = 0 + rowsAffected = nil + statusMessage = nil + executionTime = 0 + phase = .idle + } + nonisolated func handlePressure(_ level: MemoryPressureMonitor.Level) async { await MainActor.run { switch level { diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index fc4e93153..6805b05ce 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -107,7 +107,7 @@ struct QueryEditorView: View { ) { Button(String(localized: "Clear"), role: .destructive) { query = "" - result = nil + viewModel.reset() appError = nil executionTime = nil } From 1faed88466fdadcc924ca5be0556db7917985a30 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:37:28 +0700 Subject: [PATCH 06/10] perf(ios): batch row publication to cut SwiftUI re-render storm during streaming --- .../ViewModels/DataBrowserViewModel.swift | 40 ++++++++++++++- .../ViewModels/QueryEditorViewModel.swift | 50 +++++++++++++++++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 97db0e5c3..97ca2e227 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -29,7 +29,12 @@ final class DataBrowserViewModel { private(set) var statusMessage: String? private(set) var executionTime: TimeInterval = 0 - private var fetchTask: Task? + @ObservationIgnored private var pendingRows: [Row] = [] + @ObservationIgnored private var flushTask: Task? + @ObservationIgnored private var fetchTask: Task? + + private static let flushBatchSize = 200 + private static let flushIntervalNanos: UInt64 = 50_000_000 init(windowCapacity: Int = 200) { self.window = RowWindow(capacity: windowCapacity) @@ -53,6 +58,7 @@ final class DataBrowserViewModel { window.clear() rowsAffected = nil statusMessage = nil + pendingRows.removeAll(keepingCapacity: true) let start = Date() let task = Task { [weak self] in @@ -62,11 +68,13 @@ final class DataBrowserViewModel { 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)) } } @@ -76,6 +84,8 @@ final class DataBrowserViewModel { func cancel() { fetchTask?.cancel() + flushTask?.cancel() + flushTask = nil } func loadFullValue(driver: DatabaseDriver, ref: CellRef) async throws -> String? { @@ -112,16 +122,42 @@ final class DataBrowserViewModel { case .columns(let cols): columns = cols case .row(let row): - window.append(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(nanoseconds: Self.flushIntervalNanos) + guard !Task.isCancelled else { return } + self?.flushPendingRows() + } + } + } + + private func flushPendingRows() { + flushTask?.cancel() + flushTask = nil + guard !pendingRows.isEmpty else { return } + window.append(contentsOf: pendingRows) + pendingRows.removeAll(keepingCapacity: true) + } + 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 index 3f8e94139..bf5f5c6b5 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -29,8 +29,14 @@ final class QueryEditorViewModel { private(set) var statusMessage: String? private(set) var executionTime: TimeInterval = 0 - private var fetchTask: Task? - private var startedAt: Date? + @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 flushIntervalNanos: UInt64 = 50_000_000 init(windowCapacity: Int = 200) { self.window = RowWindow(capacity: windowCapacity) @@ -56,6 +62,8 @@ final class QueryEditorViewModel { rowsAffected = nil statusMessage = nil executionTime = 0 + pendingRows.removeAll(keepingCapacity: true) + pendingRowsReceived = 0 startedAt = Date() let task = Task { [weak self] in @@ -65,14 +73,17 @@ final class QueryEditorViewModel { 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)) } @@ -87,12 +98,16 @@ final class QueryEditorViewModel { func reset() { fetchTask?.cancel() + flushTask?.cancel() + flushTask = nil columns = [] window.clear() rowsReceived = 0 rowsAffected = nil statusMessage = nil executionTime = 0 + pendingRows.removeAll(keepingCapacity: true) + pendingRowsReceived = 0 phase = .idle } @@ -117,17 +132,44 @@ final class QueryEditorViewModel { case .columns(let cols): columns = cols case .row(let row): - window.append(row) - rowsReceived += 1 + 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(nanoseconds: Self.flushIntervalNanos) + guard !Task.isCancelled else { return } + self?.flushPendingRows() + } + } + } + + private func flushPendingRows() { + flushTask?.cancel() + flushTask = nil + guard !pendingRows.isEmpty else { return } + window.append(contentsOf: pendingRows) + rowsReceived = pendingRowsReceived + pendingRows.removeAll(keepingCapacity: true) + } + private func finalizeTiming() { if let startedAt { executionTime = Date().timeIntervalSince(startedAt) From ed45f2560855dc7c7b58d1c449f27fb816407966 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:40:18 +0700 Subject: [PATCH 07/10] fix(ios): raise window capacity so editor results aren't sliced to last 200 rows --- .../TableProMobile/ViewModels/DataBrowserViewModel.swift | 2 +- .../TableProMobile/ViewModels/QueryEditorViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 97ca2e227..60d610b92 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -36,7 +36,7 @@ final class DataBrowserViewModel { private static let flushBatchSize = 200 private static let flushIntervalNanos: UInt64 = 50_000_000 - init(windowCapacity: Int = 200) { + init(windowCapacity: Int = 1_000) { self.window = RowWindow(capacity: windowCapacity) } diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index bf5f5c6b5..70382ed31 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -38,7 +38,7 @@ final class QueryEditorViewModel { private static let flushBatchSize = 200 private static let flushIntervalNanos: UInt64 = 50_000_000 - init(windowCapacity: Int = 200) { + init(windowCapacity: Int = 100_000) { self.window = RowWindow(capacity: windowCapacity) } From 12b13343fe902a490785069754446e6edd4b577f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:46:22 +0700 Subject: [PATCH 08/10] perf(ios): cache legacyValues at flush time so each keystroke does not remap whole window --- .../ViewModels/DataBrowserViewModel.swift | 14 ++++++++++++++ .../ViewModels/QueryEditorViewModel.swift | 16 ++++++++++++++++ .../TableProMobile/Views/DataBrowserView.swift | 2 +- .../TableProMobile/Views/QueryEditorView.swift | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 60d610b92..698820817 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -23,6 +23,7 @@ final class 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? @@ -56,6 +57,7 @@ final class DataBrowserViewModel { phase = .loading columns = [] window.clear() + legacyRows.removeAll(keepingCapacity: true) rowsAffected = nil statusMessage = nil pendingRows.removeAll(keepingCapacity: true) @@ -109,9 +111,11 @@ final class DataBrowserViewModel { 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() } } @@ -154,10 +158,20 @@ final class DataBrowserViewModel { 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 index 70382ed31..bde0ad751 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -23,6 +23,7 @@ final class 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? @@ -58,6 +59,7 @@ final class QueryEditorViewModel { phase = .running columns = [] window.clear() + legacyRows.removeAll(keepingCapacity: true) rowsReceived = 0 rowsAffected = nil statusMessage = nil @@ -102,6 +104,7 @@ final class QueryEditorViewModel { flushTask = nil columns = [] window.clear() + legacyRows.removeAll(keepingCapacity: true) rowsReceived = 0 rowsAffected = nil statusMessage = nil @@ -119,14 +122,21 @@ final class QueryEditorViewModel { 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): @@ -165,7 +175,13 @@ final class QueryEditorViewModel { 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) } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index f8fa1cc43..dc923ed8d 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -57,7 +57,7 @@ struct DataBrowserView: View { } private var columns: [ColumnInfo] { viewModel.columns } - private var rows: [[String?]] { viewModel.window.rows.map(\.legacyValues) } + private var rows: [[String?]] { viewModel.legacyRows } private var paginationLabel: String { guard !rows.isEmpty else { return "" } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 6805b05ce..db713b060 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -28,7 +28,7 @@ struct QueryEditorView: View { }() return QueryResult( columns: viewModel.columns, - rows: viewModel.window.rows.map(\.legacyValues), + rows: viewModel.legacyRows, rowsAffected: viewModel.rowsAffected ?? 0, executionTime: viewModel.executionTime, isTruncated: isTruncated, From a307600d5e8a89dede0026d821d7747e49465066 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 19:59:55 +0700 Subject: [PATCH 09/10] refactor(ios): cell-aware row detail with native paging and lazy full-value loader --- .../Sources/TableProModels/Cell.swift | 6 +- .../ViewModels/DataBrowserViewModel.swift | 4 +- .../ViewModels/QueryEditorViewModel.swift | 4 +- .../Views/DataBrowserView.swift | 11 +- .../Views/QueryEditorView.swift | 5 +- .../TableProMobile/Views/RowDetailView.swift | 111 +++++++++++++++--- 6 files changed, 109 insertions(+), 32 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProModels/Cell.swift b/Packages/TableProCore/Sources/TableProModels/Cell.swift index 195f46686..e5dd09074 100644 --- a/Packages/TableProCore/Sources/TableProModels/Cell.swift +++ b/Packages/TableProCore/Sources/TableProModels/Cell.swift @@ -78,10 +78,8 @@ public extension Row { return nil case .text(let value): return value - case .truncatedText(let head, _, _): - return head - case .binary: - return nil + case .truncatedText, .binary: + return cell.displayString } } } diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 698820817..34c6e04d7 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -35,7 +35,7 @@ final class DataBrowserViewModel { @ObservationIgnored private var fetchTask: Task? private static let flushBatchSize = 200 - private static let flushIntervalNanos: UInt64 = 50_000_000 + private static let flushInterval: Duration = .milliseconds(50) init(windowCapacity: Int = 1_000) { self.window = RowWindow(capacity: windowCapacity) @@ -147,7 +147,7 @@ final class DataBrowserViewModel { } if flushTask == nil { flushTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: Self.flushIntervalNanos) + try? await Task.sleep(for: Self.flushInterval) guard !Task.isCancelled else { return } self?.flushPendingRows() } diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index bde0ad751..923d30bef 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -37,7 +37,7 @@ final class QueryEditorViewModel { @ObservationIgnored private var startedAt: Date? private static let flushBatchSize = 200 - private static let flushIntervalNanos: UInt64 = 50_000_000 + private static let flushInterval: Duration = .milliseconds(50) init(windowCapacity: Int = 100_000) { self.window = RowWindow(capacity: windowCapacity) @@ -164,7 +164,7 @@ final class QueryEditorViewModel { } if flushTask == nil { flushTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: Self.flushIntervalNanos) + try? await Task.sleep(for: Self.flushInterval) guard !Task.isCancelled else { return } self?.flushPendingRows() } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index dc923ed8d..7e8da4dd7 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -255,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, @@ -267,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( diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index db713b060..6fff4ee43 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -237,11 +237,12 @@ struct QueryEditorView: View { private func resultList(_ result: QueryResult) -> some View { List { - ForEach(Array(result.rows.enumerated()), id: \.offset) { rowIndex, row in + ForEach(result.rows.indices, id: \.self) { rowIndex in + let row = result.rows[rowIndex] NavigationLink { RowDetailView( columns: result.columns, - rows: result.rows, + rows: viewModel.window.rows, initialIndex: rowIndex ) } label: { diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 14d1b2ff6..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 { @@ -75,18 +89,19 @@ struct RowDetailView: View { } var body: some View { - rowContent(at: currentIndex) - .gesture( - DragGesture(minimumDistance: 30) - .onEnded { value in - guard !isEditing else { return } - if value.translation.width < -50, currentIndex < rows.count - 1 { - currentIndex += 1 - } else if value.translation.width > 50, currentIndex > 0 { - currentIndex -= 1 - } + Group { + if isEditing { + rowContent(at: currentIndex) + } else { + TabView(selection: $currentIndex) { + ForEach(rows.indices, id: \.self) { index in + rowContent(at: index) + .tag(index) } - ) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + } .background(Color(.systemGroupedBackground)) .onDisappear { dismissSuccessTask?.cancel() @@ -220,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: { @@ -414,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() From 8da641335c1c1b47072ab3f58172b462e585a186 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 20:06:28 +0700 Subject: [PATCH 10/10] perf(ios): drop QueryResult synthesis on every render in QueryEditorView --- .../Views/QueryEditorView.swift | 54 ++++++++----------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 6fff4ee43..ac670a677 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -20,20 +20,12 @@ struct QueryEditorView: View { @State private var executionTime: TimeInterval? @State private var executeTask: Task? - private var result: QueryResult? { - guard !viewModel.columns.isEmpty || viewModel.rowsAffected != nil else { return nil } - let isTruncated: Bool = { - if case .truncated = viewModel.phase { return true } - return false - }() - return QueryResult( - columns: viewModel.columns, - rows: viewModel.legacyRows, - rowsAffected: viewModel.rowsAffected ?? 0, - executionTime: viewModel.executionTime, - isTruncated: isTruncated, - statusMessage: viewModel.statusMessage - ) + 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? @@ -121,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 } @@ -164,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) } @@ -208,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 { @@ -235,21 +227,21 @@ struct QueryEditorView: View { } } - private func resultList(_ result: QueryResult) -> some View { + private var resultList: some View { List { - ForEach(result.rows.indices, id: \.self) { rowIndex in - let row = result.rows[rowIndex] + ForEach(viewModel.legacyRows.indices, id: \.self) { rowIndex in + let row = viewModel.legacyRows[rowIndex] NavigationLink { RowDetailView( - columns: result.columns, + 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) } } } @@ -325,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 @@ -343,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)