diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb1e6610..6dd6d16d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cancelling a pending SSH connection now closes its tunnel instead of leaving the local forward port open (#1369) - Importing connections from DBeaver now brings over the username (#1355) - Copying rows now includes only the visible columns, in their current order, instead of every column (#1354) +- The query shown in the editor when you open a table now matches the query that actually runs, instead of showing `SELECT *` and then running a different one +- Large text columns are no longer truncated to 256 characters when browsing a table. The full value is loaded, the same way BLOB columns already worked, so editing such a cell no longer risks saving a shortened value ## [0.43.1] - 2026-05-20 diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index a3f78d8b4..654460ba8 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -34,16 +34,15 @@ final class FilterCoordinator { let tab = parent.tabManager.tabs[capturedTabIndex] let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) - let exclusions = parent.columnExclusions(for: capturedTableName) let newQuery = parent.queryBuilder.buildFilteredQuery( tableName: capturedTableName, + schemaName: tab.tableContext.schemaName, filters: capturedFilters, logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, columns: buffer.columns, limit: tab.pagination.pageSize, - offset: tab.pagination.currentOffset, - columnExclusions: exclusions + offset: tab.pagination.currentOffset ) parent.tabManager.mutate(at: capturedTabIndex) { $0.content.query = newQuery } @@ -68,14 +67,13 @@ final class FilterCoordinator { let tab = parent.tabManager.tabs[capturedTabIndex] let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) - let exclusions = parent.columnExclusions(for: capturedTableName) let newQuery = parent.queryBuilder.buildBaseQuery( tableName: capturedTableName, + schemaName: tab.tableContext.schemaName, sortState: tab.sortState, columns: buffer.columns, limit: tab.pagination.pageSize, - offset: tab.pagination.currentOffset, - columnExclusions: exclusions + offset: tab.pagination.currentOffset ) parent.tabManager.mutate(at: capturedTabIndex) { $0.content.query = newQuery } @@ -98,28 +96,27 @@ final class FilterCoordinator { let tab = parent.tabManager.tabs[tabIndex] let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) let hasFilters = tab.filterState.hasAppliedFilters - let exclusions = parent.columnExclusions(for: tableName) let newQuery: String if hasFilters { newQuery = parent.queryBuilder.buildFilteredQuery( tableName: tableName, + schemaName: tab.tableContext.schemaName, filters: tab.filterState.appliedFilters, logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, columns: buffer.columns, limit: tab.pagination.pageSize, - offset: tab.pagination.currentOffset, - columnExclusions: exclusions + offset: tab.pagination.currentOffset ) } else { newQuery = parent.queryBuilder.buildBaseQuery( tableName: tableName, + schemaName: tab.tableContext.schemaName, sortState: tab.sortState, columns: buffer.columns, limit: tab.pagination.pageSize, - offset: tab.pagination.currentOffset, - columnExclusions: exclusions + offset: tab.pagination.currentOffset ) } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index ece446f7e..7a91315db 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -155,12 +155,6 @@ extension QueryExecutionCoordinator { } parent.toolbarState.isResultsCollapsed = false - if let tbl = tableName, !tbl.isEmpty, hasSchema { - let cacheKey = "\(conn.id):\(parent.activeDatabaseName):\(tbl)" - parent.cachedTableColumnTypes[cacheKey] = columnTypes - parent.cachedTableColumnNames[cacheKey] = columns - } - let resolvedPKs: [String] if let pks = metadata?.primaryKeyColumns, !pks.isEmpty { resolvedPKs = pks @@ -486,18 +480,4 @@ extension QueryExecutionCoordinator { } parent.runQuery() } - - func columnExclusions(for tableName: String) -> [ColumnExclusion] { - let cacheKey = "\(parent.connectionId):\(parent.activeDatabaseName):\(tableName)" - guard let cachedTypes = parent.cachedTableColumnTypes[cacheKey], - let cachedCols = parent.cachedTableColumnNames[cacheKey] else { - return [] - } - return ColumnExclusionPolicy.exclusions( - columns: cachedCols, - columnTypes: cachedTypes, - databaseType: parent.connection.type, - quoteIdentifier: parent.queryBuilder.quoteIdentifier - ) - } } diff --git a/TablePro/Core/Database/LazyLoadColumnsService.swift b/TablePro/Core/Database/LazyLoadColumnsService.swift deleted file mode 100644 index 8b5d9da26..000000000 --- a/TablePro/Core/Database/LazyLoadColumnsService.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// LazyLoadColumnsService.swift -// TablePro -// - -import Foundation -import os -import TableProPluginKit - -@MainActor -struct LazyLoadColumnsService { - private static let logger = Logger(subsystem: "com.TablePro", category: "LazyLoadColumns") - - let connectionId: UUID - let databaseType: DatabaseType - let queryBuilder: TableQueryBuilder - - func fetchValues( - tableName: String, - primaryKeyColumn: String, - primaryKeyValue: String, - excludedColumnNames: [String] - ) async throws -> [String: String?] { - guard !excludedColumnNames.isEmpty else { return [:] } - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - throw DatabaseError.notConnected - } - - let quotedCols = excludedColumnNames.map { queryBuilder.quoteIdentifier($0) } - let quotedTable = queryBuilder.quoteIdentifier(tableName) - let quotedPK = queryBuilder.quoteIdentifier(primaryKeyColumn) - - let paramStyle = PluginMetadataRegistry.shared - .snapshot(forTypeId: databaseType.pluginTypeId)?.parameterStyle ?? .questionMark - let placeholder: String - switch paramStyle { - case .dollar: - placeholder = "$1" - case .questionMark: - placeholder = "?" - } - - let query = "SELECT \(quotedCols.joined(separator: ", ")) FROM \(quotedTable) WHERE \(quotedPK) = \(placeholder)" - - Self.logger.debug("Lazy-loading excluded columns: \(excludedColumnNames.joined(separator: ", "), privacy: .public)") - - let result = try await driver.executeParameterized( - query: query, - parameters: [primaryKeyValue] - ) - - guard let row = result.rows.first else { - Self.logger.warning("No row returned for lazy-load query") - return [:] - } - - var dict: [String: String?] = [:] - for (index, colName) in excludedColumnNames.enumerated() where index < row.count { - switch row[index] { - case .null: - dict[colName] = .some(nil) - case .text(let s): - dict[colName] = .some(s) - case .bytes(let data): - dict[colName] = .some(String(data: data, encoding: .isoLatin1) ?? "") - } - } - return dict - } -} diff --git a/TablePro/Core/Services/Query/ColumnExclusionPolicy.swift b/TablePro/Core/Services/Query/ColumnExclusionPolicy.swift deleted file mode 100644 index befd7d7cd..000000000 --- a/TablePro/Core/Services/Query/ColumnExclusionPolicy.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// ColumnExclusionPolicy.swift -// TablePro -// -// Determines which columns should be excluded from table browse queries -// to avoid fetching large BLOB/TEXT data unnecessarily. -// - -import Foundation - -/// Describes a column excluded from SELECT with a placeholder expression -struct ColumnExclusion { - let columnName: String - let placeholderExpression: String -} - -/// Determines which columns to exclude from table browse queries -enum ColumnExclusionPolicy { - static func exclusions( - columns: [String], - columnTypes: [ColumnType], - databaseType: DatabaseType, - quoteIdentifier: (String) -> String - ) -> [ColumnExclusion] { - // NoSQL databases use custom query builders, not SQL SELECT - if databaseType == .mongodb || databaseType == .redis { return [] } - - var result: [ColumnExclusion] = [] - let count = min(columns.count, columnTypes.count) - - for i in 0.. String { - switch dbType { - case .sqlite: - return "SUBSTR(\(column), 1, \(length))" - default: - return "SUBSTRING(\(column), 1, \(length))" - } - } -} diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index b5ad73ded..9bc60ea65 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -65,8 +65,7 @@ struct TableQueryBuilder { sortState: SortState? = nil, columns: [String] = [], limit: Int = 200, - offset: Int = 0, - columnExclusions: [ColumnExclusion] = [] + offset: Int = 0 ) -> String { if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) @@ -79,10 +78,9 @@ struct TableQueryBuilder { } let quotedTable = qualifiedTable(tableName, schema: schemaName) - let selectClause = buildSelectClause(columns: columns, exclusions: columnExclusions) - var query = "SELECT \(selectClause) FROM \(quotedTable)" + var query = "SELECT * FROM \(quotedTable)" - if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { + if let orderBy = orderByOrOffsetFetchDefault(sortState: sortState, columns: columns) { query += " \(orderBy)" } @@ -98,8 +96,7 @@ struct TableQueryBuilder { sortState: SortState? = nil, columns: [String] = [], limit: Int = 200, - offset: Int = 0, - columnExclusions: [ColumnExclusion] = [] + offset: Int = 0 ) -> String { if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) @@ -124,8 +121,7 @@ struct TableQueryBuilder { } let quotedTable = qualifiedTable(tableName, schema: schemaName) - let selectClause = buildSelectClause(columns: columns, exclusions: columnExclusions) - var query = "SELECT \(selectClause) FROM \(quotedTable)" + var query = "SELECT * FROM \(quotedTable)" if let dialect { let activeFilters = filters.filter { $0.isEnabled } @@ -136,7 +132,7 @@ struct TableQueryBuilder { } } - if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { + if let orderBy = orderByOrOffsetFetchDefault(sortState: sortState, columns: columns) { query += " \(orderBy)" } @@ -205,19 +201,6 @@ struct TableQueryBuilder { // MARK: - Private Helpers - private func buildSelectClause(columns: [String], exclusions: [ColumnExclusion]) -> String { - guard !exclusions.isEmpty, !columns.isEmpty else { return "*" } - - let exclusionMap = Dictionary(exclusions.map { ($0.columnName, $0.placeholderExpression) }) { _, last in last } - - return columns.map { col in - if let placeholder = exclusionMap[col] { - return "\(placeholder) AS \(quote(col))" - } - return quote(col) - }.joined(separator: ", ") - } - private func buildPaginationClause(limit: Int, offset: Int) -> String { if let dialect, dialect.paginationStyle == .offsetFetch { return "OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" @@ -232,6 +215,14 @@ struct TableQueryBuilder { } ?? [] } + private func orderByOrOffsetFetchDefault(sortState: SortState?, columns: [String]) -> String? { + if let orderBy = buildOrderByClause(sortState: sortState, columns: columns) { + return orderBy + } + guard dialect?.paginationStyle == .offsetFetch else { return nil } + return dialect?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)" + } + private func buildOrderByClause(sortState: SortState?, columns: [String]) -> String? { guard let state = sortState, state.isSorting else { return nil } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 841a6514a..b955c79a7 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -115,20 +115,18 @@ struct QueryTab: Identifiable, Equatable { return "SCAN 0 MATCH * COUNT \(pageSize)" default: let dialect = try resolveSQLDialect(for: databaseType) - let quote = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) - let qualifiedName: String - if let schema = schemaName, !schema.isEmpty { - qualifiedName = "\(quote(schema)).\(quote(tableName))" - } else { - qualifiedName = quote(tableName) - } - switch PluginManager.shared.paginationStyle(for: databaseType) { - case .offsetFetch: - let orderBy = PluginManager.shared.offsetFetchOrderBy(for: databaseType) - return "SELECT * FROM \(qualifiedName) \(orderBy) OFFSET 0 ROWS FETCH NEXT \(pageSize) ROWS ONLY;" - case .limit: - return "SELECT * FROM \(qualifiedName) LIMIT \(pageSize);" - } + let builder = TableQueryBuilder( + databaseType: databaseType, + pluginDriver: nil, + dialect: dialect, + dialectQuote: quoteIdentifier ?? quoteIdentifierFromDialect(dialect) + ) + return builder.buildBaseQuery( + tableName: tableName, + schemaName: schemaName, + limit: pageSize, + offset: 0 + ) } } diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index fa1651ab5..b48f68f93 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -31,10 +31,6 @@ struct FieldEditState: Identifiable { var isPendingDefault: Bool - var isTruncated: Bool = false - - var isLoadingFullValue: Bool = false - var hasEdit: Bool { pendingValue != nil || isPendingNull || isPendingDefault } @@ -73,7 +69,6 @@ final class MultiRowEditState { columns: [String], columnTypes: [ColumnType], externallyModifiedColumns: Set = [], - excludedColumnNames: Set = [], primaryKeyColumns: Set = [], foreignKeyColumns: Set = [] ) { @@ -118,11 +113,6 @@ final class MultiRowEditState { var isPendingNull = false var isPendingDefault = false - let isExcluded = excludedColumnNames.contains(columnName) - var preservedOriginalValue: String? = originalValue - var preservedIsTruncated = isExcluded - var preservedIsLoadingFullValue = isExcluded - if !columnsChanged, !selectionChanged, colIndex < fields.count { let oldField = fields[colIndex] // Preserve pending edits when original data matches @@ -132,12 +122,6 @@ final class MultiRowEditState { isPendingNull = oldField.isPendingNull isPendingDefault = oldField.isPendingDefault } - // Preserve resolved truncation state — don't reset already-fetched full values - if isExcluded && !oldField.isTruncated && oldField.columnName == columnName { - preservedOriginalValue = oldField.originalValue - preservedIsTruncated = false - preservedIsLoadingFullValue = false - } } // Mark externally modified columns (e.g., edited in data grid) @@ -152,13 +136,11 @@ final class MultiRowEditState { isLongText: isLongText, isPrimaryKey: primaryKeyColumns.contains(columnName), isForeignKey: foreignKeyColumns.contains(columnName), - originalValue: preservedOriginalValue, + originalValue: originalValue, hasMultipleValues: hasMultipleValues, pendingValue: pendingValue, isPendingNull: isPendingNull, - isPendingDefault: isPendingDefault, - isTruncated: preservedIsTruncated, - isLoadingFullValue: preservedIsLoadingFullValue + isPendingDefault: isPendingDefault ) if let preservedId { newField.id = preservedId @@ -234,28 +216,6 @@ final class MultiRowEditState { } } - /// Apply lazy-loaded full values for previously truncated columns - func applyFullValues(_ fullValues: [String: String?]) { - for i in 0.. [(columnIndex: Int, columnName: String, newValue: String?)] { fields.compactMap { field in - guard field.hasEdit, !field.isTruncated else { return nil } + guard field.hasEdit else { return nil } return (field.columnIndex, field.columnName, field.effectiveValue) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift deleted file mode 100644 index b524419e0..000000000 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LazyLoadColumns.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// MainContentCoordinator+LazyLoadColumns.swift -// TablePro -// - -import Foundation - -internal extension MainContentCoordinator { - func fetchFullValuesForExcludedColumns( - tableName: String, - primaryKeyColumn: String, - primaryKeyValue: String, - excludedColumnNames: [String] - ) async throws -> [String: String?] { - try await LazyLoadColumnsService( - connectionId: connectionId, - databaseType: connection.type, - queryBuilder: queryBuilder - ).fetchValues( - tableName: tableName, - primaryKeyColumn: primaryKeyColumn, - primaryKeyValue: primaryKeyValue, - excludedColumnNames: excludedColumnNames - ) - } -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 0bfcbe45d..012da8eaf 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -112,8 +112,4 @@ extension MainContentCoordinator { func restoreSchemaAndRunQuery(_ schema: String) async { await queryExecutionCoordinator.restoreSchemaAndRunQuery(schema) } - - func columnExclusions(for tableName: String) -> [ColumnExclusion] { - queryExecutionCoordinator.columnExclusions(for: tableName) - } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 6daa63dec..c2a8f01aa 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -192,13 +192,6 @@ extension MainContentView { modifiedColumns.formUnion(changeManager.getModifiedColumnsForRow(rowIndex)) } - let excludedNames: Set - if let tableName = tab.tableContext.tableName { - excludedNames = Set(coordinator.columnExclusions(for: tableName).map(\.columnName)) - } else { - excludedNames = [] - } - let pkColumns = Set(tab.tableContext.primaryKeyColumns) let fkColumns = Set(tableRows.columnForeignKeys.keys) @@ -217,7 +210,6 @@ extension MainContentView { columns: tableRows.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, - excludedColumnNames: excludedNames, primaryKeyColumns: pkColumns, foreignKeyColumns: fkColumns ) @@ -240,9 +232,7 @@ extension MainContentView { let originalRow = Array(tableRows.rows[rowIndex].values) let oldValue: PluginCellValue - if columnIndex < capturedEditState.fields.count, - !capturedEditState.fields[columnIndex].isTruncated - { + if columnIndex < capturedEditState.fields.count { oldValue = PluginCellValue.fromOptional(capturedEditState.fields[columnIndex].originalValue) } else if columnIndex < originalRow.count { oldValue = originalRow[columnIndex] @@ -261,64 +251,4 @@ extension MainContentView { } } } - - func lazyLoadExcludedColumnsIfNeeded() { - guard let tab = coordinator.tabManager.selectedTab else { return } - let selectedIndices = coordinator.selectionState.indices - - let excludedNames: Set - if let tableName = tab.tableContext.tableName { - excludedNames = Set(coordinator.columnExclusions(for: tableName).map(\.columnName)) - } else { - excludedNames = [] - } - - let capturedCoordinator = coordinator - let capturedEditState = rightPanelState.editState - - let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) - if !excludedNames.isEmpty, - selectedIndices.count == 1, - let tableName = tab.tableContext.tableName, - let pkColumn = tab.tableContext.primaryKeyColumn, - let rowIndex = selectedIndices.first, - rowIndex < tableRows.rows.count - { - let row = tableRows.rows[rowIndex].values - if let pkColIndex = tableRows.columns.firstIndex(of: pkColumn), - pkColIndex < row.count, - let pkValue = row[pkColIndex].asText - { - let excludedList = Array(excludedNames) - - lazyLoadTask?.cancel() - lazyLoadTask = Task { @MainActor in - let expectedRowIndex = rowIndex - do { - let fullValues = - try await capturedCoordinator.fetchFullValuesForExcludedColumns( - tableName: tableName, - primaryKeyColumn: pkColumn, - primaryKeyValue: pkValue, - excludedColumnNames: excludedList - ) - guard !Task.isCancelled, - capturedEditState.selectedRowIndices.count == 1, - capturedEditState.selectedRowIndices.first == expectedRowIndex - else { return } - capturedEditState.applyFullValues(fullValues) - } catch { - guard !Task.isCancelled, - capturedEditState.selectedRowIndices.count == 1, - capturedEditState.selectedRowIndices.first == expectedRowIndex - else { return } - for i in 0..? @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @@ -622,8 +617,6 @@ final class MainContentCoordinator { tabSessionRegistry.removeAll() querySortCache.removeAll() displayFormatsCache.removeAll() - cachedTableColumnTypes.removeAll() - cachedTableColumnNames.removeAll() tabManager.tabs.removeAll() tabManager.selectedTabId = nil diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 2e1e51883..a7027a6f9 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -53,7 +53,6 @@ struct MainContentView: View { @State var commandActions: MainContentCommandActions? @State var queryResultsSummaryCache: (tabId: UUID, version: Int, summary: String?)? @State var inspectorUpdateTask: Task? - @State var lazyLoadTask: Task? /// Stable identifier for this window in WindowLifecycleMonitor @State var windowId = UUID() @State var hasInitialized = false @@ -433,7 +432,7 @@ struct MainContentView: View { { coordinator.inspectorProxy?.showInspector() } - scheduleInspectorUpdate(lazyLoadExcludedColumns: true) + scheduleInspectorUpdate() }, onFilterColumn: { columnName in coordinator.addFilterForColumn(columnName) diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 577421fe8..84c096fb6 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -13,8 +13,6 @@ internal struct FieldDetailView: View { let isPendingNull: Bool let isPendingDefault: Bool let isModified: Bool - let isTruncated: Bool - let isLoadingFullValue: Bool let databaseType: DatabaseType let onSetNull: () -> Void let onSetDefault: () -> Void @@ -50,8 +48,6 @@ internal struct FieldDetailView: View { PendingStateOverlay( isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, - isLoadingFullValue: isLoadingFullValue, - isTruncated: isTruncated, minHeight: editorMinHeight(for: kind) ) { resolvedEditor(for: kind) @@ -106,16 +102,6 @@ internal struct FieldDetailView: View { Spacer() TypeBadge(context.columnType.badgeLabel) - - if isTruncated && !isLoadingFullValue { - Text("truncated") - .font(.caption2.weight(.medium)) - .foregroundStyle(.orange) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(.orange.opacity(0.15)) - .clipShape(Capsule()) - } } } diff --git a/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift b/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift index dea1007d9..d77c08fc9 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/PendingStateOverlay.swift @@ -8,27 +8,11 @@ import SwiftUI internal struct PendingStateOverlay: View { let isPendingNull: Bool let isPendingDefault: Bool - let isLoadingFullValue: Bool - let isTruncated: Bool var minHeight: CGFloat? @ViewBuilder let editor: () -> Editor var body: some View { - if isLoadingFullValue { - TextField("", text: .constant("")) - .textFieldStyle(.roundedBorder) - .font(.subheadline) - .disabled(true) - .overlay { - ProgressView() - .controlSize(.small) - } - } else if isTruncated { - TextField(String(localized: "Value excluded from query"), text: .constant("")) - .textFieldStyle(.roundedBorder) - .font(.subheadline) - .disabled(true) - } else if isPendingNull || isPendingDefault { + if isPendingNull || isPendingDefault { Text(isPendingNull ? "NULL" : "DEFAULT") .font(.system(.subheadline, design: .monospaced)) .foregroundStyle(.secondary) diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index a480141b4..8b162d0bf 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -270,8 +270,6 @@ struct RightSidebarView: View { isPendingNull: field.isPendingNull, isPendingDefault: field.isPendingDefault, isModified: field.hasEdit, - isTruncated: field.isTruncated, - isLoadingFullValue: field.isLoadingFullValue, databaseType: databaseType, onSetNull: { editState.setFieldToNull(at: index) }, onSetDefault: { editState.setFieldToDefault(at: index) }, diff --git a/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift b/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift deleted file mode 100644 index 24c1e7de7..000000000 --- a/TableProTests/Core/Services/ColumnExclusionPolicyTests.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// ColumnExclusionPolicyTests.swift -// TableProTests -// -// Tests for ColumnExclusionPolicy selective column exclusion logic. -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -@Suite("ColumnExclusionPolicy") -struct ColumnExclusionPolicyTests { - private func quoteMySQL(_ name: String) -> String { - "`\(name)`" - } - - private func quoteStandard(_ name: String) -> String { - "\"\(name)\"" - } - - @Test("BLOB column NOT excluded (no lazy-load fetch path for editing/export)") - func blobColumnNotExcluded() { - let columns = ["id", "name", "photo"] - let types: [ColumnType] = [ - .integer(rawType: "INT"), - .text(rawType: "VARCHAR"), - .blob(rawType: "BLOB") - ] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .mysql, quoteIdentifier: quoteMySQL - ) - #expect(exclusions.isEmpty) - } - - @Test("LONGTEXT column excluded with SUBSTRING expression") - func longTextColumnExcluded() { - let columns = ["id", "content"] - let types: [ColumnType] = [ - .integer(rawType: "INT"), - .text(rawType: "LONGTEXT") - ] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .mysql, quoteIdentifier: quoteMySQL - ) - #expect(exclusions.count == 1) - #expect(exclusions[0].columnName == "content") - #expect(exclusions[0].placeholderExpression == "SUBSTRING(`content`, 1, 256)") - } - - @Test("VARCHAR and INTEGER columns NOT excluded") - func normalColumnsNotExcluded() { - let columns = ["id", "name", "age"] - let types: [ColumnType] = [ - .integer(rawType: "INT"), - .text(rawType: "VARCHAR"), - .integer(rawType: "BIGINT") - ] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .mysql, quoteIdentifier: quoteMySQL - ) - #expect(exclusions.isEmpty) - } - - @Test("DATE and TIMESTAMP columns NOT excluded") - func dateColumnsNotExcluded() { - let columns = ["created_at", "updated_at"] - let types: [ColumnType] = [ - .date(rawType: "DATE"), - .timestamp(rawType: "TIMESTAMP") - ] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .postgresql, quoteIdentifier: quoteStandard - ) - #expect(exclusions.isEmpty) - } - - @Test("Empty columns produces no exclusions") - func emptyColumnsNoExclusions() { - let exclusions = ColumnExclusionPolicy.exclusions( - columns: [], columnTypes: [], - databaseType: .mysql, quoteIdentifier: quoteMySQL - ) - #expect(exclusions.isEmpty) - } - - @Test("MSSQL BLOB column NOT excluded") - func mssqlBlobNotExcluded() { - let columns = ["data"] - let types: [ColumnType] = [.blob(rawType: "VARBINARY")] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .mssql, quoteIdentifier: quoteStandard - ) - #expect(exclusions.isEmpty) - } - - @Test("Plain TEXT column NOT excluded (only MEDIUMTEXT/LONGTEXT/CLOB)") - func plainTextNotExcluded() { - let columns = ["body"] - let types: [ColumnType] = [.text(rawType: "TEXT")] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .sqlite, quoteIdentifier: quoteStandard - ) - #expect(exclusions.isEmpty) - } - - @Test("SQLite uses SUBSTR for CLOB columns") - func sqliteUsesSubstr() { - let columns = ["body"] - let types: [ColumnType] = [.text(rawType: "CLOB")] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .sqlite, quoteIdentifier: quoteStandard - ) - #expect(exclusions.count == 1) - #expect(exclusions[0].placeholderExpression == "SUBSTR(\"body\", 1, 256)") - } - - @Test("Only MEDIUMTEXT excluded in mixed column set, BLOB kept") - func mixedExclusions() { - let columns = ["id", "photo", "content", "name"] - let types: [ColumnType] = [ - .integer(rawType: "INT"), - .blob(rawType: "BLOB"), - .text(rawType: "MEDIUMTEXT"), - .text(rawType: "VARCHAR") - ] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .postgresql, quoteIdentifier: quoteStandard - ) - #expect(exclusions.count == 1) - #expect(exclusions[0].columnName == "content") - #expect(exclusions[0].placeholderExpression == "SUBSTRING(\"content\", 1, 256)") - } - - @Test("Mismatched column/type counts handled safely") - func mismatchedCounts() { - let columns = ["id", "name", "photo"] - let types: [ColumnType] = [ - .integer(rawType: "INT"), - .text(rawType: "VARCHAR") - ] - let exclusions = ColumnExclusionPolicy.exclusions( - columns: columns, columnTypes: types, - databaseType: .mysql, quoteIdentifier: quoteMySQL - ) - #expect(exclusions.isEmpty) - } -} diff --git a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift index 61a2d5a25..d902649af 100644 --- a/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift +++ b/TableProTests/Core/Services/TableQueryBuilderMSSQLTests.swift @@ -95,4 +95,19 @@ struct TableQueryBuilderMSSQLTests { let normalized = query.uppercased() #expect(!normalized.contains(" LIMIT ")) } + + // MARK: - OFFSET FETCH Fallback (no plugin browse query) + + @Test("Base query without a plugin driver still emits ORDER BY (SELECT NULL) before OFFSET FETCH") + func baseQueryFallbackEmitsOrderBy() { + let dialect = PluginManager.shared.sqlDialect(for: .mssql) + let fallback = TableQueryBuilder( + databaseType: .mssql, + pluginDriver: nil, + dialect: dialect, + dialectQuote: dialect.map(quoteIdentifierFromDialect) + ) + let query = fallback.buildBaseQuery(tableName: "users") + #expect(query == "SELECT * FROM [users] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 200 ROWS ONLY") + } } diff --git a/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift b/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift deleted file mode 100644 index b03b145eb..000000000 --- a/TableProTests/Core/Services/TableQueryBuilderSelectiveTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// TableQueryBuilderSelectiveTests.swift -// TableProTests -// -// Tests for TableQueryBuilder selective column query building with exclusions. -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -@Suite("Table Query Builder - Selective Column Queries") -struct TableQueryBuilderSelectiveTests { - private let builder = TableQueryBuilder(databaseType: .mysql) - - @Test("No exclusions produces SELECT *") - func noExclusionsSelectStar() { - let query = builder.buildBaseQuery(tableName: "users") - #expect(query.contains("SELECT *")) - } - - @Test("Empty exclusions with columns still produces SELECT *") - func emptyExclusionsSelectStar() { - let query = builder.buildBaseQuery( - tableName: "users", - columns: ["id", "name"], - columnExclusions: [] - ) - #expect(query.contains("SELECT *")) - } - - @Test("BLOB exclusion produces LENGTH in column list") - func blobExclusionWithLength() { - let exclusions = [ColumnExclusion(columnName: "photo", placeholderExpression: "LENGTH(\"photo\")")] - let query = builder.buildBaseQuery( - tableName: "users", - columns: ["id", "name", "photo"], - columnExclusions: exclusions - ) - #expect(!query.contains("SELECT *")) - #expect(query.contains("LENGTH(\"photo\") AS")) - #expect(query.contains("\"id\"")) - #expect(query.contains("\"name\"")) - } - - @Test("TEXT exclusion produces SUBSTRING in column list") - func textExclusionWithSubstring() { - let exclusions = [ColumnExclusion( - columnName: "content", - placeholderExpression: "SUBSTRING(\"content\", 1, 256)" - )] - let query = builder.buildBaseQuery( - tableName: "posts", - columns: ["id", "title", "content"], - columnExclusions: exclusions - ) - #expect(query.contains("SUBSTRING(\"content\", 1, 256) AS")) - #expect(query.contains("\"id\"")) - #expect(query.contains("\"title\"")) - } - - @Test("Exclusions work with sort and pagination") - func exclusionsWithSortAndPagination() { - let exclusions = [ColumnExclusion(columnName: "data", placeholderExpression: "LENGTH(\"data\")")] - let query = builder.buildBaseQuery( - tableName: "files", - columns: ["id", "name", "data"], - limit: 50, - offset: 100, - columnExclusions: exclusions - ) - #expect(query.contains("LENGTH(\"data\") AS")) - #expect(query.contains("LIMIT 50")) - #expect(query.contains("OFFSET 100")) - } - - @Test("Filtered query with exclusions uses column list") - func filteredQueryWithExclusions() { - let exclusions = [ColumnExclusion(columnName: "photo", placeholderExpression: "LENGTH(\"photo\")")] - let query = builder.buildFilteredQuery( - tableName: "users", - filters: [], - columns: ["id", "name", "photo"], - columnExclusions: exclusions - ) - #expect(!query.contains("SELECT *")) - #expect(query.contains("LENGTH(\"photo\") AS")) - } - - // TODO: Re-enable when buildQuickSearchQuery API is restored - #if false - @Test("Quick search query with exclusions uses column list") - func quickSearchWithExclusions() { - let exclusions = [ColumnExclusion(columnName: "body", placeholderExpression: "SUBSTRING(\"body\", 1, 256)")] - let query = builder.buildQuickSearchQuery( - tableName: "posts", - searchText: "hello", - columns: ["id", "title", "body"], - columnExclusions: exclusions - ) - #expect(!query.contains("SELECT *")) - #expect(query.contains("SUBSTRING(\"body\", 1, 256) AS")) - } - #endif - - // TODO: Re-enable when buildCombinedQuery API is restored - #if false - @Test("Combined query with exclusions uses column list") - func combinedQueryWithExclusions() { - let exclusions = [ColumnExclusion(columnName: "data", placeholderExpression: "LENGTH(\"data\")")] - let query = builder.buildCombinedQuery( - tableName: "files", - filters: [], - searchText: "test", - searchColumns: ["name"], - columns: ["id", "name", "data"], - columnExclusions: exclusions - ) - #expect(!query.contains("SELECT *")) - #expect(query.contains("LENGTH(\"data\") AS")) - } - #endif - - @Test("Exclusions with no columns still produces SELECT *") - func exclusionsButNoColumnsSelectStar() { - let exclusions = [ColumnExclusion(columnName: "photo", placeholderExpression: "LENGTH(\"photo\")")] - let query = builder.buildBaseQuery( - tableName: "users", - columns: [], - columnExclusions: exclusions - ) - #expect(query.contains("SELECT *")) - } - - @Test("quoteIdentifier exposes identifier quoting") - func quoteIdentifierPublic() { - let quoted = builder.quoteIdentifier("my column") - #expect(quoted == "\"my column\"") - } -} diff --git a/TableProTests/Models/MultiRowEditStateTruncationTests.swift b/TableProTests/Models/MultiRowEditStateTruncationTests.swift deleted file mode 100644 index 7024470c2..000000000 --- a/TableProTests/Models/MultiRowEditStateTruncationTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// MultiRowEditStateTruncationTests.swift -// TableProTests -// -// Tests for truncation support in MultiRowEditState. -// - -import TableProPluginKit -@testable import TablePro -import Testing - -@MainActor @Suite("MultiRowEditState Truncation") -struct MultiRowEditStateTruncationTests { - // MARK: - Helper - - private func makeSUT( - columns: [String] = ["id", "name", "content"], - columnTypes: [ColumnType]? = nil, - rows: [[String?]] = [["1", "Alice", "short..."]], - selectedIndices: Set = [0], - excludedColumnNames: Set = [] - ) -> MultiRowEditState { - let sut = MultiRowEditState() - let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } - sut.configure( - selectedRowIndices: selectedIndices, - allRows: rows, - columns: columns, - columnTypes: types, - excludedColumnNames: excludedColumnNames - ) - return sut - } - - // MARK: - FieldEditState defaults - - @Test("isTruncated defaults to false") - func isTruncatedDefaultsToFalse() { - let field = FieldEditState( - columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, - pendingValue: nil, isPendingNull: false, isPendingDefault: false, - isTruncated: false, isLoadingFullValue: false - ) - #expect(field.isTruncated == false) - } - - @Test("isLoadingFullValue defaults to false") - func isLoadingFullValueDefaultsToFalse() { - let field = FieldEditState( - columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, - pendingValue: nil, isPendingNull: false, isPendingDefault: false, - isTruncated: false, isLoadingFullValue: false - ) - #expect(field.isLoadingFullValue == false) - } - - // MARK: - configure() with excludedColumnNames - - @Test("configure with excludedColumnNames marks matching fields as truncated") - func configureWithExcludedColumnNamesMarksTruncated() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - #expect(sut.fields[0].isTruncated == false) // id - #expect(sut.fields[1].isTruncated == false) // name - #expect(sut.fields[2].isTruncated == true) // content - } - - @Test("configure without excludedColumnNames leaves all fields not truncated") - func configureWithoutExcludedColumnNamesLeavesNotTruncated() { - let sut = makeSUT() - - for field in sut.fields { - #expect(field.isTruncated == false) - } - } - - @Test("configure sets isLoadingFullValue to true for excluded columns") - func configureSetsIsLoadingFullValueForExcludedColumns() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - #expect(sut.fields[0].isLoadingFullValue == false) // id - #expect(sut.fields[1].isLoadingFullValue == false) // name - #expect(sut.fields[2].isLoadingFullValue == true) // content (excluded) - } - - // MARK: - applyFullValues() - - @Test("applyFullValues patches originalValue and clears isTruncated") - func applyFullValuesPatchesOriginalValueAndClearsTruncated() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - #expect(sut.fields[2].isTruncated == true) - - sut.applyFullValues(["content": "full long text that was previously truncated"]) - - #expect(sut.fields[2].originalValue == "full long text that was previously truncated") - #expect(sut.fields[2].isTruncated == false) - #expect(sut.fields[2].isLoadingFullValue == false) - } - - @Test("applyFullValues preserves pending edits") - func applyFullValuesPreservesPendingEdits() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - sut.fields[2].pendingValue = "user edit" - - sut.applyFullValues(["content": "full text"]) - - #expect(sut.fields[2].pendingValue == "user edit") - #expect(sut.fields[2].originalValue == "full text") - #expect(sut.fields[2].isTruncated == false) - } - - @Test("applyFullValues ignores columns not in dictionary") - func applyFullValuesIgnoresUnknownColumns() { - let sut = makeSUT(excludedColumnNames: ["content"]) - let originalContentValue = sut.fields[2].originalValue - - sut.applyFullValues(["nonexistent": "value"]) - - #expect(sut.fields[2].originalValue == originalContentValue) - #expect(sut.fields[2].isTruncated == true) // still truncated - } - - @Test("applyFullValues handles nil values") - func applyFullValuesHandlesNilValues() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - sut.applyFullValues(["content": nil]) - - #expect(sut.fields[2].originalValue == nil) - #expect(sut.fields[2].isTruncated == false) - } - - // MARK: - getEditedFields() safety net - - @Test("getEditedFields excludes fields still marked as truncated") - func getEditedFieldsExcludesTruncatedFields() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - // Set a pending value on the truncated field without clearing isTruncated - sut.fields[2].pendingValue = "some edit" - - let editedFields = sut.getEditedFields() - - // Should NOT include the truncated field even though it has a pending edit - #expect(editedFields.isEmpty) - } - - // MARK: - updateField works after applyFullValues - - @Test("updateField works normally after applyFullValues patches value") - func updateFieldWorksAfterApplyFullValues() { - let sut = makeSUT(excludedColumnNames: ["content"]) - - sut.applyFullValues(["content": "full original text"]) - - sut.updateField(at: 2, value: "new edited value") - - #expect(sut.fields[2].pendingValue == "new edited value") - #expect(sut.fields[2].isTruncated == false) - - let editedFields = sut.getEditedFields() - #expect(editedFields.count == 1) - #expect(editedFields[0].columnName == "content") - #expect(editedFields[0].newValue == "new edited value") - } -} diff --git a/TableProTests/Models/Query/QueryTabBaseQueryTests.swift b/TableProTests/Models/Query/QueryTabBaseQueryTests.swift new file mode 100644 index 000000000..91b947693 --- /dev/null +++ b/TableProTests/Models/Query/QueryTabBaseQueryTests.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("QueryTab.buildBaseTableQuery") +struct QueryTabBaseQueryTests { + init() { + FakeMSSQLPluginRegistration.registerIfNeeded() + } + + @Test("Editor query for opening a table equals the executed browse query") + func editorQueryMatchesExecutedQuery() throws { + let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize + let dialect = PluginManager.shared.sqlDialect(for: .mssql) + let quote = dialect.map(quoteIdentifierFromDialect) + + let editorQuery = try QueryTab.buildBaseTableQuery( + tableName: "users", + databaseType: .mssql, + schemaName: nil, + quoteIdentifier: quote + ) + + let executed = TableQueryBuilder( + databaseType: .mssql, + pluginDriver: PluginManager.shared.queryBuildingDriver(for: .mssql), + dialect: dialect, + dialectQuote: quote + ).buildBaseQuery(tableName: "users", schemaName: nil, limit: pageSize, offset: 0) + + #expect(editorQuery == executed) + } + + @Test("Editor query is not truncated and carries no SUBSTRING projection") + func editorQueryHasNoSubstringProjection() throws { + let dialect = PluginManager.shared.sqlDialect(for: .mssql) + let query = try QueryTab.buildBaseTableQuery( + tableName: "users", + databaseType: .mssql, + schemaName: nil, + quoteIdentifier: dialect.map(quoteIdentifierFromDialect) + ) + + #expect(query.contains("SELECT * FROM")) + #expect(!query.uppercased().contains("SUBSTRING")) + #expect(!query.hasSuffix(";")) + } +}