diff --git a/CHANGELOG.md b/CHANGELOG.md index 586a2a9ad..f6b29ceb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS: add data to a table from the Shortcuts app. Two new shortcuts, Add Row to Table and Add Rows to Table, pick a saved connection, database or schema, and table, then insert from JSON or CSV. They run without opening the app. (#1788) - Refresh button in Settings > Plugins > Browse to reload the plugin list on demand. (#1799) - SQL Favorites: put ;; in a saved query to set where the cursor lands after keyword expansion in the editor. (#1795) +- SELECT queries without their own LIMIT now run with the row cap applied as a real LIMIT on the statement sent to the server, so forgetting a LIMIT no longer pulls millions of rows. The query text in the editor never changes, a query with its own LIMIT is sent as written and not capped, and Execute Query Without Limit (Option+Cmd+Enter, also in the Query menu and the Execute button menu) skips the cap for one run. (#1794) ### Fixed - Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796) +- Execute All Statements now applies the query result row cap to each SELECT in the batch instead of returning every row, and each result tab tracks its own truncation state so Fetch All loads the rest of the result you are viewing. (#1794) +- Queries starting with a comment or with a newline right after the first keyword are now classified correctly: the row cap applies to them, and Safe Mode recognizes such writes and destructive statements instead of letting them through unprompted. +- Enum and set value pickers now populate when the driver reports the values only inside the column type instead of as a separate list. - The plugin list no longer goes stale. The app now checks the plugin registry for changes at launch, when the plugin browser opens, and before reporting a plugin as missing, so newly published plugins show up and install right away. A registry that cannot be reached now reports a connection problem instead of "Plugin not found". (#1799) - Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800) diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 98592e76b..fb67df17e 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -414,26 +414,30 @@ final class MariaDBPluginConnection: @unchecked Sendable { // MARK: - Query Execution - func executeQuery(_ query: String) async throws -> MariaDBPluginQueryResult { + func executeQuery(_ query: String, rowCap: Int? = nil) async throws -> MariaDBPluginQueryResult { let queryToRun = String(query) return try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { throw MariaDBPluginError.notConnected } - return try executeQuerySync(queryToRun) + return try executeQuerySync(queryToRun, rowCap: rowCap) } } - func executeParameterizedQuery(_ query: String, parameters: [PluginCellValue]) async throws -> MariaDBPluginQueryResult { + func executeParameterizedQuery( + _ query: String, + parameters: [PluginCellValue], + rowCap: Int? = nil + ) async throws -> MariaDBPluginQueryResult { let queryToRun = String(query) let params = parameters return try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { throw MariaDBPluginError.notConnected } - return try executeParameterizedQuerySync(queryToRun, parameters: params) + return try executeParameterizedQuerySync(queryToRun, parameters: params, rowCap: rowCap) } } - private func executeQuerySync(_ query: String) throws -> MariaDBPluginQueryResult { + private func executeQuerySync(_ query: String, rowCap: Int? = nil) throws -> MariaDBPluginQueryResult { guard !isShuttingDown, let mysql = self.mysql else { throw MariaDBPluginError.notConnected } @@ -500,7 +504,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { var rows: [[PluginCellValue]] = [] rows.reserveCapacity(min(1_000, PluginRowLimits.emergencyMax)) - let maxRows = PluginRowLimits.emergencyMax + let maxRows = rowCap.map { min(max($0, 1), PluginRowLimits.emergencyMax) } ?? PluginRowLimits.emergencyMax var truncated = false while let rowPtr = mysql_fetch_row(resultPtr) { @@ -659,7 +663,8 @@ final class MariaDBPluginConnection: @unchecked Sendable { columns: [String], columnTypes: [UInt32], columnTypeNames: [String], - columnIsBinary: [Bool] + columnIsBinary: [Bool], + rowCap: Int? = nil ) throws -> (rows: [[PluginCellValue]], isTruncated: Bool) { let numFields = columns.count var resultBinds: [MYSQL_BIND] = Array(repeating: MYSQL_BIND(), count: numFields) @@ -694,7 +699,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { } var rows: [[PluginCellValue]] = [] - let maxRows = PluginRowLimits.emergencyMax + let maxRows = rowCap.map { min(max($0, 1), PluginRowLimits.emergencyMax) } ?? PluginRowLimits.emergencyMax var truncated = false while true { @@ -762,7 +767,11 @@ final class MariaDBPluginConnection: @unchecked Sendable { return (rows: rows, isTruncated: truncated) } - private func executeParameterizedQuerySync(_ query: String, parameters: [PluginCellValue]) throws -> MariaDBPluginQueryResult { + private func executeParameterizedQuerySync( + _ query: String, + parameters: [PluginCellValue], + rowCap: Int? = nil + ) throws -> MariaDBPluginQueryResult { guard !isShuttingDown, let mysql = self.mysql else { throw MariaDBPluginError.notConnected } @@ -857,7 +866,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { let fetchResult = try fetchResultSet( from: stmt, metadata: metadata, columns: columns, columnTypes: columnTypes, columnTypeNames: columnTypeNames, - columnIsBinary: columnIsBinary + columnIsBinary: columnIsBinary, rowCap: rowCap ) return MariaDBPluginQueryResult( diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index fadb612d5..723bd989a 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -111,6 +111,27 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { try await executeWithReconnect(query: query, isRetry: false) } + func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult { + let cap = rowCap.flatMap { $0 > 0 ? $0 : nil } + guard let parameters else { + return try await executeWithReconnect(query: query, isRetry: false, rowCap: cap) + } + guard let conn = mariadbConnection else { + throw MariaDBPluginError.notConnected + } + let startTime = Date() + let result = try await conn.executeParameterizedQuery(query, parameters: parameters, rowCap: cap) + return PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypeNames, + rows: result.rows, + rowsAffected: Int(result.affectedRows), + executionTime: Date().timeIntervalSince(startTime), + isTruncated: result.isTruncated, + columnMeta: result.columnMeta + ) + } + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { guard let conn = mariadbConnection else { throw MariaDBPluginError.notConnected @@ -134,7 +155,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { mariadbConnection?.cancelCurrentQuery() } - private func executeWithReconnect(query: String, isRetry: Bool) async throws -> PluginQueryResult { + private func executeWithReconnect(query: String, isRetry: Bool, rowCap: Int? = nil) async throws -> PluginQueryResult { let startTime = Date() guard let conn = mariadbConnection else { @@ -142,7 +163,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } do { - let result = try await conn.executeQuery(query) + let result = try await conn.executeQuery(query, rowCap: rowCap) if result.columns.isEmpty && result.rows.isEmpty { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) @@ -171,7 +192,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } catch let error as MariaDBPluginError where !isRetry && isConnectionLostError(error) { try await reconnect() - return try await executeWithReconnect(query: query, isRetry: true) + return try await executeWithReconnect(query: query, isRetry: true, rowCap: rowCap) } } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 7ab9f248c..929faeafd 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -168,6 +168,9 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // EXPLAIN query building (optional) func buildExplainQuery(_ sql: String) -> String? + // Row limit injection for executed queries (optional, return nil to use app-level fallback) + func injectRowLimit(_ sql: String, limit: Int) -> String? + func quoteIdentifier(_ name: String) -> String func escapeStringLiteral(_ value: String) -> String @@ -347,6 +350,8 @@ public extension PluginDatabaseDriver { func buildExplainQuery(_ sql: String) -> String? { nil } + func injectRowLimit(_ sql: String, limit: Int) -> String? { nil } + func createViewTemplate() -> String? { nil } func editViewFallbackTemplate(viewName: String) -> String? { nil } func castColumnToText(_ column: String) -> String { column } diff --git a/Plugins/TableProPluginKit/SqlDialect.swift b/Plugins/TableProPluginKit/SqlDialect.swift index 7c01ad9c8..cb1d0950b 100644 --- a/Plugins/TableProPluginKit/SqlDialect.swift +++ b/Plugins/TableProPluginKit/SqlDialect.swift @@ -27,6 +27,10 @@ public enum SqlDialect: String, Sendable, CaseIterable { self == .postgres } + public var supportsHashLineComments: Bool { + self == .mysql + } + public var supportsEscapeStringPrefix: Bool { self == .postgres } diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 8f274b3fb..eae727b89 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -231,6 +231,7 @@ final class PaginationCoordinator { tab.execution.executionTime = result.executionTime tab.schemaVersion += 1 tab.pagination.resetLoadMore() + tab.display.activeResultSet?.isTruncated = false } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(replaceDelta) parent.toolbarState.setExecuting(false) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 11c2e5f4a..9034dc172 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -57,7 +57,8 @@ extension QueryExecutionCoordinator { sql: String, connection conn: DatabaseConnection, isTruncated: Bool = false, - queryParameterValues: [QueryParameter]? = nil + queryParameterValues: [QueryParameter]? = nil, + historySQL: String? = nil ) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -148,6 +149,8 @@ extension QueryExecutionCoordinator { rs.tableName = tab.tableContext.tableName rs.isEditable = tab.tableContext.isEditable rs.metadataVersion = tab.metadataVersion + rs.isTruncated = isTruncated + rs.baseQuery = sql let pinned = tab.display.resultSets.filter(\.isPinned) tab.display.resultSets = pinned + [rs] @@ -191,7 +194,7 @@ extension QueryExecutionCoordinator { } QueryHistoryManager.shared.recordQuery( - query: sql, + query: historySQL ?? sql, connectionId: conn.id, databaseName: parent.activeDatabaseName, executionTime: executionTime, diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index 952507f1b..4cb9e652f 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -3,195 +3,72 @@ // TablePro // -import AppKit import Foundation -import os import TableProPluginKit -private let multiStatementLogger = Logger(subsystem: "com.TablePro", category: "MultiStatement") - extension QueryExecutionCoordinator { - func executeMultipleStatements(_ statements: [String]) { - guard let index = parent.tabManager.selectedTabIndex else { return } - guard !parent.tabManager.tabs[index].execution.isExecuting else { return } - - parent.currentQueryTask?.cancel() - parent.queryGeneration += 1 - let capturedGeneration = parent.queryGeneration + func executeMultipleStatements(_ statements: [String], bypassRowLimit: Bool = false) { + executeMultipleStatementsWithParameters(statements, parameters: [], bypassRowLimit: bypassRowLimit) + } - parent.tabManager.mutate(at: index) { tab in - tab.execution.isExecuting = true - tab.execution.executionTime = nil - tab.execution.errorMessage = nil + func executeStatement( + plan: QueryLimitPlan, + originalSQL: String, + driver: DatabaseDriver, + parameters: [Any?]? = nil + ) async throws -> QueryResult { + if plan.rowCap != nil { + return try await driver.executeUserQuery(query: plan.executedSQL, rowCap: plan.rowCap, parameters: parameters) } - parent.toolbarState.setExecuting(true) - - let conn = parent.connection - let tabId = parent.tabManager.tabs[index].id - let totalCount = statements.count - - parent.currentQueryTask = Task { [weak self, parent] in - guard let self else { return } - var cumulativeTime: TimeInterval = 0 - var lastSelectResult: QueryResult? - var lastSelectSQL: String? - var totalRowsAffected = 0 - var executedCount = 0 - var failedSQL: String? - var newResultSets: [ResultSet] = [] - - do { - guard let driver = DatabaseManager.shared.driver(for: conn.id) else { - throw DatabaseError.notConnected - } - - let useTransaction = driver.supportsTransactions - - if useTransaction { - try await driver.beginTransaction() - } - - @MainActor func rollbackAndResetState() async { - if useTransaction { - do { - try await driver.rollbackTransaction() - } catch { - multiStatementLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") - } - } - parent.tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } - parent.currentQueryTask = nil - parent.toolbarState.setExecuting(false) - } - - for (stmtIndex, sql) in statements.enumerated() { - guard !Task.isCancelled else { - await rollbackAndResetState() - return - } - guard capturedGeneration == parent.queryGeneration else { - await rollbackAndResetState() - return - } - - failedSQL = sql - let result = try await driver.execute(query: sql) - failedSQL = nil - executedCount = stmtIndex + 1 - cumulativeTime += result.executionTime - totalRowsAffected += result.rowsAffected - - if !result.columns.isEmpty { - lastSelectResult = result - lastSelectSQL = sql - } - - let stmtTableName = await MainActor.run { parent.extractTableName(from: sql) } - let stmtRows = TableRows.from( - queryRows: result.rows, - columns: result.columns.map { String($0) }, - columnTypes: result.columnTypes - ) - let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)", tableRows: stmtRows) - rs.executionTime = result.executionTime - rs.rowsAffected = result.rowsAffected - rs.statusMessage = result.statusMessage - rs.tableName = stmtTableName - newResultSets.append(rs) - - let historySQL = sql.hasSuffix(";") ? sql : sql + ";" - await MainActor.run { - QueryHistoryManager.shared.recordQuery( - query: historySQL, - connectionId: conn.id, - databaseName: parent.activeDatabaseName, - executionTime: result.executionTime, - rowCount: result.rows.count, - wasSuccessful: true, - errorMessage: nil - ) - } - } - - if useTransaction { - try await driver.commitTransaction() - } - - await MainActor.run { - applyMultiStatementResults( - tabId: tabId, - capturedGeneration: capturedGeneration, - cumulativeTime: cumulativeTime, - totalRowsAffected: totalRowsAffected, - lastSelectResult: lastSelectResult, - lastSelectSQL: lastSelectSQL, - newResultSets: newResultSets - ) - } - } catch { - if let driver = DatabaseManager.shared.driver(for: conn.id), driver.supportsTransactions { - do { - try await driver.rollbackTransaction() - } catch { - multiStatementLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") - } - } - - if capturedGeneration != parent.queryGeneration - || error is CancellationError - || Task.isCancelled { - await MainActor.run { [weak self] in - guard let self else { return } - parent.tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } - parent.currentQueryTask = nil - parent.toolbarState.setExecuting(false) - } - return - } - - let failedStmtIndex = executedCount + 1 - let contextMsg = "Statement \(failedStmtIndex)/\(totalCount) failed: " - + error.localizedDescription - - let errorRS = ResultSet(label: "Error \(failedStmtIndex)") - errorRS.errorMessage = error.localizedDescription - newResultSets.append(errorRS) - - await MainActor.run { [weak self] in - guard let self else { return } - parent.currentQueryTask = nil - parent.toolbarState.setExecuting(false) - - parent.tabManager.mutate(tabId: tabId) { tab in - tab.execution.errorMessage = contextMsg - tab.execution.isExecuting = false - tab.execution.executionTime = cumulativeTime - - let pinnedResults = tab.display.resultSets.filter(\.isPinned) - tab.display.resultSets = pinnedResults + newResultSets - tab.display.activeResultSetId = newResultSets.last?.id - } - - let rawSQL = failedSQL ?? statements[min(executedCount, totalCount - 1)] - let recordSQL = rawSQL.hasSuffix(";") ? rawSQL : rawSQL + ";" - QueryHistoryManager.shared.recordQuery( - query: recordSQL, - connectionId: conn.id, - databaseName: parent.activeDatabaseName, - executionTime: cumulativeTime, - rowCount: 0, - wasSuccessful: false, - errorMessage: error.localizedDescription - ) + if let parameters { + return try await driver.executeParameterized(query: originalSQL, parameters: parameters) + } + return try await driver.execute(query: originalSQL) + } - AlertHelper.showErrorSheet( - title: String(localized: "Query Execution Failed"), - message: contextMsg, - window: parent.contentWindow - ) - } - } + func makeStatementResultSet( + result: QueryResult, + sql: String, + index: Int, + baseQuery: String, + baseQueryParameterValues: [String?]? = nil + ) -> ResultSet { + let tableName = parent.extractTableName(from: sql) + let rows = TableRows.from( + queryRows: result.rows, + columns: result.columns.map { String($0) }, + columnTypes: result.columnTypes + ) + let resultSet = ResultSet(label: tableName ?? "Result \(index + 1)", tableRows: rows) + resultSet.executionTime = result.executionTime + resultSet.rowsAffected = result.rowsAffected + resultSet.statusMessage = result.statusMessage + resultSet.tableName = tableName + if !result.columns.isEmpty { + resultSet.isTruncated = result.isTruncated + resultSet.baseQuery = baseQuery + resultSet.baseQueryParameterValues = baseQueryParameterValues } + return resultSet + } + + func recordStatementHistory( + sql: String, + result: QueryResult, + connection: DatabaseConnection, + parameterValues: [QueryParameter]? = nil + ) { + let historySQL = sql.hasSuffix(";") ? sql : sql + ";" + QueryHistoryManager.shared.recordQuery( + query: historySQL, + connectionId: connection.id, + databaseName: parent.activeDatabaseName, + executionTime: result.executionTime, + rowCount: result.rows.count, + wasSuccessful: true, + errorMessage: nil, + parameterValues: parameterValues + ) } func applyMultiStatementResults( @@ -260,6 +137,16 @@ extension QueryExecutionCoordinator { if tab.display.isResultsCollapsed { tab.display.isResultsCollapsed = false } + + let activeResultSet = newResultSets.last + if activeResultSet?.isTruncated == true { + tab.pagination.hasMoreRows = true + tab.pagination.isLoadingMore = false + } else { + tab.pagination.resetLoadMore() + } + tab.pagination.baseQueryForMore = activeResultSet?.baseQuery + tab.pagination.baseQueryParameterValues = activeResultSet?.baseQueryParameterValues } parent.toolbarState.isResultsCollapsed = false diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index f8b1f42af..b82c54d9b 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -14,7 +14,7 @@ extension QueryExecutionCoordinator { QueryExecutor.detectAndReconcileParameters(sql: sql, existing: existing) } - func executeQueryWithParameters(_ sql: String, parameters: [QueryParameter]) { + func executeQueryWithParameters(_ sql: String, parameters: [QueryParameter], bypassRowLimit: Bool = false) { guard let (_, index) = parent.tabManager.selectedTabAndIndex else { return } let missing = parameters.filter { @@ -44,14 +44,18 @@ extension QueryExecutionCoordinator { executeQueryInternalParameterized( conversion.sql, parameters: conversion.values, - originalParameters: parameters + originalParameters: parameters, + bypassRowLimit: bypassRowLimit, + originalSQL: sql ) } func executeQueryInternalParameterized( _ sql: String, parameters: [Any?], - originalParameters: [QueryParameter] + originalParameters: [QueryParameter], + bypassRowLimit: Bool = false, + originalSQL: String? = nil ) { guard let (selectedTab, index) = parent.tabManager.selectedTabAndIndex, !selectedTab.execution.isExecuting else { return } @@ -85,7 +89,7 @@ extension QueryExecutionCoordinator { let conn = parent.connection let tabId = parent.tabManager.tabs[index].id - let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType) + let plan = resolveExecutionPlan(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) let (tableName, isEditable) = parent.resolveTableEditability(tab: tab, sql: sql) let needsMetadataFetch: Bool @@ -108,9 +112,9 @@ extension QueryExecutionCoordinator { do { let fetchResult = try await parent.queryExecutor.executeQuery( - sql: sql, + sql: plan.executedSQL, parameters: parameters, - rowCap: rowCap + rowCap: plan.rowCap ) guard !Task.isCancelled else { @@ -133,7 +137,8 @@ extension QueryExecutionCoordinator { connection: conn, capturedGeneration: capturedGeneration, originalParameters: originalParameters, - nativeParameters: parameters + nativeParameters: parameters, + originalSQL: originalSQL ) if isEditable, let tableName { @@ -173,13 +178,17 @@ extension QueryExecutionCoordinator { parent.toolbarState.setExecuting(false) if error is CancellationError || Task.isCancelled { return } guard capturedGeneration == parent.queryGeneration else { return } - handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn) + handleQueryExecutionError(error, sql: plan.executedSQL, tabId: tabId, connection: conn) } } } } - func executeMultipleStatementsWithParameters(_ statements: [String], parameters: [QueryParameter]) { + func executeMultipleStatementsWithParameters( + _ statements: [String], + parameters: [QueryParameter], + bypassRowLimit: Bool = false + ) { guard let (selectedTab, index) = parent.tabManager.selectedTabAndIndex, !selectedTab.execution.isExecuting else { return } @@ -215,6 +224,8 @@ extension QueryExecutionCoordinator { let tabId = parent.tabManager.tabs[index].id let totalCount = statements.count + let tabType = parent.tabManager.tabs[index].tabType + parent.currentQueryTask = Task { [weak self, parent] in guard let self else { return } var cumulativeTime: TimeInterval = 0 @@ -259,24 +270,22 @@ extension QueryExecutionCoordinator { return } - failedSQL = stmtSQL - let stmtParamNames = SQLParameterExtractor.extractParameters(from: stmtSQL) - - let result: QueryResult - if stmtParamNames.isEmpty { - result = try await driver.execute(query: stmtSQL) - } else { - let conversion = SQLParameterExtractor.convertToNativeStyle( - sql: stmtSQL, - parameters: parameters, - style: style - ) - result = try await driver.executeParameterized( - query: conversion.sql, - parameters: conversion.values - ) - } - + let stmtParamNames = parameters.isEmpty + ? [] + : SQLParameterExtractor.extractParameters(from: stmtSQL) + let conversion = stmtParamNames.isEmpty + ? nil + : SQLParameterExtractor.convertToNativeStyle(sql: stmtSQL, parameters: parameters, style: style) + let statementSQL = conversion?.sql ?? stmtSQL + + let plan = resolveExecutionPlan(sql: statementSQL, tabType: tabType, bypassLimit: bypassRowLimit) + failedSQL = plan.executedSQL + let result = try await executeStatement( + plan: plan, + originalSQL: statementSQL, + driver: driver, + parameters: conversion?.values + ) failedSQL = nil executedCount = stmtIndex + 1 cumulativeTime += result.executionTime @@ -284,35 +293,22 @@ extension QueryExecutionCoordinator { if !result.columns.isEmpty { lastSelectResult = result - lastSelectSQL = stmtSQL + lastSelectSQL = statementSQL } - let stmtTableName = await MainActor.run { parent.extractTableName(from: stmtSQL) } - let stmtRows = TableRows.from( - queryRows: result.rows, - columns: result.columns.map { String($0) }, - columnTypes: result.columnTypes + newResultSets.append(makeStatementResultSet( + result: result, + sql: stmtSQL, + index: stmtIndex, + baseQuery: statementSQL, + baseQueryParameterValues: conversion?.values.map { $0 as? String } + )) + recordStatementHistory( + sql: stmtSQL, + result: result, + connection: conn, + parameterValues: stmtParamNames.isEmpty ? nil : parameters ) - let rs = ResultSet(label: stmtTableName ?? "Result \(stmtIndex + 1)", tableRows: stmtRows) - rs.executionTime = result.executionTime - rs.rowsAffected = result.rowsAffected - rs.statusMessage = result.statusMessage - rs.tableName = stmtTableName - newResultSets.append(rs) - - let historySQL = stmtSQL.hasSuffix(";") ? stmtSQL : stmtSQL + ";" - await MainActor.run { - QueryHistoryManager.shared.recordQuery( - query: historySQL, - connectionId: conn.id, - databaseName: parent.activeDatabaseName, - executionTime: result.executionTime, - rowCount: result.rows.count, - wasSuccessful: true, - errorMessage: nil, - parameterValues: stmtParamNames.isEmpty ? nil : parameters - ) - } } if useTransaction { @@ -357,7 +353,8 @@ extension QueryExecutionCoordinator { connection: DatabaseConnection, capturedGeneration: Int, originalParameters: [QueryParameter], - nativeParameters: [Any?] + nativeParameters: [Any?], + originalSQL: String? = nil ) async { await MainActor.run { [weak self] in guard let self else { return } @@ -388,11 +385,14 @@ extension QueryExecutionCoordinator { sql: sql, connection: connection, isTruncated: fetchResult.isTruncated, - queryParameterValues: originalParameters + queryParameterValues: originalParameters, + historySQL: originalSQL ) + let parameterValues = nativeParameters.map { $0 as? String } parent.tabManager.mutate(tabId: tabId) { - $0.pagination.baseQueryParameterValues = nativeParameters.map { $0 as? String } + $0.pagination.baseQueryParameterValues = parameterValues + $0.display.activeResultSet?.baseQueryParameterValues = parameterValues } } } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+RowLimit.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+RowLimit.swift new file mode 100644 index 000000000..4163899b4 --- /dev/null +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+RowLimit.swift @@ -0,0 +1,39 @@ +// +// QueryExecutionCoordinator+RowLimit.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct QueryLimitPlan { + let rowCap: Int? + let executedSQL: String +} + +extension QueryExecutionCoordinator { + func resolveExecutionPlan(sql: String, tabType: TabType, bypassLimit: Bool = false) -> QueryLimitPlan { + guard !bypassLimit, let rowCap = resolveRowCap(sql: sql, tabType: tabType) else { + return QueryLimitPlan(rowCap: nil, executedSQL: sql) + } + let overFetchLimit = rowCap + 1 + if let adapter = DatabaseManager.shared.driver(for: parent.connectionId) as? PluginDriverAdapter, + let injected = adapter.injectRowLimit(sql, limit: overFetchLimit) { + return QueryLimitPlan(rowCap: rowCap, executedSQL: injected) + } + let injection = SQLLimitInjector.inject( + into: sql, + limit: overFetchLimit, + autoLimitStyle: PluginManager.shared.autoLimitStyle(for: parent.connection.type), + lexicalDialect: parent.sqlDialect + ) + switch injection { + case .injected(let injectedSQL): + return QueryLimitPlan(rowCap: rowCap, executedSQL: injectedSQL) + case .alreadyLimited: + return QueryLimitPlan(rowCap: nil, executedSQL: sql) + case .notInjectable: + return QueryLimitPlan(rowCap: rowCap, executedSQL: sql) + } + } +} diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift index 4df3e6855..49964f82f 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift @@ -50,7 +50,7 @@ final class QueryExecutionCoordinator { dispatchStatements(statements, tabIndex: index) } - func dispatchStatements(_ statements: [String], tabIndex index: Int) { + func dispatchStatements(_ statements: [String], tabIndex index: Int, bypassRowLimit: Bool = false) { guard !parent.isShowingSafeModePrompt else { return } parent.isShowingSafeModePrompt = true let request = makeExecuteRequest(statements: statements) @@ -59,9 +59,9 @@ final class QueryExecutionCoordinator { switch await ExecutionGateProvider.shared.authorize(request) { case .authorized: if statements.count == 1 { - parent.executeQueryInternal(statements[0]) + parent.executeQueryInternal(statements[0], bypassRowLimit: bypassRowLimit) } else { - executeMultipleStatements(statements) + executeMultipleStatements(statements, bypassRowLimit: bypassRowLimit) } case .denied(let reason): parent.tabManager.mutate(at: index) { $0.execution.errorMessage = reason } @@ -84,7 +84,8 @@ final class QueryExecutionCoordinator { func dispatchParameterizedStatements( _ statements: [String], parameters: [QueryParameter], - tabIndex index: Int + tabIndex index: Int, + bypassRowLimit: Bool = false ) { guard !parent.isShowingSafeModePrompt else { return } parent.isShowingSafeModePrompt = true @@ -94,7 +95,7 @@ final class QueryExecutionCoordinator { defer { parent.isShowingSafeModePrompt = false } switch await ExecutionGateProvider.shared.authorize(request) { case .authorized: - executeParameterizedAfterSafeMode(statements, parameters: parameters) + executeParameterizedAfterSafeMode(statements, parameters: parameters, bypassRowLimit: bypassRowLimit) case .denied(let reason): parent.tabManager.mutate(tabId: tabId) { $0.execution.errorMessage = reason } } @@ -103,12 +104,13 @@ final class QueryExecutionCoordinator { private func executeParameterizedAfterSafeMode( _ statements: [String], - parameters: [QueryParameter] + parameters: [QueryParameter], + bypassRowLimit: Bool ) { if statements.count == 1 { - executeQueryWithParameters(statements[0], parameters: parameters) + executeQueryWithParameters(statements[0], parameters: parameters, bypassRowLimit: bypassRowLimit) } else { - executeMultipleStatementsWithParameters(statements, parameters: parameters) + executeMultipleStatementsWithParameters(statements, parameters: parameters, bypassRowLimit: bypassRowLimit) } } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index a2e3e39f2..28b44a469 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -619,6 +619,12 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.buildExplainQuery(sql) } + // MARK: - Row Limit Injection + + func injectRowLimit(_ sql: String, limit: Int) -> String? { + pluginDriver.injectRowLimit(sql, limit: limit) + } + // MARK: - View Templates func createViewTemplate() -> String? { diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 3e1a65e5a..49efc0f2c 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -174,6 +174,8 @@ final class QueryExecutor { for col in schema.columns { if let values = col.allowedValues, !values.isEmpty { enumValues[col.name] = values + } else if let values = EnumValueParser.parseMySQLEnumOrSet(from: col.dataType), !values.isEmpty { + enumValues[col.name] = values } if let comment = col.comment?.nilIfEmpty { comments[col.name] = comment @@ -215,19 +217,22 @@ final class QueryExecutor { static func resolveRowCap(sql: String, tabType: TabType, databaseType: DatabaseType) -> Int? { let dataGridSettings = AppSettingsManager.shared.dataGrid - let trimmedUpper = sql.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - let isSelectQuery = trimmedUpper.hasPrefix("SELECT ") || trimmedUpper.hasPrefix("WITH ") - let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType) - let isDDL = isDDLStatement(sql) - - guard tabType == .query, isSelectQuery, !isWrite, !isDDL, - dataGridSettings.truncateQueryResults + guard dataGridSettings.truncateQueryResults, + qualifiesForRowCap(sql: sql, tabType: tabType, databaseType: databaseType) else { return nil } return dataGridSettings.validatedQueryResultRowCap } + static func qualifiesForRowCap(sql: String, tabType: TabType, databaseType: DatabaseType) -> Bool { + guard tabType == .query else { return false } + let keyword = QueryClassifier.leadingKeyword(of: sql) + return (keyword == "SELECT" || keyword == "WITH") + && !QueryClassifier.isWriteQuery(sql, databaseType: databaseType) + && !isDDLStatement(sql) + } + private static let ddlPrefixes: [String] = [ "CREATE", "DROP", "ALTER", "TRUNCATE", "RENAME", ] diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index be3a12fa6..597589322 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -13,11 +13,11 @@ enum QueryTier { } enum QueryClassifier { - private static let writeQueryPrefixes: [String] = [ - "INSERT ", "UPDATE ", "DELETE ", "REPLACE ", - "DROP ", "TRUNCATE ", "ALTER ", "CREATE ", - "RENAME ", "GRANT ", "REVOKE ", - "MERGE ", "UPSERT ", "CALL ", "EXEC ", "EXECUTE ", "LOAD ", + private static let writeQueryKeywords: Set = [ + "INSERT", "UPDATE", "DELETE", "REPLACE", + "DROP", "TRUNCATE", "ALTER", "CREATE", + "RENAME", "GRANT", "REVOKE", + "MERGE", "UPSERT", "CALL", "EXEC", "EXECUTE", "LOAD", ] private static let redisWriteCommands: Set = [ @@ -37,7 +37,7 @@ enum QueryClassifier { private static let whereClauseRegex = try? NSRegularExpression(pattern: "\\sWHERE\\s", options: []) static func isWriteQuery(_ sql: String, databaseType: DatabaseType) -> Bool { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = strippingLeadingComments(sql).trimmingCharacters(in: .whitespacesAndNewlines) if databaseType == .redis { let firstToken = trimmed.prefix(while: { !$0.isWhitespace }).uppercased() @@ -48,14 +48,15 @@ enum QueryClassifier { return redisWriteCommands.contains(firstToken) } - let uppercased = trimmed.uppercased() - if writeQueryPrefixes.contains(where: { uppercased.hasPrefix($0) }) { + let keyword = leadingKeyword(of: trimmed) + if writeQueryKeywords.contains(keyword) { return true } - if uppercased.hasPrefix("WITH ") { + if keyword == "WITH" { + let uppercased = trimmed.uppercased() let dmlKeywords = ["INSERT ", "UPDATE ", "DELETE ", "MERGE "] - for keyword in dmlKeywords where uppercased.contains(keyword) { + for dmlKeyword in dmlKeywords where uppercased.contains(dmlKeyword) { return true } } @@ -64,7 +65,7 @@ enum QueryClassifier { } static func isDangerousQuery(_ sql: String, databaseType: DatabaseType) -> Bool { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = strippingLeadingComments(sql).trimmingCharacters(in: .whitespacesAndNewlines) if databaseType == .redis { let firstToken = trimmed.prefix(while: { !$0.isWhitespace }).uppercased() @@ -75,17 +76,14 @@ enum QueryClassifier { return redisDangerousCommands.contains(firstToken) } - let uppercased = trimmed.uppercased() + let keyword = leadingKeyword(of: trimmed) - if uppercased.hasPrefix("DROP ") { + if keyword == "DROP" || keyword == "TRUNCATE" { return true } - if uppercased.hasPrefix("TRUNCATE ") { - return true - } - - if uppercased.hasPrefix("DELETE ") { + if keyword == "DELETE" { + let uppercased = trimmed.uppercased() let range = NSRange(uppercased.startIndex..., in: uppercased) let hasWhere = whereClauseRegex?.firstMatch(in: uppercased, options: [], range: range) != nil return !hasWhere @@ -95,8 +93,7 @@ enum QueryClassifier { } static func classifyTier(_ sql: String, databaseType: DatabaseType) -> QueryTier { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - let uppercased = trimmed.uppercased() + let trimmed = strippingLeadingComments(sql).trimmingCharacters(in: .whitespacesAndNewlines) if databaseType == .redis { let firstToken = trimmed.prefix(while: { !$0.isWhitespace }).uppercased() @@ -104,20 +101,22 @@ enum QueryClassifier { return .destructive } } else { - if uppercased.hasPrefix("DROP ") || uppercased.hasPrefix("TRUNCATE ") { + let keyword = leadingKeyword(of: trimmed) + if keyword == "DROP" || keyword == "TRUNCATE" { return .destructive } - if uppercased.hasPrefix("ALTER ") && uppercased.range(of: " DROP ", options: .literal) != nil { + if keyword == "ALTER", trimmed.uppercased().range(of: " DROP ", options: .literal) != nil { return .destructive } - if uppercased.hasPrefix("WITH ") { + if keyword == "WITH" { + let uppercased = trimmed.uppercased() let destructiveKeywords = ["DROP ", "TRUNCATE "] - for keyword in destructiveKeywords where uppercased.contains(keyword) { + for destructiveKeyword in destructiveKeywords where uppercased.contains(destructiveKeyword) { return .destructive } let writeKeywords = ["INSERT ", "UPDATE ", "DELETE ", "MERGE "] - for keyword in writeKeywords where uppercased.contains(keyword) { + for writeKeyword in writeKeywords where uppercased.contains(writeKeyword) { return .write } } @@ -147,7 +146,12 @@ enum QueryClassifier { } } - private static func strippingLeadingComments(_ sql: String) -> String { + static func leadingKeyword(of sql: String) -> String { + let stripped = strippingLeadingComments(sql) + return stripped.prefix { $0.isLetter || $0.isNumber || $0 == "_" }.uppercased() + } + + static func strippingLeadingComments(_ sql: String) -> String { var remaining = sql[...] while true { let trimmed = remaining.drop { $0.isWhitespace } diff --git a/TablePro/Core/Utilities/SQL/SQLLimitInjector.swift b/TablePro/Core/Utilities/SQL/SQLLimitInjector.swift new file mode 100644 index 000000000..34061561d --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLLimitInjector.swift @@ -0,0 +1,248 @@ +// +// SQLLimitInjector.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +enum SQLLimitInjectionResult: Equatable { + case injected(String) + case alreadyLimited + case notInjectable +} + +enum SQLLimitInjector { + static func inject( + into sql: String, + limit: Int, + autoLimitStyle: AutoLimitStyle, + lexicalDialect: SqlDialect + ) -> SQLLimitInjectionResult { + guard autoLimitStyle != .none, limit > 0 else { return .notInjectable } + switch scan(sql, dialect: lexicalDialect, autoLimitStyle: autoLimitStyle) { + case .insertAt(let index): + guard autoLimitStyle == .limit else { return .notInjectable } + var result = sql + result.insert(contentsOf: " LIMIT \(limit)", at: String.Index(utf16Offset: index, in: result)) + return .injected(result) + case .alreadyLimited: + return .alreadyLimited + case .notInjectable: + return .notInjectable + } + } + + private enum ScanResult { + case insertAt(Int) + case alreadyLimited + case notInjectable + } + + private static let limitingKeywords: [String] = ["LIMIT", "FETCH"] + private static let blockingKeywords: [String] = [ + "OFFSET", "FOR", "INTO", "LOCK", "FORMAT", "SETTINGS", "ALLOW", "FILTERING", + ] + + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let semicolon = UInt16(UnicodeScalar(";").value) + private static let dash = UInt16(UnicodeScalar("-").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let star = UInt16(UnicodeScalar("*").value) + private static let hash = UInt16(UnicodeScalar("#").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let backslash = UInt16(UnicodeScalar("\\").value) + private static let dollar = UInt16(UnicodeScalar("$").value) + private static let openParen = UInt16(UnicodeScalar("(").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + + private static func scan( + _ sql: String, + dialect: SqlDialect, + autoLimitStyle: AutoLimitStyle + ) -> ScanResult { + let buffer = sql as NSString + let length = buffer.length + guard length > 0 else { return .notInjectable } + + let dollarQuotesEnabled = dialect.supportsDollarQuotes + let hashCommentsEnabled = dialect.supportsHashLineComments + var inString = false + var stringChar: UInt16 = 0 + var inLineComment = false + var inBlockComment = false + var inDollarQuote = false + var dollarTag = "" + var parenDepth = 0 + var lastCodeEnd = 0 + var sawStatementEnd = false + var i = 0 + + while i < length { + let ch = buffer.character(at: i) + + if inLineComment { + if ch == newline { inLineComment = false } + i += 1 + continue + } + + if inBlockComment { + if ch == star, i + 1 < length, buffer.character(at: i + 1) == slash { + inBlockComment = false + i += 2 + continue + } + i += 1 + continue + } + + if inDollarQuote { + if ch == dollar, SqlDollarQuote.matchesClose(at: i, tag: dollarTag, in: buffer, bufLen: length) { + inDollarQuote = false + i += (dollarTag as NSString).length + 2 + lastCodeEnd = i + dollarTag = "" + continue + } + i += 1 + lastCodeEnd = i + continue + } + + if !inString, ch == dash, i + 1 < length, buffer.character(at: i + 1) == dash { + inLineComment = true + i += 2 + continue + } + + if !inString, ch == slash, i + 1 < length, buffer.character(at: i + 1) == star { + inBlockComment = true + i += 2 + continue + } + + if !inString, hashCommentsEnabled, ch == hash { + inLineComment = true + i += 1 + continue + } + + if isWhitespace(ch), !inString { + i += 1 + continue + } + + if sawStatementEnd { return .notInjectable } + + if inString, ch == backslash, i + 1 < length { + i += 2 + lastCodeEnd = i + continue + } + + if ch == singleQuote || ch == doubleQuote || ch == backtick { + if !inString { + inString = true + stringChar = ch + } else if ch == stringChar { + if i + 1 < length, buffer.character(at: i + 1) == stringChar { + i += 2 + lastCodeEnd = i + continue + } + inString = false + } + i += 1 + lastCodeEnd = i + continue + } + + if inString { + i += 1 + lastCodeEnd = i + continue + } + + if dollarQuotesEnabled, ch == dollar, + case .opener(let openerLength, let tag) = SqlDollarQuote.scanOpener(at: i, in: buffer, bufLen: length) { + inDollarQuote = true + dollarTag = tag + i += openerLength + lastCodeEnd = i + continue + } + + if ch == openParen { + parenDepth += 1 + i += 1 + lastCodeEnd = i + continue + } + + if ch == closeParen { + parenDepth -= 1 + guard parenDepth >= 0 else { return .notInjectable } + i += 1 + lastCodeEnd = i + continue + } + + if ch == semicolon, parenDepth == 0 { + sawStatementEnd = true + i += 1 + continue + } + + if SqlDollarQuote.isIdentifierStart(ch), i == 0 || !SqlDollarQuote.isIdentifierContinuation(buffer.character(at: i - 1)) { + var end = i + 1 + while end < length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 } + if parenDepth == 0 { + if matchesAny(limitingKeywords, in: buffer, start: i, end: end) { + return .alreadyLimited + } + if autoLimitStyle == .top, matchesKeyword("TOP", in: buffer, start: i, end: end) { + return .alreadyLimited + } + if matchesAny(blockingKeywords, in: buffer, start: i, end: end) { + return .notInjectable + } + } + i = end + lastCodeEnd = end + continue + } + + i += 1 + lastCodeEnd = i + } + + guard !inString, !inBlockComment, !inDollarQuote, parenDepth == 0, lastCodeEnd > 0 else { + return .notInjectable + } + return .insertAt(lastCodeEnd) + } + + private static func matchesAny(_ keywords: [String], in buffer: NSString, start: Int, end: Int) -> Bool { + keywords.contains { matchesKeyword($0, in: buffer, start: start, end: end) } + } + + private static func matchesKeyword(_ keyword: String, in buffer: NSString, start: Int, end: Int) -> Bool { + let keywordBuffer = keyword as NSString + guard end - start == keywordBuffer.length else { return false } + for offset in 0.. UInt16 { + (ch >= 0x61 && ch <= 0x7A) ? ch - 0x20 : ch + } + + private static func isWhitespace(_ ch: UInt16) -> Bool { + ch == 0x20 || ch == 0x09 || ch == 0x0A || ch == 0x0D + } +} diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index bfc22b679..67489f330 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -22,6 +22,9 @@ final class ResultSet: Identifiable { var tableName: String? var isEditable: Bool = false var isPinned: Bool = false + var isTruncated: Bool = false + var baseQuery: String? + var baseQueryParameterValues: [String?]? var metadataVersion: Int = 0 var sortState = SortState() var pagination = PaginationState() diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 304a61232..f128e2240 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -71,6 +71,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case saveAs case executeQuery case executeAllStatements + case executeQueryWithoutLimit case cancelQuery case explainQuery case formatQuery @@ -130,8 +131,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .manageConnections, .newConnection, .openDatabase, .switchConnection: return .connections case .openFile, .saveChanges, .saveAs, .executeQuery, .executeAllStatements, - .cancelQuery, .explainQuery, .formatQuery, .previewSQL, .findNext, - .findPrevious, .aiExplainQuery, .aiOptimizeQuery: + .executeQueryWithoutLimit, .cancelQuery, .explainQuery, .formatQuery, + .previewSQL, .findNext, .findPrevious, .aiExplainQuery, .aiOptimizeQuery: return .editor case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, @@ -148,9 +149,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { var context: ShortcutContext { switch self { - case .executeQuery, .executeAllStatements, .cancelQuery, .explainQuery, - .formatQuery, .previewSQL, .findNext, .findPrevious, .aiExplainQuery, - .aiOptimizeQuery: + case .executeQuery, .executeAllStatements, .executeQueryWithoutLimit, + .cancelQuery, .explainQuery, .formatQuery, .previewSQL, .findNext, + .findPrevious, .aiExplainQuery, .aiOptimizeQuery: return .editor case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow, .delete, .truncateTable, .previewFKReference, .saveAsFavorite, @@ -176,6 +177,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .newConnection: return String(localized: "New Connection") case .executeQuery: return String(localized: "Execute Query") case .executeAllStatements: return String(localized: "Execute All Statements") + case .executeQueryWithoutLimit: return String(localized: "Execute Query Without Limit") case .cancelQuery: return String(localized: "Cancel Query") case .newTab: return String(localized: "New Tab") case .openDatabase: return String(localized: "Open Database") @@ -400,6 +402,7 @@ struct KeyboardSettings: Codable, Equatable { .saveAs: .character("s", command: true, shift: true), .executeQuery: .special(.return, command: true), .executeAllStatements: .special(.return, command: true, shift: true), + .executeQueryWithoutLimit: .special(.return, command: true, option: true), .cancelQuery: .character(".", command: true), .explainQuery: .character("e", command: true, option: true), .formatQuery: .character("l", command: true, shift: true), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 1ac21f65a..4ece4318d 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -394,6 +394,12 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .executeAllStatements)) .disabled(!(actions?.isConnected ?? false) || !(actions?.hasQueryText ?? false)) + Button(String(localized: "Execute Query Without Limit")) { + actions?.runQueryWithoutLimit() + } + .optionalKeyboardShortcut(shortcut(for: .executeQueryWithoutLimit)) + .disabled(!(actions?.isConnected ?? false) || !(actions?.hasQueryText ?? false)) + Button("Explain Query") { actions?.explainQuery() } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 785555ae4..ea797058e 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -17,6 +17,7 @@ struct QueryEditorView: View { @Binding var parameters: [QueryParameter] @Binding var isParameterPanelVisible: Bool var onExecute: () -> Void + var onExecuteWithoutLimit: (() -> Void)? var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? var connectionId: UUID? @@ -135,14 +136,25 @@ struct QueryEditorView: View { explainButton(hasQueryText: hasQueryText) - Button(action: onExecute) { + Menu { + Button(String(localized: "Execute Without Limit")) { + onExecuteWithoutLimit?() + } + .optionalKeyboardShortcut( + AppSettingsManager.shared.keyboard.keyboardShortcut(for: .executeQueryWithoutLimit) + ) + } label: { HStack(spacing: 4) { Image(systemName: "play.fill") Text("Execute") } + } primaryAction: { + onExecute() } + .menuStyle(.button) .buttonStyle(.borderedProminent) .controlSize(.small) + .fixedSize() .help(shortcutHint(String(localized: "Execute"), for: .executeQuery)) .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: .executeQuery)) } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 196570300..24b9d500e 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -334,6 +334,7 @@ struct MainEditorContentView: View { parameters: parameterBinding(for: tab), isParameterPanelVisible: parameterVisibilityBinding(for: tab), onExecute: { coordinator.runQuery() }, + onExecuteWithoutLimit: { coordinator.runQuery(bypassRowLimit: true) }, schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id), databaseType: coordinator.connection.type, connectionId: coordinator.connection.id, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift index 33f343dd8..701da3c40 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ExecuteAll.swift @@ -10,19 +10,21 @@ extension MainContentCoordinator { queryExecutionCoordinator.runAllStatements() } - internal func dispatchStatements(_ statements: [String], tabIndex index: Int) { - queryExecutionCoordinator.dispatchStatements(statements, tabIndex: index) + internal func dispatchStatements(_ statements: [String], tabIndex index: Int, bypassRowLimit: Bool = false) { + queryExecutionCoordinator.dispatchStatements(statements, tabIndex: index, bypassRowLimit: bypassRowLimit) } internal func dispatchParameterizedStatements( _ statements: [String], parameters: [QueryParameter], - tabIndex index: Int + tabIndex index: Int, + bypassRowLimit: Bool = false ) { queryExecutionCoordinator.dispatchParameterizedStatements( statements, parameters: parameters, - tabIndex: index + tabIndex: index, + bypassRowLimit: bypassRowLimit ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift deleted file mode 100644 index 0e54eba57..000000000 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// MainContentCoordinator+MultiStatement.swift -// TablePro -// - -import Foundation - -extension MainContentCoordinator { - func executeMultipleStatements(_ statements: [String]) { - queryExecutionCoordinator.executeMultipleStatements(statements) - } - - internal func applyMultiStatementResults( - tabId: UUID, - capturedGeneration: Int, - cumulativeTime: TimeInterval, - totalRowsAffected: Int, - lastSelectResult: QueryResult?, - lastSelectSQL: String?, - newResultSets: [ResultSet] - ) { - queryExecutionCoordinator.applyMultiStatementResults( - tabId: tabId, - capturedGeneration: capturedGeneration, - cumulativeTime: cumulativeTime, - totalRowsAffected: totalRowsAffected, - lastSelectResult: lastSelectResult, - lastSelectSQL: lastSelectSQL, - newResultSets: newResultSets - ) - } -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 1c1a2f095..f3011a3b8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -23,8 +23,8 @@ extension MainContentCoordinator { } } - func resolveRowCap(sql: String, tabType: TabType) -> Int? { - queryExecutionCoordinator.resolveRowCap(sql: sql, tabType: tabType) + func resolveExecutionPlan(sql: String, tabType: TabType, bypassLimit: Bool = false) -> QueryLimitPlan { + queryExecutionCoordinator.resolveExecutionPlan(sql: sql, tabType: tabType, bypassLimit: bypassLimit) } func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift index bda11af91..9bc3dcf05 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryParameters.swift @@ -10,24 +10,4 @@ extension MainContentCoordinator { func detectAndReconcileParameters(sql: String, existing: [QueryParameter]) -> [QueryParameter] { queryExecutionCoordinator.detectAndReconcileParameters(sql: sql, existing: existing) } - - func executeQueryWithParameters(_ sql: String, parameters: [QueryParameter]) { - queryExecutionCoordinator.executeQueryWithParameters(sql, parameters: parameters) - } - - internal func executeQueryInternalParameterized( - _ sql: String, - parameters: [Any?], - originalParameters: [QueryParameter] - ) { - queryExecutionCoordinator.executeQueryInternalParameterized( - sql, - parameters: parameters, - originalParameters: originalParameters - ) - } - - func executeMultipleStatementsWithParameters(_ statements: [String], parameters: [QueryParameter]) { - queryExecutionCoordinator.executeMultipleStatementsWithParameters(statements, parameters: parameters) - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index afb5bd314..96fb92c23 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -36,10 +36,25 @@ extension MainContentCoordinator { tabManager.mutate(at: tabIdx) { $0.display.activeResultSetId = resultSetId } if let incoming = tabManager.tabs[tabIdx].display.activeResultSet { tabSessionRegistry.setTableRows(incoming.tableRows, for: tabId) + syncLoadMoreState(from: incoming, at: tabIdx) notifyFullReplaceIfActive(tabId: tabId) } } + private func syncLoadMoreState(from resultSet: ResultSet, at tabIdx: Int) { + guard tabManager.tabs[tabIdx].tabType == .query else { return } + tabManager.mutate(at: tabIdx) { tab in + if resultSet.isTruncated { + tab.pagination.hasMoreRows = true + tab.pagination.isLoadingMore = false + } else { + tab.pagination.resetLoadMore() + } + tab.pagination.baseQueryForMore = resultSet.baseQuery + tab.pagination.baseQueryParameterValues = resultSet.baseQueryParameterValues + } + } + private func notifyFullReplaceIfActive(tabId: UUID) { guard let idx = tabManager.selectedTabIndex, idx < tabManager.tabs.count, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index d3969bf39..7384fac21 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -761,6 +761,10 @@ final class MainContentCommandActions { coordinator?.runQuery() } + func runQueryWithoutLimit() { + coordinator?.runQuery(bypassRowLimit: true) + } + func runAllStatements() { coordinator?.runAllStatements() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 388179859..a65cc3df0 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -862,7 +862,7 @@ final class MainContentCoordinator { // MARK: - Query Execution - func runQuery(trigger: TableLoadTrigger = .userInitiated) { + func runQuery(trigger: TableLoadTrigger = .userInitiated, bypassRowLimit: Bool = false) { guard let (tab, index) = tabManager.selectedTabAndIndex, !tab.execution.isExecuting else { return } @@ -921,7 +921,8 @@ final class MainContentCoordinator { dispatchParameterizedStatements( paramStatements, parameters: reconciled, - tabIndex: index + tabIndex: index, + bypassRowLimit: bypassRowLimit ) return } @@ -931,7 +932,7 @@ final class MainContentCoordinator { guard !statements.isEmpty else { return } tabManager.tabStructureVersion += 1 - dispatchStatements(statements, tabIndex: index) + dispatchStatements(statements, tabIndex: index, bypassRowLimit: bypassRowLimit) } /// Execute table tab query directly. @@ -1120,20 +1121,13 @@ final class MainContentCoordinator { internal func executeQueryInternal( _ sql: String, isAutoLoad: Bool = false, - trigger: TableLoadTrigger = .userInitiated + trigger: TableLoadTrigger = .userInitiated, + bypassRowLimit: Bool = false ) { guard let (selectedTab, index) = tabManager.selectedTabAndIndex, !selectedTab.execution.isExecuting else { return } - if currentQueryTask != nil { - currentQueryTask?.cancel() - do { - try services.databaseManager.driver(for: connectionId)?.cancelQuery() - } catch { - Self.logger.warning("cancelQuery failed: \(error.localizedDescription, privacy: .public)") - } - currentQueryTask = nil - } + cancelInFlightQueryTask() queryGeneration += 1 let capturedGeneration = queryGeneration @@ -1154,7 +1148,7 @@ final class MainContentCoordinator { let conn = connection let tabId = tabManager.tabs[index].id - let rowCap = resolveRowCap(sql: sql, tabType: tab.tabType) + let plan = resolveExecutionPlan(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: sql) let needsMetadataFetch: Bool @@ -1207,9 +1201,9 @@ final class MainContentCoordinator { do { let fetchResult = try await queryExecutor.executeQuery( - sql: sql, + sql: plan.executedSQL, parameters: nil, - rowCap: rowCap + rowCap: plan.rowCap ) guard !Task.isCancelled else { @@ -1295,12 +1289,23 @@ final class MainContentCoordinator { pendingLoadTrigger = trigger return } - handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn, trigger: trigger) + handleQueryExecutionError(error, sql: plan.executedSQL, tabId: tabId, connection: conn, trigger: trigger) } } } } + private func cancelInFlightQueryTask() { + guard currentQueryTask != nil else { return } + currentQueryTask?.cancel() + do { + try services.databaseManager.driver(for: connectionId)?.cancelQuery() + } catch { + Self.logger.warning("cancelQuery failed: \(error.localizedDescription, privacy: .public)") + } + currentQueryTask = nil + } + /// Reset execution state when a query is cancelled @MainActor internal func resetExecutionState(tabId: UUID, executionTime: TimeInterval) { @@ -1380,7 +1385,10 @@ final class MainContentCoordinator { if tab.tabType == .query { let tabId = tab.id let capturedSort = newState - let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query + let hasBoundParameters = tab.pagination.baseQueryParameterValues?.isEmpty == false + let baseQuery = hasBoundParameters + ? tab.content.query + : (tab.pagination.baseQueryForMore ?? tab.content.query) let capturedColumns = tableRows.columns confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in guard let self, confirmed else { return } diff --git a/TablePro/Views/Settings/Sections/DataGridSection.swift b/TablePro/Views/Settings/Sections/DataGridSection.swift index 5f7acdaaa..dfbe5c9c9 100644 --- a/TablePro/Views/Settings/Sections/DataGridSection.swift +++ b/TablePro/Views/Settings/Sections/DataGridSection.swift @@ -73,7 +73,7 @@ struct DataGridSection: View { Section { Toggle("Truncate query results", isOn: $settings.truncateQueryResults) - .help(String(localized: "Cap user query results at the configured row count")) + .help(String(localized: "Apply a row limit when running queries and cap results at the configured row count")) if settings.truncateQueryResults { Picker("Row cap:", selection: $settings.queryResultRowCap) { @@ -96,7 +96,11 @@ struct DataGridSection: View { Text("Query Result Row Cap") } footer: { if settings.truncateQueryResults, settings.queryResultRowCapValidationError == nil { - Text("Capped results show a Fetch All button to load the full set") + Text(""" + SELECT queries without their own LIMIT run with this cap applied. The query text in the \ + editor never changes. Capped results show a Fetch All button, and Execute Without Limit \ + skips the cap for one run. + """) } } } diff --git a/TableProTests/Core/Database/ExecuteUserQueryTests.swift b/TableProTests/Core/Database/ExecuteUserQueryTests.swift index e8069494a..0d3995859 100644 --- a/TableProTests/Core/Database/ExecuteUserQueryTests.swift +++ b/TableProTests/Core/Database/ExecuteUserQueryTests.swift @@ -79,6 +79,19 @@ struct ExecuteUserQueryTests { #expect(driver.lastExecutedQuery == userSql) } + @Test("Passes SQL with an app-appended LIMIT through byte-for-byte and still caps post-fetch") + func passesInjectedLimitSqlUnchanged() async throws { + let rows = (1...6).map { ["row_\($0)"] } + let driver = StubPluginDriver(rows: rows) + let injectedSql = "SELECT * FROM t LIMIT 6" + + let result = try await driver.executeUserQuery(query: injectedSql, rowCap: 5, parameters: nil) + + #expect(driver.lastExecutedQuery == injectedSql) + #expect(result.rows.count == 5) + #expect(result.isTruncated) + } + @Test("Routes parameterized queries through executeParameterized with the same SQL") func parameterizedRoutesCorrectly() async throws { let driver = StubPluginDriver(rows: [["x"]]) diff --git a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift index 59347aaa9..a2e3271e5 100644 --- a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift +++ b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift @@ -287,3 +287,31 @@ struct DriverPluginCustomOverridesTests { // NOTE: Per-plugin metadata tests (MySQL, PostgreSQL, etc.) cannot run in xcodebuild test // because .tableplugin bundles are loaded at runtime by the main app, not the test runner. // The protocol defaults and override mechanism are fully covered by the mock-based tests above. + +@Suite("Registry defaults auto-limit styles") +struct RegistryAutoLimitStyleTests { + private var defaults: [String: PluginMetadataSnapshot] { + Dictionary( + PluginMetadataRegistry.shared.registryPluginDefaults().map { ($0.typeId, $0.snapshot) }, + uniquingKeysWith: { first, _ in first } + ) + } + + @Test("Non-SQL plugins declare no SQL dialect so auto-limit resolves to none") + func nonSqlPluginsHaveNoDialect() { + #expect(defaults["MongoDB"]?.editor.sqlDialect == nil) + #expect(defaults["Redis"]?.editor.sqlDialect == nil) + #expect(defaults["etcd"]?.editor.sqlDialect == nil) + } + + @Test("SQL plugins declare the auto-limit style matching their dialect") + func sqlPluginsDeclareStyle() { + #expect(defaults["SQL Server"]?.editor.sqlDialect?.autoLimitStyle == .top) + #expect(defaults["Oracle"]?.editor.sqlDialect?.autoLimitStyle == .fetchFirst) + #expect(defaults["ClickHouse"]?.editor.sqlDialect?.autoLimitStyle == .limit) + #expect(defaults["DuckDB"]?.editor.sqlDialect?.autoLimitStyle == .limit) + #expect(defaults["Cassandra"]?.editor.sqlDialect?.autoLimitStyle == .limit) + #expect(defaults["Cloudflare D1"]?.editor.sqlDialect?.autoLimitStyle == .limit) + #expect(defaults["libSQL"]?.editor.sqlDialect?.autoLimitStyle == .limit) + } +} diff --git a/TableProTests/Core/Services/Query/QueryExecutorTests.swift b/TableProTests/Core/Services/Query/QueryExecutorTests.swift index 1c522d21b..db0c477b5 100644 --- a/TableProTests/Core/Services/Query/QueryExecutorTests.swift +++ b/TableProTests/Core/Services/Query/QueryExecutorTests.swift @@ -108,6 +108,63 @@ struct QueryExecutorTests { #expect(!QueryExecutor.isDDLStatement("DELETE FROM foo")) } + // MARK: - Row cap qualification + + @Test("qualifiesForRowCap accepts SELECT and WITH queries on query tabs") + func qualifiesForRowCapSelects() { + #expect(QueryExecutor.qualifiesForRowCap( + sql: "SELECT * FROM users", tabType: .query, databaseType: .mysql + )) + #expect(QueryExecutor.qualifiesForRowCap( + sql: "WITH cte AS (SELECT 1) SELECT * FROM cte", tabType: .query, databaseType: .postgresql + )) + } + + @Test("qualifiesForRowCap accepts SELECT followed by newline, tab, or punctuation") + func qualifiesForRowCapKeywordBoundaries() { + #expect(QueryExecutor.qualifiesForRowCap( + sql: "SELECT\n *\nFROM big_table", tabType: .query, databaseType: .mysql + )) + #expect(QueryExecutor.qualifiesForRowCap( + sql: "SELECT\t* FROM t", tabType: .query, databaseType: .mysql + )) + #expect(QueryExecutor.qualifiesForRowCap( + sql: "SELECT*FROM t", tabType: .query, databaseType: .mysql + )) + #expect(!QueryExecutor.qualifiesForRowCap( + sql: "SELECTX FROM t", tabType: .query, databaseType: .mysql + )) + } + + @Test("qualifiesForRowCap accepts SELECT queries preceded by comments") + func qualifiesForRowCapCommentPrefixed() { + #expect(QueryExecutor.qualifiesForRowCap( + sql: "-- top users\nSELECT * FROM users", tabType: .query, databaseType: .mysql + )) + #expect(QueryExecutor.qualifiesForRowCap( + sql: "/* audit */ SELECT * FROM users", tabType: .query, databaseType: .mysql + )) + } + + @Test("qualifiesForRowCap rejects writes, DDL, EXPLAIN, and table tabs") + func qualifiesForRowCapRejections() { + #expect(!QueryExecutor.qualifiesForRowCap( + sql: "DELETE FROM users", tabType: .query, databaseType: .mysql + )) + #expect(!QueryExecutor.qualifiesForRowCap( + sql: "CREATE TABLE foo (id INT)", tabType: .query, databaseType: .mysql + )) + #expect(!QueryExecutor.qualifiesForRowCap( + sql: "EXPLAIN SELECT * FROM users", tabType: .query, databaseType: .mysql + )) + #expect(!QueryExecutor.qualifiesForRowCap( + sql: "SELECT * FROM users", tabType: .table, databaseType: .mysql + )) + #expect(!QueryExecutor.qualifiesForRowCap( + sql: "WITH cte AS (SELECT 1) DELETE FROM users", tabType: .query, databaseType: .postgresql + )) + } + // MARK: - Parameter detection @Test("detectAndReconcileParameters returns empty when SQL has no placeholders") diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift index 2b45c449e..1eca6dd73 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift @@ -45,3 +45,51 @@ struct QueryClassifierExplainTests { #expect(!QueryClassifier.isExplainStatement("")) } } + +@Suite("QueryClassifier classification with leading comments") +struct QueryClassifierLeadingCommentTests { + @Test("isWriteQuery detects writes preceded by comments") + func writeDetectionWithComments() { + #expect(QueryClassifier.isWriteQuery("-- cleanup\nDELETE FROM users", databaseType: .mysql)) + #expect(QueryClassifier.isWriteQuery("/* batch */ INSERT INTO t VALUES (1)", databaseType: .postgresql)) + #expect(!QueryClassifier.isWriteQuery("-- note\nSELECT * FROM users", databaseType: .mysql)) + } + + @Test("isDangerousQuery detects destructive statements preceded by comments") + func dangerousDetectionWithComments() { + #expect(QueryClassifier.isDangerousQuery("-- reset\nDROP TABLE users", databaseType: .mysql)) + #expect(QueryClassifier.isDangerousQuery("/* wipe */ TRUNCATE users", databaseType: .postgresql)) + #expect(QueryClassifier.isDangerousQuery("-- purge\nDELETE FROM users", databaseType: .mysql)) + #expect(!QueryClassifier.isDangerousQuery("-- purge\nDELETE FROM users WHERE id = 1", databaseType: .mysql)) + } + + @Test("classifyTier classifies statements preceded by comments") + func tierClassificationWithComments() { + #expect(QueryClassifier.classifyTier("-- reset\nDROP TABLE users", databaseType: .mysql) == .destructive) + #expect(QueryClassifier.classifyTier("/* batch */ UPDATE t SET x = 1", databaseType: .mysql) == .write) + #expect(QueryClassifier.classifyTier("-- note\nSELECT 1", databaseType: .mysql) == .safe) + } +} + +@Suite("QueryClassifier keyword boundary handling") +struct QueryClassifierKeywordBoundaryTests { + @Test("isWriteQuery detects writes followed by newline or tab") + func writeDetectionAcrossWhitespace() { + #expect(QueryClassifier.isWriteQuery("DELETE\nFROM users", databaseType: .mysql)) + #expect(QueryClassifier.isWriteQuery("INSERT\tINTO t VALUES (1)", databaseType: .postgresql)) + #expect(!QueryClassifier.isWriteQuery("DELETED_ROWS", databaseType: .mysql)) + } + + @Test("isDangerousQuery detects destructive statements followed by newline") + func dangerousDetectionAcrossWhitespace() { + #expect(QueryClassifier.isDangerousQuery("DROP\nTABLE users", databaseType: .mysql)) + #expect(QueryClassifier.isDangerousQuery("DELETE\nFROM users", databaseType: .mysql)) + #expect(!QueryClassifier.isDangerousQuery("DELETE\nFROM users WHERE id = 1", databaseType: .mysql)) + } + + @Test("classifyTier classifies statements followed by newline") + func tierClassificationAcrossWhitespace() { + #expect(QueryClassifier.classifyTier("TRUNCATE\nusers", databaseType: .mysql) == .destructive) + #expect(QueryClassifier.classifyTier("UPDATE\nt SET x = 1", databaseType: .mysql) == .write) + } +} diff --git a/TableProTests/Core/Utilities/SQL/SQLLimitInjectorTests.swift b/TableProTests/Core/Utilities/SQL/SQLLimitInjectorTests.swift new file mode 100644 index 000000000..808be598f --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/SQLLimitInjectorTests.swift @@ -0,0 +1,172 @@ +// +// SQLLimitInjectorTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("SQLLimitInjector appends a LIMIT only when the statement has none") +struct SQLLimitInjectorTests { + private func inject( + _ sql: String, + limit: Int = 501, + style: AutoLimitStyle = .limit, + dialect: SqlDialect = .generic + ) -> SQLLimitInjectionResult { + SQLLimitInjector.inject(into: sql, limit: limit, autoLimitStyle: style, lexicalDialect: dialect) + } + + @Test("Appends LIMIT to a bare SELECT") + func appendsToBareSelect() { + #expect(inject("SELECT * FROM users") == .injected("SELECT * FROM users LIMIT 501")) + } + + @Test("Reports alreadyLimited for a top-level LIMIT") + func detectsExistingLimit() { + #expect(inject("SELECT * FROM users LIMIT 10") == .alreadyLimited) + #expect(inject("select * from users limit 10 offset 5") == .alreadyLimited) + } + + @Test("Reports alreadyLimited for FETCH FIRST") + func detectsFetchFirst() { + #expect(inject("SELECT * FROM t FETCH FIRST 5 ROWS ONLY") == .alreadyLimited) + } + + @Test("Reports alreadyLimited for TOP only under the top style") + func detectsTopOnlyForTopStyle() { + #expect(inject("SELECT TOP 5 * FROM t", style: .top) == .alreadyLimited) + #expect(inject("SELECT top FROM quotas") == .injected("SELECT top FROM quotas LIMIT 501")) + } + + @Test("Reports notInjectable for a top-level OFFSET without LIMIT") + func offsetAloneIsNotInjectable() { + #expect(inject("SELECT * FROM users OFFSET 5") == .notInjectable) + } + + @Test("Injects on the outer statement when a CTE has an inner LIMIT") + func injectsOuterStatementForCte() { + let sql = "WITH cte AS (SELECT * FROM t LIMIT 5) SELECT * FROM cte" + #expect(inject(sql) == .injected("WITH cte AS (SELECT * FROM t LIMIT 5) SELECT * FROM cte LIMIT 501")) + } + + @Test("Injects when only a subquery has a LIMIT") + func injectsWhenSubqueryLimited() { + let sql = "SELECT * FROM (SELECT * FROM t LIMIT 5) sub" + #expect(inject(sql) == .injected("SELECT * FROM (SELECT * FROM t LIMIT 5) sub LIMIT 501")) + } + + @Test("Inserts before a trailing line comment") + func insertsBeforeTrailingLineComment() { + #expect(inject("SELECT * FROM t -- fetch it all") == .injected("SELECT * FROM t LIMIT 501 -- fetch it all")) + #expect(inject("SELECT * FROM t\n-- done") == .injected("SELECT * FROM t LIMIT 501\n-- done")) + } + + @Test("Inserts before a trailing block comment") + func insertsBeforeTrailingBlockComment() { + #expect(inject("SELECT * FROM t /* LIMIT 9 */") == .injected("SELECT * FROM t LIMIT 501 /* LIMIT 9 */")) + } + + @Test("Keeps a leading comment and injects at the end") + func keepsLeadingComment() { + #expect(inject("-- top users\nSELECT * FROM users") == .injected("-- top users\nSELECT * FROM users LIMIT 501")) + } + + @Test("Treats hash comments as comments only for MySQL") + func hashCommentsAreDialectGated() { + #expect(inject("SELECT * FROM t # note", dialect: .mysql) == .injected("SELECT * FROM t LIMIT 501 # note")) + #expect(inject("SELECT * FROM t # see LIMIT docs", dialect: .mysql) + == .injected("SELECT * FROM t LIMIT 501 # see LIMIT docs")) + #expect(inject("SELECT 1 # 2", dialect: .postgres) == .injected("SELECT 1 # 2 LIMIT 501")) + } + + @Test("Reports notInjectable for Cassandra ALLOW FILTERING") + func allowFilteringIsNotInjectable() { + #expect(inject("SELECT * FROM t WHERE x = 1 ALLOW FILTERING") == .notInjectable) + } + + @Test("Appends once after a UNION without a top-level LIMIT") + func appendsAfterUnion() { + let sql = "SELECT a FROM t1 UNION ALL SELECT b FROM t2" + #expect(inject(sql) == .injected("SELECT a FROM t1 UNION ALL SELECT b FROM t2 LIMIT 501")) + } + + @Test("Reports alreadyLimited when a LIMIT applies to the whole UNION") + func detectsUnionTopLevelLimit() { + #expect(inject("SELECT a FROM t1 UNION SELECT b FROM t2 LIMIT 5") == .alreadyLimited) + } + + @Test("Appends after a parenthesized UNION whose branches have inner LIMITs") + func appendsAfterParenthesizedUnion() { + let sql = "(SELECT a FROM t1 LIMIT 5) UNION (SELECT b FROM t2 LIMIT 5)" + #expect(inject(sql) == .injected("(SELECT a FROM t1 LIMIT 5) UNION (SELECT b FROM t2 LIMIT 5) LIMIT 501")) + } + + @Test("Preserves a trailing semicolon after the injected clause") + func preservesTrailingSemicolon() { + #expect(inject("SELECT * FROM t;") == .injected("SELECT * FROM t LIMIT 501;")) + #expect(inject("SELECT * FROM t; -- done") == .injected("SELECT * FROM t LIMIT 501; -- done")) + } + + @Test("Ignores LIMIT-like text inside string literals") + func ignoresLimitInsideStrings() { + #expect(inject("SELECT * FROM t WHERE note = 'no LIMIT here'") + == .injected("SELECT * FROM t WHERE note = 'no LIMIT here' LIMIT 501")) + } + + @Test("Ignores LIMIT inside dollar-quoted bodies for PostgreSQL") + func ignoresLimitInsideDollarQuotes() { + let sql = "SELECT $tag$LIMIT 5$tag$ FROM t" + #expect(inject(sql, dialect: .postgres) == .injected("SELECT $tag$LIMIT 5$tag$ FROM t LIMIT 501")) + } + + @Test("Does not mistake identifiers containing limit for a LIMIT clause") + func ignoresLimitLikeIdentifiers() { + #expect(inject("SELECT limit_used FROM quotas") == .injected("SELECT limit_used FROM quotas LIMIT 501")) + #expect(inject("SELECT `limit` FROM quotas") == .injected("SELECT `limit` FROM quotas LIMIT 501")) + } + + @Test("Reports notInjectable for trailing clauses that must not precede LIMIT") + func trailingClausesAreNotInjectable() { + #expect(inject("SELECT * FROM t FOR UPDATE") == .notInjectable) + #expect(inject("SELECT * FROM t LOCK IN SHARE MODE") == .notInjectable) + #expect(inject("SELECT * INTO backup FROM t") == .notInjectable) + #expect(inject("SELECT * FROM t FORMAT JSON") == .notInjectable) + #expect(inject("SELECT * FROM t SETTINGS max_threads = 1") == .notInjectable) + } + + @Test("Reports notInjectable for non-limit dialect styles") + func nonLimitStylesAreNotInjectable() { + #expect(inject("SELECT * FROM t", style: .top) == .notInjectable) + #expect(inject("SELECT * FROM t", style: .fetchFirst) == .notInjectable) + #expect(inject("SELECT * FROM t", style: AutoLimitStyle.none) == .notInjectable) + } + + @Test("Detects an existing constraint even for non-limit dialect styles") + func detectsConstraintForNonLimitStyles() { + #expect(inject("SELECT * FROM t FETCH FIRST 5 ROWS ONLY", style: .fetchFirst) == .alreadyLimited) + } + + @Test("Reports notInjectable for non-positive limits, empty input, and unbalanced statements") + func invalidInputIsNotInjectable() { + #expect(inject("SELECT * FROM t", limit: 0) == .notInjectable) + #expect(inject("") == .notInjectable) + #expect(inject(" ") == .notInjectable) + #expect(inject("-- only a comment") == .notInjectable) + #expect(inject("SELECT * FROM (t") == .notInjectable) + #expect(inject("SELECT 'unterminated FROM t") == .notInjectable) + } + + @Test("Reports notInjectable when code follows a top-level semicolon") + func multiStatementInputIsNotInjectable() { + #expect(inject("SELECT 1; SELECT 2") == .notInjectable) + } + + @Test("Handles escaped quotes inside string literals") + func handlesEscapedQuotes() { + #expect(inject("SELECT * FROM t WHERE name = 'it''s'") + == .injected("SELECT * FROM t WHERE name = 'it''s' LIMIT 501")) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 8fc291259..90f71eae9 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -151,7 +151,7 @@ Copies follow the grid as shown: hidden columns are left out and columns keep th Pagination controls -**Query tabs** cap results at 10,000 rows by default to stay responsive. When the cap applies, the status bar shows **Fetch All** to load the rest (with a confirmation, since large results use a lot of memory). Your own `LIMIT` and `OFFSET` are passed through unchanged. Adjust the cap in **Settings > Editor** (**Truncate query results**, **Row cap**). +**Query tabs** cap results at 10,000 rows by default to stay responsive. For databases with `LIMIT` syntax, TablePro appends the cap to the statement it sends to the server, so the database stops early; the query text in the editor never changes. A query with its own `LIMIT` or `FETCH FIRST` is sent as written and not capped. When the cap trims a result, the status bar shows **Fetch All** to load the rest (with a confirmation, since large results use a lot of memory); in a multi-statement run, each result tab remembers its own truncation state. Run one query without the cap via **Execute Without Limit** (`Option+Cmd+Enter`). Adjust the cap in **Settings > Editor** (**Truncate query results**, **Row cap**). Press `Cmd+.` to cancel a running query or a Fetch All. diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index ffaa96769..225771edb 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -38,6 +38,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------|-------------| | Execute query | `Cmd+Enter` | Run query at cursor. Shows parameter panel if `:name` placeholders are detected | | Execute all statements | `Cmd+Shift+Enter` | Run all statements in the editor. Shows parameter panel if parameters are detected | +| Execute query without limit | `Option+Cmd+Enter` | Run query at cursor without the row cap | | Cancel query | `Cmd+.` | Stop the currently running query | | Explain query | `Option+Cmd+E` | Show execution plan for query at cursor | | Format SQL | `Cmd+Shift+L` | Format the selected SQL, or the whole query when nothing is selected | diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 53c64b8e5..dba43212e 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -121,10 +121,19 @@ See [Query Parameters](/features/query-parameters) for details. | Action | Shortcut | Description | |--------|----------|-------------| | Execute query | `Cmd+Enter` | Runs query at cursor, or all selected statements | +| Execute without limit | `Option+Cmd+Enter` | Runs the query at cursor without the row cap | | Cancel query | `Cmd+.` | Stops the running query | | Explain query | `Option+Cmd+E` | Show the execution plan for the query at cursor | | Format query | `Cmd+Shift+L` | Format the current query for readability | +### Automatic Row Limit + +SELECT and WITH queries that have no `LIMIT` or `FETCH FIRST` of their own run with the configured row cap applied. For MySQL, PostgreSQL, SQLite, ClickHouse, DuckDB, Cassandra, Cloudflare D1, and libSQL, TablePro appends a `LIMIT` to the statement it sends to the server, so the database stops early instead of returning millions of rows. The query text in the editor never changes, and query history records your query text, not the rewritten statement. + +Your own `LIMIT` or `FETCH FIRST` always wins: the query is sent as written and the row cap does not apply to that run. Statements like `EXPLAIN`, `SHOW`, writes, and DDL are never limited. Statements ending in clauses that must follow `LIMIT` (such as `FOR UPDATE` or CQL's `ALLOW FILTERING`) are sent as written, with the cap enforced after the query runs. SQL Server and Oracle also keep the row cap applied after the query runs. + +When the cap trims a result, the status bar shows **truncated** with a **Fetch All** link. To run one query without the cap, use the Execute button's menu (**Execute Without Limit**), the Query menu, or `Option+Cmd+Enter`. Configure or disable the cap in **Settings > Editor** (**Truncate query results**, **Row cap**). + ### Query Results Results appear in the data grid below the editor with row count and execution time. Large result sets are paginated.