diff --git a/.github/hub-ratchet-baseline.json b/.github/hub-ratchet-baseline.json index 5cd6ad237c..b128509ac8 100644 --- a/.github/hub-ratchet-baseline.json +++ b/.github/hub-ratchet-baseline.json @@ -1,7 +1,7 @@ { "MenuValidationContext stored properties": 86, "menu validation `case #selector` arms": 115, - "ShortcutAction cases": 98, + "ShortcutAction cases": 103, "TabType cases": 9, "SettingsPane cases": 12, "ActiveSheet cases": 17, diff --git a/CHANGELOG.md b/CHANGELOG.md index 72eda2181c..752ac8721a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Generate UUID and Generate UUIDv7 in Set Value, the row inspector and Fill Column, a new value for each cell. (#3443) +- Edit > Set Value in the menu bar, with rows in Settings > Keyboard. (#3443) +- New UUID in a UUID primary key the server does not fill, on Add Row, Duplicate Row and Paste. (#3443) + +### Changed + +- Set Value applies to every selected cell, or to the clicked column across the selected rows. (#3443) +- SQL functions in Set Value and the row inspector listed per database and only where they fit the column. (#3443) +- UUID functions in SQL Functions replaced by Generate UUID, which writes the value itself. (#3443) +- Text typed into a column that is not a date or time saved as typed, even when it reads like `now()`. (#3443) +- Set NULL and Set DEFAULT in the row inspector shown only where the column takes them, as in the data grid. (#3443) +- Default only on new rows on SQLite, libSQL, Turso, D1, ClickHouse, Databend, Snowflake, Trino, Cassandra. (#3443) +- Pending SQL functions in the data grid drawn like a pending default. (#3443) +- PluginKit ABI 39, additive: existing plugins keep loading. (#3443) + ### Fixed - Every ClickHouse query after connecting failing with `UNKNOWN_SETTING` on 23.8.0 to 23.8.8. (#3444) @@ -17,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse SQL export writing the current database's table structure for a table in another database. - Opening a ClickHouse executable function's definition showing a syntax error. - SQL Server 2016 RTM offered a `CREATE OR ALTER VIEW` template it cannot run, and Azure SQL a drop-and-create one. +- `gen_random_uuid()`, `UUID()`, `NEWID()` and `datetime('now')` from SQL Functions saved as quoted text. (#3443) +- SQL functions saved as text on SQL Server, Teradata, Spanner and BigQuery. (#3443) +- `NOW()` offered on SQLite, Oracle, Cassandra and MongoDB, where it fails or saves the text. (#3443) +- SQLite's `datetime('now')` offered on DuckDB and `gen_random_uuid()` on Redshift. (#3443) +- Stored text such as `CURRENT_DATE` turning into today's date on Duplicate Row or Restore Previous Values. (#3443) +- Copy as INSERT and Copy as UPDATE quoting a staged SQL function. (#3443) +- Default on a saved row failing on SQLite, and storing `__DEFAULT__` on Teradata and DynamoDB. (#3443) +- Duplicate Row and Paste failing to save on a UUID primary key with no default. (#3443) +- Date functions and a date picker offered on Cassandra and ScyllaDB `timeuuid` columns. (#3443) +- MariaDB `uuid` columns read as `CHAR`. (#3443) +- Empty field in the multi-row inspector after the selected rows were given different values. (#3443) ## [0.81.0] - 2026-10-16 diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 65ee8c159a..de9540bd79 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -243,6 +243,11 @@ let package = Package( dependencies: ["TableProBigtableCore", "TableProGoogleCloud"], path: "Tests/TableProBigtableCoreTests" ), + .testTarget( + name: "TableProSpannerCoreTests", + dependencies: ["TableProSpannerCore"], + path: "Tests/TableProSpannerCoreTests" + ), .testTarget( name: "TableProTabularIOTests", dependencies: ["TableProTabularIO", "TableProPluginKit"], diff --git a/Packages/TableProCore/Sources/TableProSpannerCore/SQL/SpannerRowEditSQL.swift b/Packages/TableProCore/Sources/TableProSpannerCore/SQL/SpannerRowEditSQL.swift index 3c98e3ddc3..c1abd4de35 100644 --- a/Packages/TableProCore/Sources/TableProSpannerCore/SQL/SpannerRowEditSQL.swift +++ b/Packages/TableProCore/Sources/TableProSpannerCore/SQL/SpannerRowEditSQL.swift @@ -22,12 +22,21 @@ public struct SpannerRowChange: Sendable, Equatable { public let kind: Kind public let cellChanges: [CellChange] public let originalRow: [SpannerCell]? - - public init(rowIndex: Int, kind: Kind, cellChanges: [CellChange] = [], originalRow: [SpannerCell]? = nil) { + // Keyed by name, as the writer collapses a row's cell changes to one value per column. + public let expressionColumns: Set + + public init( + rowIndex: Int, + kind: Kind, + cellChanges: [CellChange] = [], + originalRow: [SpannerCell]? = nil, + expressionColumns: Set = [] + ) { self.rowIndex = rowIndex self.kind = kind self.cellChanges = cellChanges self.originalRow = originalRow + self.expressionColumns = expressionColumns } } @@ -108,7 +117,9 @@ internal struct SpannerRowEditTarget { ?? Self.lastValuePerColumn(change.cellChanges) let written = cells.filter { !Self.isDefaultSentinel($0.value) } guard written.isEmpty else { - return Self.insertStatement(table: table, cells: written, dialect: dialect) + return Self.insertStatement( + table: table, cells: written, dialect: dialect, expressionColumns: change.expressionColumns + ) } guard let firstColumn = columns.first else { return nil } return SpannerRenderedStatement( @@ -122,6 +133,7 @@ internal struct SpannerRowEditTarget { let assignments = Self.lastValuePerColumn(change.cellChanges).map { cell in let column = dialect.quoteIdentifier(cell.column) guard !Self.isDefaultSentinel(cell.value) else { return "\(column) = DEFAULT" } + if let sql = Self.expression(cell, in: change.expressionColumns) { return "\(column) = \(sql)" } parameters.append(cell.value) return "\(column) = ?" } @@ -139,16 +151,28 @@ internal struct SpannerRowEditTarget { static func insertStatement( table: String, cells: [SpannerRowChange.CellChange], - dialect: SpannerDialect + dialect: SpannerDialect, + expressionColumns: Set = [] ) -> SpannerRenderedStatement { let names = cells.map { dialect.quoteIdentifier($0.column) }.joined(separator: ", ") - let placeholders = Array(repeating: "?", count: cells.count).joined(separator: ", ") + var parameters: [SpannerCell] = [] + let values = cells.map { cell -> String in + if let sql = expression(cell, in: expressionColumns) { return sql } + parameters.append(cell.value) + return "?" + } return SpannerRenderedStatement( - sql: "INSERT INTO \(table) (\(names)) VALUES (\(placeholders))", - parameters: cells.map(\.value) + sql: "INSERT INTO \(table) (\(names)) VALUES (\(values.joined(separator: ", ")))", + parameters: parameters ) } + // The host checked a marked cell's text against the engine's vocabulary before handing it over. + private static func expression(_ cell: SpannerRowChange.CellChange, in expressionColumns: Set) -> String? { + guard expressionColumns.contains(cell.column), case .text(let sql) = cell.value else { return nil } + return sql + } + private func keyPredicate(for change: SpannerRowChange) -> (sql: String, parameters: [SpannerCell])? { guard !primaryKey.isEmpty, let originalRow = change.originalRow else { return nil } var terms: [String] = [] diff --git a/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLExpressionTests.swift b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLExpressionTests.swift new file mode 100644 index 0000000000..c8e36de955 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProSpannerCoreTests/SpannerRowEditSQLExpressionTests.swift @@ -0,0 +1,63 @@ +import TableProSpannerCore +import Testing + +struct SpannerRowEditSQLExpressionTests { + private let columns = ["id", "created_at", "name"] + private let originalRow: [SpannerCell] = [.text("1"), .null, .text("a")] + + private func statements( + _ change: SpannerRowChange, + insertedRows: [Int: [SpannerCell]] = [:] + ) -> [SpannerRenderedStatement]? { + SpannerRowEditSQL.statements( + schema: "", + table: "t", + columns: columns, + primaryKey: ["id"], + changes: [change], + insertedRows: insertedRows, + deletedRows: [], + insertedRowIndices: Set(insertedRows.keys), + dialect: .postgreSQL + ) + } + + @Test("A marked insert cell is written as SQL and the other cells stay bound") + func markedInsertCell() { + let change = SpannerRowChange(rowIndex: 0, kind: .insert, expressionColumns: ["created_at"]) + let written = statements(change, insertedRows: [0: [.text("1"), .text("CURRENT_TIMESTAMP"), .text("a")]]) + #expect(written == [SpannerRenderedStatement( + sql: #"INSERT INTO "t" ("id", "created_at", "name") VALUES (?, CURRENT_TIMESTAMP, ?)"#, + parameters: [.text("1"), .text("a")] + )]) + } + + @Test("A marked update cell is written as SQL") + func markedUpdateCell() { + let change = SpannerRowChange( + rowIndex: 0, + kind: .update, + cellChanges: [SpannerRowChange.CellChange(column: "created_at", value: .text("CURRENT_TIMESTAMP"))], + originalRow: originalRow, + expressionColumns: ["created_at"] + ) + #expect(statements(change) == [SpannerRenderedStatement( + sql: #"UPDATE "t" SET "created_at" = CURRENT_TIMESTAMP WHERE "id" = ?"#, + parameters: [.text("1")] + )]) + } + + @Test("Text that spells a function is bound when its cell is not marked") + func unmarkedTextIsBound() { + let change = SpannerRowChange( + rowIndex: 0, + kind: .update, + cellChanges: [SpannerRowChange.CellChange(column: "name", value: .text("CURRENT_TIMESTAMP"))], + originalRow: originalRow + ) + #expect(statements(change) == [SpannerRenderedStatement( + sql: #"UPDATE "t" SET "name" = ? WHERE "id" = ?"#, + parameters: [.text("CURRENT_TIMESTAMP"), .text("1")] + )]) + } +} diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist index efb90dccc5..5eb0eeab09 100644 --- a/Plugins/BeancountDriverPlugin/Info.plist +++ b/Plugins/BeancountDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Beancount diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 2cf215920d..d9e93cfe4e 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -68,6 +68,8 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send .cancelQuery, .materializedViews, .dataCompare, + .cellExpressions, + .uuidCellText, ] } diff --git a/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift b/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift index 98a198b60d..0ee163ee61 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryStatementGenerator.swift @@ -76,12 +76,19 @@ internal struct BigQueryStatementGenerator { ) -> Statement? { guard let firstColumn = columns.first else { return nil } let values = insertValues(for: change, insertedRowData: insertedRowData) + let expressionColumns = change.expressionColumns ?? [] var names: [String] = [] + var placeholders: [String] = [] var parameters: [PluginCellValue] = [] - for column in columns { + for (index, column) in columns.enumerated() { guard let value = values[column], !Self.isDefaultSentinel(value) else { continue } names.append(GoogleSQLLiteral.quotedIdentifier(column)) - parameters.append(value) + if let sql = Self.expression(value, isMarked: expressionColumns.contains(index)) { + placeholders.append(sql) + } else { + placeholders.append(Self.placeholder) + parameters.append(value) + } } guard !names.isEmpty else { @@ -89,8 +96,8 @@ internal struct BigQueryStatementGenerator { return (statement: "INSERT INTO \(qualifiedTable) (\(target)) VALUES (DEFAULT)", parameters: []) } - let placeholders = Array(repeating: Self.placeholder, count: parameters.count).joined(separator: ", ") - let statement = "INSERT INTO \(qualifiedTable) (\(names.joined(separator: ", "))) VALUES (\(placeholders))" + let statement = "INSERT INTO \(qualifiedTable) (\(names.joined(separator: ", "))) " + + "VALUES (\(placeholders.joined(separator: ", ")))" return (statement: statement, parameters: parameters) } @@ -113,10 +120,17 @@ internal struct BigQueryStatementGenerator { private func updateStatement(for change: PluginRowChange) -> Statement? { guard let key = keyCondition(for: change) else { return nil } + let expressionColumns = change.expressionColumns ?? [] var parameters: [PluginCellValue] = [] let assignments = change.cellChanges.map { cellChange -> String in let column = GoogleSQLLiteral.quotedIdentifier(cellChange.columnName) guard !Self.isDefaultSentinel(cellChange.newValue) else { return "\(column) = DEFAULT" } + if let sql = Self.expression( + cellChange.newValue, + isMarked: expressionColumns.contains(cellChange.columnIndex) + ) { + return "\(column) = \(sql)" + } parameters.append(cellChange.newValue) return "\(column) = \(Self.placeholder)" } @@ -174,4 +188,10 @@ internal struct BigQueryStatementGenerator { private static func isDefaultSentinel(_ value: PluginCellValue) -> Bool { value.asText == defaultSentinel } + + // The host checked a marked cell's text against the engine's vocabulary before handing it over. + private static func expression(_ value: PluginCellValue, isMarked: Bool) -> String? { + guard isMarked, case .text(let sql) = value else { return nil } + return sql + } } diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index 27d57a5bfc..1f46e12387 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/BigtableDriverPlugin/Info.plist b/Plugins/BigtableDriverPlugin/Info.plist index 2da24fdd81..df5147fef9 100644 --- a/Plugins/BigtableDriverPlugin/Info.plist +++ b/Plugins/BigtableDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.81.0 TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Bigtable diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index bf4797210e..bc844bd4ea 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds csv diff --git a/Plugins/CSVImportPlugin/Info.plist b/Plugins/CSVImportPlugin/Info.plist index d847241181..c92042694f 100644 --- a/Plugins/CSVImportPlugin/Info.plist +++ b/Plugins/CSVImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesImportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 210f6a0a90..c6090a6a05 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -154,6 +154,8 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen .parameterizedQueries, .materializedViews, .alterTableDDL, + .cellExpressions, + .uuidCellText, ] } diff --git a/Plugins/CassandraDriverPlugin/CassandraRowWriter.swift b/Plugins/CassandraDriverPlugin/CassandraRowWriter.swift index 7fe1e4cd59..0c7ce00b73 100644 --- a/Plugins/CassandraDriverPlugin/CassandraRowWriter.swift +++ b/Plugins/CassandraDriverPlugin/CassandraRowWriter.swift @@ -15,11 +15,6 @@ enum CassandraRowWriter { static let defaultSentinel = "__DEFAULT__" static let unappliedMarker = "[applied]" - private static let functionCalls: Set = [ - "now()", "uuid()", "currenttimestamp()", "currentdate()", "currenttime()", "currenttimeuuid()", - "totimestamp(now())", "todate(now())" - ] - static func rowWrites( keyspace: String?, table: String, @@ -82,13 +77,14 @@ enum CassandraRowWriter { )) } let row = values ?? rowFromCellChanges(change) + let expressionColumns = change.expressionColumns ?? [] var names: [String] = [] var placeholders: [String] = [] var parameters: [PluginCellValue] = [] for (index, value) in row.enumerated() where index < columns.count { if value.asText == defaultSentinel { continue } names.append(CassandraBrowseRenderer.quote(columns[index])) - if let call = functionCall(value) { + if let call = expression(value, isMarked: expressionColumns.contains(index)) { placeholders.append(call) } else { placeholders.append("?") @@ -111,6 +107,7 @@ enum CassandraRowWriter { func update(_ change: PluginRowChange) throws -> PluginRowWrite? { guard !change.cellChanges.isEmpty else { return nil } + let expressionColumns = change.expressionColumns ?? [] var assignments: [String] = [] var parameters: [PluginCellValue] = [] for cell in change.cellChanges { @@ -128,7 +125,7 @@ enum CassandraRowWriter { )) } let column = CassandraBrowseRenderer.quote(cell.columnName) - if let call = functionCall(cell.newValue) { + if let call = expression(cell.newValue, isMarked: expressionColumns.contains(cell.columnIndex)) { assignments.append("\(column) = \(call)") } else { assignments.append("\(column) = ?") @@ -175,10 +172,10 @@ enum CassandraRowWriter { return row } - private func functionCall(_ value: PluginCellValue) -> String? { - guard let text = value.asText?.trimmingCharacters(in: .whitespaces) else { return nil } - let lowered = text.lowercased() - return functionCalls.contains(lowered) ? lowered : nil + // The host checked a marked cell's text against the engine's vocabulary before handing it over. + private func expression(_ value: PluginCellValue, isMarked: Bool) -> String? { + guard isMarked, case .text(let cql) = value else { return nil } + return cql } } } diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index ba8b91119d..2a3f80f788 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index e1817ef08e..5189083c91 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index 27d57a5bfc..1f46e12387 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist index 972b37dc53..edd28fb2d7 100644 --- a/Plugins/CloudflareR2SQLDriverPlugin/Info.plist +++ b/Plugins/CloudflareR2SQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Cloudflare R2 SQL diff --git a/Plugins/DamengDriverPlugin/Info.plist b/Plugins/DamengDriverPlugin/Info.plist index f4205b427c..767f6b3e09 100644 --- a/Plugins/DamengDriverPlugin/Info.plist +++ b/Plugins/DamengDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Dameng diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index 1a631ea62b..88fdb3660c 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Writes.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Writes.swift index 8f628b29ba..281c1fd8e9 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Writes.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver+Writes.swift @@ -19,6 +19,24 @@ extension DynamoDBPluginDriver { ) } + func generateRowWrites( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) throws -> [PluginRowWrite]? { + try DynamoDBWriteStatements(table: table, columns: columns, keyColumns: primaryKeyColumns).rowWrites( + for: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + ) + } + func generateIdentityPreservingInsert( table: String, schema: String?, diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBWriteStatements.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBWriteStatements.swift index ef3e8e0f14..913fdb9e3d 100644 --- a/Plugins/DynamoDBDriverPlugin/DynamoDBWriteStatements.swift +++ b/Plugins/DynamoDBDriverPlugin/DynamoDBWriteStatements.swift @@ -15,26 +15,45 @@ struct DynamoDBWriteStatements { let columns: [String] let keyColumns: [String] - func statements( + func rowWrites( for changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set - ) -> [(statement: String, parameters: [PluginCellValue])] { - changes.compactMap { change in + ) throws -> [PluginRowWrite] { + try changes.compactMap { change in + let written: (statement: String, parameters: [PluginCellValue])? switch change.type { case .insert: guard insertedRowIndices.contains(change.rowIndex) else { return nil } - return insert(change, rowData: insertedRowData[change.rowIndex]) + written = insert(change, rowData: insertedRowData[change.rowIndex]) case .update: - return update(change) + written = try update(change) case .delete: guard deletedRowIndices.contains(change.rowIndex) else { return nil } - return delete(change) + written = delete(change) + } + return written.map { + PluginRowWrite(statement: $0.statement, parameters: $0.parameters, rowIndices: [change.rowIndex]) } } } + func statements( + for changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set + ) -> [(statement: String, parameters: [PluginCellValue])] { + let writes = (try? rowWrites( + for: changes, + insertedRowData: insertedRowData, + deletedRowIndices: deletedRowIndices, + insertedRowIndices: insertedRowIndices + )) ?? [] + return writes.map { (statement: $0.statement, parameters: $0.parameters) } + } + func insert(row: [PluginCellValue]) -> (statement: String, parameters: [PluginCellValue]) { var names: [String] = [] var parameters: [PluginCellValue] = [] @@ -63,7 +82,7 @@ struct DynamoDBWriteStatements { return insert(row: row) } - private func update(_ change: PluginRowChange) -> (statement: String, parameters: [PluginCellValue])? { + private func update(_ change: PluginRowChange) throws -> (statement: String, parameters: [PluginCellValue])? { guard let original = change.originalRow, !change.cellChanges.isEmpty else { return nil } var assignments: [String] = [] var assignedValues: [PluginCellValue] = [] @@ -72,6 +91,12 @@ struct DynamoDBWriteStatements { var guardValues: [PluginCellValue] = [] for cell in change.cellChanges { + if case .text(let text) = cell.newValue, text == Self.defaultSentinel { + throw PluginRowWriteRefusal(rowIndex: change.rowIndex, reason: String( + format: String(localized: "DynamoDB has no default values, so %@ cannot be set to DEFAULT."), + cell.columnName + )) + } let name = DynamoDBStatement.quote(cell.columnName) if case .null = cell.newValue { removals.append(name) diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index 27d57a5bfc..1f46e12387 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist index 3200049047..302d83a253 100644 --- a/Plugins/ElasticsearchDriverPlugin/Info.plist +++ b/Plugins/ElasticsearchDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.53.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index 27d57a5bfc..1f46e12387 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/FirebaseDriverPlugin/Info.plist b/Plugins/FirebaseDriverPlugin/Info.plist index 93bd68ed49..7862226b67 100644 --- a/Plugins/FirebaseDriverPlugin/Info.plist +++ b/Plugins/FirebaseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.80.0 TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Firestore diff --git a/Plugins/HanaDriverPlugin/Info.plist b/Plugins/HanaDriverPlugin/Info.plist index c7dd896d4f..2494774fd1 100644 --- a/Plugins/HanaDriverPlugin/Info.plist +++ b/Plugins/HanaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds SAP HANA diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index 079645d4e0..ceea251c9b 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds json diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist index 61a909101a..f8fb2ead03 100644 --- a/Plugins/JSONImportPlugin/Info.plist +++ b/Plugins/JSONImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesImportFormatIds json diff --git a/Plugins/KafkaDriverPlugin/Info.plist b/Plugins/KafkaDriverPlugin/Info.plist index 71efb2bb14..06a6c6dba0 100644 --- a/Plugins/KafkaDriverPlugin/Info.plist +++ b/Plugins/KafkaDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Kafka diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index 27d57a5bfc..1f46e12387 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index cf55b2d22d..29ea7006fe 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index 1a631ea62b..88fdb3660c 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index a5d3d840e0..0ce548e07c 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -302,6 +302,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .schemaCompare, .dataCompare, .resultSetBatches, + .cellExpressions, + .uuidCellText, ] } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLStatementGenerator.swift b/Plugins/MSSQLDriverPlugin/MSSQLStatementGenerator.swift index 224dfedb84..f7d472f612 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLStatementGenerator.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLStatementGenerator.swift @@ -34,7 +34,11 @@ struct MSSQLStatementGenerator { case .insert: guard insertedRowIndices.contains(change.rowIndex), let values = insertedRowData[change.rowIndex] else { continue } - let statement = try insertStatement(values: values, rowIndex: change.rowIndex) + let statement = try insertStatement( + values: values, + rowIndex: change.rowIndex, + expressionColumns: change.expressionColumns ?? [] + ) writes.append(PluginRowWrite(statement: statement.statement, parameters: statement.parameters, rowIndices: [change.rowIndex])) case .update: guard let statement = try updateStatement(for: change) else { continue } @@ -66,20 +70,28 @@ struct MSSQLStatementGenerator { func insertStatement( values: [PluginCellValue], - rowIndex: Int = 0 + rowIndex: Int = 0, + expressionColumns: Set = [] ) throws -> (statement: String, parameters: [PluginCellValue]) { - let written = zip(columns, values).filter { $0.1 != Self.defaultMarker } - if let owned = written.first(where: { context.serverOwnedColumns.contains($0.0) }) { - throw PluginRowWriteRefusal(rowIndex: rowIndex, reason: Self.serverOwnedReason(owned.0)) + let written = zip(columns, values).enumerated() + .filter { $0.element.1 != Self.defaultMarker } + .map { (index: $0.offset, column: $0.element.0, value: $0.element.1) } + if let owned = written.first(where: { context.serverOwnedColumns.contains($0.column) }) { + throw PluginRowWriteRefusal(rowIndex: rowIndex, reason: Self.serverOwnedReason(owned.column)) } guard !written.isEmpty else { return (statement: "INSERT INTO \(qualifiedTable) DEFAULT VALUES", parameters: []) } - let columnList = written.map { Self.quote($0.0) }.joined(separator: ", ") - let placeholders = written.map { _ in "?" }.joined(separator: ", ") + var parameters: [PluginCellValue] = [] + let valueList = written.map { cell -> String in + if let sql = Self.expression(cell.value, isMarked: expressionColumns.contains(cell.index)) { return sql } + parameters.append(cell.value) + return "?" + } + let columnList = written.map { Self.quote($0.column) }.joined(separator: ", ") return ( - statement: "INSERT INTO \(qualifiedTable) (\(columnList)) VALUES (\(placeholders))", - parameters: written.map(\.1) + statement: "INSERT INTO \(qualifiedTable) (\(columnList)) VALUES (\(valueList.joined(separator: ", ")))", + parameters: parameters ) } @@ -89,10 +101,17 @@ struct MSSQLStatementGenerator { throw PluginRowWriteRefusal(rowIndex: change.rowIndex, reason: Self.serverOwnedReason(owned.columnName)) } + let expressionColumns = change.expressionColumns ?? [] var parameters: [PluginCellValue] = [] let assignments = change.cellChanges.map { cellChange -> String in let column = Self.quote(cellChange.columnName) guard cellChange.newValue != Self.defaultMarker else { return "\(column) = DEFAULT" } + if let sql = Self.expression( + cellChange.newValue, + isMarked: expressionColumns.contains(cellChange.columnIndex) + ) { + return "\(column) = \(sql)" + } parameters.append(cellChange.newValue) return "\(column) = ?" } @@ -169,6 +188,12 @@ struct MSSQLStatementGenerator { ) } + // The host checked a marked cell's text against the engine's vocabulary before handing it over. + private static func expression(_ value: PluginCellValue, isMarked: Bool) -> String? { + guard isMarked, case .text(let sql) = value else { return nil } + return sql + } + static func quote(_ name: String) -> String { "[\(name.replacingOccurrences(of: "]", with: "]]"))]" } diff --git a/Plugins/MarkdownExportPlugin/Info.plist b/Plugins/MarkdownExportPlugin/Info.plist index 3a58b1bef3..cb1c7d34de 100644 --- a/Plugins/MarkdownExportPlugin/Info.plist +++ b/Plugins/MarkdownExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds md diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index 1a631ea62b..88fdb3660c 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index d173aa6f79..2cca4834c1 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift b/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift index 05eabb20ea..2c1511c9b8 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBCharacterSet.swift @@ -137,16 +137,11 @@ nonisolated func mysqlTypeToString(_ fieldPtr: UnsafePointer) -> St let field = fieldPtr.pointee let flags = UInt(field.flags) - // MariaDB extended metadata: detect JSON stored as LONGTEXT. - // `MARIADB_CONST_STRING` is length-prefixed (not null-terminated), so we must read - // exactly `attr.length` bytes. `String(cString:)` would scan past the buffer into - // adjacent memory and intermittently fail the comparison when that memory is non-zero. - var attr = MARIADB_CONST_STRING() - if mariadb_field_attr(&attr, fieldPtr, MARIADB_FIELD_ATTR_FORMAT_NAME) == 0, - let str = attr.str, attr.length > 0, - let value = String(data: Data(bytes: str, count: Int(attr.length)), encoding: .utf8), - value == "json" { - return "JSON" + if let extended = mariaDBExtendedTypeName( + formatName: mariaDBFieldAttribute(fieldPtr, MARIADB_FIELD_ATTR_FORMAT_NAME), + dataTypeName: mariaDBFieldAttribute(fieldPtr, MARIADB_FIELD_ATTR_DATA_TYPE_NAME) + ) { + return extended } if (flags & mysqlEnumFlag) != 0 { return "ENUM" } @@ -159,3 +154,14 @@ nonisolated func mysqlTypeToString(_ fieldPtr: UnsafePointer) -> St length: field.length ) } + +// `MARIADB_CONST_STRING` is length-prefixed, not NUL-terminated: `String(cString:)` would scan past the buffer into +// adjacent memory and intermittently fail the comparison when that memory is non-zero. +nonisolated private func mariaDBFieldAttribute( + _ fieldPtr: UnsafePointer, + _ attribute: mariadb_field_attr_t +) -> String? { + var value = MARIADB_CONST_STRING() + guard mariadb_field_attr(&value, fieldPtr, attribute) == 0, let str = value.str, value.length > 0 else { return nil } + return String(data: Data(bytes: str, count: Int(value.length)), encoding: .utf8) +} diff --git a/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift b/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift index 00b61c5c8b..9b6fab6db0 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBFieldMetadata.swift @@ -24,6 +24,13 @@ nonisolated internal func makeColumnMeta(name: String, typeName: String, flags: ) } +// MariaDB 10.5+ extended metadata: a `json` column arrives as LONGTEXT and a `uuid` one as CHAR. MySQL sends neither. +nonisolated internal func mariaDBExtendedTypeName(formatName: String?, dataTypeName: String?) -> String? { + if formatName == "json" { return "JSON" } + if dataTypeName == "uuid" { return "UUID" } + return nil +} + nonisolated internal func mariaDBTypeName( typeRaw: UInt32, flags: UInt, diff --git a/Plugins/MySQLDriverTests/MariaDBTypeNameTests.swift b/Plugins/MySQLDriverTests/MariaDBTypeNameTests.swift index 586e89c63b..f029fa39ab 100644 --- a/Plugins/MySQLDriverTests/MariaDBTypeNameTests.swift +++ b/Plugins/MySQLDriverTests/MariaDBTypeNameTests.swift @@ -99,6 +99,26 @@ struct MariaDBTypeNameTests { #expect(resolve(typeRaw: 251, charsetnr: 33, flags: 0) == "LONGTEXT") } + // MARK: - MariaDB extended metadata + + @Test("A MariaDB uuid column is named UUID, not the CHAR its type code says") + func extendedUUID() { + #expect(mariaDBExtendedTypeName(formatName: nil, dataTypeName: "uuid") == "UUID") + } + + @Test("A MariaDB json column is named JSON, not the LONGTEXT its type code says") + func extendedJSON() { + #expect(mariaDBExtendedTypeName(formatName: "json", dataTypeName: nil) == "JSON") + } + + @Test( + "inet4, inet6 and a server that sends no attribute keep the type code's name", + arguments: [nil, "inet4", "inet6"] as [String?] + ) + func extendedOtherNames(dataTypeName: String?) { + #expect(mariaDBExtendedTypeName(formatName: nil, dataTypeName: dataTypeName) == nil) + } + @Test("Generic BLOB (252) routes by length and binary flag") func blobLengthRouting() { let (flags, charsetnr) = binaryFlagAndCharset diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index 1a631ea62b..88fdb3660c 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index fcfed99b2d..0bb7d1ebe1 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -191,6 +191,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { .multiSchema, .schemaCompare, .dataCompare, + .cellExpressions, + .uuidCellText, ] } diff --git a/Plugins/OracleDriverPlugin/OracleRowWriter.swift b/Plugins/OracleDriverPlugin/OracleRowWriter.swift index fe48c92ea3..1ba8facbb3 100644 --- a/Plugins/OracleDriverPlugin/OracleRowWriter.swift +++ b/Plugins/OracleDriverPlugin/OracleRowWriter.swift @@ -52,7 +52,11 @@ internal struct OracleRowWriter { case .insert: guard insertedRowIndices.contains(change.rowIndex), let values = insertedRowData[change.rowIndex], - let write = try insert(values: values, rowIndex: change.rowIndex) else { continue } + let write = try insert( + values: values, + rowIndex: change.rowIndex, + expressionColumns: change.expressionColumns ?? [] + ) else { continue } try flushDeletes() writes.append(write) case .update: @@ -72,18 +76,25 @@ internal struct OracleRowWriter { /// Oracle has no `DEFAULT VALUES`. A row whose every column is the server's is written as one column's `DEFAULT`, /// which an identity or virtual column takes (measured on 23ai). - private func insert(values: [PluginCellValue], rowIndex: Int) throws -> PluginRowWrite? { + private func insert( + values: [PluginCellValue], + rowIndex: Int, + expressionColumns: Set + ) throws -> PluginRowWrite? { guard let firstColumn = columns.first else { return nil } var names: [String] = [] var placeholders: [String] = [] var parameters: [PluginCellValue] = [] - for (column, value) in zip(columns, values) where !context.serverOwnedColumns.contains(column) { + for (index, (column, value)) in zip(columns, values).enumerated() + where !context.serverOwnedColumns.contains(column) { names.append(Self.quote(column)) - placeholders.append( - value == Self.defaultMarker - ? "DEFAULT" - : try sql(for: value, column: column, rowIndex: rowIndex, into: ¶meters) - ) + if value == Self.defaultMarker { + placeholders.append("DEFAULT") + } else if let expression = Self.expression(value, isMarked: expressionColumns.contains(index)) { + placeholders.append(expression) + } else { + placeholders.append(try sql(for: value, column: column, rowIndex: rowIndex, into: ¶meters)) + } } guard !names.isEmpty else { return PluginRowWrite( @@ -101,10 +112,14 @@ internal struct OracleRowWriter { if let owned = change.cellChanges.first(where: { context.serverOwnedColumns.contains($0.columnName) }) { throw PluginRowWriteRefusal(rowIndex: change.rowIndex, reason: Self.serverOwnedReason(owned.columnName)) } + let expressionColumns = change.expressionColumns ?? [] var parameters: [PluginCellValue] = [] let assignments = try change.cellChanges.map { cell -> String in let column = Self.quote(cell.columnName) guard cell.newValue != Self.defaultMarker else { return "\(column) = DEFAULT" } + if let expression = Self.expression(cell.newValue, isMarked: expressionColumns.contains(cell.columnIndex)) { + return "\(column) = \(expression)" + } let value = try sql(for: cell.newValue, column: cell.columnName, rowIndex: change.rowIndex, into: ¶meters) return "\(column) = \(value)" } @@ -267,6 +282,12 @@ internal struct OracleRowWriter { // MARK: - Helpers + // The host checked a marked cell's text against the engine's vocabulary before handing it over. + private static func expression(_ value: PluginCellValue, isMarked: Bool) -> String? { + guard isMarked, case .text(let sql) = value else { return nil } + return sql + } + static func quote(_ name: String) -> String { "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" } @@ -356,21 +377,14 @@ internal enum OracleValueKind: Equatable { return OracleNumericLiteral.isValid(text) ? text + "f" : nil case .binaryDouble: return OracleNumericLiteral.isValid(text) ? text + "d" : nil - case .date, .timestamp, .timestampWithTimeZone, .timestampWithLocalTimeZone: - let keyword = text.trimmingCharacters(in: .whitespaces).uppercased() - return Self.temporalFunctions.contains(keyword) ? keyword : nil case .nationalText: return OracleNationalText.sql(for: text, asLOB: false) case .nationalLOB: return OracleNationalText.sql(for: text, asLOB: true) - case .bfile, .other: + case .date, .timestamp, .timestampWithTimeZone, .timestampWithLocalTimeZone, .bfile, .other: return nil } } - - private static let temporalFunctions: Set = [ - "SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP" - ] } /// An Oracle numeric literal in ASCII: an optional sign, digits with at most one decimal point, and an optional diff --git a/Plugins/ParquetExportPlugin/Info.plist b/Plugins/ParquetExportPlugin/Info.plist index 5bcb4498d9..0d5a128b54 100644 --- a/Plugins/ParquetExportPlugin/Info.plist +++ b/Plugins/ParquetExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds parquet diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index 5f4d29d5bd..b118efa2ba 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index 90c2405de5..535609076b 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index cb5e6e80c9..279c69fe5a 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index e07a342bce..369a47df4b 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 3193203ba4..56a956e9dd 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist index f3d805857c..ae2952b33f 100644 --- a/Plugins/SnowflakeDriverPlugin/Info.plist +++ b/Plugins/SnowflakeDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.48.0 TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/SpannerDriverPlugin/Info.plist b/Plugins/SpannerDriverPlugin/Info.plist index 935ed5e828..07dfe685ea 100644 --- a/Plugins/SpannerDriverPlugin/Info.plist +++ b/Plugins/SpannerDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Spanner diff --git a/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Editing.swift b/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Editing.swift index 07068cb3bc..b70c2a5b05 100644 --- a/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Editing.swift +++ b/Plugins/SpannerDriverPlugin/SpannerPluginDriver+Editing.swift @@ -80,7 +80,7 @@ extension SpannerPluginDriver { table: table, columns: columns, primaryKey: primaryKeyColumns, - changes: changes.map(Self.rowChange), + changes: changes.map { Self.rowChange($0, columns: columns) }, insertedRows: insertedRowData.mapValues { $0.map(Self.spannerCell) }, deletedRows: deletedRowIndices, insertedRowIndices: insertedRowIndices, @@ -174,17 +174,30 @@ extension SpannerPluginDriver { ) } - private static func rowChange(_ change: PluginRowChange) -> SpannerRowChange { + private static func rowChange(_ change: PluginRowChange, columns: [String]) -> SpannerRowChange { SpannerRowChange( rowIndex: change.rowIndex, kind: rowChangeKind(change.type), cellChanges: change.cellChanges.map { SpannerRowChange.CellChange(column: $0.columnName, value: spannerCell($0.newValue)) }, - originalRow: change.originalRow?.map(spannerCell) + originalRow: change.originalRow?.map(spannerCell), + expressionColumns: expressionColumnNames(of: change, columns: columns) ) } + private static func expressionColumnNames(of change: PluginRowChange, columns: [String]) -> Set { + guard let marked = change.expressionColumns, !marked.isEmpty else { return [] } + switch change.type { + case .update: + return Set(change.cellChanges.filter { marked.contains($0.columnIndex) }.map(\.columnName)) + case .insert: + return Set(marked.compactMap { columns.indices.contains($0) ? columns[$0] : nil }) + case .delete: + return [] + } + } + private static func rowChangeKind(_ type: PluginRowChange.ChangeType) -> SpannerRowChange.Kind { switch type { case .insert: diff --git a/Plugins/SpannerDriverPlugin/SpannerPluginDriver.swift b/Plugins/SpannerDriverPlugin/SpannerPluginDriver.swift index b6407499d1..67b75b893c 100644 --- a/Plugins/SpannerDriverPlugin/SpannerPluginDriver.swift +++ b/Plugins/SpannerDriverPlugin/SpannerPluginDriver.swift @@ -37,7 +37,7 @@ internal final class SpannerPluginDriver: PluginDatabaseDriver, @unchecked Senda } var capabilities: PluginCapabilities { - [.multiSchema, .cancelQuery, .transactions, .truncateTable] + [.multiSchema, .cancelQuery, .transactions, .truncateTable, .cellExpressions, .uuidCellText] } var serverVersion: String? { diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist index 83deb254f9..acb61d7be8 100644 --- a/Plugins/SurrealDBDriverPlugin/Info.plist +++ b/Plugins/SurrealDBDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds SurrealDB diff --git a/Plugins/TableProPluginKit/PluginCapabilities.swift b/Plugins/TableProPluginKit/PluginCapabilities.swift index e5c06eba27..0c7ad14baa 100644 --- a/Plugins/TableProPluginKit/PluginCapabilities.swift +++ b/Plugins/TableProPluginKit/PluginCapabilities.swift @@ -25,4 +25,8 @@ public struct PluginCapabilities: OptionSet, Sendable { public static let schemaCompare = PluginCapabilities(rawValue: 1 << 13) public static let dataCompare = PluginCapabilities(rawValue: 1 << 14) public static let resultSetBatches = PluginCapabilities(rawValue: 1 << 15) + /// The driver writes the cells `PluginRowChange.expressionColumns` names unquoted, as SQL. + public static let cellExpressions = PluginCapabilities(rawValue: 1 << 16) + /// The driver stores canonical UUID text written into a UUID-typed column as a UUID. + public static let uuidCellText = PluginCapabilities(rawValue: 1 << 17) } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 37eb622f3e..f44c266c16 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -63,6 +63,11 @@ public struct PluginRowChange: Sendable { /// shown rows with such cells, so every other driver can ignore it. public var absentColumns: Set? + /// The columns whose new value is a SQL expression the host vetted, such as `now()`, to be written as it is + /// rather than bound as text: an update's `cellChanges`, or a new row's `insertedRowData` cells. Only a driver + /// that declares `PluginCapabilities.cellExpressions` is handed any. + public var expressionColumns: Set? + public init( rowIndex: Int, type: ChangeType, diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist index 1a631ea62b..88fdb3660c 100644 --- a/Plugins/TeradataDriverPlugin/Info.plist +++ b/Plugins/TeradataDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index 20910cd743..b417b59570 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -128,7 +128,7 @@ final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { true } var capabilities: PluginCapabilities { - [.cancelQuery, .transactions, .alterTableDDL, .dataCompare] + [.cancelQuery, .transactions, .alterTableDDL, .dataCompare, .cellExpressions, .uuidCellText] } init(config: DriverConnectionConfig) { diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift index af5f2bc985..3ca74332b4 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Editing.swift @@ -32,9 +32,22 @@ extension TeradataPluginDriver { case .insert: guard insertedRowIndices.contains(change.rowIndex), let values = insertedRowData[change.rowIndex], values.count == columns.count else { continue } - statements.append((TeradataRowEditSQL.insert(target: target, columns: columns, values: values), [])) + let insert = TeradataRowEditSQL.insert( + target: target, + columns: columns, + values: values, + expressionColumns: change.expressionColumns ?? [] + ) + statements.append((insert, [])) case .update: - let assignments = change.cellChanges.map { "\(quote($0.columnName)) = \(sqlLiteral($0.newValue))" } + let expressionColumns = change.expressionColumns ?? [] + let assignments = change.cellChanges.map { + TeradataRowEditSQL.assignment( + column: $0.columnName, + value: $0.newValue, + isExpression: expressionColumns.contains($0.columnIndex) + ) + } guard !assignments.isEmpty else { continue } let whereClause = keyPredicate(primaryKeyColumns, columns: columns, change: change) statements.append(("UPDATE \(target) SET \(assignments.joined(separator: ", "))\(whereClause)", [])) diff --git a/Plugins/TeradataDriverPlugin/TeradataRowEditSQL.swift b/Plugins/TeradataDriverPlugin/TeradataRowEditSQL.swift index e932000b7a..d2ad138869 100644 --- a/Plugins/TeradataDriverPlugin/TeradataRowEditSQL.swift +++ b/Plugins/TeradataDriverPlugin/TeradataRowEditSQL.swift @@ -24,11 +24,30 @@ enum TeradataRowEditSQL { } } - static func insert(target: String, columns: [String], values: [PluginCellValue]) -> String { - let written = zip(columns, values).filter { $0.1 != defaultMarker } + static func insert( + target: String, + columns: [String], + values: [PluginCellValue], + expressionColumns: Set = [] + ) -> String { + let written = zip(columns, values).enumerated().filter { $0.element.1 != defaultMarker } guard !written.isEmpty else { return "INSERT INTO \(target) DEFAULT VALUES" } - let columnList = written.map { TeradataSchemaQueries.quoteIdentifier($0.0) }.joined(separator: ", ") - let valueList = written.map { literal($0.1) }.joined(separator: ", ") + let columnList = written.map { TeradataSchemaQueries.quoteIdentifier($0.element.0) }.joined(separator: ", ") + let valueList = written.map { cell in + value(cell.element.1, isExpression: expressionColumns.contains(cell.offset)) + }.joined(separator: ", ") return "INSERT INTO \(target) (\(columnList)) VALUES (\(valueList))" } + + static func assignment(column: String, value: PluginCellValue, isExpression: Bool) -> String { + let name = TeradataSchemaQueries.quoteIdentifier(column) + guard value != defaultMarker else { return "\(name) = DEFAULT" } + return "\(name) = \(Self.value(value, isExpression: isExpression))" + } + + // The host checked a marked cell's text against the engine's vocabulary before handing it over. + private static func value(_ value: PluginCellValue, isExpression: Bool) -> String { + guard isExpression, case .text(let sql) = value else { return literal(value) } + return sql + } } diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist index 1a631ea62b..88fdb3660c 100644 --- a/Plugins/TrinoDriverPlugin/Info.plist +++ b/Plugins/TrinoDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 38 + 39 diff --git a/Plugins/TypesenseDriverPlugin/Info.plist b/Plugins/TypesenseDriverPlugin/Info.plist index f61dcd3213..dca18efec4 100644 --- a/Plugins/TypesenseDriverPlugin/Info.plist +++ b/Plugins/TypesenseDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Typesense diff --git a/Plugins/WeaviateDriverPlugin/Info.plist b/Plugins/WeaviateDriverPlugin/Info.plist index 9e38b50117..28579a91f5 100644 --- a/Plugins/WeaviateDriverPlugin/Info.plist +++ b/Plugins/WeaviateDriverPlugin/Info.plist @@ -5,7 +5,7 @@ TableProMinAppVersion 0.73.0 TableProPluginKitVersion - 38 + 39 TableProProvidesDatabaseTypeIds Weaviate diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index 56627bb15f..1f676a78e2 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesExportFormatIds xlsx diff --git a/Plugins/XLSXImportPlugin/Info.plist b/Plugins/XLSXImportPlugin/Info.plist index e64e3ac6dc..5407e53283 100644 --- a/Plugins/XLSXImportPlugin/Info.plist +++ b/Plugins/XLSXImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 38 + 39 TableProProvidesImportFormatIds xlsx diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index d6a0cae0fe..33c13b438c 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -13,7 +13,10 @@ protocol ChangeManaging: AnyObject { var modifiedCells: [RowID: Set] { get } var generatedColumns: Set { get } var supportsFieldRemoval: Bool { get } + var cellWriterSupport: CellWriterSupport { get } func isRowDeleted(_ rowID: RowID) -> Bool + func isExpression(rowID: RowID, columnIndex: Int) -> Bool + func expressionColumns(forRow rowID: RowID) -> Set func recordCellChange( rowID: RowID, columnIndex: Int, @@ -31,6 +34,16 @@ protocol ChangeManaging: AnyObject { originalRow: [PluginCellValue]?, absence: FieldAbsence ) + func recordCellChange( + rowID: RowID, + columnIndex: Int, + columnName: String, + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]?, + absence: FieldAbsence, + isExpression: Bool + ) func undoRowDeletion(rowID: RowID) } @@ -45,6 +58,12 @@ extension ChangeManaging { var supportsFieldRemoval: Bool { false } + var cellWriterSupport: CellWriterSupport { .none } + + func isExpression(rowID: RowID, columnIndex: Int) -> Bool { false } + + func expressionColumns(forRow rowID: RowID) -> Set { [] } + /// Only the data grid can show a row without a field; every other grid records the value. func recordCellChange( rowID: RowID, @@ -64,6 +83,28 @@ extension ChangeManaging { originalRow: originalRow ) } + + /// Only the data grid writes SQL expressions; `cellWriterSupport` keeps every other grid from offering one. + func recordCellChange( + rowID: RowID, + columnIndex: Int, + columnName: String, + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]?, + absence: FieldAbsence, + isExpression: Bool + ) { + recordCellChange( + rowID: rowID, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: originalRow, + absence: absence + ) + } } @MainActor @@ -79,11 +120,20 @@ final class AnyChangeManager: ObservableObject { var modifiedCells: [RowID: Set] { wrapped.modifiedCells } var generatedColumns: Set { wrapped.generatedColumns } var supportsFieldRemoval: Bool { wrapped.supportsFieldRemoval } + var cellWriterSupport: CellWriterSupport { wrapped.cellWriterSupport } func isRowDeleted(_ rowID: RowID) -> Bool { wrapped.isRowDeleted(rowID) } + func isExpression(rowID: RowID, columnIndex: Int) -> Bool { + wrapped.isExpression(rowID: rowID, columnIndex: columnIndex) + } + + func expressionColumns(forRow rowID: RowID) -> Set { + wrapped.expressionColumns(forRow: rowID) + } + func recordCellChange( rowID: RowID, columnIndex: Int, @@ -122,6 +172,28 @@ final class AnyChangeManager: ObservableObject { ) } + func recordCellChange( + rowID: RowID, + columnIndex: Int, + columnName: String, + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue], + absence: FieldAbsence, + isExpression: Bool + ) { + wrapped.recordCellChange( + rowID: rowID, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: originalRow, + absence: absence, + isExpression: isExpression + ) + } + func undoRowDeletion(rowID: RowID) { wrapped.undoRowDeletion(rowID: rowID) } diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index eaa1c920fc..364a91bcd6 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -91,9 +91,11 @@ final class DataChangeManager: ObservableObject, ChangeManaging { var newValue: PluginCellValue let originalRow: [PluginCellValue]? var absence: FieldAbsence + var expression: ExpressionTransition var isNoOp: Bool { previousValue == newValue && absence.wasAbsent == absence.isAbsent + && expression.wasExpression == expression.isExpression } } @@ -191,6 +193,17 @@ final class DataChangeManager: ObservableObject, ChangeManaging { return PluginManager.shared.supportsFieldRemoval(for: databaseType) } + /// Asked of the driver instance, not of the plugin's metadata: ScyllaDB is a variant of the Cassandra plugin and + /// takes its curated entry, not the plugin's flags. + var cellWriterSupport: CellWriterSupport { + guard let pluginDriver else { return .host } + let capabilities = pluginDriver.capabilities + return CellWriterSupport( + acceptsExpressions: capabilities.contains(.cellExpressions), + acceptsUUIDText: capabilities.contains(.uuidCellText) + ) + } + /// Whether the app may send a value for this column at all: the server computes or allocates it, /// or the driver declares it immutable, as MongoDB does for `_id`. Both halves belong here, /// because this is the boundary every staging path crosses and the grid's own copy of the @@ -233,11 +246,30 @@ final class DataChangeManager: ObservableObject, ChangeManaging { newValue: PluginCellValue, originalRow: [PluginCellValue]?, absence: FieldAbsence + ) { + recordCellChange( + rowID: rowID, columnIndex: columnIndex, columnName: columnName, + oldValue: oldValue, newValue: newValue, originalRow: originalRow, + absence: absence, isExpression: false + ) + } + + /// `isExpression` stages `newValue`, a `.text` holding SQL from `CellExpressionVocabulary`, to be written + /// unquoted. + func recordCellChange( + rowID: RowID, + columnIndex: Int, + columnName: String, + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]?, + absence: FieldAbsence, + isExpression: Bool ) { record( rowID: rowID, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, newValue: newValue, originalRow: originalRow, - absence: absence, coalescesWithPrevious: false + absence: absence, isExpression: isExpression, coalescesWithPrevious: false ) } @@ -250,12 +282,13 @@ final class DataChangeManager: ObservableObject, ChangeManaging { oldValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]? = nil, - absence: FieldAbsence = FieldAbsence() + absence: FieldAbsence = FieldAbsence(), + isExpression: Bool = false ) { record( rowID: rowID, columnIndex: columnIndex, columnName: columnName, oldValue: oldValue, newValue: newValue, originalRow: originalRow, - absence: absence, coalescesWithPrevious: true + absence: absence, isExpression: isExpression, coalescesWithPrevious: true ) } @@ -267,6 +300,7 @@ final class DataChangeManager: ObservableObject, ChangeManaging { newValue: PluginCellValue, originalRow: [PluginCellValue]?, absence: FieldAbsence, + isExpression: Bool, coalescesWithPrevious: Bool ) { /// The last gate before a change becomes pending, and the only one every path crosses. The @@ -280,6 +314,10 @@ final class DataChangeManager: ObservableObject, ChangeManaging { ) return } + let expression = ExpressionTransition( + wasExpression: pending.isExpression(rowID: rowID, columnIndex: columnIndex), + isExpression: isExpression + ) let recorded = pending.recordCellChange( rowID: rowID, columnIndex: columnIndex, @@ -287,7 +325,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { oldValue: oldValue, newValue: newValue, originalRow: originalRow, - absence: absence + absence: absence, + isExpression: isExpression ) guard recorded else { hasChanges = !pending.isEmpty @@ -296,14 +335,15 @@ final class DataChangeManager: ObservableObject, ChangeManaging { if coalescesWithPrevious { bufferCoalescedEdit( rowID: rowID, columnIndex: columnIndex, columnName: columnName, - oldValue: oldValue, newValue: newValue, originalRow: originalRow, absence: absence + oldValue: oldValue, newValue: newValue, originalRow: originalRow, + absence: absence, expression: expression ) } else { registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue, originalRow: originalRow, - absence: absence + absence: absence, expression: expression )) } } @@ -317,18 +357,21 @@ final class DataChangeManager: ObservableObject, ChangeManaging { oldValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]?, - absence: FieldAbsence + absence: FieldAbsence, + expression: ExpressionTransition ) { let key = CoalescedCellKey(rowID: rowID, columnIndex: columnIndex) if var existing = coalescedEdits[key] { existing.newValue = newValue existing.absence.isAbsent = absence.isAbsent + existing.expression.isExpression = expression.isExpression coalescedEdits[key] = existing return } coalescedEdits[key] = CoalescedCellEdit( rowID: rowID, columnIndex: columnIndex, columnName: columnName, - previousValue: oldValue, newValue: newValue, originalRow: originalRow, absence: absence + previousValue: oldValue, newValue: newValue, originalRow: originalRow, + absence: absence, expression: expression ) coalescedOrder.append(key) } @@ -350,7 +393,7 @@ final class DataChangeManager: ObservableObject, ChangeManaging { target.applyDataUndo(.cellEdit( rowID: edit.rowID, columnIndex: edit.columnIndex, columnName: edit.columnName, previousValue: edit.previousValue, newValue: edit.newValue, originalRow: edit.originalRow, - absence: edit.absence + absence: edit.absence, expression: edit.expression )) } } @@ -398,8 +441,16 @@ final class DataChangeManager: ObservableObject, ChangeManaging { hasChanges = true } - func recordRowInsertion(rowID: RowID, values: [PluginCellValue], absentColumns: Set = []) { - pending.recordRowInsertion(rowID: rowID, values: values, absentColumns: absentColumns) + /// `expressionColumns` are cells of `values` staged as SQL, as a duplicate of a pending row carries them. + func recordRowInsertion( + rowID: RowID, + values: [PluginCellValue], + absentColumns: Set = [], + expressionColumns: Set = [] + ) { + pending.recordRowInsertion( + rowID: rowID, values: values, absentColumns: absentColumns, expressionColumns: expressionColumns + ) registerUndo(actionName: String(localized: "Insert Row")) { target in target.applyDataUndo(.rowInsertion(rowID: rowID)) } @@ -417,12 +468,14 @@ final class DataChangeManager: ObservableObject, ChangeManaging { let validRows = rows.filter { pending.isRowInserted($0.rowID) } guard !validRows.isEmpty else { return } let rowAbsentColumns = validRows.map { pending.insertedAbsentColumns(forRow: $0.rowID) } + let rowExpressionColumns = validRows.map { pending.insertedExpressionColumns(forRow: $0.rowID) } let rowValues = pending.undoBatchRowInsertion( rowIDs: validRows.map(\.rowID), columnCount: columns.count ) registerUndo(actionName: String(localized: "Insert Rows")) { target in target.applyDataUndo(.batchRowInsertion( - rows: validRows, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns + rows: validRows, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns, + rowExpressionColumns: rowExpressionColumns )) } hasChanges = !pending.isEmpty @@ -433,11 +486,11 @@ final class DataChangeManager: ObservableObject, ChangeManaging { private func applyDataUndo(_ action: UndoAction) { switch action { case .cellEdit(let rowID, let columnIndex, let columnName, let previousValue, let newValue, let originalRow, - let absence): + let absence, let expression): applyCellEditUndo( rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: previousValue, newValue: newValue, originalRow: originalRow, - absence: absence, action: action + absence: absence, expression: expression, action: action ) case .rowInsertion(let rowID, let image): @@ -449,9 +502,10 @@ final class DataChangeManager: ObservableObject, ChangeManaging { case .batchRowDeletion(let rows, let absentColumns): applyBatchRowDeletionUndo(rows: rows, absentColumns: absentColumns, action: action) - case .batchRowInsertion(let rows, let rowValues, let rowAbsentColumns): + case .batchRowInsertion(let rows, let rowValues, let rowAbsentColumns, let rowExpressionColumns): applyBatchRowInsertionUndo( - rows: rows, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns, action: action + rows: rows, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns, + rowExpressionColumns: rowExpressionColumns, action: action ) } @@ -465,13 +519,13 @@ final class DataChangeManager: ObservableObject, ChangeManaging { private func applyCellEditUndo( rowID: RowID, columnIndex: Int, columnName: String, previousValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]?, - absence: FieldAbsence, action: UndoAction + absence: FieldAbsence, expression: ExpressionTransition, action: UndoAction ) { registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: newValue, newValue: previousValue, originalRow: originalRow, - absence: absence.reversed + absence: absence.reversed, expression: expression.reversed )) } @@ -480,20 +534,22 @@ final class DataChangeManager: ObservableObject, ChangeManaging { pending.revertUpdateCell( rowID: rowID, columnIndex: columnIndex, columnName: columnName, previousValue: previousValue, - previousIsAbsent: absence.wasAbsent + previousIsAbsent: absence.wasAbsent, + previousIsExpression: expression.wasExpression ) } } else if pending.change(forRow: rowID, type: .insert) != nil { pending.updateInsertedCellDirectly( rowID: rowID, columnIndex: columnIndex, - columnName: columnName, newValue: previousValue, isAbsent: absence.wasAbsent + columnName: columnName, newValue: previousValue, + isAbsent: absence.wasAbsent, isExpression: expression.wasExpression ) } else { pending.reapplyCellChange( rowID: rowID, columnIndex: columnIndex, columnName: columnName, originalDBValue: newValue, newValue: previousValue, originalRow: originalRow, - absence: absence.reversed + absence: absence.reversed, expression: expression.reversed ) } lastUndoResult = UndoResult( @@ -510,7 +566,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { } let absentColumns = image?.absentColumns ?? [] pending.reinsertRow( - rowID: rowID, columns: columns, savedValues: image?.values, absentColumns: absentColumns + rowID: rowID, columns: columns, savedValues: image?.values, absentColumns: absentColumns, + expressionColumns: image?.expressionColumns ?? [] ) lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: image?.values, @@ -521,7 +578,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { let removed = InsertedRowImage( values: pending.savedInsertedValues(forRow: rowID), - absentColumns: pending.insertedAbsentColumns(forRow: rowID) + absentColumns: pending.insertedAbsentColumns(forRow: rowID), + expressionColumns: pending.insertedExpressionColumns(forRow: rowID) ) registerUndo(actionName: String(localized: "Insert Row")) { target in target.applyDataUndo(.rowInsertion(rowID: rowID, restoring: removed)) @@ -586,11 +644,16 @@ final class DataChangeManager: ObservableObject, ChangeManaging { } private func applyBatchRowInsertionUndo( - rows: [InsertedRowLocation], rowValues: [[PluginCellValue]], rowAbsentColumns: [Set], action: UndoAction + rows: [InsertedRowLocation], + rowValues: [[PluginCellValue]], + rowAbsentColumns: [Set], + rowExpressionColumns: [Set], + action: UndoAction ) { registerUndo(actionName: String(localized: "Insert Rows")) { target in target.applyDataUndo(.batchRowInsertion( - rows: rows, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns + rows: rows, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns, + rowExpressionColumns: rowExpressionColumns )) } @@ -603,7 +666,8 @@ final class DataChangeManager: ObservableObject, ChangeManaging { ) } else { pending.reinsertBatch( - rowIDs: rowIDs, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns, columns: columns + rowIDs: rowIDs, rowValues: rowValues, rowAbsentColumns: rowAbsentColumns, + rowExpressionColumns: rowExpressionColumns, columns: columns ) lastUndoResult = UndoResult( action: action, needsRowRemoval: false, needsRowRestore: true, restoreRow: nil @@ -771,4 +835,13 @@ final class DataChangeManager: ObservableObject, ChangeManaging { func getModifiedColumnsForRow(_ rowID: RowID) -> Set { pending.modifiedColumns(forRow: rowID) } + + func isExpression(rowID: RowID, columnIndex: Int) -> Bool { + pending.isExpression(rowID: rowID, columnIndex: columnIndex) + } + + /// The row's cells staged as SQL, which Copy as INSERT or UPDATE writes unquoted. + func expressionColumns(forRow rowID: RowID) -> Set { + pending.expressionColumns(forRow: rowID) + } } diff --git a/TablePro/Core/ChangeTracking/DataChangeModels.swift b/TablePro/Core/ChangeTracking/DataChangeModels.swift index 9416a3bf4c..52fbfc2d58 100644 --- a/TablePro/Core/ChangeTracking/DataChangeModels.swift +++ b/TablePro/Core/ChangeTracking/DataChangeModels.swift @@ -29,6 +29,17 @@ struct FieldAbsence: Equatable, Sendable { } } +/// Whether a cell held a SQL expression before an edit and holds one after it. The value alone cannot say: an +/// expression is staged as its text, which a stored or typed value can also be. +struct ExpressionTransition: Equatable, Sendable { + var wasExpression = false + var isExpression = false + + var reversed: ExpressionTransition { + ExpressionTransition(wasExpression: isExpression, isExpression: wasExpression) + } +} + struct CellChange: Identifiable, Equatable { let id: UUID let columnIndex: Int @@ -37,6 +48,10 @@ struct CellChange: Identifiable, Equatable { let newValue: PluginCellValue let oldIsAbsent: Bool let newIsAbsent: Bool + let oldIsExpression: Bool + /// The new value is `.text` holding SQL from `CellExpressionVocabulary`, written unquoted. Never set with + /// `newIsAbsent`. + let newIsExpression: Bool init( columnIndex: Int, @@ -44,7 +59,9 @@ struct CellChange: Identifiable, Equatable { oldValue: PluginCellValue, newValue: PluginCellValue, oldIsAbsent: Bool = false, - newIsAbsent: Bool = false + newIsAbsent: Bool = false, + oldIsExpression: Bool = false, + newIsExpression: Bool = false ) { self.id = UUID() self.columnIndex = columnIndex @@ -53,12 +70,14 @@ struct CellChange: Identifiable, Equatable { self.newValue = newValue self.oldIsAbsent = oldIsAbsent self.newIsAbsent = newIsAbsent + self.oldIsExpression = oldIsExpression + self.newIsExpression = newIsExpression } - /// Whether the edit leaves the cell as it was read: the same value, and the field there or - /// missing on both sides. + /// Whether the edit leaves the cell as it was read: the same value, the field there or missing on both sides, + /// and no expression in place of text that reads the same. var restoresOriginal: Bool { - oldValue == newValue && oldIsAbsent == newIsAbsent + oldValue == newValue && oldIsAbsent == newIsAbsent && oldIsExpression == newIsExpression } } @@ -73,6 +92,10 @@ struct RowChange: Identifiable, Equatable { /// stands for an insert. Empty on every engine that cannot tell a missing field from NULL. var absentColumns: Set + /// An insert's cells that hold a SQL expression, as the row stands. An update keeps its own on each + /// `CellChange`. + var expressionColumns: Set + /// The order the user made this change in. /// /// Not the array position. `PendingChanges` removes a cancelled change by swapping the last @@ -87,6 +110,7 @@ struct RowChange: Identifiable, Equatable { cellChanges: [CellChange] = [], originalRow: [PluginCellValue]? = nil, absentColumns: Set = [], + expressionColumns: Set = [], sequence: Int = 0 ) { self.id = UUID() @@ -95,6 +119,7 @@ struct RowChange: Identifiable, Equatable { self.cellChanges = cellChanges self.originalRow = originalRow self.absentColumns = absentColumns + self.expressionColumns = expressionColumns self.sequence = sequence } @@ -121,11 +146,13 @@ struct InsertedRowLocation { let storageIndex: Int } -/// What a new row held when its insertion was undone: its values, and the fields it had none of. -/// Redoing the insertion puts back both, since the row's change is gone by then and cannot say. +/// What a new row held when its insertion was undone: its values, the fields it had none of, and the cells that +/// held an expression. Redoing the insertion puts back all three, since the row's change is gone by then and cannot +/// say. struct InsertedRowImage { let values: [PluginCellValue]? let absentColumns: Set + var expressionColumns: Set = [] } enum UndoAction { @@ -136,7 +163,8 @@ enum UndoAction { previousValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]?, - absence: FieldAbsence = FieldAbsence() + absence: FieldAbsence = FieldAbsence(), + expression: ExpressionTransition = ExpressionTransition() ) case rowInsertion(rowID: RowID, restoring: InsertedRowImage? = nil) case rowDeletion(rowID: RowID, originalRow: [PluginCellValue], absentColumns: Set = []) @@ -147,6 +175,7 @@ enum UndoAction { case batchRowInsertion( rows: [InsertedRowLocation], rowValues: [[PluginCellValue]], - rowAbsentColumns: [Set] = [] + rowAbsentColumns: [Set] = [], + rowExpressionColumns: [Set] = [] ) } diff --git a/TablePro/Core/ChangeTracking/PendingChanges.swift b/TablePro/Core/ChangeTracking/PendingChanges.swift index 7d6e999e66..e9f9cb655c 100644 --- a/TablePro/Core/ChangeTracking/PendingChanges.swift +++ b/TablePro/Core/ChangeTracking/PendingChanges.swift @@ -52,13 +52,30 @@ struct PendingChanges: Equatable { return changes[idx] } + /// The row's cells that hold a SQL expression now: a new row's from its insert, a saved row's from its edits. + func expressionColumns(forRow rowID: RowID) -> Set { + if let insert = change(forRow: rowID, type: .insert) { + return insert.expressionColumns + } + guard let update = change(forRow: rowID, type: .update) else { return [] } + return Set(update.cellChanges.lazy.filter(\.newIsExpression).map(\.columnIndex)) + } + + func isExpression(rowID: RowID, columnIndex: Int) -> Bool { + if let insert = change(forRow: rowID, type: .insert) { + return insert.expressionColumns.contains(columnIndex) + } + return change(forRow: rowID, type: .update)? + .cellChanges.first { $0.columnIndex == columnIndex }?.newIsExpression ?? false + } + // MARK: - Mutate (recording user edits) /// Whether the recorded edit is a no-op (oldValue == newValue with no prior modification). /// Returns the result so the caller can decide whether to register undo. /// /// A field going missing, or coming back, is a change even when the value reads `.null` on - /// both sides. + /// both sides. So is an expression put in place of text that reads the same, or text in place of one. @discardableResult mutating func recordCellChange( rowID: RowID, @@ -67,11 +84,14 @@ struct PendingChanges: Equatable { oldValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]? = nil, - absence: FieldAbsence = FieldAbsence() + absence: FieldAbsence = FieldAbsence(), + isExpression: Bool = false ) -> Bool { - if oldValue == newValue && absence.wasAbsent == absence.isAbsent { + let wasExpression = self.isExpression(rowID: rowID, columnIndex: columnIndex) + if oldValue == newValue && absence.wasAbsent == absence.isAbsent && wasExpression == isExpression { return rollbackCellIfMatchesOriginal( - rowID: rowID, columnIndex: columnIndex, restoredValue: newValue, restoredIsAbsent: absence.isAbsent + rowID: rowID, columnIndex: columnIndex, restoredValue: newValue, restoredIsAbsent: absence.isAbsent, + restoredIsExpression: isExpression ) } @@ -81,12 +101,16 @@ struct PendingChanges: Equatable { oldValue: oldValue, newValue: newValue, oldIsAbsent: absence.wasAbsent, - newIsAbsent: absence.isAbsent + newIsAbsent: absence.isAbsent, + oldIsExpression: wasExpression, + newIsExpression: isExpression ) if let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] { - updateInsertedCell(at: insertIdx, columnIndex: columnIndex, - columnName: columnName, newValue: newValue, isAbsent: absence.isAbsent) + updateInsertedCell( + at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue, + isAbsent: absence.isAbsent, isExpression: isExpression + ) return true } @@ -114,16 +138,25 @@ struct PendingChanges: Equatable { deletedRowIDs.insert(rowID) } - mutating func recordRowInsertion(rowID: RowID, values: [PluginCellValue], absentColumns: Set = []) { + mutating func recordRowInsertion( + rowID: RowID, + values: [PluginCellValue], + absentColumns: Set = [], + expressionColumns: Set = [] + ) { guard !insertedRowIDs.contains(rowID) else { insertedRowData[rowID] = values if let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] { changes[insertIdx].absentColumns = absentColumns + changes[insertIdx].expressionColumns = expressionColumns } return } insertedRowData[rowID] = values - appendChange(RowChange(rowID: rowID, type: .insert, cellChanges: [], absentColumns: absentColumns)) + appendChange(RowChange( + rowID: rowID, type: .insert, cellChanges: [], + absentColumns: absentColumns, expressionColumns: expressionColumns + )) insertedRowIDs.insert(rowID) } @@ -178,7 +211,8 @@ struct PendingChanges: Equatable { originalDBValue: PluginCellValue, newValue: PluginCellValue, originalRow: [PluginCellValue]?, - absence: FieldAbsence = FieldAbsence() + absence: FieldAbsence = FieldAbsence(), + expression: ExpressionTransition = ExpressionTransition() ) { let cellChange = CellChange( columnIndex: columnIndex, @@ -186,12 +220,16 @@ struct PendingChanges: Equatable { oldValue: originalDBValue, newValue: newValue, oldIsAbsent: absence.wasAbsent, - newIsAbsent: absence.isAbsent + newIsAbsent: absence.isAbsent, + oldIsExpression: expression.wasExpression, + newIsExpression: expression.isExpression ) if let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] { - updateInsertedCell(at: insertIdx, columnIndex: columnIndex, - columnName: columnName, newValue: newValue, isAbsent: absence.isAbsent) + updateInsertedCell( + at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue, + isAbsent: absence.isAbsent, isExpression: expression.isExpression + ) return } @@ -216,11 +254,13 @@ struct PendingChanges: Equatable { columnIndex: Int, columnName: String, newValue: PluginCellValue, - isAbsent: Bool = false + isAbsent: Bool = false, + isExpression: Bool = false ) { guard let insertIdx = changeIndex[RowChangeKey(rowID: rowID, type: .insert)] else { return } updateInsertedCell( - at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue, isAbsent: isAbsent + at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue, + isAbsent: isAbsent, isExpression: isExpression ) } @@ -230,14 +270,16 @@ struct PendingChanges: Equatable { columnIndex: Int, columnName: String, previousValue: PluginCellValue, - previousIsAbsent: Bool = false + previousIsAbsent: Bool = false, + previousIsExpression: Bool = false ) { guard let updateIdx = changeIndex[RowChangeKey(rowID: rowID, type: .update)], let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) else { return } let original = changes[updateIdx].cellChanges[cellIdx] - if previousValue == original.oldValue && previousIsAbsent == original.oldIsAbsent { + if previousValue == original.oldValue && previousIsAbsent == original.oldIsAbsent + && previousIsExpression == original.oldIsExpression { changes[updateIdx].cellChanges.remove(at: cellIdx) removeModifiedCell(rowID: rowID, columnIndex: columnIndex) if changes[updateIdx].cellChanges.isEmpty { @@ -250,7 +292,9 @@ struct PendingChanges: Equatable { oldValue: original.oldValue, newValue: previousValue, oldIsAbsent: original.oldIsAbsent, - newIsAbsent: previousIsAbsent + newIsAbsent: previousIsAbsent, + oldIsExpression: original.oldIsExpression, + newIsExpression: previousIsExpression ) } } @@ -260,17 +304,22 @@ struct PendingChanges: Equatable { rowID: RowID, columns: [String], savedValues: [PluginCellValue]?, - absentColumns: Set = [] + absentColumns: Set = [], + expressionColumns: Set = [] ) { insertedRowIDs.insert(rowID) let cellChanges = columns.enumerated().map { index, columnName in CellChange( columnIndex: index, columnName: columnName, oldValue: nil, newValue: savedValues?[safe: index] ?? nil, - newIsAbsent: absentColumns.contains(index) + newIsAbsent: absentColumns.contains(index), + newIsExpression: expressionColumns.contains(index) ) } - appendChange(RowChange(rowID: rowID, type: .insert, cellChanges: cellChanges, absentColumns: absentColumns)) + appendChange(RowChange( + rowID: rowID, type: .insert, cellChanges: cellChanges, + absentColumns: absentColumns, expressionColumns: expressionColumns + )) if let savedValues { insertedRowData[rowID] = savedValues } @@ -281,19 +330,25 @@ struct PendingChanges: Equatable { rowIDs: [RowID], rowValues: [[PluginCellValue]], rowAbsentColumns: [Set] = [], + rowExpressionColumns: [Set] = [], columns: [String] ) { for (offset, (rowID, values)) in zip(rowIDs, rowValues).enumerated() { let absentColumns = rowAbsentColumns[safe: offset] ?? [] + let expressionColumns = rowExpressionColumns[safe: offset] ?? [] let cellChanges = values.enumerated().map { colIndex, value in CellChange( columnIndex: colIndex, columnName: columns[safe: colIndex] ?? "", oldValue: nil, newValue: value, - newIsAbsent: absentColumns.contains(colIndex) + newIsAbsent: absentColumns.contains(colIndex), + newIsExpression: expressionColumns.contains(colIndex) ) } - appendChange(RowChange(rowID: rowID, type: .insert, cellChanges: cellChanges, absentColumns: absentColumns)) + appendChange(RowChange( + rowID: rowID, type: .insert, cellChanges: cellChanges, + absentColumns: absentColumns, expressionColumns: expressionColumns + )) insertedRowIDs.insert(rowID) insertedRowData[rowID] = values } @@ -309,6 +364,10 @@ struct PendingChanges: Equatable { change(forRow: rowID, type: .insert)?.absentColumns ?? [] } + func insertedExpressionColumns(forRow rowID: RowID) -> Set { + change(forRow: rowID, type: .insert)?.expressionColumns ?? [] + } + // MARK: - Reset / persistence mutating func clear() { @@ -389,7 +448,12 @@ struct PendingChanges: Equatable { } private mutating func updateInsertedCell( - at insertIdx: Int, columnIndex: Int, columnName: String, newValue: PluginCellValue, isAbsent: Bool + at insertIdx: Int, + columnIndex: Int, + columnName: String, + newValue: PluginCellValue, + isAbsent: Bool, + isExpression: Bool ) { let rowID = changes[insertIdx].rowID if var stored = insertedRowData[rowID], columnIndex < stored.count { @@ -401,10 +465,15 @@ struct PendingChanges: Equatable { } else { changes[insertIdx].absentColumns.remove(columnIndex) } + if isExpression { + changes[insertIdx].expressionColumns.insert(columnIndex) + } else { + changes[insertIdx].expressionColumns.remove(columnIndex) + } let replacement = CellChange( columnIndex: columnIndex, columnName: columnName, - oldValue: nil, newValue: newValue, newIsAbsent: isAbsent + oldValue: nil, newValue: newValue, newIsAbsent: isAbsent, newIsExpression: isExpression ) if let cellIdx = changes[insertIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) { changes[insertIdx].cellChanges[cellIdx] = replacement @@ -430,7 +499,9 @@ struct PendingChanges: Equatable { oldValue: original.oldValue, newValue: cellChange.newValue, oldIsAbsent: original.oldIsAbsent, - newIsAbsent: cellChange.newIsAbsent + newIsAbsent: cellChange.newIsAbsent, + oldIsExpression: original.oldIsExpression, + newIsExpression: cellChange.newIsExpression ) changes[updateIdx].cellChanges[cellIdx] = merged @@ -444,13 +515,18 @@ struct PendingChanges: Equatable { @discardableResult private mutating func rollbackCellIfMatchesOriginal( - rowID: RowID, columnIndex: Int, restoredValue: PluginCellValue, restoredIsAbsent: Bool + rowID: RowID, + columnIndex: Int, + restoredValue: PluginCellValue, + restoredIsAbsent: Bool, + restoredIsExpression: Bool ) -> Bool { let updateKey = RowChangeKey(rowID: rowID, type: .update) guard let updateIdx = changeIndex[updateKey], let cellIdx = changes[updateIdx].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }), changes[updateIdx].cellChanges[cellIdx].oldValue == restoredValue, - changes[updateIdx].cellChanges[cellIdx].oldIsAbsent == restoredIsAbsent else { + changes[updateIdx].cellChanges[cellIdx].oldIsAbsent == restoredIsAbsent, + changes[updateIdx].cellChanges[cellIdx].oldIsExpression == restoredIsExpression else { return false } changes[updateIdx].cellChanges.remove(at: cellIdx) diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index a5aacc12e3..5a6342292a 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -178,12 +178,14 @@ struct SQLStatementGenerator { -> ParameterizedStatement? { if let values = insertedRowData[change.rowID] { - return generateInsertSQLFromStoredData(values: values) + return generateInsertSQLFromStoredData(values: values, expressionColumns: change.expressionColumns) } return generateInsertSQLFromCellChanges(for: change) } - private func generateInsertSQLFromStoredData(values: [PluginCellValue]) + /// A cell is written as SQL only when its change says so. Text that merely reads like a function, typed or + /// stored, is bound like any other value. + private func generateInsertSQLFromStoredData(values: [PluginCellValue], expressionColumns: Set) -> ParameterizedStatement? { var nonDefaultColumns: [String] = [] @@ -200,8 +202,8 @@ struct SQLStatementGenerator { nonDefaultColumns.append(quoteIdentifierFn(columnName)) switch value { - case .text(let s) where isSQLFunctionExpression(s): - placeholderParts.append(s.trimmingCharacters(in: .whitespaces).uppercased()) + case .text(let expression) where expressionColumns.contains(index): + placeholderParts.append(expression) default: bindParameters.append(value.asAny) placeholderParts.append(placeholder(at: bindParameters.count - 1)) @@ -296,8 +298,8 @@ struct SQLStatementGenerator { var parameters: [Any?] = [] let placeholders = nonDefaultChanges.map { cellChange -> String in switch cellChange.newValue { - case .text(let s) where isSQLFunctionExpression(s): - return s.trimmingCharacters(in: .whitespaces).uppercased() + case .text(let expression) where cellChange.newIsExpression: + return expression default: parameters.append(cellChange.newValue.asAny) return placeholder(at: parameters.count - 1) @@ -323,8 +325,8 @@ struct SQLStatementGenerator { switch cellChange.newValue { case .text(let s) where s == "__DEFAULT__": return "\(quoteIdentifierFn(cellChange.columnName)) = DEFAULT" - case .text(let s) where isSQLFunctionExpression(s): - return "\(quoteIdentifierFn(cellChange.columnName)) = \(s.trimmingCharacters(in: .whitespaces).uppercased())" + case .text(let expression) where cellChange.newIsExpression: + return "\(quoteIdentifierFn(cellChange.columnName)) = \(expression)" default: parameters.append(cellChange.newValue.asAny) return "\(quoteIdentifierFn(cellChange.columnName)) = \(placeholder(at: parameters.count - 1))" @@ -496,11 +498,4 @@ struct SQLStatementGenerator { let sql = "DELETE FROM \(qualifiedTableName) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } - - // MARK: - Helper Functions - - /// Check if a string is a SQL function expression that should not be quoted - private func isSQLFunctionExpression(_ value: String) -> Bool { - SQLEscaping.isTemporalFunction(value) - } } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index fe869d877b..71e717689f 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -124,6 +124,7 @@ extension QueryExecutionCoordinator { var columnNullable: [String: Bool] = [:] var columnComments: [String: String] = [:] var columnIdentity: [String: IdentityKind] = [:] + var columnDeclaredTypes: [String: String] = [:] var generatedColumns: Set = [] var rowMatchPolicy: RowMatchPolicy = .none var hasAuthoritativeSchema = false @@ -157,6 +158,7 @@ extension QueryExecutionCoordinator { resolved.columnNullable = metadata.columnNullable resolved.columnComments = metadata.columnComments resolved.columnIdentity = metadata.columnIdentity + resolved.columnDeclaredTypes = metadata.columnDeclaredTypes resolved.generatedColumns = metadata.generatedColumns resolved.rowMatchPolicy = metadata.rowMatchPolicy resolved.hasAuthoritativeSchema = metadata.isAuthoritative @@ -260,6 +262,7 @@ extension QueryExecutionCoordinator { columnNullable: resolved.columnNullable, columnComments: resolved.columnComments, columnIdentity: resolved.columnIdentity, + columnDeclaredTypes: resolved.columnDeclaredTypes, generatedColumns: generatedColumns, rowMatchPolicy: resolved.rowMatchPolicy, hasAuthoritativeSchema: resolved.hasAuthoritativeSchema, @@ -585,6 +588,7 @@ extension QueryExecutionCoordinator { columnNullable: parsed.columnNullable, columnComments: parsed.columnComments, columnIdentity: parsed.columnIdentity, + columnDeclaredTypes: parsed.columnDeclaredTypes, generatedColumns: parsed.generatedColumns, rowMatchPolicy: parsed.rowMatchPolicy, hasAuthoritativeSchema: parsed.isAuthoritative @@ -630,6 +634,7 @@ extension QueryExecutionCoordinator { columnNullable: parsed.columnNullable, columnComments: parsed.columnComments, columnIdentity: parsed.columnIdentity, + columnDeclaredTypes: parsed.columnDeclaredTypes, generatedColumns: parsed.generatedColumns, rowMatchPolicy: parsed.rowMatchPolicy, hasAuthoritativeSchema: parsed.isAuthoritative diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index a5cf0f4c13..db20df4583 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -49,13 +49,15 @@ final class RowEditingCoordinator: ObservableObject { let tabId = tab.id /// A new row is pre-filled from the schema's account of which columns the server owns, so /// staging one before that account exists writes NULL into an identity column. - guard parent.tabSessionRegistry.tableRows(for: tabId).hasAuthoritativeSchema else { return } + let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) + guard tableRows.hasAuthoritativeSchema else { return } parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + let uuidKeys = uuidKeyForms(in: tableRows, tab: tab) var addResult: RowOperationsManager.AddNewRowResult? parent.mutateActiveTableRows(for: tabId) { rows in - let result = parent.rowOperationsManager.addNewRow(tableRows: &rows) + let result = parent.rowOperationsManager.addNewRow(tableRows: &rows, uuidKeyForms: uuidKeys) addResult = result return result?.delta ?? .none } @@ -64,7 +66,7 @@ final class RowEditingCoordinator: ObservableObject { parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - selectAndEditInsertedRow(result.rowID, tabId: tabId) + selectAndEditInsertedRow(result.rowID, tabId: tabId, skipping: result.prefilledColumns) } func deleteSelectedRows(indices: Set) { @@ -124,11 +126,13 @@ final class RowEditingCoordinator: ObservableObject { parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + let uuidKeys = uuidKeyForms(in: tableRows, tab: tab) var dupResult: RowOperationsManager.AddNewRowResult? parent.mutateActiveTableRows(for: tabId) { rows in let result = parent.rowOperationsManager.duplicateRow( sourceRowIndex: storageIndex, - tableRows: &rows + tableRows: &rows, + uuidKeyForms: uuidKeys ) dupResult = result return result?.delta ?? .none @@ -138,14 +142,73 @@ final class RowEditingCoordinator: ObservableObject { parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - selectAndEditInsertedRow(result.rowID, tabId: tabId) + selectAndEditInsertedRow(result.rowID, tabId: tabId, skipping: result.prefilledColumns) } - private func selectAndEditInsertedRow(_ rowID: RowID, tabId: UUID) { + /// The caret skips a key the row was given a fresh UUID for, so the first keystroke does not replace it. + private func selectAndEditInsertedRow(_ rowID: RowID, tabId: UUID, skipping prefilled: Set) { guard selectionPointsTheGrid, let displayIndex = displayIndex(of: rowID, tabId: tabId) else { return } parent.selectionState.indices = [displayIndex] - parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: displayIndex) + parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn( + displayRow: displayIndex, skipping: prefilled + ) + } + + private func uuidKeyForms(in tableRows: TableRows, tab: QueryTab) -> [Int: UUIDCellForm] { + let changeManager = parent.changeManager + return Self.uuidKeyForms( + in: tableRows, + primaryKeyColumns: changeManager.primaryKeyColumns, + displayFormats: parent.displayFormats(for: tab), + databaseType: parent.connection.type, + writer: changeManager.cellWriterSupport, + isColumnWritable: { changeManager.isColumnWritable($0) } + ) + } + + /// The key columns a new, duplicated or pasted row gets a fresh UUID in: a UUID key the server does not fill, + /// whose `__DEFAULT__` would leave the key out of the INSERT. Asked of `SetValueOptions`, so a foreign key, a + /// read-only column or a writer that cannot store UUID text gets none, exactly as Generate UUID is hidden. + static func uuidKeyForms( + in tableRows: TableRows, + primaryKeyColumns: [String], + displayFormats: [ValueDisplayFormat?], + databaseType: DatabaseType, + writer: CellWriterSupport, + isColumnWritable: (String) -> Bool + ) -> [Int: UUIDCellForm] { + let keys = Set(primaryKeyColumns) + guard writer.acceptsUUIDText, !keys.isEmpty else { return [:] } + var forms: [Int: UUIDCellForm] = [:] + for (index, column) in tableRows.columns.enumerated() where keys.contains(column) { + guard !tableRows.serverAssignsValue(forColumn: column), + !tableRows.generatedColumns.contains(column) else { continue } + let context = SetValueContext( + columnType: tableRows.editingColumnType(at: index), + isNullable: tableRows.columnNullable[column] ?? true, + serverAssignsValue: false, + isForeignKey: tableRows.columnForeignKeys[column] != nil, + isWritable: isColumnWritable(column), + displayFormat: displayFormats[safe: index] ?? nil, + sampleText: sampleText(atColumn: index, in: tableRows), + declaredType: tableRows.declaredType(at: index), + databaseType: databaseType, + rowIsInserted: true, + writer: writer + ) + if let form = SetValueOptions.uuidForm(for: context) { + forms[index] = form + } + } + return forms + } + + private static func sampleText(atColumn index: Int, in tableRows: TableRows) -> String? { + for row in tableRows.rows where row.values.indices.contains(index) { + if case .text(let text) = row.values[index], text != "__DEFAULT__" { return text } + } + return nil } private func displayIndex(of rowID: RowID, tabId: UUID) -> Int? { @@ -164,47 +227,59 @@ final class RowEditingCoordinator: ObservableObject { return Set(displayIDs.indices.filter { rowIDs.contains(displayIDs[$0]) }) } - /// Stages a row inspector field edit exactly as the grid's own cell editor stages one: the - /// change, the shared `TableRows` buffer both surfaces read, and the repaint. - /// - /// The rows are named by `RowID` rather than by the display positions the selection carries, - /// and each contributes its own `oldValue`. A shared one is wrong the moment a multi-row - /// selection disagrees on the field, where the inspector holds no value for it at all. /// Ends the typed run an inspector field was building, so the next thing to register an undo /// step lands after it rather than inside it. func endInspectorEditRun() { parent.changeManager.endCoalescedUndoRun() } + /// Stages a row inspector field edit exactly as the grid's own cell editor stages one: the + /// change, the shared `TableRows` buffer both surfaces read, and the repaint. + /// + /// The rows are named by `RowID` rather than by the display positions the selection carries, + /// and each contributes its own `oldValue`. A shared one is wrong the moment a multi-row + /// selection disagrees on the field, where the inspector holds no value for it at all. + /// + /// `isExpression` stages `value`, SQL from `CellExpressionVocabulary`, to be written unquoted. func stageInspectorFieldEdit( columnIndex: Int, value: PluginCellValue, rowIDs: [RowID], - continuity: FieldEditContinuity + continuity: FieldEditContinuity, + isExpression: Bool = false ) { stageInspectorEdits( valuesByRow: Dictionary(rowIDs.map { ($0, value) }, uniquingKeysWith: { first, _ in first }), columnIndex: columnIndex, - continuity: continuity + continuity: continuity, + expressionRowIDs: isExpression ? Set(rowIDs) : [] ) } + /// A value of its own for each row, one undo step for all of them. + func stageInspectorFieldValues(columnIndex: Int, valuesByRow: [RowID: PluginCellValue]) { + stageInspectorEdits(valuesByRow: valuesByRow, columnIndex: columnIndex, continuity: .discrete) + } + /// Puts a field back to the values the inspector was configured with. /// /// A field the selected rows disagree on has no value of its own, and clearing it asks for each /// row's own value back rather than for one value across all of them. /// - /// `absentRowIDs` are the rows that had no field for the column, which it goes back to missing. + /// `absentRowIDs` are the rows that had no field for the column, which it goes back to missing, and + /// `expressionRowIDs` the rows whose value was staged SQL, which goes back as SQL rather than as text. func revertInspectorFieldEdit( columnIndex: Int, valuesByRow: [RowID: PluginCellValue], - absentRowIDs: Set = [] + absentRowIDs: Set = [], + expressionRowIDs: Set = [] ) { stageInspectorEdits( valuesByRow: valuesByRow, columnIndex: columnIndex, continuity: .typing, - removingFieldFrom: absentRowIDs + removingFieldFrom: absentRowIDs, + expressionRowIDs: expressionRowIDs ) } @@ -224,7 +299,8 @@ final class RowEditingCoordinator: ObservableObject { valuesByRow: [RowID: PluginCellValue], columnIndex: Int, continuity: FieldEditContinuity, - removingFieldFrom removedRowIDs: Set = [] + removingFieldFrom removedRowIDs: Set = [], + expressionRowIDs: Set = [] ) { if continuity == .discrete { endInspectorEditRun() @@ -250,8 +326,11 @@ final class RowEditingCoordinator: ObservableObject { let absence = FieldAbsence( wasAbsent: row.isAbsent(columnIndex), isAbsent: removesField, originalRow: row.absentColumns ) + let isExpression = expressionRowIDs.contains(rowID) && !removesField + let wasExpression = parent.changeManager.isExpression(rowID: rowID, columnIndex: columnIndex) guard values.indices.contains(columnIndex), - values[columnIndex] != value || absence.wasAbsent != absence.isAbsent else { continue } + values[columnIndex] != value || absence.wasAbsent != absence.isAbsent + || wasExpression != isExpression else { continue } if continuity == .typing { parent.changeManager.recordTypedCellChange( rowID: rowID, @@ -260,7 +339,8 @@ final class RowEditingCoordinator: ObservableObject { oldValue: values[columnIndex], newValue: value, originalRow: values, - absence: absence + absence: absence, + isExpression: isExpression ) } else { parent.changeManager.recordCellChange( @@ -270,7 +350,8 @@ final class RowEditingCoordinator: ObservableObject { oldValue: values[columnIndex], newValue: value, originalRow: values, - absence: absence + absence: absence, + isExpression: isExpression ) } edits.append((row: storageRow, column: columnIndex, value: value)) @@ -377,15 +458,18 @@ final class RowEditingCoordinator: ObservableObject { let tabId = tab.id /// Pasted rows hand the columns the server owns back to it, which only the schema names, so pasting waits for /// it the way Add Row does rather than staging an identity value the save would send. - guard parent.tabSessionRegistry.tableRows(for: tabId).hasAuthoritativeSchema else { return } - let columns = parent.tabSessionRegistry.tableRows(for: tabId).columns + let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) + guard tableRows.hasAuthoritativeSchema else { return } + let columns = tableRows.columns + let uuidKeys = uuidKeyForms(in: tableRows, tab: tab) var pasteResult = RowOperationsManager.PasteRowsResult(pastedRows: [], delta: .none) parent.mutateActiveTableRows(for: tabId) { rows in let result = parent.rowOperationsManager.pasteRowsFromClipboard( columns: columns, primaryKeyColumns: parent.changeManager.primaryKeyColumns, - tableRows: &rows + tableRows: &rows, + uuidKeyForms: uuidKeys ) pasteResult = result return result.delta diff --git a/TablePro/Core/DataWrite/RowChangeStatementFactory.swift b/TablePro/Core/DataWrite/RowChangeStatementFactory.swift index faf789e9f2..989c745ca4 100644 --- a/TablePro/Core/DataWrite/RowChangeStatementFactory.swift +++ b/TablePro/Core/DataWrite/RowChangeStatementFactory.swift @@ -112,7 +112,10 @@ struct RowChangeStatementFactory { insertedRowIDs: Set = [] ) throws -> RowWriteStatements { try refuseServerOwnedEdits(in: changes) + try refuseDefaultOnUpdate(in: changes) + try refuseUnwritableExpressions(in: changes, insertedRowData: insertedRowData) let insertedRowData = insertedRowData.mapValues(leavingServerOwnedColumnsToTheServer) + let changes = changes.map(droppingServerOwnedExpressions) if let driverStatements = try pluginRowWrites( for: changes, insertedRowData: insertedRowData, @@ -152,6 +155,88 @@ struct RowChangeStatementFactory { } } + /// Where `SET c = DEFAULT` does not parse, the save is refused naming the column rather than sent to fail. + private func refuseDefaultOnUpdate(in changes: [RowChange]) throws { + guard !CellExpressionVocabulary.updateTakesDefault(databaseType) else { return } + for change in changes where change.type == .update { + guard let defaulted = change.cellChanges.first(where: { $0.newValue.isDefaultMarker }) else { continue } + throw DataWriteError.changeRefused( + table: tableName, + kind: .update, + reason: String( + format: String(localized: "This database cannot set %@ back to its default on a saved row."), + defaulted.columnName + ) + ) + } + } + + /// A cell marked as SQL is written unquoted, so its text has to be one this engine's vocabulary holds, spelled + /// exactly, whichever path staged it. A driver that writes its own statements has to say it reads the mark; + /// one that does not would bind the SQL as text. + private func refuseUnwritableExpressions( + in changes: [RowChange], + insertedRowData: [RowID: [PluginCellValue]] + ) throws { + let marked = changes.flatMap { change in + Self.markedCells(of: change, insertedRowData: insertedRowData, columns: columns) + } + guard !marked.isEmpty else { return } + for cell in marked { + guard case .text(let sql) = cell.value, CellExpressionVocabulary.isCanonical(sql, for: databaseType) + else { + throw DataWriteError.changeRefused( + table: tableName, + kind: cell.kind, + reason: String( + format: String(localized: "The SQL staged for %@ is not one this database runs as a value."), + cell.columnName + ) + ) + } + } + if let pluginDriver, !pluginDriver.capabilities.contains(.cellExpressions), pluginOwnsStatementGeneration, + let first = marked.first { + throw DataWriteError.changeRefused( + table: tableName, + kind: first.kind, + reason: String( + format: String(localized: "This database's driver cannot write SQL into %@. Update the driver."), + first.columnName + ) + ) + } + } + + private struct MarkedCell { + let kind: RowWriteKind + let columnName: String + let value: PluginCellValue + } + + // Plugin writers read the mark by column, so every value written into a marked column is checked, not only the + // change that carries the mark. + private static func markedCells( + of change: RowChange, + insertedRowData: [RowID: [PluginCellValue]], + columns: [String] + ) -> [MarkedCell] { + guard let marked = PluginRowChange.expressionColumns(of: change) else { return [] } + let kind: RowWriteKind = change.type == .insert ? .insert : .update + let stored = change.type == .insert ? insertedRowData[change.rowID] : nil + return marked.sorted().flatMap { index in + let columnName = columns[safe: index] ?? "" + var values = change.cellChanges.filter { $0.columnIndex == index }.map(\.newValue) + if let stored { + values.append(stored[safe: index] ?? .null) + } + if values.isEmpty { + values = [.null] + } + return values.map { MarkedCell(kind: kind, columnName: columnName, value: $0) } + } + } + /// A new row leaves every column the server owns to the server, whatever the row was staged with. private func leavingServerOwnedColumnsToTheServer(_ values: [PluginCellValue]) -> [PluginCellValue] { guard !generatedColumns.isEmpty else { return values } @@ -161,6 +246,16 @@ struct RowChangeStatementFactory { } } + /// The cell now holds the default marker, so it is no longer SQL to write. + private func droppingServerOwnedExpressions(_ change: RowChange) -> RowChange { + guard change.type == .insert, !change.expressionColumns.isEmpty, !generatedColumns.isEmpty else { return change } + var kept = change + kept.expressionColumns = change.expressionColumns.filter { + !(columns.indices.contains($0) && generatedColumns.contains(columns[$0])) + } + return kept + } + private static let defaultMarker = PluginCellValue.text("__DEFAULT__") private var rowWriteContext: PluginRowWriteContext { @@ -462,6 +557,20 @@ private extension PluginRowChange { originalRow: change.originalRow ) absentColumns = Self.absentColumns(after: change) + expressionColumns = Self.expressionColumns(of: change) + } + + static func expressionColumns(of change: RowChange) -> Set? { + let marked: Set + switch change.type { + case .update: + marked = Set(change.cellChanges.filter(\.newIsExpression).map(\.columnIndex)) + case .insert: + marked = change.expressionColumns.union(change.cellChanges.filter(\.newIsExpression).map(\.columnIndex)) + case .delete: + return nil + } + return marked.isEmpty ? nil : marked } /// The fields the row has none of once the change is written: the ones an update removes, or diff --git a/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift b/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift index 3c9934f966..544124e4b6 100644 --- a/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift +++ b/TablePro/Core/DataWrite/RowWriteOperationBuilder.swift @@ -5,7 +5,7 @@ // Turns the pending change set into the record of what each row looked like before and after. // // This runs at plan time, before the write, because after the write the pre-image is gone. It -// is also where a row is declared beyond rescue: a column set to NOW(), a key the server is +// is also where a row is declared beyond rescue: a column set to SQL such as now(), a key the server is // about to choose, a value too large to keep. Deciding that here means the review sheet can say // why a row cannot be restored instead of failing at the moment someone asks it to. // @@ -102,7 +102,9 @@ enum RowWriteOperationBuilder { ) -> RewindRefusal? { if containsTableOperation { return .destructiveTableOperation } if primaryKeyColumns.isEmpty || preImage == nil { return .noPrimaryKey } - if change.cellChanges.contains(where: { isServerComputed($0.newValue) }) { return .serverComputedValue } + if change.cellChanges.contains(where: { $0.newIsExpression || $0.newValue.isDefaultMarker }) { + return .serverComputedValue + } if change.cellChanges.contains(where: { generatedColumns.contains($0.columnName) }) { return .serverComputedValue } @@ -156,9 +158,13 @@ enum RowWriteOperationBuilder { return .serverAssignedKey } let value = values[index] - if value.isNull || value.isDefaultMarker { return .serverAssignedKey } + if value.isNull || value.isDefaultMarker || change.expressionColumns.contains(index) { + return .serverAssignedKey + } + } + if values.contains(where: \.isDefaultMarker) || !change.expressionColumns.isEmpty { + return .serverComputedValue } - if values.contains(where: isServerComputed) { return .serverComputedValue } if values.contains(where: exceedsCapture) { return .valueTooLarge } return nil }() @@ -176,13 +182,6 @@ enum RowWriteOperationBuilder { ) } - /// A value the server decides, so the app never learns what was actually stored. - private static func isServerComputed(_ value: PluginCellValue) -> Bool { - if value.isDefaultMarker { return true } - guard case .text(let text) = value else { return false } - return SQLEscaping.isTemporalFunction(text) - } - private static func exceedsCapture(_ value: PluginCellValue) -> Bool { switch value { case .null: diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index 774404ad85..b6e675696e 100644 --- a/TablePro/Core/Database/SQLEscaping.swift +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -32,20 +32,6 @@ enum SQLEscaping { return "\(quote)\(escaped)\(quote)" } - /// Known SQL temporal function expressions that should not be quoted/parameterized. - /// Canonical source — used by SQLStatementGenerator and sidebar save logic. - static let temporalFunctionExpressions: Set = [ - "NOW()", "CURRENT_TIMESTAMP()", "CURRENT_TIMESTAMP", - "CURDATE()", "CURTIME()", "UTC_TIMESTAMP()", "UTC_DATE()", "UTC_TIME()", - "LOCALTIME()", "LOCALTIME", "LOCALTIMESTAMP()", "LOCALTIMESTAMP", - "SYSDATE()", "UNIX_TIMESTAMP()", "CURRENT_DATE()", "CURRENT_DATE", - "CURRENT_TIME()", "CURRENT_TIME", - ] - - static func isTemporalFunction(_ value: String) -> Bool { - temporalFunctionExpressions.contains(value.trimmingCharacters(in: .whitespaces).uppercased()) - } - /// Escape wildcards in LIKE patterns while preserving intentional wildcards /// /// This is useful when building LIKE clauses where the search term should be treated literally. diff --git a/TablePro/Core/Menu/EditMenuBuilder.swift b/TablePro/Core/Menu/EditMenuBuilder.swift index 20ab0a33c4..48da255e55 100644 --- a/TablePro/Core/Menu/EditMenuBuilder.swift +++ b/TablePro/Core/Menu/EditMenuBuilder.swift @@ -85,6 +85,7 @@ enum EditMenuBuilder { shortcut: .duplicateRow, keyboard: keyboard ), + setValueSubmenu(keyboard: keyboard), MenuItemFactory.item( String(localized: "Edit Document…"), action: #selector(MainSplitViewController.editDocument(_:)) @@ -143,6 +144,43 @@ enum EditMenuBuilder { ]) } + // Only the focused data grid implements these, so anywhere else AppKit dims them rather than hiding them. + private static func setValueSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { + MenuItemFactory.submenu(String(localized: "Set Value"), items: [ + MenuItemFactory.item( + SetValueOptions.title(for: .empty), + action: #selector(SetValueCommandResponding.setValueEmpty(_:)), + shortcut: .setValueEmpty, + keyboard: keyboard + ), + MenuItemFactory.item( + SetValueOptions.title(for: .null), + action: #selector(SetValueCommandResponding.setValueNull(_:)), + shortcut: .setValueNull, + keyboard: keyboard + ), + MenuItemFactory.item( + SetValueOptions.title(for: .defaultValue), + action: #selector(SetValueCommandResponding.setValueDefault(_:)), + shortcut: .setValueDefault, + keyboard: keyboard + ), + MenuItemFactory.separator, + MenuItemFactory.item( + SetValueOptions.title(for: .uuid(.v4)), + action: #selector(SetValueCommandResponding.generateUUIDv4(_:)), + shortcut: .generateUUIDv4, + keyboard: keyboard + ), + MenuItemFactory.item( + SetValueOptions.title(for: .uuid(.v7)), + action: #selector(SetValueCommandResponding.generateUUIDv7(_:)), + shortcut: .generateUUIDv7, + keyboard: keyboard + ) + ]) + } + private static func findSubmenu(keyboard: KeyboardSettings) -> NSMenuItem { MenuItemFactory.submenu(String(localized: "Find"), items: [ MenuItemFactory.item( diff --git a/TablePro/Core/Menu/SetValueCommandResponding.swift b/TablePro/Core/Menu/SetValueCommandResponding.swift new file mode 100644 index 0000000000..2518bbdc5c --- /dev/null +++ b/TablePro/Core/Menu/SetValueCommandResponding.swift @@ -0,0 +1,17 @@ +// +// SetValueCommandResponding.swift +// TablePro +// + +import AppKit + +/// Edit > Set Value, named once so the menu builder needs no class. The focused data grid answers, +/// for the cells it has selected; anywhere else the items stay in the menu, dimmed. +@MainActor +@objc protocol SetValueCommandResponding { + func setValueEmpty(_ sender: Any?) + func setValueNull(_ sender: Any?) + func setValueDefault(_ sender: Any?) + func generateUUIDv4(_ sender: Any?) + func generateUUIDv7(_ sender: Any?) +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 2290dfa69d..de92bb268c 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -135,7 +135,12 @@ final class PluginManager: ObservableObject { /// succeeded, such as an Oracle password in its grace period. The default is `nil`, so an /// already-built plugin keeps loading and reports none; the minimum stays and no bulk re-release /// is needed. - nonisolated static let currentPluginKitVersion = 38 + /// + /// 39 adds `PluginRowChange.expressionColumns` and the `cellExpressions` and `uuidCellText` bits of + /// `PluginCapabilities`. An already-built plugin declares neither bit, so the host hands it no + /// expression and offers no generated UUID through it; the minimum stays and no bulk re-release is + /// needed. + nonisolated static let currentPluginKitVersion = 39 /// Still 19, so every plugin already published for the previous release keeps loading. nonisolated static let minimumCompatiblePluginKitVersion = 19 diff --git a/TablePro/Core/Services/CellValues/CellEntry.swift b/TablePro/Core/Services/CellValues/CellEntry.swift new file mode 100644 index 0000000000..4e3c4db2a7 --- /dev/null +++ b/TablePro/Core/Services/CellValues/CellEntry.swift @@ -0,0 +1,23 @@ +// +// CellEntry.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Nil keeps the caller on its literal path, which an older plugin still reads its own way. +enum CellEntry { + static func typed( + _ text: String, + columnType: ColumnType?, + databaseType: DatabaseType, + writer: CellWriterSupport + ) -> (value: PluginCellValue, isExpression: Bool)? { + guard writer.acceptsExpressions, + let expression = CellExpressionVocabulary.expression( + typed: text, databaseType: databaseType, columnType: columnType + ) else { return nil } + return (value: PluginCellValue.text(expression.sql), isExpression: true) + } +} diff --git a/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift new file mode 100644 index 0000000000..e5a3898a0e --- /dev/null +++ b/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -0,0 +1,253 @@ +// +// CellExpressionVocabulary.swift +// TablePro +// + +import Foundation + +enum CellExpressionKind: Hashable, Sendable { + case timestamp + case datetime + case date + case time + case timeUUID + + init?(columnType: ColumnType) { + if columnType.isTimeUUIDType { + self = .timeUUID + return + } + switch columnType { + case .date: + self = .date + case .timestamp: + self = columnType.isTimeOnly ? .time : .timestamp + case .datetime: + self = columnType.isTimeOnly ? .time : .datetime + default: + return nil + } + } +} + +struct CellExpression: Hashable, Sendable { + /// Written into the statement exactly as spelled here. + let sql: String + let kinds: Set + /// Other spellings typed into a cell that mean this expression; each one is written as `sql`. + let aliases: [String] + + init(_ sql: String, kinds: Set, aliases: [String] = []) { + self.sql = sql + self.kinds = kinds + self.aliases = aliases + } + + func matches(typed text: String) -> Bool { + sql.caseInsensitiveCompare(text) == .orderedSame + || aliases.contains { $0.caseInsensitiveCompare(text) == .orderedSame } + } +} + +/// The SQL a cell may be set to, keyed by database type the way `ColumnDefaultVocabulary` is. +/// +/// Every entry was run against the engine or read from its DML reference, per column kind: the same name is a +/// syntax error on one engine and a type error on another kind of column (PostgreSQL rejects `CURRENT_TIMESTAMP()`, +/// SQLite has no `NOW()`, ClickHouse spells `today()` in lowercase only). Aliases are the spellings that saved before +/// the vocabulary existed and still run on that engine, so typed text keeps working. An unlisted engine gets nothing, +/// since `DatabaseType` is open. +enum CellExpressionVocabulary { + static func expressions(for databaseType: DatabaseType) -> [CellExpression] { + switch databaseType { + case .postgresql, .pglite: postgreSQL + case .cockroachdb: cockroachDB + case .redshift: redshift + case .mysql, .mariadb, .tidb, .oceanbase: mySQL + case .databend: databend + case .sqlite, .libsql, .turso, .cloudflareD1: sqlite + case .duckdb: duckDB + case .clickhouse: clickHouse + case .mssql: sqlServer + case .oracle: oracle + case .cassandra, .scylladb: cassandra + case .teradata: teradata + case .sapHana: sapHana + case .dameng: dameng + case .spanner: spanner + case .bigQuery: bigQuery + case .snowflake: snowflake + case .trino: trino + default: [] + } + } + + static func expressions(for databaseType: DatabaseType, columnType: ColumnType?) -> [CellExpression] { + guard let columnType, let kind = CellExpressionKind(columnType: columnType) else { return [] } + return expressions(for: databaseType).filter { $0.kinds.contains(kind) } + } + + static func expression(typed text: String, databaseType: DatabaseType, columnType: ColumnType?) -> CellExpression? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return expressions(for: databaseType, columnType: columnType).first { $0.matches(typed: trimmed) } + } + + static func isCanonical(_ sql: String, for databaseType: DatabaseType) -> Bool { + expressions(for: databaseType).contains { $0.sql == sql } + } + + /// Whether `UPDATE ... SET c = DEFAULT` parses. Where it does not, a new row still takes Default, because its + /// INSERT leaves the column out. + static func updateTakesDefault(_ databaseType: DatabaseType) -> Bool { + switch databaseType { + case .sqlite, .libsql, .turso, .cloudflareD1, .clickhouse, .databend, .snowflake, .trino, + .cassandra, .scylladb: + false + default: + true + } + } + + private static let moment: Set = [.timestamp, .datetime] + + private static let postgreSQL: [CellExpression] = [ + CellExpression("now()", kinds: moment), + CellExpression("CURRENT_TIMESTAMP", kinds: moment), + CellExpression("LOCALTIMESTAMP", kinds: moment), + CellExpression("CURRENT_DATE", kinds: [.date]), + CellExpression("CURRENT_TIME", kinds: [.time]), + CellExpression("LOCALTIME", kinds: [.time]), + ] + + private static let cockroachDB: [CellExpression] = [ + CellExpression("now()", kinds: moment), + CellExpression("current_timestamp()", kinds: moment, aliases: ["CURRENT_TIMESTAMP"]), + CellExpression("localtimestamp", kinds: moment, aliases: ["LOCALTIMESTAMP()"]), + CellExpression("current_date", kinds: [.date], aliases: ["CURRENT_DATE()"]), + CellExpression("current_time", kinds: [.time], aliases: ["CURRENT_TIME()"]), + CellExpression("localtime", kinds: [.time], aliases: ["LOCALTIME()"]), + ] + + /// `NOW`, `CURRENT_TIMESTAMP` and `CURRENT_TIME` are leader-node only and fail in a statement on a table. + private static let redshift: [CellExpression] = [ + CellExpression("GETDATE()", kinds: moment), + CellExpression("SYSDATE", kinds: moment), + CellExpression("CURRENT_DATE", kinds: [.date]), + ] + + private static let mySQL: [CellExpression] = [ + CellExpression("NOW()", kinds: moment, aliases: ["LOCALTIMESTAMP", "LOCALTIMESTAMP()", "LOCALTIME", "LOCALTIME()"]), + CellExpression("CURRENT_TIMESTAMP", kinds: moment, aliases: ["CURRENT_TIMESTAMP()"]), + // Not an alias of NOW(): it reads the clock when evaluated and ignores SET TIMESTAMP. + CellExpression("SYSDATE()", kinds: moment), + CellExpression("UTC_TIMESTAMP()", kinds: moment), + CellExpression("CURDATE()", kinds: [.date], aliases: ["CURRENT_DATE", "CURRENT_DATE()"]), + CellExpression("UTC_DATE()", kinds: [.date]), + CellExpression("CURTIME()", kinds: [.time], aliases: ["CURRENT_TIME", "CURRENT_TIME()"]), + CellExpression("UTC_TIME()", kinds: [.time]), + ] + + private static let databend: [CellExpression] = [ + CellExpression("NOW()", kinds: moment, aliases: ["CURRENT_TIMESTAMP()"]), + CellExpression("TODAY()", kinds: [.date]), + ] + + private static let sqlite: [CellExpression] = [ + CellExpression("CURRENT_TIMESTAMP", kinds: moment), + CellExpression("datetime('now')", kinds: moment), + CellExpression("datetime('now','localtime')", kinds: moment), + CellExpression("CURRENT_DATE", kinds: [.date]), + CellExpression("date('now')", kinds: [.date]), + CellExpression("CURRENT_TIME", kinds: [.time]), + CellExpression("time('now')", kinds: [.time]), + ] + + private static let duckDB: [CellExpression] = [ + CellExpression("now()", kinds: moment, aliases: ["CURRENT_TIMESTAMP"]), + CellExpression("current_localtimestamp()", kinds: moment, aliases: ["LOCALTIMESTAMP"]), + CellExpression("current_date", kinds: [.date], aliases: ["CURRENT_DATE()"]), + CellExpression("current_time", kinds: [.time]), + CellExpression("localtime", kinds: [.time]), + ] + + private static let clickHouse: [CellExpression] = [ + CellExpression("now()", kinds: moment, aliases: ["CURRENT_TIMESTAMP()", "UTC_TIMESTAMP()"]), + CellExpression("today()", kinds: [.date], aliases: ["CURDATE()", "CURRENT_DATE()"]), + ] + + /// Each type converts implicitly from `datetime`, so the two current-moment functions fill a date or a time too. + private static let sqlServer: [CellExpression] = [ + CellExpression("SYSDATETIME()", kinds: [.timestamp, .datetime, .date, .time]), + CellExpression("GETDATE()", kinds: [.timestamp, .datetime, .date, .time]), + CellExpression("GETUTCDATE()", kinds: moment), + CellExpression("SYSUTCDATETIME()", kinds: moment), + CellExpression("CURRENT_TIMESTAMP", kinds: moment), + ] + + /// An Oracle `DATE` holds a time of day, and each of these converts to `DATE` and `TIMESTAMP` on assignment. + private static let oracle: [CellExpression] = [ + CellExpression("SYSTIMESTAMP", kinds: oracleMoment), + CellExpression("CURRENT_TIMESTAMP", kinds: oracleMoment), + CellExpression("LOCALTIMESTAMP", kinds: oracleMoment), + CellExpression("SYSDATE", kinds: oracleMoment), + CellExpression("CURRENT_DATE", kinds: oracleMoment), + ] + + private static let oracleMoment: Set = [.timestamp, .datetime, .date] + + private static let cassandra: [CellExpression] = [ + CellExpression("currentTimestamp()", kinds: moment, aliases: ["toTimestamp(now())"]), + CellExpression("currentDate()", kinds: [.date], aliases: ["toDate(now())"]), + CellExpression("currentTime()", kinds: [.time]), + CellExpression("now()", kinds: [.timeUUID]), + CellExpression("currentTimeUUID()", kinds: [.timeUUID]), + ] + + private static let teradata: [CellExpression] = [ + CellExpression("CURRENT_TIMESTAMP", kinds: moment), + CellExpression("CURRENT_DATE", kinds: [.date]), + CellExpression("CURRENT_TIME", kinds: [.time]), + ] + + private static let sapHana: [CellExpression] = [ + CellExpression("CURRENT_TIMESTAMP", kinds: moment, aliases: ["NOW()"]), + CellExpression("CURRENT_DATE", kinds: [.date]), + CellExpression("CURRENT_TIME", kinds: [.time]), + ] + + private static let dameng: [CellExpression] = [ + CellExpression("CURRENT_TIMESTAMP", kinds: moment), + CellExpression("SYSDATE", kinds: moment), + CellExpression("CURRENT_DATE", kinds: [.date]), + CellExpression("CURRENT_TIME", kinds: [.time]), + ] + + /// Without parentheses, the one spelling both the GoogleSQL and the PostgreSQL dialect parse. + private static let spanner: [CellExpression] = [ + CellExpression("CURRENT_TIMESTAMP", kinds: moment), + CellExpression("CURRENT_DATE", kinds: [.date]), + ] + + private static let bigQuery: [CellExpression] = [ + CellExpression("CURRENT_TIMESTAMP()", kinds: [.timestamp], aliases: ["CURRENT_TIMESTAMP"]), + CellExpression("CURRENT_DATETIME()", kinds: [.datetime], aliases: ["CURRENT_DATETIME"]), + CellExpression("CURRENT_DATE()", kinds: [.date], aliases: ["CURRENT_DATE"]), + CellExpression("CURRENT_TIME()", kinds: [.time], aliases: ["CURRENT_TIME"]), + ] + + private static let snowflake: [CellExpression] = [ + CellExpression( + "CURRENT_TIMESTAMP()", kinds: moment, + aliases: ["CURRENT_TIMESTAMP", "LOCALTIMESTAMP", "LOCALTIMESTAMP()"] + ), + CellExpression("CURRENT_DATE()", kinds: [.date], aliases: ["CURRENT_DATE"]), + CellExpression("CURRENT_TIME()", kinds: [.time], aliases: ["CURRENT_TIME", "LOCALTIME", "LOCALTIME()"]), + ] + + private static let trino: [CellExpression] = [ + CellExpression("current_timestamp", kinds: moment, aliases: ["LOCALTIMESTAMP"]), + CellExpression("now()", kinds: moment), + CellExpression("current_date", kinds: [.date]), + CellExpression("current_time", kinds: [.time], aliases: ["LOCALTIME"]), + ] +} diff --git a/TablePro/Core/Services/CellValues/SetValueOptions.swift b/TablePro/Core/Services/CellValues/SetValueOptions.swift new file mode 100644 index 0000000000..d36ce16660 --- /dev/null +++ b/TablePro/Core/Services/CellValues/SetValueOptions.swift @@ -0,0 +1,121 @@ +// +// SetValueOptions.swift +// TablePro +// + +import Foundation + +enum SetValueCommand: Hashable, Sendable { + case empty + case null + case defaultValue + case expression(CellExpression) + case uuid(UUIDVersion) +} + +/// What the writer that saves this table can take beyond plain values. +struct CellWriterSupport: Sendable, Equatable { + var acceptsExpressions: Bool + var acceptsUUIDText: Bool + + static let none = CellWriterSupport(acceptsExpressions: false, acceptsUUIDText: false) + /// The host's `SQLStatementGenerator`, which writes both. + static let host = CellWriterSupport(acceptsExpressions: true, acceptsUUIDText: true) +} + +struct SetValueContext: Sendable { + var columnType: ColumnType? + var isNullable: Bool + var serverAssignsValue: Bool + var isForeignKey: Bool + var isWritable: Bool + var displayFormat: ValueDisplayFormat? + var sampleText: String? + var declaredType: String? + var databaseType: DatabaseType + var rowIsInserted: Bool + var writer: CellWriterSupport + + init( + columnType: ColumnType?, + isNullable: Bool, + serverAssignsValue: Bool, + isForeignKey: Bool, + isWritable: Bool, + displayFormat: ValueDisplayFormat? = nil, + sampleText: String? = nil, + declaredType: String? = nil, + databaseType: DatabaseType, + rowIsInserted: Bool, + writer: CellWriterSupport + ) { + self.columnType = columnType + self.isNullable = isNullable + self.serverAssignsValue = serverAssignsValue + self.isForeignKey = isForeignKey + self.isWritable = isWritable + self.displayFormat = displayFormat + self.sampleText = sampleText + self.declaredType = declaredType + self.databaseType = databaseType + self.rowIsInserted = rowIsInserted + self.writer = writer + } +} + +/// The values a cell can be set to without typing, in menu order. Every surface that offers Set Value reads this, +/// so the grid, the menu bar and the inspector cannot disagree about what a column takes. +enum SetValueOptions { + static func commands(for context: SetValueContext) -> [SetValueCommand] { + guard context.isWritable else { return [] } + var commands: [SetValueCommand] = [] + if context.columnType?.hasEmptyValue ?? true { + commands.append(.empty) + } + if context.isNullable { + commands.append(.null) + } + if context.serverAssignsValue, + context.rowIsInserted || CellExpressionVocabulary.updateTakesDefault(context.databaseType) { + commands.append(.defaultValue) + } + if context.writer.acceptsExpressions { + commands += CellExpressionVocabulary + .expressions(for: context.databaseType, columnType: context.columnType) + .map(SetValueCommand.expression) + } + if uuidForm(for: context) != nil { + commands += [.uuid(.v4), .uuid(.v7)] + } + return commands + } + + /// Nil wherever Generate UUID is not offered. A foreign key takes a value that exists in the table it points at, + /// which a new UUID never does. + static func uuidForm(for context: SetValueContext) -> UUIDCellForm? { + guard context.isWritable, !context.isForeignKey, context.writer.acceptsUUIDText else { return nil } + return UUIDCellForm( + columnType: context.columnType, + displayFormat: context.displayFormat, + sampleText: context.sampleText, + declaredType: context.declaredType + ) + } + + static func title(for command: SetValueCommand) -> String { + switch command { + case .empty: + String(localized: "Empty") + case .null: + String(localized: "NULL") + case .defaultValue: + String(localized: "Default") + case .expression(let expression): + expression.sql + case .uuid(.v4): + String(localized: "Generate UUID") + case .uuid(.v7): + String(localized: "Generate UUIDv7") + } + } +} diff --git a/TablePro/Core/Services/CellValues/UUIDCellForm.swift b/TablePro/Core/Services/CellValues/UUIDCellForm.swift new file mode 100644 index 0000000000..f7ac04aec7 --- /dev/null +++ b/TablePro/Core/Services/CellValues/UUIDCellForm.swift @@ -0,0 +1,75 @@ +// +// UUIDCellForm.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// How a column holds a UUID, and so how a generated one is written into it. +/// +/// A column typed as a UUID takes the canonical lowercase text, which every engine parses and the +/// ones that store text (SQLite, a MySQL `CHAR(36)`) keep as given. A column only the `.uuid` +/// display format marks takes the shape it was declared with: 32 hex digits for a `CHAR(32)`, +/// 16 bytes for a `BINARY(16)` or `RAW(16)`, which reject the 36-character text. +enum UUIDCellForm: Equatable, Sendable { + case hyphenated + case compact + case bytes + + /// Nil for a column that does not hold UUIDs, and for `timeuuid`, which takes only version 1. + /// + /// A MySQL result types a `CHAR(32)` as `CHAR` with no length. The length then comes from `declaredType`, the + /// schema's spelling, and with no schema from `sampleText`, a value the column already holds. + init?( + columnType: ColumnType?, + displayFormat: ValueDisplayFormat?, + sampleText: String? = nil, + declaredType: String? = nil + ) { + guard let columnType, !columnType.isTimeUUIDType else { return nil } + if columnType.isUUIDType { + self = .hyphenated + return + } + guard displayFormat == .uuid else { return nil } + switch columnType { + case .blob: + self = .bytes + case .text(let rawType): + let length = Self.declaredLength(rawType) ?? Self.declaredLength(declaredType) + if length == 32 { + self = .compact + } else if length == nil, Self.isCompactHex(sampleText) { + self = .compact + } else { + self = .hyphenated + } + default: + return nil + } + } + + func value(for uuid: UUID) -> PluginCellValue { + switch self { + case .hyphenated: + return .text(uuid.uuidString.lowercased()) + case .compact: + return .text(uuid.uuidString.lowercased().replacingOccurrences(of: "-", with: "")) + case .bytes: + return .bytes(withUnsafeBytes(of: uuid.uuid) { Data($0) }) + } + } + + /// The number in `CHAR(32)` or `Nullable(FixedString(32))`. + private static func declaredLength(_ rawType: String?) -> Int? { + guard let rawType, + let range = rawType.range(of: #"\(\s*\d+\s*\)"#, options: .regularExpression) else { return nil } + return Int(rawType[range].filter(\.isNumber)) + } + + private static func isCompactHex(_ text: String?) -> Bool { + guard let text = text?.trimmingCharacters(in: .whitespaces), text.utf8.count == 32 else { return false } + return text.allSatisfy(\.isHexDigit) + } +} diff --git a/TablePro/Core/Services/CellValues/UUIDValueGenerator.swift b/TablePro/Core/Services/CellValues/UUIDValueGenerator.swift new file mode 100644 index 0000000000..af2d58072e --- /dev/null +++ b/TablePro/Core/Services/CellValues/UUIDValueGenerator.swift @@ -0,0 +1,68 @@ +// +// UUIDValueGenerator.swift +// TablePro +// + +import Foundation + +enum UUIDVersion: Sendable, CaseIterable { + case v4 + case v7 +} + +/// Makes the UUIDs the grid, the inspector and Fill Column write into a cell. Foundation makes only +/// version 4, so version 7 is built here from RFC 9562 section 5.7. +final class UUIDValueGenerator: @unchecked Sendable { + static let shared = UUIDValueGenerator() + + private let lock = NSLock() + private let now: @Sendable () -> Date + /// The last v7 timestamp handed out: Unix milliseconds shifted left 12, plus the sub-millisecond + /// fraction in the low 12 bits (RFC 9562 section 6.2, Method 3). A burst inside one tick, such as + /// Fill Column over a thousand rows, takes the next tick, so the values still sort in creation order. + private var lastTick: UInt64 = 0 + + init(now: @escaping @Sendable () -> Date = { Date() }) { + self.now = now + } + + func make(_ version: UUIDVersion) -> UUID { + switch version { + case .v4: UUID() + case .v7: makeVersion7() + } + } + + private func makeVersion7() -> UUID { + // Rounded to the microsecond: the Double behind a Date holds 0.123 s as 0.122999808, and truncating that + // lands in the millisecond before. + let microseconds = UInt64((max(0, now().timeIntervalSince1970) * 1_000_000).rounded()) + var tick = (microseconds / 1_000) << 12 | ((microseconds % 1_000) * 4_096 / 1_000) + lock.lock() + if tick <= lastTick { + tick = lastTick + 1 + } + lastTick = tick + lock.unlock() + + var generator = SystemRandomNumberGenerator() + let random: UInt64 = generator.next() + let milliseconds = tick >> 12 + let randA = tick & 0x0FFF + + var bytes = [UInt8](repeating: 0, count: 16) + for index in 0..<6 { + bytes[index] = UInt8(truncatingIfNeeded: milliseconds >> (8 * (5 - index))) + } + bytes[6] = 0x70 | UInt8(truncatingIfNeeded: randA >> 8) + bytes[7] = UInt8(truncatingIfNeeded: randA) + for index in 0..<8 { + bytes[8 + index] = UInt8(truncatingIfNeeded: random >> (8 * (7 - index))) + } + bytes[8] = 0x80 | (bytes[8] & 0x3F) + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] + )) + } +} diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index 8415e0b895..6648e9c8f9 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -190,6 +190,19 @@ enum ColumnType: Equatable, Sendable { "INET", "CIDR", "MACADDR", "MACADDR8", "IPV4", "IPV6", "INTERVAL" ] + /// A column the engine types as a UUID. Cassandra's `timeuuid` is one, though it takes version 1 only. + var isUUIDType: Bool { + guard case .text(let rawType) = self else { return false } + return Self.uuidTypeNames.contains(Self.baseTypeName(rawType)) + } + + var isTimeUUIDType: Bool { + guard case .text(let rawType) = self else { return false } + return Self.baseTypeName(rawType) == "TIMEUUID" + } + + private static let uuidTypeNames: Set = ["UUID", "UNIQUEIDENTIFIER", "TIMEUUID"] + /// `Nullable(UUID)`, `year(4)` and `INTERVAL DAY TO SECOND` all name their type in the first word. private static func baseTypeName(_ rawType: String?) -> String { guard var name = rawType?.uppercased() else { return "" } diff --git a/TablePro/Core/Services/ColumnTypeClassifier.swift b/TablePro/Core/Services/ColumnTypeClassifier.swift index 6e54dd3a59..dd16c7b555 100644 --- a/TablePro/Core/Services/ColumnTypeClassifier.swift +++ b/TablePro/Core/Services/ColumnTypeClassifier.swift @@ -234,7 +234,7 @@ struct ColumnTypeClassifier { "TEXT", "VARCHAR", "CHAR", "NVARCHAR", "NCHAR", "NTEXT", "VARCHAR2", "NVARCHAR2", "CLOB", "NCLOB", "ROWID", "UROWID", "REF", "BFILE", "STRING", "FIXEDSTRING", - "UUID", "UNIQUEIDENTIFIER", "SQL_VARIANT", + "UUID", "TIMEUUID", "UNIQUEIDENTIFIER", "SQL_VARIANT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT" ] { map[key] = { .text(rawType: $0) } diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift index d98dae8e28..ffeb972d4d 100644 --- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -13,22 +13,35 @@ struct GridRowsClipboardPayload: Codable, Equatable { let rows: [[PluginCellValue]] /// Row index to the columns that row has no field for, so a paste leaves them missing. var absentCells: [Int: Set]? + /// Row index to the columns holding SQL the grid staged, so a paste stages it as SQL and every other value as text. + var expressionCells: [Int: Set]? } extension GridRowsClipboardPayload { /// Rows copied out of a grid, in the columns the copy carries. Every copy builds its payload /// here, so the fields a row has none of travel with its values rather than each copy path /// deciding for itself whether to carry them. - init(columns: [String], copying copiedRows: [Row], projection: VisibleColumnProjection) { + /// + /// `expressionColumns` are, per copied row, the data columns holding staged SQL. + init( + columns: [String], + copying copiedRows: [Row], + expressionColumns: [Set], + projection: VisibleColumnProjection + ) { var absentCells: [Int: Set] = [:] + var expressionCells: [Int: Set] = [:] for (index, row) in copiedRows.enumerated() { let absent = projection.absentColumns(row.absentColumns) if !absent.isEmpty { absentCells[index] = absent } + let marked = projection.positions(of: expressionColumns[safe: index] ?? []) + if !marked.isEmpty { expressionCells[index] = marked } } self.init( columns: columns, rows: copiedRows.map { projection.values(Array($0.values)) }, - absentCells: absentCells.isEmpty ? nil : absentCells + absentCells: absentCells.isEmpty ? nil : absentCells, + expressionCells: expressionCells.isEmpty ? nil : expressionCells ) } } @@ -42,6 +55,8 @@ struct GridCellsClipboardPayload: Codable, Equatable { let text: String /// The fields by line, in the order the copy wrote them, nil for a NULL. let fields: [[String?]] + /// Line index to the fields in it holding SQL the grid staged. + var expressionFields: [Int: Set]? } protocol ClipboardProvider { diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 1756299cae..e44f97e5db 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -55,6 +55,7 @@ struct ParsedSchemaMetadata { /// value from that knowledge waits for it rather than guessing from an empty set. let isAuthoritative: Bool var rowMatchPolicy: RowMatchPolicy = .none + var columnDeclaredTypes: [String: String] = [:] /// The metadata a tab already holds, captured at the moment the cache decision is made. /// @@ -73,7 +74,8 @@ struct ParsedSchemaMetadata { columnEnumValues: rows.columnEnumValues, columnComments: rows.columnComments, isAuthoritative: rows.hasAuthoritativeSchema, - rowMatchPolicy: rows.rowMatchPolicy + rowMatchPolicy: rows.rowMatchPolicy, + columnDeclaredTypes: rows.columnDeclaredTypes ) } } @@ -266,10 +268,12 @@ final class QueryExecutor { var defaults: [String: String?] = [:] var nullable: [String: Bool] = [:] var identity: [String: IdentityKind] = [:] + var declaredTypes: [String: String] = [:] for col in schema.columns { defaults[col.name] = col.defaultValue nullable[col.name] = col.isNullable identity[col.name] = col.identityKind + declaredTypes[col.name] = col.dataType } var fks: [String: ForeignKeyInfo]? if let foreignKeys = schema.foreignKeys { @@ -309,7 +313,8 @@ final class QueryExecutor { rowMatchPolicy: RowMatchPolicy( excludedColumns: columns(in: schema.columns, typedAnyOf: rowMatchExcludedTypePrefixes), textColumns: columns(in: schema.columns, typedAnyOf: rowMatchTextTypePrefixes) - ) + ), + columnDeclaredTypes: declaredTypes ) } diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index ca6d9ffcf1..f062f9c749 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -13,6 +13,8 @@ final class RowOperationsManager { let rowID: RowID let values: [PluginCellValue] let delta: Delta + /// Key columns given a fresh UUID, which the caret skips. + var prefilledColumns: Set = [] } struct DeleteRowsResult { @@ -46,29 +48,42 @@ final class RowOperationsManager { /// A new row holds DEFAULT where the server assigns the value and NULL elsewhere. On an engine /// that tells a missing field from NULL those other fields start missing instead, so a document /// saved as it was added holds only what the user typed. - func addNewRow(tableRows: inout TableRows) -> AddNewRowResult? { + /// + /// `uuidKeyForms` are the UUID keys the server does not fill, by column. Each gets a fresh UUID, since NULL + /// there is a key the INSERT is refused for. + func addNewRow(tableRows: inout TableRows, uuidKeyForms: [Int: UUIDCellForm] = [:]) -> AddNewRowResult? { var newRowValues: [PluginCellValue] = [] var absentColumns: Set = [] + var prefilled: Set = [] for (index, column) in tableRows.columns.enumerated() { if tableRows.generatedColumns.contains(column) || tableRows.serverAssignsValue(forColumn: column) { newRowValues.append(.text("__DEFAULT__")) + } else if let form = uuidKeyForms[index] { + newRowValues.append(Self.freshUUID(form)) + prefilled.insert(index) } else { newRowValues.append(.null) if changeManager.supportsFieldRemoval { absentColumns.insert(index) } } } - return appendInsertedRow(values: newRowValues, absentColumns: absentColumns, to: &tableRows) + return appendInsertedRow( + values: newRowValues, absentColumns: absentColumns, prefilledColumns: prefilled, to: &tableRows + ) } + /// A pending source row keeps the SQL staged in it, so a copy of `now()` is `now()` again, not the text. func duplicateRow( sourceRowIndex: Int, - tableRows: inout TableRows + tableRows: inout TableRows, + uuidKeyForms: [Int: UUIDCellForm] = [:] ) -> AddNewRowResult? { guard sourceRowIndex >= 0, sourceRowIndex < tableRows.count else { return nil } let source = tableRows.rows[sourceRowIndex] var newValues = Array(source.values) var absentColumns = source.absentColumns + var expressionColumns = changeManager.expressionColumns(forRow: source.id).filter { $0 < newValues.count } + var prefilled: Set = [] /// An identity column is not always the primary key, and copying its value verbatim is /// what the server rejects. @@ -77,22 +92,42 @@ final class RowOperationsManager { + Array(tableRows.columnIdentity.keys) for resetColumn in resetColumns { if let index = tableRows.columns.firstIndex(of: resetColumn), index < newValues.count { - newValues[index] = .text("__DEFAULT__") + if let form = uuidKeyForms[index] { + newValues[index] = Self.freshUUID(form) + prefilled.insert(index) + } else { + newValues[index] = .text("__DEFAULT__") + } absentColumns.remove(index) + expressionColumns.remove(index) } } - return appendInsertedRow(values: newValues, absentColumns: absentColumns, to: &tableRows) + return appendInsertedRow( + values: newValues, + absentColumns: absentColumns, + expressionColumns: expressionColumns, + prefilledColumns: prefilled, + to: &tableRows + ) + } + + private static func freshUUID(_ form: UUIDCellForm) -> PluginCellValue { + form.value(for: UUIDValueGenerator.shared.make(.v4)) } private func appendInsertedRow( values: [PluginCellValue], absentColumns: Set, + expressionColumns: Set = [], + prefilledColumns: Set = [], to tableRows: inout TableRows ) -> AddNewRowResult { let rowID = RowID.inserted(UUID()) let delta = tableRows.appendInsertedRow(id: rowID, values: values, absentColumns: absentColumns) - changeManager.recordRowInsertion(rowID: rowID, values: values, absentColumns: absentColumns) - return AddNewRowResult(rowID: rowID, values: values, delta: delta) + changeManager.recordRowInsertion( + rowID: rowID, values: values, absentColumns: absentColumns, expressionColumns: expressionColumns + ) + return AddNewRowResult(rowID: rowID, values: values, delta: delta, prefilledColumns: prefilledColumns) } func deleteSelectedRows( @@ -164,7 +199,7 @@ final class RowOperationsManager { func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult { switch result.action { - case .cellEdit(let rowID, let columnIndex, _, let previousValue, _, _, let absence): + case .cellEdit(let rowID, let columnIndex, _, let previousValue, _, _, let absence, _): guard let storageRow = tableRows.index(of: rowID) else { return UndoApplicationResult(adjustedSelection: nil, delta: .none) } @@ -194,7 +229,7 @@ final class RowOperationsManager { case .rowDeletion, .batchRowDeletion: return UndoApplicationResult(adjustedSelection: nil, delta: result.delta) - case .batchRowInsertion(let rows, let rowValues, let rowAbsentColumns): + case .batchRowInsertion(let rows, let rowValues, let rowAbsentColumns, _): if result.needsRowRemoval { let delta = tableRows.remove(rowIDs: Set(rows.map(\.rowID))) return UndoApplicationResult(adjustedSelection: nil, delta: delta) @@ -291,7 +326,12 @@ final class RowOperationsManager { result.append("\n(truncated, showing first \(Self.maxClipboardRows) of \(totalSelected) rows)") } - let payload = GridRowsClipboardPayload(columns: columns, copying: copiedRows, projection: projection) + let payload = GridRowsClipboardPayload( + columns: columns, + copying: copiedRows, + expressionColumns: copiedRows.map { changeManager.expressionColumns(forRow: $0.id) }, + projection: projection + ) ClipboardService.shared.writeRows(tsv: result, html: nil, gridRows: payload) } @@ -299,6 +339,7 @@ final class RowOperationsManager { columns: [String], primaryKeyColumns: [String], tableRows: inout TableRows, + uuidKeyForms: [Int: UUIDCellForm] = [:], clipboard: ClipboardProvider? = nil, parser: RowDataParser? = nil ) -> PasteRowsResult { @@ -313,7 +354,7 @@ final class RowOperationsManager { let parsedRows = Self.reconcileStructuredRows( payload, schema: schema, keepsMissingFields: keepsMissingFields ) - return insertParsedRows(parsedRows, into: &tableRows) + return insertParsedRows(parsedRows, uuidKeyForms: uuidKeyForms, into: &tableRows) } guard let clipboardText = clipboardProvider.readText() else { @@ -324,6 +365,7 @@ final class RowOperationsManager { let parsedRows = Self.rows(fromCopiedCells: cells, schema: schema, tableRows: tableRows) return insertParsedRows( keepsMissingFields ? parsedRows.map(Self.nullsAsMissingFields) : parsedRows, + uuidKeyForms: uuidKeyForms, into: &tableRows ) } @@ -335,6 +377,7 @@ final class RowOperationsManager { case .success(let parsedRows): return insertParsedRows( keepsMissingFields ? parsedRows.map(Self.nullsAsMissingFields) : parsedRows, + uuidKeyForms: uuidKeyForms, into: &tableRows ) @@ -355,42 +398,60 @@ final class RowOperationsManager { return payload.rows.enumerated().map { index, row in let sourceAbsent = payload.absentCells?[index] ?? [] + let sourceExpressions = payload.expressionCells?[index] ?? [] var absentColumns: Set = [] + var expressionColumns: Set = [] var values: [PluginCellValue] = sourceForDestination.enumerated().map { destination, sourceIndex in guard let sourceIndex, sourceIndex < row.count else { if keepsMissingFields { absentColumns.insert(destination) } return .null } if keepsMissingFields, sourceAbsent.contains(sourceIndex) { absentColumns.insert(destination) } + if sourceExpressions.contains(sourceIndex), case .text = row[sourceIndex] { + expressionColumns.insert(destination) + } return row[sourceIndex] } if let pkIndex = schema.primaryKeyIndex, pkIndex < values.count { values[pkIndex] = .text("__DEFAULT__") absentColumns.remove(pkIndex) + expressionColumns.remove(pkIndex) } - return ParsedRow(values: values, sourceLineNumber: index + 1, absentColumns: absentColumns) + return ParsedRow( + values: values, + sourceLineNumber: index + 1, + absentColumns: absentColumns, + expressionColumns: expressionColumns.subtracting(absentColumns) + ) } } /// Cells the grid copied, as wide as the table, become rows field by field, so a value holding a /// tab or a line break stays one value and a NULL stays NULL. An empty field is read for its - /// column, as a typed one is. + /// column, as a typed one is, and a field the copy marked as SQL stays SQL. private static func rows( fromCopiedCells cells: GridCellsClipboardPayload, schema: TableSchema, tableRows: TableRows ) -> [ParsedRow] { cells.fields.enumerated().map { index, fields in + let marked = cells.expressionFields?[index] ?? [] + var expressionColumns: Set = [] var values: [PluginCellValue] = (0.. PasteRowsResult { var pastedRowInfo: [PastedRowInfo] = [] @@ -449,12 +513,21 @@ final class RowOperationsManager { for parsedRow in parsedRows { var rowValues = parsedRow.values var absentColumns = parsedRow.absentColumns + var expressionColumns = parsedRow.expressionColumns.filter { $0 < rowValues.count } for index in serverOwned where index < rowValues.count { rowValues[index] = .text("__DEFAULT__") absentColumns.remove(index) + expressionColumns.remove(index) + } + for (index, form) in uuidKeyForms where index < rowValues.count && rowValues[index] == .text("__DEFAULT__") { + rowValues[index] = Self.freshUUID(form) + absentColumns.remove(index) + expressionColumns.remove(index) } insertedIndices.insert(tableRows.count) - let inserted = appendInsertedRow(values: rowValues, absentColumns: absentColumns, to: &tableRows) + let inserted = appendInsertedRow( + values: rowValues, absentColumns: absentColumns, expressionColumns: expressionColumns, to: &tableRows + ) pastedRowInfo.append(PastedRowInfo(rowID: inserted.rowID, values: rowValues)) } diff --git a/TablePro/Core/Services/Query/SQLFunctionProvider.swift b/TablePro/Core/Services/Query/SQLFunctionProvider.swift deleted file mode 100644 index b3a1747d9b..0000000000 --- a/TablePro/Core/Services/Query/SQLFunctionProvider.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// SQLFunctionProvider.swift -// TablePro - -import TableProPluginKit - -internal enum SQLFunctionProvider { - internal struct SQLFunction { - let label: String - let expression: String - } - - static func functions(for databaseType: DatabaseType) -> [SQLFunction] { - if databaseType == .databend { - return [ - SQLFunction(label: "NOW()", expression: "NOW()"), - SQLFunction(label: "CURRENT_TIMESTAMP()", expression: "CURRENT_TIMESTAMP()"), - SQLFunction(label: "UUID()", expression: "UUID()") - ] - } - if SqlDialect.from(databaseTypeId: databaseType.rawValue) == .mysql { - return [ - SQLFunction(label: "NOW()", expression: "NOW()"), - SQLFunction(label: "CURRENT_TIMESTAMP()", expression: "CURRENT_TIMESTAMP()"), - SQLFunction(label: "CURDATE()", expression: "CURDATE()"), - SQLFunction(label: "CURTIME()", expression: "CURTIME()"), - SQLFunction(label: "UTC_TIMESTAMP()", expression: "UTC_TIMESTAMP()"), - SQLFunction(label: "UUID()", expression: "UUID()") - ] - } - if databaseType == .postgresql || databaseType == .redshift || databaseType == .cockroachdb { - return [ - SQLFunction(label: "now()", expression: "now()"), - SQLFunction(label: "CURRENT_TIMESTAMP", expression: "CURRENT_TIMESTAMP"), - SQLFunction(label: "CURRENT_DATE", expression: "CURRENT_DATE"), - SQLFunction(label: "CURRENT_TIME", expression: "CURRENT_TIME"), - SQLFunction(label: "gen_random_uuid()", expression: "gen_random_uuid()") - ] - } else if databaseType == .sqlite || databaseType == .duckdb || databaseType == .cloudflareD1 { - return [ - SQLFunction(label: "datetime('now')", expression: "datetime('now')"), - SQLFunction(label: "date('now')", expression: "date('now')"), - SQLFunction(label: "time('now')", expression: "time('now')"), - SQLFunction(label: "datetime('now','localtime')", expression: "datetime('now','localtime')") - ] - } else if databaseType == .mssql { - return [ - SQLFunction(label: "GETDATE()", expression: "GETDATE()"), - SQLFunction(label: "GETUTCDATE()", expression: "GETUTCDATE()"), - SQLFunction(label: "SYSDATETIME()", expression: "SYSDATETIME()"), - SQLFunction(label: "NEWID()", expression: "NEWID()") - ] - } else if databaseType == .clickhouse { - return [ - SQLFunction(label: "now()", expression: "now()"), - SQLFunction(label: "today()", expression: "today()"), - SQLFunction(label: "yesterday()", expression: "yesterday()"), - SQLFunction(label: "generateUUIDv4()", expression: "generateUUIDv4()") - ] - } else { - return [ - SQLFunction(label: "CURRENT_TIMESTAMP", expression: "CURRENT_TIMESTAMP"), - SQLFunction(label: "CURRENT_DATE", expression: "CURRENT_DATE"), - SQLFunction(label: "CURRENT_TIME", expression: "CURRENT_TIME") - ] - } - } -} diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index d805d06f87..428ce3aaeb 100644 --- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -15,22 +15,26 @@ internal struct SQLRowToStatementConverter { /// /// `defaultedColumns` names the cells the grid staged as left to the server's default. The grid spells that as the /// text `__DEFAULT__`, which a stored value can also be, so only a cell named here is read as the marker. + /// `expressionColumns` names the cells staged as SQL, which go out unquoted; text anywhere else is a literal. internal struct SourceRow { internal let values: [PluginCellValue] internal let storedValues: [PluginCellValue] internal let isPendingInsert: Bool internal let defaultedColumns: Set + internal let expressionColumns: Set internal init( values: [PluginCellValue], storedValues: [PluginCellValue]? = nil, isPendingInsert: Bool = false, - defaultedColumns: Set = [] + defaultedColumns: Set = [], + expressionColumns: Set = [] ) { self.values = values self.storedValues = storedValues ?? values self.isPendingInsert = isPendingInsert self.defaultedColumns = defaultedColumns + self.expressionColumns = expressionColumns } } @@ -95,15 +99,17 @@ internal struct SQLRowToStatementConverter { private func insertStatement(row: SourceRow) -> String? { let written = zip(columns.indices, zip(columns, row.values)).filter { index, cell in !unwritableColumns.contains(cell.0) && !row.defaultedColumns.contains(index) - }.map(\.1) + } guard !written.isEmpty else { let firstWritable = columns.first { !unwritableColumns.contains($0) }.map(quoteIdentifierFn) return AllDefaultsInsert.sql( into: qualifiedTable, databaseType: databaseType, firstWritableColumn: firstWritable ).map { "\($0);" } } - let columnList = written.map { quoteIdentifierFn($0.0) }.joined(separator: ", ") - let values = written.map { formatValue($0.1) }.joined(separator: ", ") + let columnList = written.map { quoteIdentifierFn($0.1.0) }.joined(separator: ", ") + let values = written.map { index, cell in + formatValue(cell.1, isExpression: row.expressionColumns.contains(index)) + }.joined(separator: ", ") return "INSERT INTO \(qualifiedTable) (\(columnList)) VALUES (\(values));" } @@ -134,12 +140,12 @@ internal struct SQLRowToStatementConverter { return (settableColumns ?? columns).compactMap { column -> String? in guard !unwritableColumns.contains(column), let index = columns.firstIndex(of: column) else { return nil } let value = row.values.indices.contains(index) ? row.values[index] : .null - if keys.contains(column) { + if keys.contains(column), !row.expressionColumns.contains(index) { let stored = row.storedValues.indices.contains(index) ? row.storedValues[index] : .null guard value != stored else { return nil } } guard !row.defaultedColumns.contains(index) else { return "\(quoteIdentifierFn(column)) = DEFAULT" } - return "\(quoteIdentifierFn(column)) = \(formatValue(value))" + return "\(quoteIdentifierFn(column)) = \(formatValue(value, isExpression: row.expressionColumns.contains(index)))" } } @@ -167,6 +173,14 @@ internal struct SQLRowToStatementConverter { // MARK: - Literals + /// A marked cell is written raw only when the engine's vocabulary holds its text exactly. + private func formatValue(_ value: PluginCellValue, isExpression: Bool) -> String { + if isExpression, case .text(let sql) = value, CellExpressionVocabulary.isCanonical(sql, for: databaseType) { + return sql + } + return formatValue(value) + } + private func formatValue(_ value: PluginCellValue) -> String { switch value { case .null: diff --git a/TablePro/Models/Query/ParsedRow.swift b/TablePro/Models/Query/ParsedRow.swift index 3d520f8afc..359054fe45 100644 --- a/TablePro/Models/Query/ParsedRow.swift +++ b/TablePro/Models/Query/ParsedRow.swift @@ -19,6 +19,9 @@ struct ParsedRow { /// The columns the row has no field for, for an engine that tells a missing field from NULL. var absentColumns: Set = [] + /// The columns holding SQL the grid staged, which only the grid's own copy carries. + var expressionColumns: Set = [] + /// Check if row has valid data var isValid: Bool { !values.isEmpty diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index fef736e0de..d3c8b8f171 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -17,6 +17,8 @@ struct TableRows: Sendable { var columnNullable: [String: Bool] var columnComments: [String: String] var columnIdentity: [String: IdentityKind] + // A result set can drop the length (MySQL types `CHAR(32)` as `CHAR`), so the schema's spelling is kept beside it. + var columnDeclaredTypes: [String: String] /// Columns the app must never write. Held here as well as on `DataChangeManager` because the /// change manager is rebuilt by `configureForTable` on every execution and only the schema /// fetch refills it, so a rerun that answered from cache left a generated or `GENERATED ALWAYS @@ -43,6 +45,7 @@ struct TableRows: Sendable { columnNullable: [String: Bool] = [:], columnComments: [String: String] = [:], columnIdentity: [String: IdentityKind] = [:], + columnDeclaredTypes: [String: String] = [:], generatedColumns: Set = [], rowMatchPolicy: RowMatchPolicy = .none, hasAuthoritativeSchema: Bool = false, @@ -58,6 +61,7 @@ struct TableRows: Sendable { self.columnNullable = columnNullable self.columnComments = columnComments self.columnIdentity = columnIdentity + self.columnDeclaredTypes = columnDeclaredTypes self.generatedColumns = generatedColumns self.rowMatchPolicy = rowMatchPolicy self.hasAuthoritativeSchema = hasAuthoritativeSchema @@ -103,6 +107,10 @@ struct TableRows: Sendable { return columnType.withAllowedValues(labels) } + func declaredType(at index: Int) -> String? { + columns.indices.contains(index) ? columnDeclaredTypes[columns[index]] : nil + } + /// What text entered for a column stages, or nil when the column holds neither it nor NULL. /// /// An emptied date in a NOT NULL column has no value to write. MySQL turns NULL there into the @@ -310,6 +318,7 @@ struct TableRows: Sendable { columnNullable: [String: Bool]? = nil, columnComments: [String: String]? = nil, columnIdentity: [String: IdentityKind]? = nil, + columnDeclaredTypes: [String: String]? = nil, generatedColumns: Set? = nil, rowMatchPolicy: RowMatchPolicy? = nil, hasAuthoritativeSchema: Bool? = nil @@ -346,6 +355,10 @@ struct TableRows: Sendable { self.columnIdentity = columnIdentity didChange = true } + if let columnDeclaredTypes, columnDeclaredTypes != self.columnDeclaredTypes { + self.columnDeclaredTypes = columnDeclaredTypes + didChange = true + } if let generatedColumns, generatedColumns != self.generatedColumns { self.generatedColumns = generatedColumns didChange = true @@ -371,6 +384,7 @@ struct TableRows: Sendable { columnNullable: [String: Bool] = [:], columnComments: [String: String] = [:], columnIdentity: [String: IdentityKind] = [:], + columnDeclaredTypes: [String: String] = [:], generatedColumns: Set = [], rowMatchPolicy: RowMatchPolicy = .none, hasAuthoritativeSchema: Bool = false, @@ -398,6 +412,7 @@ struct TableRows: Sendable { columnNullable: columnNullable, columnComments: columnComments, columnIdentity: columnIdentity, + columnDeclaredTypes: columnDeclaredTypes, generatedColumns: generatedColumns, rowMatchPolicy: rowMatchPolicy, hasAuthoritativeSchema: hasAuthoritativeSchema, diff --git a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift index fe37b6b27a..0b7fafe7db 100644 --- a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift +++ b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift @@ -56,8 +56,7 @@ struct ForeignKeyLookupColumn: Equatable, Sendable, Identifiable { /// A UUID takes no `LIKE`, but it does take equality against a literal the engine can parse. var isUuid: Bool { - guard let base = Self.baseTypeName(of: type.rawType) else { return false } - return Self.uuidTypeNames.contains(base) + type.isUUIDType } private static let characterTypeNames: Set = [ @@ -68,8 +67,6 @@ struct ForeignKeyLookupColumn: Equatable, Sendable, Identifiable { "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", ] - private static let uuidTypeNames: Set = ["UUID", "UNIQUEIDENTIFIER"] - /// The same shape `ColumnTypeClassifier` reads: the wrappers off, the parameters off, uppercased. static func baseTypeName(of rawType: String?) -> String? { guard let rawType else { return nil } diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 91bc836942..c29ce3eac6 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -107,6 +107,11 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case clearSelection case addRow case duplicateRow + case setValueEmpty + case setValueNull + case setValueDefault + case generateUUIDv4 + case generateUUIDv7 case restorePreviousValues case truncateTable case toggleHeaderRow @@ -180,6 +185,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .editor case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, + .setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .restorePreviousValues, .truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage, .nextPage, .firstPage, .lastPage, .refresh, .export, .importData, .jumpToColumn: @@ -213,6 +219,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .previewSQL, .aiReviewQuery, .aiExplainQuery, .aiOptimizeQuery: return .editor case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow, + .setValueEmpty, .setValueNull, .setValueDefault, .generateUUIDv4, .generateUUIDv7, .delete, .truncateTable, .previewFKReference, .saveAsFavorite, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn, /// Named rather than left to the `default:` below. It reverses a row the grid is @@ -297,6 +304,11 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .clearSelection: return String(localized: "Clear Selection") case .addRow: return String(localized: "Add Row") case .duplicateRow: return String(localized: "Duplicate Row") + case .setValueEmpty: return String(localized: "Set Value to Empty") + case .setValueNull: return String(localized: "Set Value to NULL") + case .setValueDefault: return String(localized: "Set Value to Default") + case .generateUUIDv4: return SetValueOptions.title(for: .uuid(.v4)) + case .generateUUIDv7: return SetValueOptions.title(for: .uuid(.v7)) case .restorePreviousValues: return String(localized: "Restore Previous Values") case .truncateTable: return String(localized: "Truncate Table") case .toggleHeaderRow: return String(localized: "Switch First Row Between Header/Data") @@ -393,7 +405,10 @@ extension ShortcutAction { (.character("q", command: true), String(localized: "Quit TablePro")), (.character("m", command: true), String(localized: "Minimize")), (.character("t", command: true, option: true), String(localized: "Show Toolbar")), - (.character("f", command: true, control: true), String(localized: "Enter Full Screen")) + (.character("f", command: true, control: true), String(localized: "Enter Full Screen")), + // The inspector's own keys: a menu item bound to either would take it first, even while disabled. + (.character("n", option: true, control: true), String(localized: "Set NULL")), + (.character("d", option: true, control: true), String(localized: "Set DEFAULT")) ] for number in 1...9 { shortcuts.append(( diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 7b0b0cda53..61b2267bb1 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -2,9 +2,6 @@ // MultiRowEditState.swift // TablePro // -// State management for multi-row editing in right sidebar. -// Tracks pending edits across multiple selected rows. -// import Combine import Foundation @@ -47,12 +44,23 @@ struct FieldEditState: Identifiable { /// False for a NOT NULL column. Nil from the schema reads as nullable, as the grid's menus do. var isNullable = true - /// The value already differs from the loaded schema because the edit is recorded elsewhere. + /// Leaving the column out of an INSERT makes the server fill it, which is what Set DEFAULT gives. + var serverAssignsValue = false + + var displayFormat: ValueDisplayFormat? + + /// A value the selected rows hold. A lengthless MySQL `CHAR` takes the compact UUID form only when its values do. + var sampleText: String? + + var declaredType: String? + + /// The edit is recorded elsewhere: by the grid that owns a schema row, or in the change manager for rows + /// that now disagree, which no single pending value can stand for. var hasCommittedEdit: Bool = false var originalValue: String? - let hasMultipleValues: Bool + var hasMultipleValues: Bool var pendingValue: String? @@ -60,6 +68,9 @@ struct FieldEditState: Identifiable { var isPendingDefault: Bool + /// `pendingValue` is SQL from `CellExpressionVocabulary`, staged to be written unquoted. + var isPendingExpression = false + /// No selected row has a field for the column, which only an engine that tells a missing field /// from NULL reports. var isAbsent = false @@ -96,28 +107,37 @@ struct InspectorFieldEdit: Equatable { /// The edit takes the field out of the row, which `newValue` cannot say: it reads nil for NULL /// as well. var removesField = false + /// `newValue` is SQL picked from the field's menu, written unquoted. + var isExpression = false } -/// Manages edit state for multi-row editing in sidebar @MainActor final class MultiRowEditState: ObservableObject { @Published var fields: [FieldEditState] = [] - /// A field's new value, and whether it arrived a character at a time. Typing is folded into one - /// undo step; choosing NULL, DEFAULT, a function or a picker value is its own step. + /// A field's new literal value, and whether it arrived a character at a time. Typing is folded into one + /// undo step; choosing NULL, DEFAULT or a picker value is its own step. @Published var onFieldChanged: ((Int, PluginCellValue, FieldEditContinuity) -> Void)? - /// A field cleared back to what the rows held, when they disagree on it or some of them have - /// no field for it. It has no single value to send, so it asks for each row's own configured - /// value instead, and for the field to go missing again on the rows that lacked it. - @Published var onFieldReverted: ((Int, [RowID: PluginCellValue], Set) -> Void)? + /// A field set to SQL the database evaluates, picked from its menu or typed. `onFieldChanged` cannot carry + /// that, and a plain `.text` must never be read as SQL. + @Published var onFieldExpressionChanged: ((Int, String, FieldEditContinuity) -> Void)? + + /// A value of its own for each row, such as a fresh UUID: one value over several rows would repeat a key. + @Published var onFieldValuesChanged: ((Int, [RowID: PluginCellValue]) -> Void)? + + /// A field cleared back to what the rows held, when they disagree on it, some of them have + /// no field for it, or some held SQL. It has no single value to send, so it asks for each row's own + /// configured value instead, for the field to go missing again on the rows that lacked it (the first set), + /// and for SQL again on the rows that held it (the second). + @Published var onFieldReverted: ((Int, [RowID: PluginCellValue], Set, Set) -> Void)? /// Remove Field on a field, for the rows the selection was configured with. @Published var onFieldRemoved: ((Int, [RowID]) -> Void)? /// A value window still open over a selection that has moved on. It names the rows it was - /// opened for, because the fields it was opened from are gone. - @Published var onDetachedFieldChanged: ((Int, PluginCellValue, [RowID]) -> Void)? + /// opened for, because the fields it was opened from are gone. The flag says the value is SQL. + @Published var onDetachedFieldChanged: ((Int, PluginCellValue, [RowID], Bool) -> Void)? @Published private(set) var selectedRowIndices: Set = [] @@ -131,10 +151,18 @@ final class MultiRowEditState: ObservableObject { @Published private(set) var allRows: [[String?]] = [] /// The columns each row in `allRows` has no field for. @Published private(set) var absentCells: [Set] = [] + /// The columns each row in `allRows` holds staged SQL in. + @Published private(set) var expressionCells: [Set] = [] @Published private(set) var columns: [String] = [] @Published private(set) var columnTypes: [ColumnType] = [] private var nonNullableColumns: Set = [] + /// The engine whose vocabulary typed text is read against, and what the table's writer takes. Nil and + /// `.none` offer no Set Value command and promote nothing. + private(set) var databaseType: DatabaseType? + private(set) var cellWriter: CellWriterSupport = .none + private(set) var rowsAreInserted = false + var hasEdits: Bool { fields.contains { $0.hasEdit } } @@ -144,6 +172,7 @@ final class MultiRowEditState: ObservableObject { rowIDs: [RowID] = [], allRows: [[String?]], absentCells: [Set] = [], + expressionCells: [Set] = [], columns: [String], columnTypes: [ColumnType], externallyModifiedColumns: Set = [], @@ -151,10 +180,13 @@ final class MultiRowEditState: ObservableObject { foreignKeyColumns: Set = [], serverOwnedColumns: Set = [], nonNullableColumns: Set = [], + serverAssignedColumns: Set = [], displayFormats: [ValueDisplayFormat?] = [], - binaryColumns: Set = [] + declaredTypes: [String: String] = [:], + binaryColumns: Set = [], + databaseType: DatabaseType? = nil, + cellWriter: CellWriterSupport = .none ) { - // Check if the underlying data has changed (not just edits) let columnsChanged = self.columns != columns let selectionChanged = self.selectedRowIndices != selectedRowIndices @@ -162,9 +194,13 @@ final class MultiRowEditState: ObservableObject { self.rowIDs = rowIDs self.allRows = allRows self.absentCells = absentCells + self.expressionCells = expressionCells self.columns = columns self.columnTypes = columnTypes self.nonNullableColumns = nonNullableColumns + self.databaseType = databaseType + self.cellWriter = cellWriter + rowsAreInserted = !rowIDs.isEmpty && rowIDs.allSatisfy { $0.isInserted } var newFields: [FieldEditState] = [] @@ -178,9 +214,11 @@ final class MultiRowEditState: ObservableObject { values.append(value) } let absence = allRows.indices.map { absentCells[safe: $0]?.contains(colIndex) == true } + let expression = allRows.indices.map { expressionCells[safe: $0]?.contains(colIndex) == true } let allSame = values.dropFirst().allSatisfy { $0 == values.first } && absence.dropFirst().allSatisfy { $0 == absence.first } + && expression.dropFirst().allSatisfy { $0 == expression.first } let hasMultipleValues = !allSame let isAbsent = !hasMultipleValues && absence.first == true @@ -191,39 +229,46 @@ final class MultiRowEditState: ObservableObject { originalValue = values.first.flatMap { $0 } } - // Preserve pending edits if data hasn't changed var preservedId: UUID? var pendingValue: String? var isPendingNull = false var isPendingDefault = false + var isPendingExpression = false var isPendingRemoval = false + var hasCommittedEdit = false if !columnsChanged, !selectionChanged, colIndex < fields.count { let oldField = fields[colIndex] - // Preserve pending edits when original data matches if oldField.originalValue == originalValue && oldField.hasMultipleValues == hasMultipleValues && oldField.isAbsent == isAbsent { preservedId = oldField.id pendingValue = oldField.pendingValue isPendingNull = oldField.isPendingNull isPendingDefault = oldField.isPendingDefault + isPendingExpression = oldField.isPendingExpression isPendingRemoval = oldField.isPendingRemoval } } - // Mark externally modified columns (e.g., edited in data grid). A modified field that - // is now missing is one an edit removed, and one that now reads NULL is a pending NULL: - // written as an empty pending value it read as `''`, and the boolean picker showed false. + /// A column edited elsewhere, in the grid or by an earlier inspector edit. A modified field that is now + /// missing is one an edit removed, and one that now reads NULL is a pending NULL: written as an empty + /// pending value it read as `''`, and the boolean picker showed false. Rows that now disagree stay + /// Multiple values: an empty pending value there read as one value, and typing into it looked like + /// editing a single cell. if externallyModifiedColumns.contains(colIndex), pendingValue == nil, !isPendingNull, !isPendingDefault, !isPendingRemoval { if isAbsent { isPendingRemoval = true - } else if originalValue == nil, !hasMultipleValues { + } else if hasMultipleValues { + hasCommittedEdit = true + } else if originalValue == nil { isPendingNull = true } else { - pendingValue = originalValue ?? "" + pendingValue = originalValue + isPendingExpression = expression.first == true } } + let displayFormat = colIndex < displayFormats.count ? displayFormats[colIndex] : nil let isJson = columnTypeEnum.isJsonType || (originalValue ?? "").looksLikeJson @@ -238,11 +283,17 @@ final class MultiRowEditState: ObservableObject { isForeignKey: foreignKeyColumns.contains(columnName), isServerOwned: serverOwnedColumns.contains(columnName), isNullable: !nonNullableColumns.contains(columnName), + serverAssignsValue: serverAssignedColumns.contains(columnName), + displayFormat: displayFormat, + sampleText: values.first(where: { $0 != nil }) ?? nil, + declaredType: declaredTypes[columnName], + hasCommittedEdit: hasCommittedEdit, originalValue: originalValue, hasMultipleValues: hasMultipleValues, pendingValue: pendingValue, isPendingNull: isPendingNull, isPendingDefault: isPendingDefault, + isPendingExpression: isPendingExpression, isAbsent: isAbsent, isPendingRemoval: isPendingRemoval ) @@ -257,7 +308,7 @@ final class MultiRowEditState: ObservableObject { for: columnTypeEnum, isLongText: isLongText, originalValue: originalValue, - displayFormatOverride: colIndex < displayFormats.count ? displayFormats[colIndex] : nil, + displayFormatOverride: displayFormat, isBinaryValue: newField.isBinaryValue ) newFields.append(newField) @@ -301,6 +352,10 @@ final class MultiRowEditState: ObservableObject { selectedRowIndices = [displayRow] rowIDs = [] absentCells = [] + expressionCells = [] + databaseType = nil + cellWriter = .none + rowsAreInserted = false columns = names columnTypes = Array(repeating: .text(rawType: nil), count: names.count) allRows = [schemaFields.map(\.value)] @@ -343,11 +398,44 @@ final class MultiRowEditState: ObservableObject { return FieldValueState.resolve(fields[index]).editableText } + /// What Set Value can do to the field, the same answer the grid's menu gives for the column. + func setValueCommands(for field: FieldEditState, isWritable: Bool) -> [SetValueCommand] { + setValueContext(for: field, isWritable: isWritable).map(SetValueOptions.commands(for:)) ?? [] + } + + func setValueContext(for field: FieldEditState, isWritable: Bool) -> SetValueContext? { + guard let databaseType, !field.isSchemaField else { return nil } + return SetValueContext( + columnType: field.columnTypeEnum, + isNullable: field.isNullable, + serverAssignsValue: field.serverAssignsValue, + isForeignKey: field.isForeignKey, + isWritable: isWritable && !field.isServerOwned, + displayFormat: field.displayFormat, + sampleText: field.sampleText, + declaredType: field.declaredType, + databaseType: databaseType, + rowIsInserted: rowsAreInserted, + writer: cellWriter + ) + } + + func setValue(_ command: SetValueCommand, at index: Int) { + switch command { + case .empty: setFieldToEmpty(at: index) + case .null: setFieldToNull(at: index) + case .defaultValue: setFieldToDefault(at: index) + case .expression(let expression): setFieldToExpression(at: index, expression: expression) + case .uuid(let version): generateUUID(at: index, version: version) + } + } + func updateField(at index: Int, value: String?) { guard index < fields.count else { return } let hadPendingEdit = fields[index].hasEdit let original = fields[index].originalValue var pending = Self.resolvePendingValue(value, original: original, isJson: fields[index].isJson) + let expression = pending.flatMap { typedExpression($0, columnType: fields[index].columnTypeEnum) } var staged = pending.map { PluginCellValue.entered($0, columnType: fields[index].columnTypeEnum) } /// A NOT NULL column whose type has no empty value holds nothing an emptied field could /// mean, so the field goes back to its stored value, as the grid's cell does. @@ -355,13 +443,13 @@ final class MultiRowEditState: ObservableObject { pending = nil staged = nil } + resetPending(at: index) fields[index].pendingValue = pending - fields[index].isPendingNull = false - fields[index].isPendingDefault = false - fields[index].isPendingRemoval = false /// The field keeps its empty text while the change it stages is NULL: swapping the editor /// for the pending NULL pill would take the field away in the middle of typing a new value. - if let staged { + if let expression { + onFieldExpressionChanged?(index, expression, .typing) + } else if let staged { onFieldChanged?(index, staged, .typing) } else if hadPendingEdit { /// `originalValue` is nil for three different situations, and only one of them is a @@ -369,14 +457,25 @@ final class MultiRowEditState: ObservableObject { /// not have. Sending it as one value wrote NULL into every selected row when the user /// cleared a field they all disagreed on, which the field then reported as unedited. let absentRows = configuredAbsentRows(atColumn: index) - if fields[index].hasMultipleValues || !absentRows.isEmpty { - onFieldReverted?(index, configuredValues(atColumn: index), absentRows) + let expressionRows = configuredExpressionRows(atColumn: index) + if fields[index].hasMultipleValues || !absentRows.isEmpty || !expressionRows.isEmpty { + onFieldReverted?(index, configuredValues(atColumn: index), absentRows, expressionRows) } else { onFieldChanged?(index, storedCell(original, atColumn: index), .typing) } } } + /// The vocabulary's SQL for typed text, as the grid reads a typed cell. A value the rows were configured + /// with never comes through here, so stored text that spells a function stays text. + private func typedExpression(_ text: String, columnType: ColumnType?) -> String? { + guard let databaseType, + let entry = CellEntry.typed(text, columnType: columnType, databaseType: databaseType, writer: cellWriter), + entry.isExpression, + case .text(let sql) = entry.value else { return nil } + return sql + } + /// What each row held when the selection was configured, which is what a field with no value of /// its own reverts to. private func configuredValues(atColumn index: Int) -> [RowID: PluginCellValue] { @@ -399,6 +498,10 @@ final class MultiRowEditState: ObservableObject { Set(rowIDs.indices.filter { absentCells[safe: $0]?.contains(index) == true }.map { rowIDs[$0] }) } + private func configuredExpressionRows(atColumn index: Int) -> Set { + Set(rowIDs.indices.filter { expressionCells[safe: $0]?.contains(index) == true }.map { rowIDs[$0] }) + } + /// A commit from a detached value window, which outlives the selection it was opened from. /// /// While that selection is still the one on screen this is an ordinary field edit. Once it has @@ -410,9 +513,13 @@ final class MultiRowEditState: ObservableObject { updateField(at: columnIndex, value: value) return } + if let value, let sql = typedExpression(value, columnType: columnTypes[safe: columnIndex]) { + onDetachedFieldChanged?(columnIndex, .text(sql), rowIDs, true) + return + } let staged = PluginCellValue.entered(value, columnType: columnTypes[safe: columnIndex]) if staged.isNull, columns[safe: columnIndex].map(nonNullableColumns.contains) == true { return } - onDetachedFieldChanged?(columnIndex, staged, rowIDs) + onDetachedFieldChanged?(columnIndex, staged, rowIDs, false) } private static func resolvePendingValue(_ value: String?, original: String?, isJson: Bool) -> String? { @@ -429,22 +536,25 @@ final class MultiRowEditState: ObservableObject { return value } - func setFieldToBytes(at index: Int, data: Data) { - guard index < fields.count else { return } - let encoded = String(data: data, encoding: .isoLatin1) ?? "" - fields[index].pendingValue = encoded + private func resetPending(at index: Int) { + fields[index].pendingValue = nil fields[index].isPendingNull = false fields[index].isPendingDefault = false + fields[index].isPendingExpression = false fields[index].isPendingRemoval = false + } + + func setFieldToBytes(at index: Int, data: Data) { + guard index < fields.count else { return } + resetPending(at: index) + fields[index].pendingValue = String(data: data, encoding: .isoLatin1) ?? "" onFieldChanged?(index, .bytes(data), .discrete) } func setFieldToNull(at index: Int) { guard index < fields.count else { return } - fields[index].pendingValue = nil + resetPending(at: index) fields[index].isPendingNull = true - fields[index].isPendingDefault = false - fields[index].isPendingRemoval = false onFieldChanged?(index, .null, .discrete) } @@ -455,39 +565,81 @@ final class MultiRowEditState: ObservableObject { /// which leaves nothing pending, exactly as typing a field's own value back does. func removeField(at index: Int) { guard index < fields.count, !rowIDs.isEmpty else { return } - fields[index].pendingValue = nil - fields[index].isPendingNull = false - fields[index].isPendingDefault = false + resetPending(at: index) fields[index].isPendingRemoval = !fields[index].isAbsent onFieldRemoved?(index, rowIDs) } func setFieldToDefault(at index: Int) { guard index < fields.count else { return } - fields[index].pendingValue = nil - fields[index].isPendingNull = false + resetPending(at: index) fields[index].isPendingDefault = true - fields[index].isPendingRemoval = false onFieldChanged?(index, .text("__DEFAULT__"), .discrete) } - func setFieldToFunction(at index: Int, function: String) { + func setFieldToExpression(at index: Int, expression: CellExpression) { guard index < fields.count else { return } - fields[index].pendingValue = function - fields[index].isPendingNull = false - fields[index].isPendingDefault = false - fields[index].isPendingRemoval = false - onFieldChanged?(index, .text(function), .discrete) + resetPending(at: index) + fields[index].pendingValue = expression.sql + fields[index].isPendingExpression = true + onFieldExpressionChanged?(index, expression.sql, .discrete) + } + + /// Several rows each get a UUID of their own, so the field then reads Multiple values, and typing over it + /// writes the typed value to every row as on any field the rows disagree on. + func generateUUID(at index: Int, version: UUIDVersion, generator: UUIDValueGenerator = .shared) { + guard fields.indices.contains(index), + let context = setValueContext(for: fields[index], isWritable: true), + let form = SetValueOptions.uuidForm(for: context) else { return } + guard rowIDs.count > 1 else { + guard allRows.count <= 1 else { return } + switch form.value(for: generator.make(version)) { + case .bytes(let data): + setFieldToBytes(at: index, data: data) + case .text(let text): + resetPending(at: index) + fields[index].pendingValue = text + onFieldChanged?(index, .text(text), .discrete) + case .null: + break + } + return + } + let values = Dictionary( + rowIDs.map { ($0, form.value(for: generator.make(version))) }, + uniquingKeysWith: { first, _ in first } + ) + /// The rows hold these values now, so clearing a later edit goes back to them, as it does once the + /// inspector is rebuilt from the rows. + for (position, rowID) in rowIDs.enumerated() { + guard let value = values[rowID], allRows.indices.contains(position), + allRows[position].indices.contains(index) else { continue } + allRows[position][index] = Self.configuredText(of: value) + if absentCells.indices.contains(position) { absentCells[position].remove(index) } + if expressionCells.indices.contains(position) { expressionCells[position].remove(index) } + } + resetPending(at: index) + fields[index].originalValue = nil + fields[index].hasMultipleValues = true + fields[index].hasCommittedEdit = true + if form == .bytes { fields[index].isBinaryValue = true } + onFieldValuesChanged?(index, values) + } + + private static func configuredText(of value: PluginCellValue) -> String? { + switch value { + case .null: nil + case .text(let text): text + case .bytes(let data): String(data: data, encoding: .isoLatin1) ?? "" + } } func setFieldToEmpty(at index: Int) { guard index < fields.count else { return } let hadPendingEdit = fields[index].hasEdit let isStoredValue = fields[index].originalValue == "" + resetPending(at: index) fields[index].pendingValue = isStoredValue ? nil : "" - fields[index].isPendingNull = false - fields[index].isPendingDefault = false - fields[index].isPendingRemoval = false if isStoredValue, hadPendingEdit { onFieldChanged?(index, storedCell("", atColumn: index), .discrete) } else if !isStoredValue { @@ -495,20 +647,17 @@ final class MultiRowEditState: ObservableObject { } } - /// Clear all pending edits func clearEdits() { - for i in 0.. [InspectorFieldEdit] { fields.compactMap { field in guard field.hasEdit else { return nil } @@ -529,7 +681,8 @@ final class MultiRowEditState: ObservableObject { columnIndex: field.columnIndex, columnName: field.columnName, newValue: field.effectiveValue, - removesField: field.isPendingRemoval + removesField: field.isPendingRemoval, + isExpression: field.isPendingExpression ) } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 0943acc3be..72cea8f928 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -67037,6 +67037,10 @@ } } }, + "DynamoDB has no default values, so %@ cannot be set to DEFAULT." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "DynamoDB has no views" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -83787,6 +83791,12 @@ } } } + }, + "Generate UUID" : { + + }, + "Generate UUIDv7" : { + }, "Generated" : { "extractionState" : "manual", @@ -111949,6 +111959,12 @@ } } } + }, + "New UUID" : { + + }, + "New UUIDv7" : { + }, "New View" : { "extractionState" : "stale", @@ -159963,6 +159979,15 @@ } } } + }, + "Set Value to Default" : { + + }, + "Set Value to Empty" : { + + }, + "Set Value to NULL" : { + }, "Sets the value to %@" : { "localizations" : { @@ -187125,6 +187150,9 @@ } } } + }, + "The SQL staged for %@ is not one this database runs as a value." : { + }, "The SQLite extensions for this connection could not be loaded." : { "extractionState" : "manual", @@ -190970,6 +190998,9 @@ } } } + }, + "This database cannot set %@ back to its default on a saved row." : { + }, "This database cannot show the source of %@" : { "extractionState" : "manual", @@ -191242,6 +191273,9 @@ } } } + }, + "This database's driver cannot write SQL into %@. Update the driver." : { + }, "This deletes only the queries listed here, across every connection. Queries the source, date, outcome and search filters are hiding stay. You cannot undo this." : { "comment" : "Message for the \"Delete this connection's query history\" button.", diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift index 32fb5df80d..2bd8a6c3ee 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift @@ -2,15 +2,11 @@ // MainContentCoordinator+SidebarSave.swift // TablePro // -// Sidebar save logic extracted from MainContentView. -// import Foundation import TableProPluginKit extension MainContentCoordinator { - // MARK: - Sidebar Save - func saveSidebarEdits( editState: MultiRowEditState ) async throws { @@ -40,12 +36,7 @@ extension MainContentCoordinator { guard !editedFields.isEmpty else { return [] } let tableRows = tabSessionRegistry.tableRows(for: tab.id) - /// Typed text is read for its column, as the grid reads a cell, and text a NOT NULL column can - /// hold neither as itself nor as NULL writes nothing. An explicit NULL stays NULL. - let writes: [(field: InspectorFieldEdit, value: PluginCellValue)] = editedFields.compactMap { field in - guard !field.removesField, let text = field.newValue else { return (field, .null) } - return tableRows.enteredValue(text, forColumn: field.columnIndex).map { (field, $0) } - } + let writes = editedFields.compactMap { sidebarWrite(for: $0, in: tableRows) } guard !writes.isEmpty else { return [] } let displayIDs = activeGridDisplayIDs let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex -> RowChange? in @@ -58,7 +49,8 @@ extension MainContentCoordinator { return RowChange( rowID: resolvedRow.id, type: .update, - cellChanges: writes.map { field, newValue in + cellChanges: writes.map { write in + let field = write.field let oldValue: PluginCellValue = field.columnIndex < originalRow.count ? originalRow[field.columnIndex] : .null @@ -66,9 +58,10 @@ extension MainContentCoordinator { columnIndex: field.columnIndex, columnName: field.columnName, oldValue: oldValue, - newValue: newValue, + newValue: write.value, oldIsAbsent: resolvedRow.isAbsent(field.columnIndex), - newIsAbsent: field.removesField + newIsAbsent: field.removesField, + newIsExpression: write.isExpression ) }, originalRow: originalRow, @@ -78,4 +71,24 @@ extension MainContentCoordinator { return try changeManager.generateSQL(for: changes) } + + /// Typed text is read for its column, as the grid reads a cell: a function name on a date column is the + /// function, and text a NOT NULL column can hold neither as itself nor as NULL writes nothing. An explicit + /// NULL stays NULL, and SQL picked from the menu is already the vocabulary's spelling. + private func sidebarWrite( + for field: InspectorFieldEdit, + in tableRows: TableRows + ) -> (field: InspectorFieldEdit, value: PluginCellValue, isExpression: Bool)? { + guard !field.removesField, let text = field.newValue else { return (field, .null, false) } + if field.isExpression { return (field, .text(text), true) } + if let typed = CellEntry.typed( + text, + columnType: tableRows.editingColumnType(at: field.columnIndex), + databaseType: changeManager.databaseType ?? connection.type, + writer: changeManager.cellWriterSupport + ) { + return (field, typed.value, typed.isExpression) + } + return tableRows.enteredValue(text, forColumn: field.columnIndex).map { (field, $0, false) } + } } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index a238aa113f..aff1dbe7ee 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -208,6 +208,7 @@ extension MainContentView { rowIDs: selectedRows.map(\.id), allRows: stringRows, absentCells: selectedRows.map(\.absentColumns), + expressionCells: selectedRows.map { changeManager.expressionColumns(forRow: $0.id) }, columns: tableRows.columns, columnTypes: columnTypes, externallyModifiedColumns: modifiedColumns, @@ -215,8 +216,12 @@ extension MainContentView { foreignKeyColumns: fkColumns, serverOwnedColumns: tableRows.generatedColumns.union(changeManager.unwritableColumns(among: tableRows.columns)), nonNullableColumns: Set(tableRows.columnNullable.compactMap { $0.value ? nil : $0.key }), + serverAssignedColumns: Set(tableRows.columns.filter { tableRows.serverAssignsValue(forColumn: $0) }), displayFormats: inspectorDisplayFormats(for: tab, columns: tableRows.columns, types: tableRows.columnTypes), - binaryColumns: MultiRowEditState.binaryColumns(in: allRows) + declaredTypes: tableRows.columnDeclaredTypes, + binaryColumns: MultiRowEditState.binaryColumns(in: allRows), + databaseType: connection.type, + cellWriter: changeManager.cellWriterSupport ) guard isSidebarEditable else { @@ -234,22 +239,40 @@ extension MainContentView { continuity: continuity ) } - trailingPaneState.inspector.editState.onFieldReverted = { columnIndex, valuesByRow, absentRowIDs in - capturedCoordinator.revertInspectorFieldEdit( - columnIndex: columnIndex, valuesByRow: valuesByRow, absentRowIDs: absentRowIDs + trailingPaneState.inspector.editState.onFieldExpressionChanged = { columnIndex, sql, continuity in + capturedCoordinator.rowEditingCoordinator.stageInspectorFieldEdit( + columnIndex: columnIndex, + value: .text(sql), + rowIDs: capturedEditState.rowIDs, + continuity: continuity, + isExpression: true + ) + } + trailingPaneState.inspector.editState.onFieldValuesChanged = { columnIndex, valuesByRow in + capturedCoordinator.rowEditingCoordinator.stageInspectorFieldValues( + columnIndex: columnIndex, valuesByRow: valuesByRow + ) + } + trailingPaneState.inspector.editState.onFieldReverted = { columnIndex, valuesByRow, absentRowIDs, expressionRowIDs in + capturedCoordinator.rowEditingCoordinator.revertInspectorFieldEdit( + columnIndex: columnIndex, + valuesByRow: valuesByRow, + absentRowIDs: absentRowIDs, + expressionRowIDs: expressionRowIDs ) } trailingPaneState.inspector.editState.onFieldRemoved = { columnIndex, rowIDs in capturedCoordinator.stageInspectorFieldRemoval(columnIndex: columnIndex, rowIDs: rowIDs) } - trailingPaneState.inspector.editState.onDetachedFieldChanged = { columnIndex, newValue, rowIDs in + trailingPaneState.inspector.editState.onDetachedFieldChanged = { columnIndex, newValue, rowIDs, isExpression in /// A value window commits on every keystroke exactly as the field it detached from /// does, so its typing folds into one undo step the same way. - capturedCoordinator.stageInspectorFieldEdit( + capturedCoordinator.rowEditingCoordinator.stageInspectorFieldEdit( columnIndex: columnIndex, value: newValue, rowIDs: rowIDs, - continuity: .typing + continuity: .typing, + isExpression: isExpression ) } } @@ -299,6 +322,8 @@ extension MainContentView { private func clearSidebarEditHandlers() { trailingPaneState.inspector.editState.onFieldChanged = nil + trailingPaneState.inspector.editState.onFieldExpressionChanged = nil + trailingPaneState.inspector.editState.onFieldValuesChanged = nil trailingPaneState.inspector.editState.onFieldReverted = nil trailingPaneState.inspector.editState.onFieldRemoved = nil trailingPaneState.inspector.editState.onDetachedFieldChanged = nil @@ -323,6 +348,8 @@ extension MainContentView { } let capturedCoordinator = coordinator + trailingPaneState.inspector.editState.onFieldExpressionChanged = nil + trailingPaneState.inspector.editState.onFieldValuesChanged = nil trailingPaneState.inspector.editState.onFieldReverted = nil trailingPaneState.inspector.editState.onFieldRemoved = nil trailingPaneState.inspector.editState.onDetachedFieldChanged = nil diff --git a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index 408913fcc5..4c7cbbcf25 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -46,15 +46,16 @@ struct DataGridCellAppearance: Equatable { let baseColor: NSColor switch content.placeholder { + // A pending SQL expression is a value the server fills in on save, as a pending DEFAULT is, and shows its SQL. + case .none where state.visualState.isExpression(columnIndex: state.columnIndex), .defaultMarker: + font = palette.mediumFont + baseColor = deletedTextColor ?? .systemBlue case .none: font = palette.regularFont baseColor = deletedTextColor ?? valueTextColor(kind: kind, rawValue: content.rawValue, palette: palette) case .null, .empty, .absent: font = palette.italicFont baseColor = deletedTextColor ?? palette.placeholderText - case .defaultMarker: - font = palette.mediumFont - baseColor = deletedTextColor ?? .systemBlue } let findTint: NSColor? = state.isCurrentFindMatch ? palette.findMatchTint : nil diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index b6e91bd44f..20df380fc6 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1123,15 +1123,21 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData beginCellEdit(row: displayRow, tableColumnIndex: displayCol) } + func beginEditingFirstEditableColumn(displayRow: Int, skipping prefilled: Set = []) { + beginEditing(displayRow: displayRow, column: firstEditableColumn(displayRow: displayRow, skipping: prefilled)) + } + /// Where the caret goes on a row the user just made. Column 0 is where the row starts, not /// necessarily where it can be typed into: a table whose first column the server owns, such as /// an identity or a stored generated column, refuses the edit and opens nothing at all, which - /// reads as Add Row having done nothing. Falls back to column 0 so a row with no editable - /// column at all is still selected and scrolled to. - func beginEditingFirstEditableColumn(displayRow: Int) { - let columnCount = tableRowsProvider().columns.count - let column = (0.. = []) -> Int { + let editable = (0..= 0 && dataColumnIndex < tableRows.columns.count - && coordinator.isColumnWritable(tableRows.columns[dataColumnIndex]) - guard coordinator.isEditable, namesWritableColumn else { return } + guard coordinator.isEditable, dataColumnIndex >= 0 else { return } - let setValueMenu = buildSetValueMenu(dataColumnIndex: dataColumnIndex, tableRows: tableRows) + let clicked = SetValueTarget(displayRow: rowIndex, columnIndex: dataColumnIndex) + let setValueMenu = buildSetValueMenu( + commands: coordinator.setValueCommands(for: coordinator.setValueTargets(clicked: clicked)), + clickedColumn: dataColumnIndex + ) if !setValueMenu.items.isEmpty { let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "") setValueItem.submenu = setValueMenu menu.addItem(setValueItem) } - guard coordinator.supportsFieldRemoval, + guard dataColumnIndex < tableRows.columns.count, + coordinator.isColumnWritable(tableRows.columns[dataColumnIndex]), + coordinator.supportsFieldRemoval, coordinator.displayRow(at: rowIndex)?.isAbsent(dataColumnIndex) == false else { return } let removeFieldItem = NSMenuItem( title: String(localized: "Remove Field"), action: #selector(removeFieldValue(_:)), keyEquivalent: "") @@ -707,58 +711,41 @@ class DataGridRowView: NSTableRowView { menu.addItem(removeFieldItem) } - /// Empty is offered only where `''` is a value of the column's type, the way NULL is offered - /// only where the column takes it. On a date it wrote the server's zero date. - func buildSetValueMenu(dataColumnIndex: Int, tableRows: TableRows) -> NSMenu { + /// Placeholders, then the engine's functions, then UUIDs, a separator between the groups. + func buildSetValueMenu(commands: [SetValueCommand], clickedColumn: Int) -> NSMenu { let setValueMenu = NSMenu() - - if tableRows.editingColumnType(at: dataColumnIndex)?.hasEmptyValue ?? true { - let emptyItem = NSMenuItem( - title: String(localized: "Empty"), action: #selector(setEmptyValue(_:)), keyEquivalent: "") - emptyItem.representedObject = dataColumnIndex - emptyItem.target = self - setValueMenu.addItem(emptyItem) - } - - let columnName = dataColumnIndex < tableRows.columns.count - ? tableRows.columns[dataColumnIndex] - : nil - - let isNullable = columnName.flatMap { tableRows.columnNullable[$0] } ?? true - if isNullable { - let nullItem = NSMenuItem( - title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") - nullItem.representedObject = dataColumnIndex - nullItem.target = self - setValueMenu.addItem(nullItem) - } - - let serverAssignsValue = columnName.map { tableRows.serverAssignsValue(forColumn: $0) } ?? false - if serverAssignsValue { - let defaultItem = NSMenuItem( - title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") - defaultItem.representedObject = dataColumnIndex - defaultItem.target = self - setValueMenu.addItem(defaultItem) - } - - let columnType: ColumnType? = dataColumnIndex < tableRows.columnTypes.count - ? tableRows.columnTypes[dataColumnIndex] - : nil - if let columnType, columnType.isDateType { - setValueMenu.addItem(.separator()) - for function in Self.dateValueFunctions(for: columnType) { - let item = NSMenuItem( - title: function, action: #selector(setSqlFunctionValue(_:)), keyEquivalent: "") - item.representedObject = DateSetterContext(columnIndex: dataColumnIndex, value: function) - item.target = self - setValueMenu.addItem(item) + var lastGroup: Int? + for command in commands { + let group = Self.setValueMenuGroup(command) + if let lastGroup, lastGroup != group { + setValueMenu.addItem(.separator()) } + lastGroup = group + let item = NSMenuItem( + title: SetValueOptions.title(for: command), action: #selector(applySetValue(_:)), keyEquivalent: "" + ) + item.representedObject = SetValueMenuChoice(command: command, clickedColumn: clickedColumn) + item.target = self + setValueMenu.addItem(item) } - return setValueMenu } + private static func setValueMenuGroup(_ command: SetValueCommand) -> Int { + switch command { + case .empty, .null, .defaultValue: 0 + case .expression: 1 + case .uuid: 2 + } + } + + /// The targets are taken when the item is chosen, from the same selection the menu was built over. + @objc private func applySetValue(_ sender: NSMenuItem) { + guard let coordinator, let choice = sender.representedObject as? SetValueMenuChoice else { return } + let clicked = SetValueTarget(displayRow: rowIndex, columnIndex: choice.clickedColumn) + coordinator.applySetValue(choice.command, to: coordinator.setValueTargets(clicked: clicked)) + } + @objc private func deleteRow() { guard let coordinator else { return } coordinator.delegate?.dataGridDeleteRows(coordinator.currentRowSelection(fallbackRow: rowIndex)) @@ -837,45 +824,11 @@ class DataGridRowView: NSTableRowView { ) } - @objc private func setNullValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn(nil, at: rowIndex, columnIndex: columnIndex) - } - @objc private func removeFieldValue(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int else { return } coordinator?.removeField(row: rowIndex, columnIndex: columnIndex) } - /// Written as `''` itself rather than read as typed text, which would turn it into NULL. - @objc private func setEmptyValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.commitTypedCellEdit(row: rowIndex, columnIndex: columnIndex, newValue: .text("")) - } - - @objc private func setDefaultValue(_ sender: NSMenuItem) { - guard let columnIndex = sender.representedObject as? Int else { return } - coordinator?.setCellValueAtColumn("__DEFAULT__", at: rowIndex, columnIndex: columnIndex) - } - - @objc private func setSqlFunctionValue(_ sender: NSMenuItem) { - guard let context = sender.representedObject as? DateSetterContext else { return } - coordinator?.setCellValueAtColumn(context.value, at: rowIndex, columnIndex: context.columnIndex) - } - - static func dateValueFunctions(for columnType: ColumnType) -> [String] { - switch columnType { - case .date: - return ["CURRENT_DATE"] - case .timestamp, .datetime: - return columnType.isTimeOnly - ? ["CURRENT_TIME"] - : ["NOW()", "CURRENT_TIMESTAMP"] - default: - return [] - } - } - @objc private func copyAsInsert() { guard let coordinator else { return } coordinator.copyRowsAsInsert(at: coordinator.currentRowSelection(fallbackRow: rowIndex)) @@ -964,14 +917,9 @@ class DataGridRowView: NSTableRowView { } } -private final class DateSetterContext { - let columnIndex: Int - let value: String - - init(columnIndex: Int, value: String) { - self.columnIndex = columnIndex - self.value = value - } +private struct SetValueMenuChoice { + let command: SetValueCommand + let clickedColumn: Int } /// The view a row's data cells are drawn into. diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index a3724a8135..5f0bc81876 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -46,21 +46,27 @@ extension TableViewCoordinator { let tsv = tsvRows.joined(separator: "\n") let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns) - let payload = GridRowsClipboardPayload(columns: columns, copying: copiedRows, projection: projection) + let payload = GridRowsClipboardPayload( + columns: columns, + copying: copiedRows, + expressionColumns: copiedRows.map { changeManager.expressionColumns(forRow: $0.id) }, + projection: projection + ) ClipboardService.shared.writeRows(tsv: tsv, html: html, gridRows: payload) } - @MainActor - func setCellValueAtColumn(_ value: String?, at rowIndex: Int, columnIndex: Int) { - commitCellEdit(row: rowIndex, columnIndex: columnIndex, newValue: value) - } - func copyCellValue(at rowIndex: Int, columnIndex: Int) { let tableRows = tableRowsProvider() guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } guard let row = displayRow(at: rowIndex), columnIndex < row.values.count else { return } let cell = row.values[columnIndex] + if changeManager.isExpression(rowID: row.id, columnIndex: columnIndex), let sql = cell.asText { + ClipboardService.shared.writeCells( + GridCellsClipboardPayload(text: sql, fields: [[sql]], expressionFields: [0: [0]]) + ) + return + } let columnTypes = tableRows.columnTypes let columnType = columnTypes.indices.contains(columnIndex) ? columnTypes[columnIndex] : nil let text = cellCopyText(cell, columnIndex: columnIndex, columnType: columnType) @@ -107,7 +113,8 @@ extension TableViewCoordinator { let rows = indices.sorted().compactMap(displayRow(at:)).map { row in SQLRowToStatementConverter.SourceRow( values: projection.values(Array(row.values)), - defaultedColumns: projection.positions(of: stagedDefaults(of: row)) + defaultedColumns: projection.positions(of: stagedDefaults(of: row)), + expressionColumns: projection.positions(of: stagedExpressions(of: row)) ) } writeCopiedStatements(converter.generateInserts(rows: rows)) @@ -157,7 +164,8 @@ extension TableViewCoordinator { values: Array(row.values), storedValues: stored, isPendingInsert: changeManager.insertedRowIDs.contains(row.id), - defaultedColumns: stagedDefaults(of: row) + defaultedColumns: stagedDefaults(of: row), + expressionColumns: stagedExpressions(of: row) ) } @@ -172,6 +180,10 @@ extension TableViewCoordinator { return Set(pendingUpdate?.cellChanges.filter { $0.newValue == marker }.map(\.columnIndex) ?? []) } + private func stagedExpressions(of row: Row) -> Set { + changeManager.expressionColumns(forRow: row.id) + } + /// A copy that produced no statement leaves the clipboard as it was, rather than replacing it with nothing. private func writeCopiedStatements(_ statements: String) { guard !statements.isEmpty else { return } @@ -406,11 +418,13 @@ extension TableViewCoordinator { var copiedLines: [[String?]] = [] copiedLines.reserveCapacity(rowRange.count) + var expressionFields: [Int: Set] = [:] for rowIndex in rowRange { guard let row = displayRow(at: rowIndex) else { copiedLines.append(Array(repeating: "", count: positions.count)) continue } + let expressionColumns = changeManager.expressionColumns(forRow: row.id) var fields: [String?] = [] fields.reserveCapacity(positions.count) for position in positions { @@ -420,8 +434,14 @@ extension TableViewCoordinator { fields.append("") continue } + let cell = row.values[columnIndex] + if expressionColumns.contains(columnIndex), let sql = cell.asText { + expressionFields[copiedLines.count, default: []].insert(fields.count) + fields.append(sql) + continue + } let columnType = columnTypes.indices.contains(columnIndex) ? columnTypes[columnIndex] : nil - fields.append(RowValueCopyFormatter.copyText(cell: row.values[columnIndex], columnType: columnType)) + fields.append(RowValueCopyFormatter.copyText(cell: cell, columnType: columnType)) } copiedLines.append(fields) } @@ -430,6 +450,10 @@ extension TableViewCoordinator { if isTruncated { text.append("\n(truncated, showing first \(maxRows) of \(selectedRowCount) rows)") } - ClipboardService.shared.writeCells(GridCellsClipboardPayload(text: text, fields: copiedLines)) + ClipboardService.shared.writeCells(GridCellsClipboardPayload( + text: text, + fields: copiedLines, + expressionFields: expressionFields.isEmpty ? nil : expressionFields + )) } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 02a9a325a7..b2436eba65 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -25,6 +25,8 @@ struct RowVisualState: Equatable { let isInserted: Bool let modifiedColumns: Set let struckColumns: Set + /// Pending cells staged as SQL, which draw like a pending DEFAULT. + let expressionColumns: Set let vocabulary: DataGridChangeVocabulary let highlight: RowHighlight @@ -33,6 +35,7 @@ struct RowVisualState: Equatable { isInserted: Bool, modifiedColumns: Set, struckColumns: Set = [], + expressionColumns: Set = [], vocabulary: DataGridChangeVocabulary = .pendingEdit, highlight: RowHighlight = .none ) { @@ -40,6 +43,7 @@ struct RowVisualState: Equatable { self.isInserted = isInserted self.modifiedColumns = modifiedColumns self.struckColumns = struckColumns + self.expressionColumns = expressionColumns self.vocabulary = vocabulary self.highlight = highlight } @@ -52,12 +56,17 @@ struct RowVisualState: Equatable { struckColumns.contains(columnIndex) } + func isExpression(columnIndex: Int) -> Bool { + expressionColumns.contains(columnIndex) + } + func highlighted(_ highlight: RowHighlight) -> RowVisualState { RowVisualState( isDeleted: isDeleted, isInserted: isInserted, modifiedColumns: modifiedColumns, struckColumns: struckColumns, + expressionColumns: expressionColumns, vocabulary: vocabulary, highlight: highlight ) diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index d9f0ceaac8..04306b4d63 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -22,13 +22,36 @@ extension TableViewCoordinator { /// False when the column holds neither the text nor NULL in its place, and nothing was staged. @discardableResult func commitEnteredText(_ text: String?, row: Int, columnIndex: Int) -> Bool { - guard let value = tableRowsProvider().enteredValue(text, forColumn: columnIndex) else { return false } - commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value) + guard let entry = enteredCell(text, columnIndex: columnIndex) else { return false } + commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: entry.value, isExpression: entry.isExpression) return true } - func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) { - guard recordCellEdit(row: row, columnIndex: columnIndex, newValue: typedNewValue) != nil else { return } + /// A function the engine runs on this kind of column stages as SQL; anything else is read for the column as + /// `TableRows.enteredValue` reads it. + func enteredCell(_ text: String?, columnIndex: Int) -> (value: PluginCellValue, isExpression: Bool)? { + let tableRows = tableRowsProvider() + if let text, let databaseType, + let expression = CellEntry.typed( + text, + columnType: tableRows.editingColumnType(at: columnIndex), + databaseType: databaseType, + writer: changeManager.cellWriterSupport + ) { + return expression + } + return tableRows.enteredValue(text, forColumn: columnIndex).map { (value: $0, isExpression: false) } + } + + func commitTypedCellEdit( + row: Int, + columnIndex: Int, + newValue typedNewValue: PluginCellValue, + isExpression: Bool = false + ) { + guard recordCellEdit( + row: row, columnIndex: columnIndex, newValue: typedNewValue, isExpression: isExpression + ) != nil else { return } repaintEditedCell(row: row, columnIndex: columnIndex) } @@ -40,7 +63,7 @@ extension TableViewCoordinator { repaintEditedCell(row: row, columnIndex: columnIndex) } - private func repaintEditedCell(row: Int, columnIndex: Int) { + func repaintEditedCell(row: Int, columnIndex: Int) { invalidateDisplayCache() updateVisualIndex(forDisplayRow: row) @@ -55,15 +78,24 @@ extension TableViewCoordinator { /// A detached editor outlives a sort, a value filter and a display-format change, none of which /// close it, and a display row names a different record after any of them. func commitCellEdit(rowID: RowID, columnIndex: Int, newValue: String?) { - guard let value = tableRowsProvider().enteredValue(newValue, forColumn: columnIndex) else { + guard let entry = enteredCell(newValue, columnIndex: columnIndex) else { NSSound.beep() return } - commitTypedCellEdit(rowID: rowID, columnIndex: columnIndex, newValue: value) + commitTypedCellEdit( + rowID: rowID, columnIndex: columnIndex, newValue: entry.value, isExpression: entry.isExpression + ) } - func commitTypedCellEdit(rowID: RowID, columnIndex: Int, newValue typedNewValue: PluginCellValue) { - guard recordCellEdit(rowID: rowID, columnIndex: columnIndex, newValue: typedNewValue) != nil else { return } + func commitTypedCellEdit( + rowID: RowID, + columnIndex: Int, + newValue typedNewValue: PluginCellValue, + isExpression: Bool = false + ) { + guard recordCellEdit( + rowID: rowID, columnIndex: columnIndex, newValue: typedNewValue, isExpression: isExpression + ) != nil else { return } invalidateDisplayCache() /// A record the value filter is hiding is written and not repainted, because it has no row @@ -80,7 +112,12 @@ extension TableViewCoordinator { } @discardableResult - func recordCellEdit(rowID: RowID, columnIndex: Int, newValue typedNewValue: PluginCellValue) -> Delta? { + func recordCellEdit( + rowID: RowID, + columnIndex: Int, + newValue typedNewValue: PluginCellValue, + isExpression: Bool = false + ) -> Delta? { let tableRows = tableRowsProvider() guard let storageRow = tableRows.index(of: rowID), storageRow < tableRows.rows.count else { return nil } return recordCellEdit( @@ -88,6 +125,7 @@ extension TableViewCoordinator { columnIndex: columnIndex, newValue: typedNewValue, removesField: false, + isExpression: isExpression, displayRow: DisplayRowMapping.displayIndex(forRowID: rowID, displayIDs: displayIDs, in: tableRows) ) } @@ -97,7 +135,8 @@ extension TableViewCoordinator { row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue, - removesField: Bool = false + removesField: Bool = false, + isExpression: Bool = false ) -> Delta? { cellCommitLogger.debug("recordCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") guard !isCommittingCellEdit else { return nil } @@ -113,6 +152,7 @@ extension TableViewCoordinator { columnIndex: columnIndex, newValue: typedNewValue, removesField: removesField, + isExpression: isExpression, displayRow: row ) } @@ -121,13 +161,15 @@ extension TableViewCoordinator { /// `displayRow` is only what the delegate is told; the record and the write are by identity. /// /// A value written into a missing field brings the field back, and `removesField` takes it out, - /// so NULL over a missing field is an edit even though both read `.null`. + /// so NULL over a missing field is an edit even though both read `.null`. An expression over text that + /// reads the same is an edit too, and so is the text over the expression. @discardableResult private func recordCellEdit( in row: Row, columnIndex: Int, newValue typedNewValue: PluginCellValue, removesField: Bool, + isExpression: Bool, displayRow: Int? ) -> Delta? { guard !isCommittingCellEdit else { return nil } @@ -141,7 +183,9 @@ extension TableViewCoordinator { isAbsent: removesField, originalRow: row.absentColumns ) - guard oldValue != typedNewValue || absence.wasAbsent != absence.isAbsent else { + let wasExpression = changeManager.isExpression(rowID: row.id, columnIndex: columnIndex) + guard oldValue != typedNewValue || absence.wasAbsent != absence.isAbsent || wasExpression != isExpression + else { cellCommitLogger.debug("recordCellEdit - value unchanged, guard returned") return nil } @@ -157,7 +201,8 @@ extension TableViewCoordinator { oldValue: oldValue, newValue: typedNewValue, originalRow: Array(row.values), - absence: absence + absence: absence, + isExpression: isExpression ) var delta: Delta = .none diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift index 86d7588611..ab742dee68 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit extension TableViewCoordinator { /// Splitting the clipboard into a cell block costs a pass over the whole string, and menu @@ -37,7 +38,6 @@ extension TableViewCoordinator { let undoManager = tableView?.window?.undoManager undoManager?.beginUndoGrouping() - undoManager?.setActionName(String(localized: "Paste Cells")) let tableRows = tableRowsProvider() var refusedCell = false @@ -50,15 +50,28 @@ extension TableViewCoordinator { let targetPosition = anchorPosition + gridCol guard targetPosition < maxCol else { break } guard let targetCol = dataColumnIndex(atDisplayPosition: targetPosition) else { continue } - let text = block.isGridCopy - ? field - : field.flatMap { Self.pastedText($0, columnType: tableRows.editingColumnType(at: targetCol)) } - if !commitEnteredText(text, row: targetRow, columnIndex: targetCol) { + let isCommitted: Bool + if block.isGridCopy { + isCommitted = commitCopiedField( + field, + isExpression: block.isExpression(line: gridRow, field: gridCol), + row: targetRow, + columnIndex: targetCol + ) + } else { + let text = field.flatMap { + Self.pastedText($0, columnType: tableRows.editingColumnType(at: targetCol)) + } + isCommitted = commitEnteredText(text, row: targetRow, columnIndex: targetCol) + } + if !isCommitted { refusedCell = true } } } + // After the loop: each cell's own step names the open group "Edit Cell". + undoManager?.setActionName(String(localized: "Paste Cells")) undoManager?.endUndoGrouping() if refusedCell { NSSound.beep() @@ -73,9 +86,15 @@ extension TableViewCoordinator { struct CellPasteBlock { let lines: [[String?]] let isGridCopy: Bool + /// Line index to the fields the grid's copy marked as staged SQL. + var expressionFields: [Int: Set] = [:] /// The widest line, since an empty line holds one field and may come first. var width: Int { lines.map(\.count).max() ?? 0 } + + func isExpression(line: Int, field: Int) -> Bool { + expressionFields[line]?.contains(field) == true + } } /// The clipboard as a cell block, or nil when it belongs to the row-paste path instead. @@ -122,7 +141,19 @@ extension TableViewCoordinator { /// in it would become several fields and a NULL would read as the text `NULL`. private static func gridCopiedBlock(matching text: String) -> CellPasteBlock? { guard let copied = ClipboardService.shared.readGridCells(), copied.text == text else { return nil } - return CellPasteBlock(lines: copied.fields, isGridCopy: true) + return CellPasteBlock(lines: copied.fields, isGridCopy: true, expressionFields: copied.expressionFields ?? [:]) + } + + /// The grid's copy says which fields were SQL, so a field it marked stages as SQL for the save to check against + /// this engine, and any other field is text however it reads: a stored `CURRENT_TIMESTAMP` is not promoted. + private func commitCopiedField(_ field: String?, isExpression: Bool, row: Int, columnIndex: Int) -> Bool { + if isExpression, let field { + commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: .text(field), isExpression: true) + return true + } + guard let value = tableRowsProvider().enteredValue(field, forColumn: columnIndex) else { return false } + commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value) + return true } /// Text from another app has no NULL of its own. A date, number or boolean cannot hold the diff --git a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift index 318a8cea5f..60b40e204f 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift @@ -6,6 +6,18 @@ import AppKit import TableProPluginKit +enum FillColumnChoice: Equatable { + case value + case null + case uuid(UUIDVersion) +} + +/// A UUID is drawn for each row. +enum FillColumnContent: Equatable { + case value(PluginCellValue, isExpression: Bool = false) + case uuid(UUIDVersion, UUIDCellForm) +} + extension TableViewCoordinator { @objc func fillColumn(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int else { return } @@ -22,8 +34,7 @@ extension TableViewCoordinator { guard rowCount > 0 else { return } let columnName = tableRows.columns[columnIndex] - let allowsNull = tableRows.columnNullable[columnName] ?? true - let accessory = FillColumnAccessoryView(allowsNull: allowsNull) + let accessory = FillColumnAccessoryView(choices: fillColumnChoices(columnIndex: columnIndex)) let alert = NSAlert() alert.messageText = String(format: String(localized: "Fill column \"%@\""), columnName) @@ -36,29 +47,48 @@ extension TableViewCoordinator { alert.beginSheetModal(for: window) { [weak self] response in guard response == .alertFirstButtonReturn, let self else { return } - guard let value = self.fillColumnValue( - text: accessory.enteredText, setNull: accessory.setsNull, columnIndex: columnIndex + guard let content = self.fillColumnContent( + choice: accessory.choice, text: accessory.enteredText, columnIndex: columnIndex ) else { NSSound.beep() return } - self.applyFillColumn(columnIndex: columnIndex, value: value) + self.applyFillColumn(columnIndex: columnIndex, content: content) + } + } + + func fillColumnChoices(columnIndex: Int) -> [FillColumnChoice] { + let tableRows = tableRowsProvider() + guard tableRows.columns.indices.contains(columnIndex) else { return [.value] } + var choices: [FillColumnChoice] = [.value] + if tableRows.columnNullable[tableRows.columns[columnIndex]] ?? true { + choices.append(.null) } + if fillUUIDForm(columnIndex: columnIndex, in: tableRows) != nil { + choices += [.uuid(.v4), .uuid(.v7)] + } + return choices } - func applyFillColumn(columnIndex: Int, value: PluginCellValue) { + func applyFillColumn(columnIndex: Int, content: FillColumnContent) { let targetRows = fillTargetRows() guard !targetRows.isEmpty else { return } let undoManager = tableView?.window?.undoManager undoManager?.beginUndoGrouping() - undoManager?.setActionName(String(localized: "Fill Column")) var didEdit = false - for row in targetRows where recordCellEdit(row: row, columnIndex: columnIndex, newValue: value) != nil { - didEdit = true + for row in targetRows { + let staged = Self.fillValue(content) + if recordCellEdit( + row: row, columnIndex: columnIndex, newValue: staged.value, isExpression: staged.isExpression + ) != nil { + didEdit = true + } } + // After the loop: each cell's own step names the open group "Edit Cell". + undoManager?.setActionName(String(localized: "Fill Column")) undoManager?.endUndoGrouping() guard didEdit else { return } @@ -66,6 +96,15 @@ extension TableViewCoordinator { tableView?.reloadData() } + private static func fillValue(_ content: FillColumnContent) -> (value: PluginCellValue, isExpression: Bool) { + switch content { + case .value(let value, let isExpression): + return (value: value, isExpression: isExpression) + case .uuid(let version, let form): + return (value: form.value(for: UUIDValueGenerator.shared.make(version)), isExpression: false) + } + } + private func fillTargetRows() -> [Int] { let tableRows = tableRowsProvider() return Self.fillTargetRows(rowCount: cachedRowCount, isEditable: isEditable) { displayIndex in @@ -81,10 +120,26 @@ extension TableViewCoordinator { return (0.. PluginCellValue? { - setNull ? .null : tableRowsProvider().enteredValue(text, forColumn: columnIndex) + /// The value is read the way a typed cell is: an empty date is NULL, a function the engine runs there is SQL. Nil + /// when the column holds neither the text nor NULL in its place. + func fillColumnContent(choice: FillColumnChoice, text: String, columnIndex: Int) -> FillColumnContent? { + switch choice { + case .value: + return enteredCell(text, columnIndex: columnIndex).map { + FillColumnContent.value($0.value, isExpression: $0.isExpression) + } + case .null: + return .value(.null) + case .uuid(let version): + return fillUUIDForm(columnIndex: columnIndex, in: tableRowsProvider()).map { + FillColumnContent.uuid(version, $0) + } + } + } + + private func fillUUIDForm(columnIndex: Int, in tableRows: TableRows) -> UUIDCellForm? { + setValueContext(forColumn: columnIndex, rowIsInserted: false, in: tableRows) + .flatMap { SetValueOptions.uuidForm(for: $0) } } static func fillImpactDescription(rowCount: Int) -> String { @@ -98,41 +153,74 @@ extension TableViewCoordinator { } } +/// Value, NULL and the UUIDs are radio buttons with one superview and one action, which AppKit groups by itself. @MainActor private final class FillColumnAccessoryView: NSView { - private let valueField = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) - private let nullCheckbox: NSButton? + private static let width: CGFloat = 260 + private static let valueRowHeight: CGFloat = 24 + private static let choiceRowHeight: CGFloat = 22 - init(allowsNull: Bool) { - nullCheckbox = allowsNull - ? NSButton(checkboxWithTitle: String(localized: "Set to NULL"), target: nil, action: nil) - : nil - super.init(frame: NSRect(x: 0, y: 0, width: 260, height: allowsNull ? 50 : 24)) + private let valueField = NSTextField(frame: .zero) + private var radios: [(choice: FillColumnChoice, button: NSButton)] = [] + + init(choices: [FillColumnChoice]) { + let others = choices.filter { $0 != .value } + let height = Self.valueRowHeight + CGFloat(others.count) * Self.choiceRowHeight + super.init(frame: NSRect(x: 0, y: 0, width: Self.width, height: height)) valueField.usesSingleLineMode = true valueField.placeholderString = String(localized: "Value") - valueField.frame = NSRect(x: 0, y: allowsNull ? 26 : 0, width: 260, height: 24) addSubview(valueField) - guard let nullCheckbox else { return } - nullCheckbox.frame = NSRect(x: 0, y: 0, width: 260, height: 18) - nullCheckbox.target = self - nullCheckbox.action = #selector(nullStateChanged) - addSubview(nullCheckbox) + let valueRowY = height - Self.valueRowHeight + guard !others.isEmpty else { + valueField.frame = NSRect(x: 0, y: valueRowY, width: Self.width, height: Self.valueRowHeight) + return + } + + let valueRadio = addRadio(.value, at: valueRowY, rowHeight: Self.valueRowHeight) + valueRadio.state = .on + let fieldX = valueRadio.frame.maxX + 6 + valueField.frame = NSRect( + x: fieldX, y: valueRowY, width: Self.width - fieldX, height: Self.valueRowHeight + ) + for (index, choice) in others.enumerated() { + addRadio(choice, at: valueRowY - CGFloat(index + 1) * Self.choiceRowHeight, rowHeight: Self.choiceRowHeight) + } } required init?(coder: NSCoder) { - nullCheckbox = nil super.init(coder: coder) } var enteredText: String { valueField.stringValue } - var setsNull: Bool { nullCheckbox?.state == .on } + var choice: FillColumnChoice { + radios.first { $0.button.state == .on }?.choice ?? .value + } var firstResponderView: NSView { valueField } - @objc private func nullStateChanged() { - valueField.isEnabled = nullCheckbox?.state != .on + @discardableResult + private func addRadio(_ choice: FillColumnChoice, at rowY: CGFloat, rowHeight: CGFloat) -> NSButton { + let button = NSButton(radioButtonWithTitle: Self.title(for: choice), target: self, action: #selector(choiceChanged)) + button.sizeToFit() + button.frame.origin = NSPoint(x: 0, y: rowY + (rowHeight - button.frame.height) / 2) + addSubview(button) + radios.append((choice, button)) + return button + } + + private static func title(for choice: FillColumnChoice) -> String { + switch choice { + case .value: String(localized: "Value") + case .null: String(localized: "NULL") + case .uuid(.v4): String(localized: "New UUID") + case .uuid(.v7): String(localized: "New UUIDv7") + } + } + + @objc private func choiceChanged() { + valueField.isEnabled = choice == .value } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+SetValue.swift b/TablePro/Views/Results/Extensions/DataGridView+SetValue.swift new file mode 100644 index 0000000000..74cf5b0c79 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+SetValue.swift @@ -0,0 +1,220 @@ +// +// DataGridView+SetValue.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +/// A display row and a data column. +struct SetValueTarget: Hashable { + let displayRow: Int + let columnIndex: Int +} + +extension TableViewCoordinator { + /// The cell selection, else the clicked column on every selected row, else the clicked cell. A context menu keeps + /// the selection only when the click lands inside it; the menu bar passes nil while a selection exists. + func setValueTargets(clicked: SetValueTarget?) -> [SetValueTarget] { + let selection = selectionController.selection + if !selection.isEmpty, clicked.map({ isTarget($0, inside: selection) }) ?? true { + return cells(in: selection) + } + guard let clicked, clicked.displayRow >= 0, clicked.columnIndex >= 0 else { return [] } + let selectedRows = tableView.map { Set($0.selectedRowIndexes) } ?? selectedRowIndices + guard selectedRows.count > 1, selectedRows.contains(clicked.displayRow) else { return [clicked] } + return selectedRows.sorted().map { SetValueTarget(displayRow: $0, columnIndex: clicked.columnIndex) } + } + + /// Any target's commands, so a selection over a date and a UUID column offers both kinds. + func setValueCommands(for targets: [SetValueTarget]) -> [SetValueCommand] { + var resolver = SetValueResolver(coordinator: self) + var seen: Set = [] + var offered: [SetValueCommand] = [] + for target in targets { + for command in resolver.commands(for: target) ?? [] where seen.insert(command).inserted { + offered.append(command) + } + } + return offered.enumerated() + .sorted { (Self.menuRank($0.element), $0.offset) < (Self.menuRank($1.element), $1.offset) } + .map { $0.element } + } + + func isSetValueOffered(_ command: SetValueCommand, for targets: [SetValueTarget]) -> Bool { + var resolver = SetValueResolver(coordinator: self) + return targets.contains { resolver.commands(for: $0)?.contains(command) == true } + } + + /// A target that does not take the command, such as NULL on a NOT NULL column or a deleted row, is skipped. + func applySetValue(_ command: SetValueCommand, to targets: [SetValueTarget]) { + guard !targets.isEmpty else { return } + var resolver = SetValueResolver(coordinator: self) + let undoManager = tableView?.window?.undoManager + undoManager?.beginUndoGrouping() + + var edited: [SetValueTarget] = [] + for target in targets { + guard let context = resolver.context(for: target), + resolver.commands(for: target)?.contains(command) == true, + let staged = Self.stagedValue(for: command, context: context), + recordCellEdit( + row: target.displayRow, + columnIndex: target.columnIndex, + newValue: staged.value, + isExpression: staged.isExpression + ) != nil else { continue } + edited.append(target) + } + + // After the loop: each cell's own step names the open group "Edit Cell". + if !edited.isEmpty { + undoManager?.setActionName(Self.undoActionName(for: command)) + } + undoManager?.endUndoGrouping() + + if edited.count == 1, let only = edited.first { + repaintEditedCell(row: only.displayRow, columnIndex: only.columnIndex) + } else if !edited.isEmpty { + invalidateAllDisplayCaches() + tableView?.reloadData() + } + } + + func setValueContext(forColumn columnIndex: Int, rowIsInserted: Bool, in tableRows: TableRows) -> SetValueContext? { + guard tableRows.columns.indices.contains(columnIndex) else { return nil } + let name = tableRows.columns[columnIndex] + let format = columnDisplayFormats.indices.contains(columnIndex) ? columnDisplayFormats[columnIndex] : nil + return SetValueContext( + columnType: tableRows.editingColumnType(at: columnIndex), + isNullable: tableRows.columnNullable[name] ?? true, + serverAssignsValue: tableRows.serverAssignsValue(forColumn: name), + isForeignKey: tableRows.columnForeignKeys[name] != nil, + isWritable: isEditable && isColumnWritable(name), + displayFormat: format, + sampleText: format == .uuid ? Self.sampleText(ofColumn: columnIndex, in: tableRows) : nil, + declaredType: tableRows.declaredType(at: columnIndex), + databaseType: databaseType ?? Self.unknownDatabaseType, + rowIsInserted: rowIsInserted, + writer: changeManager.cellWriterSupport + ) + } + + /// A grid with no engine named gets nothing the vocabulary keys by engine. + private static let unknownDatabaseType = DatabaseType(rawValue: "") + + /// A MySQL `CHAR(32)` result has no length, so its values are the one sign it takes the compact UUID form. + private static func sampleText(ofColumn columnIndex: Int, in tableRows: TableRows) -> String? { + for row in tableRows.rows where columnIndex < row.values.count { + guard let text = row.values[columnIndex].asText, + !text.isEmpty, text != PluginCellValue.defaultMarkerText else { continue } + return text + } + return nil + } + + private func isTarget(_ target: SetValueTarget, inside selection: GridSelection) -> Bool { + guard let position = displayPosition(ofDataColumnIndex: target.columnIndex) else { return false } + return selection.contains(row: target.displayRow, displayColumn: position) + } + + /// Overlapping rectangles name a cell once. + private func cells(in selection: GridSelection) -> [SetValueTarget] { + let rowCount = displayIDs?.count ?? tableRowsProvider().count + let presented = presentedDataColumns + func dataColumns(_ positions: IndexSet) -> [Int] { + positions.compactMap { presented.indices.contains($0) ? presented[$0] : nil } + } + var cells: [SetValueTarget] = [] + if selection.rectangles.count == 1, let only = selection.rectangles.first { + let columns = dataColumns(IndexSet(integersIn: only.columns.lowerBound...only.columns.upperBound)) + for row in only.rows where row >= 0 && row < rowCount { + cells += columns.map { SetValueTarget(displayRow: row, columnIndex: $0) } + } + return cells + } + for row in selection.affectedRows where row < rowCount { + cells += dataColumns(selection.columns(in: row)).map { SetValueTarget(displayRow: row, columnIndex: $0) } + } + return cells + } + + private static func stagedValue( + for command: SetValueCommand, + context: SetValueContext + ) -> (value: PluginCellValue, isExpression: Bool)? { + let value: PluginCellValue + switch command { + case .empty: + value = .text("") + case .null: + value = .null + case .defaultValue: + value = .text(PluginCellValue.defaultMarkerText) + case .expression(let expression): + return (value: PluginCellValue.text(expression.sql), isExpression: true) + case .uuid(let version): + guard let form = SetValueOptions.uuidForm(for: context) else { return nil } + value = form.value(for: UUIDValueGenerator.shared.make(version)) + } + return (value: value, isExpression: false) + } + + private static func undoActionName(for command: SetValueCommand) -> String { + guard case .uuid = command else { return String(localized: "Set Value") } + return SetValueOptions.title(for: command) + } + + private static func menuRank(_ command: SetValueCommand) -> Int { + switch command { + case .empty: 0 + case .null: 1 + case .defaultValue: 2 + case .expression: 3 + case .uuid(.v4): 4 + case .uuid(.v7): 5 + } + } +} + +/// A context depends only on the column and whether the row is new, so a tall selection resolves a few, not one per cell. +@MainActor +private struct SetValueResolver { + private struct Key: Hashable { + let columnIndex: Int + let rowIsInserted: Bool + } + + private let coordinator: TableViewCoordinator + private let tableRows: TableRows + private let insertedRowIDs: Set + private let deletedRowIDs: Set + private var resolved: [Key: (context: SetValueContext, commands: [SetValueCommand])?] = [:] + + init(coordinator: TableViewCoordinator) { + self.coordinator = coordinator + tableRows = coordinator.tableRowsProvider() + insertedRowIDs = coordinator.changeManager.insertedRowIDs + deletedRowIDs = coordinator.changeManager.deletedRowIDs + } + + mutating func context(for target: SetValueTarget) -> SetValueContext? { + options(for: target)?.context + } + + mutating func commands(for target: SetValueTarget) -> [SetValueCommand]? { + options(for: target)?.commands + } + + private mutating func options(for target: SetValueTarget) -> (context: SetValueContext, commands: [SetValueCommand])? { + guard let row = coordinator.displayRow(at: target.displayRow, in: tableRows), + !deletedRowIDs.contains(row.id) else { return nil } + let key = Key(columnIndex: target.columnIndex, rowIsInserted: insertedRowIDs.contains(row.id)) + if let cached = resolved[key] { return cached } + let options = coordinator + .setValueContext(forColumn: key.columnIndex, rowIsInserted: key.rowIsInserted, in: tableRows) + .map { (context: $0, commands: SetValueOptions.commands(for: $0)) } + resolved[key] = .some(options) + return options + } +} diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index d11a89fb31..61c399955e 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -419,6 +419,9 @@ final class KeyHandlingTableView: NSTableView { } override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + if let command = Self.setValueCommand(for: item.action) { + return canApplySetValue(command) + } switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): let hasGridSelection = gridSelection?.isEmpty == false @@ -843,3 +846,64 @@ final class KeyHandlingTableView: NSTableView { return super.menu(for: event) } } + +// MARK: - Edit > Set Value + +extension KeyHandlingTableView: SetValueCommandResponding { + func setValueEmpty(_ sender: Any?) { + applySetValue(.empty) + } + + func setValueNull(_ sender: Any?) { + applySetValue(.null) + } + + func setValueDefault(_ sender: Any?) { + applySetValue(.defaultValue) + } + + func generateUUIDv4(_ sender: Any?) { + applySetValue(.uuid(.v4)) + } + + func generateUUIDv7(_ sender: Any?) { + applySetValue(.uuid(.v7)) + } + + static func setValueCommand(for action: Selector?) -> SetValueCommand? { + switch action { + case #selector(SetValueCommandResponding.setValueEmpty(_:)): + return SetValueCommand.empty + case #selector(SetValueCommandResponding.setValueNull(_:)): + return SetValueCommand.null + case #selector(SetValueCommandResponding.setValueDefault(_:)): + return SetValueCommand.defaultValue + case #selector(SetValueCommandResponding.generateUUIDv4(_:)): + return SetValueCommand.uuid(.v4) + case #selector(SetValueCommandResponding.generateUUIDv7(_:)): + return SetValueCommand.uuid(.v7) + default: + return nil + } + } + + /// Dimmed unless some target cell takes the command, as the context menu leaves the item out. + private func canApplySetValue(_ command: SetValueCommand) -> Bool { + guard let coordinator else { return false } + return coordinator.isSetValueOffered(command, for: setValueTargets()) + } + + private func applySetValue(_ command: SetValueCommand) { + coordinator?.applySetValue(command, to: setValueTargets()) + } + + /// The cell selection, else the focused cell, the way the context menu reads the clicked one. + private func setValueTargets() -> [SetValueTarget] { + guard let coordinator else { return [] } + if gridSelection?.isEmpty == false { + return coordinator.setValueTargets(clicked: nil) + } + guard let anchor = pasteAnchorCell() else { return [] } + return coordinator.setValueTargets(clicked: SetValueTarget(displayRow: anchor.row, columnIndex: anchor.column)) + } +} diff --git a/TablePro/Views/Results/RowVisualIndex.swift b/TablePro/Views/Results/RowVisualIndex.swift index 9e0d71d1c5..4990b2aafc 100644 --- a/TablePro/Views/Results/RowVisualIndex.swift +++ b/TablePro/Views/Results/RowVisualIndex.swift @@ -67,7 +67,19 @@ final class RowVisualIndex { return RowVisualState( isDeleted: isDeleted, isInserted: isInserted, - modifiedColumns: modifiedColumns + modifiedColumns: modifiedColumns, + expressionColumns: expressionColumns(of: rowChange) ) } + + private static func expressionColumns(of rowChange: RowChange) -> Set { + switch rowChange.type { + case .insert: + rowChange.expressionColumns + case .update: + Set(rowChange.cellChanges.lazy.filter(\.newIsExpression).map(\.columnIndex)) + case .delete: + [] + } + } } diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldMenuContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldMenuContent.swift index be646dbf8f..5b288b0da0 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldMenuContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldMenuContent.swift @@ -5,35 +5,40 @@ import SwiftUI -/// The field actions (Set NULL/DEFAULT/EMPTY, copy, SQL functions). Shared by the row's own pull +/// The field actions (Set NULL/DEFAULT/EMPTY, Generate UUID, copy, SQL functions). Shared by the row's own pull /// down and its context menu so both stay in sync. /// /// A read-only field keeps the copy actions and loses the mutating ones. Hiding the whole menu -/// left a value that is neither selectable nor copyable. Set EMPTY is offered only where `''` is a -/// value of the column's type. +/// left a value that is neither selectable nor copyable. Each Set action is one `SetValueOptions` offers for +/// the column, so the inspector cannot offer NULL on a NOT NULL column the grid refuses it on. internal struct FieldMenuContent: View { let value: String let columnType: ColumnType - let sqlFunctions: [SQLFunctionProvider.SQLFunction] + let commands: [SetValueCommand] let canMutate: Bool let isPendingNull: Bool let isPendingDefault: Bool var isPendingRemoval = false - let onSetNull: () -> Void - let onSetDefault: () -> Void - let onSetEmpty: () -> Void - let onSetFunction: (String) -> Void + var isPendingExpression = false + let onSetValue: (SetValueCommand) -> Void /// Offered only on an engine that tells a missing field from NULL, and only while the field is /// there to remove. var onRemoveField: (() -> Void)? let onClear: () -> Void var body: some View { - if canMutate { - Button("Set NULL") { onSetNull() } - Button("Set DEFAULT") { onSetDefault() } - if columnType.hasEmptyValue { - Button("Set EMPTY") { onSetEmpty() } + if canMutate, hasSetActions { + if commands.contains(.null) { + Button("Set NULL") { onSetValue(.null) } + } + if commands.contains(.defaultValue) { + Button("Set DEFAULT") { onSetValue(.defaultValue) } + } + if commands.contains(.empty) { + Button("Set EMPTY") { onSetValue(.empty) } + } + ForEach(uuidCommands, id: \.self) { command in + Button(SetValueOptions.title(for: command)) { onSetValue(command) } } if let onRemoveField { Button("Remove Field") { onRemoveField() } @@ -63,18 +68,40 @@ internal struct FieldMenuContent: View { } if canMutate { - Divider() + if !expressionCommands.isEmpty { + Divider() - Menu("SQL Functions") { - ForEach(sqlFunctions, id: \.expression) { function in - Button(function.label) { onSetFunction(function.expression) } + Menu("SQL Functions") { + ForEach(expressionCommands, id: \.self) { command in + Button(SetValueOptions.title(for: command)) { onSetValue(command) } + } } } - if isPendingNull || isPendingDefault || isPendingRemoval { + if isPendingNull || isPendingDefault || isPendingRemoval || isPendingExpression { Divider() Button("Clear") { onClear() } } } } + + private var hasSetActions: Bool { + onRemoveField != nil || commands.contains { !Self.isExpression($0) } + } + + private var uuidCommands: [SetValueCommand] { + commands.filter { command in + if case .uuid = command { return true } + return false + } + } + + private var expressionCommands: [SetValueCommand] { + commands.filter(Self.isExpression) + } + + private static func isExpression(_ command: SetValueCommand) -> Bool { + if case .expression = command { return true } + return false + } } diff --git a/TablePro/Views/RowInspector/InspectorFieldListView.swift b/TablePro/Views/RowInspector/InspectorFieldListView.swift index e6361909e7..a5838e308e 100644 --- a/TablePro/Views/RowInspector/InspectorFieldListView.swift +++ b/TablePro/Views/RowInspector/InspectorFieldListView.swift @@ -81,8 +81,8 @@ internal struct InspectorFieldListView: View { /// /// One answer, read by the editor, by the value menu and by the keyboard shortcuts alike. A /// PHP-serialized value cannot be round-tripped without PHP, so the field is presented but never - /// mutated: Set NULL, Set DEFAULT, Set EMPTY and the SQL functions would each overwrite the - /// serialized payload with something the app cannot rebuild. + /// mutated: every Set Value command would overwrite the serialized payload with something the app + /// cannot rebuild. internal static func isFieldEditable( _ field: FieldEditState, kind: FieldEditorKind, @@ -146,6 +146,7 @@ internal struct InspectorFieldListView: View { private func row(for field: FieldEditState) -> some View { let kind = FieldEditorResolver.resolve(field: field) let editable = Self.isFieldEditable(field, kind: kind, rowIsEditable: isEditable) + let commands = editState.setValueCommands(for: field, isWritable: editable && offersDatabaseValues) InspectorFieldRow( context: context(for: field, kind: kind, isEditable: editable), layout: InspectorFieldLayout.resolve(for: kind, isSchemaField: field.isSchemaField), @@ -155,10 +156,9 @@ internal struct InspectorFieldListView: View { isForeignKey: field.isForeignKey, databaseType: databaseType, isExpanded: expandedFieldID == field.id, - onSetNull: { editState.setFieldToNull(at: field.columnIndex) }, - onSetDefault: { editState.setFieldToDefault(at: field.columnIndex) }, - onSetEmpty: { editState.setFieldToEmpty(at: field.columnIndex) }, - onSetFunction: { editState.setFieldToFunction(at: field.columnIndex, function: $0) }, + commands: commands, + isPendingExpression: field.isPendingExpression, + onSetValue: { editState.setValue($0, at: field.columnIndex) }, onRemoveField: offersFieldRemoval && !field.isSchemaField ? { editState.removeField(at: field.columnIndex) } : nil, @@ -245,15 +245,26 @@ internal struct InspectorFieldListView: View { let focusedField, let field = editState.fields.first(where: { $0.id == focusedField }), !field.isSchemaField, - Self.isFieldEditable(field, kind: FieldEditorResolver.resolve(field: field), rowIsEditable: isEditable) + Self.isFieldEditable(field, kind: FieldEditorResolver.resolve(field: field), rowIsEditable: isEditable), + let command = Self.stateShortcutCommand( + for: key, offered: editState.setValueCommands(for: field, isWritable: true) + ) else { return .ignored } - if key == "n" { - editState.setFieldToNull(at: field.columnIndex) - } else { - editState.setFieldToDefault(at: field.columnIndex) - } + editState.setValue(command, at: field.columnIndex) return .handled } + + /// Control-Option-N and Control-Option-D do only what the field's menu offers. Unhandled, the key goes on + /// to the next responder rather than staging a NULL the column refuses. + internal static func stateShortcutCommand(for key: Character, offered: [SetValueCommand]) -> SetValueCommand? { + let command: SetValueCommand + switch key { + case "n": command = .null + case "d": command = .defaultValue + default: return nil + } + return offered.contains(command) ? command : nil + } } /// Fields whose editor holds nothing Tab can land on. A reference that publishes nothing, so a diff --git a/TablePro/Views/RowInspector/InspectorFieldRow.swift b/TablePro/Views/RowInspector/InspectorFieldRow.swift index 729e03b1ea..71acd168f7 100644 --- a/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -6,7 +6,7 @@ import SwiftUI /// One field of the inspected row. Its value menu is always drawn: it is the only route to Set -/// NULL, Set DEFAULT and the SQL functions, and a hover-gated one hid them from the keyboard. +/// NULL, Set DEFAULT, Generate UUID and the SQL functions, and a hover-gated one hid them from the keyboard. internal struct InspectorFieldRow: View { @ObservedObject private var themeEngine = ThemeEngine.shared internal let context: FieldEditorContext @@ -17,10 +17,9 @@ internal struct InspectorFieldRow: View { internal let isForeignKey: Bool internal let databaseType: DatabaseType internal let isExpanded: Bool - internal let onSetNull: () -> Void - internal let onSetDefault: () -> Void - internal let onSetEmpty: () -> Void - internal let onSetFunction: (String) -> Void + internal let commands: [SetValueCommand] + internal var isPendingExpression = false + internal let onSetValue: (SetValueCommand) -> Void internal var onRemoveField: (() -> Void)? internal var onToggleExpand: (() -> Void)? internal var onPopOut: ((String) -> Void)? @@ -180,15 +179,13 @@ internal struct InspectorFieldRow: View { FieldMenuContent( value: context.copyableValue, columnType: context.columnType, - sqlFunctions: SQLFunctionProvider.functions(for: databaseType), + commands: commands, canMutate: context.canMutate, isPendingNull: context.valueState == .pendingNull, isPendingDefault: context.valueState == .pendingDefault, isPendingRemoval: context.valueState == .pendingRemoval, - onSetNull: onSetNull, - onSetDefault: onSetDefault, - onSetEmpty: onSetEmpty, - onSetFunction: onSetFunction, + isPendingExpression: isPendingExpression, + onSetValue: onSetValue, onRemoveField: context.valueState == .absent || context.valueState == .pendingRemoval ? nil : onRemoveField, onClear: { context.value.wrappedValue = context.originalValue ?? "" } @@ -202,8 +199,8 @@ internal struct InspectorFieldRow: View { context: context, kind: kind, databaseType: databaseType, - onSetNull: context.canMutate ? onSetNull : nil, - onSetDefault: context.canMutate ? onSetDefault : nil, + onSetNull: context.canMutate && commands.contains(.null) ? { onSetValue(.null) } : nil, + onSetDefault: context.canMutate && commands.contains(.defaultValue) ? { onSetValue(.defaultValue) } : nil, onPopOut: onPopOut, isExpanded: isExpanded, onTabStopChange: onTabStopChange diff --git a/TableProTests/Core/ChangeTracking/CellExpressionMarkerTests.swift b/TableProTests/Core/ChangeTracking/CellExpressionMarkerTests.swift new file mode 100644 index 0000000000..8985a07c04 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/CellExpressionMarkerTests.swift @@ -0,0 +1,302 @@ +// +// CellExpressionMarkerTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct CellExpressionPendingChangesTests { + private let row = RowID.existing(0) + + @Test("An expression over stored text that reads the same is a change") + func expressionOverEqualTextIsAChange() throws { + var pending = PendingChanges() + + let recorded = pending.recordCellChange( + rowID: row, columnIndex: 2, columnName: "note", oldValue: "now()", newValue: "now()", + originalRow: ["1", "Ada", "now()"], isExpression: true + ) + + #expect(recorded) + let cell = try #require(pending.changes.first?.cellChanges.first) + #expect(cell.newIsExpression && !cell.oldIsExpression) + #expect(pending.isExpression(rowID: row, columnIndex: 2)) + #expect(pending.expressionColumns(forRow: row) == [2]) + } + + @Test("Setting the stored text back over the expression leaves nothing pending") + func textBackOverExpressionCollapses() { + var pending = PendingChanges() + pending.recordCellChange( + rowID: row, columnIndex: 2, columnName: "note", oldValue: "now()", newValue: "now()", isExpression: true + ) + pending.recordCellChange( + rowID: row, columnIndex: 2, columnName: "note", oldValue: "now()", newValue: "now()", isExpression: false + ) + + #expect(pending.isEmpty) + } + + @Test("The same expression set twice is a no-op the second time") + func sameExpressionTwiceIsANoOp() { + var pending = PendingChanges() + pending.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "2026-01-01", newValue: "now()", isExpression: true + ) + let second = pending.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "now()", newValue: "now()", isExpression: true + ) + + #expect(!second) + #expect(pending.isExpression(rowID: row, columnIndex: 2)) + } + + @Test("A new row's cell takes and drops the mark with each edit") + func insertedCellFollowsEachEdit() { + var pending = PendingChanges() + let inserted = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: inserted, values: ["1", .null, .null]) + + pending.recordCellChange( + rowID: inserted, columnIndex: 2, columnName: "at", oldValue: .null, newValue: "now()", isExpression: true + ) + #expect(pending.insertedExpressionColumns(forRow: inserted) == [2]) + #expect(pending.insertedRowData[inserted]?[2] == "now()") + + pending.recordCellChange( + rowID: inserted, columnIndex: 2, columnName: "at", oldValue: "now()", newValue: "2026-01-01" + ) + #expect(pending.insertedExpressionColumns(forRow: inserted).isEmpty) + } + + @Test("Recording a new row again replaces its marks with the new ones") + func reRecordReplacesMarks() { + var pending = PendingChanges() + let inserted = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: inserted, values: ["1", "now()"], expressionColumns: [1]) + pending.recordRowInsertion(rowID: inserted, values: ["1", "x"]) + + #expect(pending.insertedExpressionColumns(forRow: inserted).isEmpty) + } + + @Test("Removing an earlier change keeps a later row's marks with that row") + func swapRemoveKeepsMarks() { + var pending = PendingChanges() + let first = RowID.inserted(UUID()) + let second = RowID.inserted(UUID()) + pending.recordRowInsertion(rowID: first, values: ["1"]) + pending.recordRowInsertion(rowID: second, values: ["now()"], expressionColumns: [0]) + + let removed = pending.undoRowInsertion(rowID: first) + + #expect(removed) + #expect(pending.insertedExpressionColumns(forRow: second) == [0]) + } +} + +@MainActor +struct CellExpressionUndoTests { + private func manager(databaseType: DatabaseType = .postgresql) -> (DataChangeManager, UndoManager) { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "events", + columns: ["id", "name", "at"], + primaryKeyColumns: ["id"], + databaseType: databaseType, + generatedColumns: [] + ) + let undoManager = UndoManager() + undoManager.groupsByEvent = false + manager.undoManagerProvider = { undoManager } + return (manager, undoManager) + } + + private let row = RowID.existing(0) + private let original: [PluginCellValue] = ["1", "Ada", "2026-01-01"] + + @Test("Undo takes the expression away and redo puts it back as an expression") + func undoAndRedo() throws { + let (manager, undoManager) = manager() + manager.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "2026-01-01", newValue: "now()", + originalRow: original, absence: FieldAbsence(), isExpression: true + ) + #expect(manager.isExpression(rowID: row, columnIndex: 2)) + + undoManager.undo() + #expect(!manager.hasChanges) + #expect(!manager.isExpression(rowID: row, columnIndex: 2)) + + undoManager.redo() + let cell = try #require(manager.changes.first?.cellChanges.first) + #expect(cell.newValue == "now()") + #expect(cell.newIsExpression) + #expect(cell.oldValue == "2026-01-01") + } + + @Test("Undoing an expression set over pending text brings the text back as text") + func undoOverPendingText() throws { + let (manager, undoManager) = manager() + manager.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "2026-01-01", newValue: "now()", + originalRow: original + ) + manager.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "now()", newValue: "now()", + originalRow: original, absence: FieldAbsence(), isExpression: true + ) + #expect(manager.isExpression(rowID: row, columnIndex: 2)) + + undoManager.undo() + let text = try #require(manager.changes.first?.cellChanges.first) + #expect(text.newValue == "now()") + #expect(!text.newIsExpression) + + undoManager.redo() + #expect(manager.isExpression(rowID: row, columnIndex: 2)) + } + + @Test("A typed run that ends on an expression undoes as one step and redoes the expression") + func coalescedRun() { + let (manager, undoManager) = manager() + manager.recordTypedCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "2026-01-01", newValue: "n", originalRow: original + ) + manager.recordTypedCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "n", newValue: "now()", originalRow: original, + isExpression: true + ) + manager.endCoalescedUndoRun() + #expect(manager.isExpression(rowID: row, columnIndex: 2)) + + undoManager.undo() + #expect(!manager.hasChanges) + + undoManager.redo() + #expect(manager.isExpression(rowID: row, columnIndex: 2)) + #expect(manager.changes.first?.cellChanges.first?.newValue == "now()") + } + + @Test("A typed run that only turns text into the same expression still registers a step") + func coalescedRunExpressionOnly() { + let (manager, undoManager) = manager() + manager.recordTypedCellChange( + rowID: row, columnIndex: 1, columnName: "name", oldValue: "now()", newValue: "now()", + originalRow: ["1", "now()", "2026-01-01"], isExpression: true + ) + manager.endCoalescedUndoRun() + + #expect(undoManager.canUndo) + undoManager.undo() + #expect(!manager.hasChanges) + } + + @Test("Undoing and redoing a new row keeps its expression cells") + func rowInsertionRedo() { + let (manager, undoManager) = manager() + let inserted = RowID.inserted(UUID()) + manager.recordRowInsertion(rowID: inserted, values: ["1", "Ada", "now()"], expressionColumns: [2]) + + undoManager.undo() + #expect(!manager.isRowInserted(inserted)) + + undoManager.redo() + #expect(manager.pending.insertedExpressionColumns(forRow: inserted) == [2]) + #expect(manager.expressionColumns(forRow: inserted) == [2]) + } + + @Test("Undoing the removal of several new rows brings back their expression cells") + func batchInsertionUndo() { + let (manager, undoManager) = manager() + let first = RowID.inserted(UUID()) + let second = RowID.inserted(UUID()) + manager.recordRowInsertion(rowID: first, values: ["1", "Ada", "now()"], expressionColumns: [2]) + manager.recordRowInsertion(rowID: second, values: ["2", "Grace", "2026-01-01"]) + + manager.undoBatchRowInsertion(rows: [ + InsertedRowLocation(rowID: first, storageIndex: 0), + InsertedRowLocation(rowID: second, storageIndex: 1), + ]) + #expect(!manager.isRowInserted(first)) + + undoManager.undo() + #expect(manager.pending.insertedExpressionColumns(forRow: first) == [2]) + #expect(manager.pending.insertedExpressionColumns(forRow: second).isEmpty) + } + + @Test("A marked cell is written as the SQL, an unmarked one with the same text is bound") + func statements() throws { + let (manager, _) = manager() + manager.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "2026-01-01", newValue: "now()", + originalRow: original, absence: FieldAbsence(), isExpression: true + ) + manager.recordCellChange( + rowID: .existing(1), columnIndex: 1, columnName: "name", oldValue: "Ada", newValue: "now()", + originalRow: ["2", "Ada", "2026-01-01"] + ) + + let statements = try manager.generateSQL() + + #expect(statements.count == 2) + let marked = try #require(statements.first { $0.sql.contains("\"at\"") }) + #expect(marked.sql.contains("\"at\" = now()")) + #expect(marked.parameters.count == 1) + let literal = try #require(statements.first { $0.sql.contains("\"name\"") }) + #expect(literal.sql.contains("\"name\" = $1")) + #expect(literal.parameters.first as? String == "now()") + } + + @Test("An expression over stored text that reads the same still saves the expression") + func expressionOverEqualStoredText() throws { + let (manager, _) = manager() + manager.recordCellChange( + rowID: row, columnIndex: 2, columnName: "at", oldValue: "now()", newValue: "now()", + originalRow: ["1", "Ada", "now()"], absence: FieldAbsence(), isExpression: true + ) + + let statements = try manager.generateSQL() + + #expect(statements.count == 1) + #expect(statements.first?.sql.contains("\"at\" = now()") == true) + } + + @Test("A new row's marked cell goes into the INSERT as written") + func insertStatement() throws { + let (manager, _) = manager() + manager.recordRowInsertion(rowID: .inserted(UUID()), values: ["1", "now()", "now()"], expressionColumns: [2]) + + let statements = try manager.generateSQL() + + let insert = try #require(statements.first) + #expect(insert.sql.contains("VALUES ($1, $2, now())")) + #expect(insert.parameters.count == 2) + #expect(insert.parameters.last as? String == "now()") + } + + @Test("The host writes both, a driver only what its capabilities say") + func writerSupport() { + let (manager, _) = manager() + #expect(manager.cellWriterSupport == .host) + + manager.pluginDriver = CapabilityStubDriver(capabilities: []) + #expect(manager.cellWriterSupport == .none) + + manager.pluginDriver = CapabilityStubDriver(capabilities: [.cellExpressions]) + #expect(manager.cellWriterSupport == CellWriterSupport(acceptsExpressions: true, acceptsUUIDText: false)) + + manager.pluginDriver = CapabilityStubDriver(capabilities: [.cellExpressions, .uuidCellText]) + #expect(manager.cellWriterSupport == .host) + } +} + +private final class CapabilityStubDriver: PluginDriverStub, @unchecked Sendable { + let capabilities: PluginCapabilities + + init(capabilities: PluginCapabilities) { + self.capabilities = capabilities + } +} diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index 919dfdbe24..7fffb0f7d6 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -174,14 +174,14 @@ struct SQLStatementGeneratorTests { #expect(statements[0].parameters.count == 3) } - @Test("Insert with SQL function is inlined") + @Test("Insert with a cell marked as SQL inlines it") func testInsertWithSQLFunction() throws { let generator = try makeGenerator() let insertedRowData: [RowID: [PluginCellValue]] = [ .existing(0): ["1", "John", "NOW()"] ] let changes: [RowChange] = [ - RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil) + RowChange(rowID: .existing(0), type: .insert, cellChanges: [], originalRow: nil, expressionColumns: [2]) ] let statements = generator.generateStatements( @@ -405,7 +405,7 @@ struct SQLStatementGeneratorTests { #expect(stmt.parameters.count == 1) } - @Test("Update with SQL function value is inlined") + @Test("Update with a cell marked as SQL inlines it") func testUpdateWithSQLFunction() throws { let generator = try makeGenerator() let changes: [RowChange] = [ @@ -413,7 +413,10 @@ struct SQLStatementGeneratorTests { rowID: .existing(0), type: .update, cellChanges: [ - CellChange(columnIndex: 2, columnName: "email", oldValue: "old@example.com", newValue: "CURRENT_TIMESTAMP()") + CellChange( + columnIndex: 2, columnName: "email", oldValue: "old@example.com", + newValue: "CURRENT_TIMESTAMP()", newIsExpression: true + ) ], originalRow: ["1", "John", "old@example.com"] ) diff --git a/TableProTests/Core/Coordinators/RowEditingCoordinatorUUIDKeyTests.swift b/TableProTests/Core/Coordinators/RowEditingCoordinatorUUIDKeyTests.swift new file mode 100644 index 0000000000..40e0894e66 --- /dev/null +++ b/TableProTests/Core/Coordinators/RowEditingCoordinatorUUIDKeyTests.swift @@ -0,0 +1,168 @@ +// +// RowEditingCoordinatorUUIDKeyTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct RowEditingCoordinatorUUIDKeyTests { + private static let columns = ["id", "name"] + + private let fixture = CoordinatorFixture(connection: TestFixtures.makeConnection(type: .postgresql)) + + private func makeCoordinator( + keyType: ColumnType = .text(rawType: "uuid"), + columnDefaults: [String: String?] = [:] + ) -> MainContentCoordinator { + var tab = QueryTab(title: "events", query: "SELECT * FROM events", tabType: .table, tableName: "events") + tab.execution.lastExecutedAt = Date() + tab.display.resultsViewMode = .data + fixture.addTab( + tab, + rows: TableRows.from( + queryRows: [[.text("0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001"), .text("a")]], + columns: Self.columns, + columnTypes: [keyType, .text(rawType: "text")], + columnDefaults: columnDefaults, + hasAuthoritativeSchema: true + ) + ) + fixture.coordinator.changeManager.configureForTable( + tableName: "events", + columns: Self.columns, + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + return fixture.coordinator + } + + private func lastRow(of coordinator: MainContentCoordinator) -> Row? { + guard let tabId = coordinator.tabManager.selectedTab?.id else { return nil } + return coordinator.tabSessionRegistry.tableRows(for: tabId).rows.last + } + + private func isFreshUUID(_ value: PluginCellValue?) -> Bool { + guard let text = value?.asText, UUID(uuidString: text) != nil else { return false } + return text == text.lowercased() && text != "0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001" + } + + /// The reporter's table: `id uuid PRIMARY KEY` with no default, which a new row sent as NULL. + @Test("Add Row on a uuid key with no default stages a fresh UUID in it") + func addRowPrefillsTheUUIDKey() throws { + let coordinator = makeCoordinator() + + coordinator.addNewRow() + + let row = try #require(lastRow(of: coordinator)) + #expect(row.id.isInserted) + #expect(isFreshUUID(row.values[0])) + #expect(row.values[1] == .null) + } + + @Test("Add Row on a uuid key the server fills keeps DEFAULT") + func addRowKeepsTheServerDefault() throws { + let coordinator = makeCoordinator(columnDefaults: ["id": "gen_random_uuid()"]) + + coordinator.addNewRow() + + #expect(try #require(lastRow(of: coordinator)).values[0] == .text("__DEFAULT__")) + } + + @Test("Add Row on an integer key leaves it as before") + func addRowOnAnIntegerKey() throws { + let coordinator = makeCoordinator(keyType: .integer(rawType: "int4")) + + coordinator.addNewRow() + + #expect(try #require(lastRow(of: coordinator)).values[0] == .null) + } + + @Test("Duplicate gives the copy a fresh UUID key") + func duplicatePrefillsTheUUIDKey() throws { + let coordinator = makeCoordinator() + + coordinator.duplicateSelectedRow(index: 0) + + let row = try #require(lastRow(of: coordinator)) + #expect(isFreshUUID(row.values[0])) + #expect(row.values[1] == .text("a")) + } + + // MARK: - Which keys + + private func forms( + types: [ColumnType] = [.text(rawType: "uuid"), .text(rawType: "text")], + rows: [[PluginCellValue]] = [], + foreignKeys: [String: ForeignKeyInfo] = [:], + declaredTypes: [String: String] = [:], + displayFormats: [ValueDisplayFormat?] = [], + writer: CellWriterSupport = .host, + isWritable: Bool = true + ) -> [Int: UUIDCellForm] { + RowEditingCoordinator.uuidKeyForms( + in: TableRows.from( + queryRows: rows, + columns: Self.columns, + columnTypes: types, + columnForeignKeys: foreignKeys, + columnDeclaredTypes: declaredTypes + ), + primaryKeyColumns: ["id"], + displayFormats: displayFormats, + databaseType: .mysql, + writer: writer, + isColumnWritable: { _ in isWritable } + ) + } + + @Test("A uuid key gets the hyphenated form") + func uuidKey() { + #expect(forms() == [0: .hyphenated]) + } + + @Test("A foreign key, a read-only key, or a writer without UUID text gets no UUID") + func keysThatGetNoUUID() { + let foreignKey = ForeignKeyInfo(name: "fk", column: "id", referencedTable: "parent", referencedColumn: "id") + #expect(forms(foreignKeys: ["id": foreignKey]).isEmpty) + #expect(forms(isWritable: false).isEmpty) + #expect(forms(writer: .none).isEmpty) + #expect(forms(types: [.integer(rawType: "INT"), .text(rawType: "text")]).isEmpty) + } + + @Test("A CHAR key shown as UUID takes the compact form when its values are 32 hex digits") + func compactCharKey() { + let types: [ColumnType] = [.text(rawType: "CHAR"), .text(rawType: "VARCHAR")] + #expect(forms(types: types).isEmpty) + #expect(forms(types: types, displayFormats: [.uuid, nil]) == [0: .hyphenated]) + #expect( + forms( + types: types, + rows: [[.text("0b1e7c9a58f44b8e9c551f1c86f0a001"), .text("a")]], + displayFormats: [.uuid, nil] + ) == [0: .compact] + ) + } + + @Test("An empty table's CHAR key shown as UUID takes the compact form when its schema says CHAR(32)") + func compactCharKeyFromSchema() { + let types: [ColumnType] = [.text(rawType: "CHAR"), .text(rawType: "VARCHAR")] + #expect( + forms(types: types, declaredTypes: ["id": "CHAR(32)"], displayFormats: [.uuid, nil]) == [0: .compact] + ) + #expect( + forms(types: types, declaredTypes: ["id": "CHAR(36)"], displayFormats: [.uuid, nil]) == [0: .hyphenated] + ) + } + + @Test("A BINARY(16) key shown as UUID takes 16 bytes") + func binaryKey() { + let types: [ColumnType] = [.blob(rawType: "BINARY(16)"), .text(rawType: "VARCHAR")] + #expect(forms(types: types, displayFormats: [.uuid, nil]) == [0: .bytes]) + } +} diff --git a/TableProTests/Core/DataWrite/RowChangeStatementFactoryExpressionTests.swift b/TableProTests/Core/DataWrite/RowChangeStatementFactoryExpressionTests.swift new file mode 100644 index 0000000000..93c24b8d32 --- /dev/null +++ b/TableProTests/Core/DataWrite/RowChangeStatementFactoryExpressionTests.swift @@ -0,0 +1,219 @@ +// +// RowChangeStatementFactoryExpressionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class ExpressionRecordingDriver: PluginDriverStub, @unchecked Sendable { + let capabilities: PluginCapabilities + private(set) var changes: [PluginRowChange] = [] + + init(capabilities: PluginCapabilities) { + self.capabilities = capabilities + } + + func generateRowWrites( + table: String, + schema: String?, + columns: [String], + primaryKeyColumns: [String], + changes: [PluginRowChange], + insertedRowData: [Int: [PluginCellValue]], + deletedRowIndices: Set, + insertedRowIndices: Set, + context: PluginRowWriteContext + ) throws -> [PluginRowWrite]? { + if !changes.contains(where: { $0.cellChanges.isEmpty && $0.type == .update }) { + self.changes = changes + } + return [PluginRowWrite(statement: "WRITE", rowIndices: changes.map(\.rowIndex))] + } + + func quoteIdentifier(_ name: String) -> String { "[\(name)]" } +} + +@MainActor +struct RowChangeStatementFactoryExpressionTests { + private let columns = ["id", "name", "stamped_at"] + + private func factory( + databaseType: DatabaseType = .postgresql, + driver: (any PluginDatabaseDriver)? = nil + ) -> RowChangeStatementFactory { + RowChangeStatementFactory( + tableName: "events", + schemaName: nil, + columns: columns, + primaryKeyColumns: ["id"], + databaseType: databaseType, + pluginDriver: driver + ) + } + + private func edit(to value: PluginCellValue, isExpression: Bool = true, column: Int = 2) -> RowChange { + RowChange( + rowID: .existing(0), + type: .update, + cellChanges: [ + CellChange( + columnIndex: column, columnName: columns[column], oldValue: "2026-01-01", newValue: value, + newIsExpression: isExpression + ), + ], + originalRow: ["1", "Ada", "2026-01-01"] + ) + } + + private func refusal(_ body: () throws -> Void) -> (kind: RowWriteKind?, reason: String)? { + do { + try body() + return nil + } catch let DataWriteError.changeRefused(_, kind, reason) { + return (kind, reason) + } catch { + Issue.record("Unexpected error: \(error)") + return nil + } + } + + @Test("A marked cell whose text is not the engine's own spelling is refused, naming the column") + func nonCanonicalTextIsRefused() { + let refused = refusal { _ = try factory().statements(for: [edit(to: "NOW()")]) } + #expect(refused?.kind == .update) + #expect(refused?.reason.contains("stamped_at") == true) + + let injected = refusal { _ = try factory().statements(for: [edit(to: "now(); DROP TABLE events")]) } + #expect(injected != nil) + } + + @Test("A new row's marked cell is checked the same way") + func nonCanonicalInsertIsRefused() { + let rowID = RowID.inserted(UUID()) + let change = RowChange(rowID: rowID, type: .insert, expressionColumns: [2]) + let refused = refusal { + _ = try factory().statements( + for: [change], insertedRowData: [rowID: ["1", "Ada", "1 + 1"]], insertedRowIDs: [rowID] + ) + } + #expect(refused?.kind == .insert) + } + + @Test("An unmarked change in a column another change marks is checked, since a driver reads the mark by column") + func unmarkedChangeInMarkedColumnIsRefused() { + let change = RowChange( + rowID: .existing(0), + type: .update, + cellChanges: [ + CellChange( + columnIndex: 2, columnName: "stamped_at", oldValue: "2026-01-01", newValue: "now()", + newIsExpression: true + ), + CellChange(columnIndex: 2, columnName: "stamped_at", oldValue: "now()", newValue: "1); DROP TABLE x; --"), + ], + originalRow: ["1", "Ada", "2026-01-01"] + ) + let driver = ExpressionRecordingDriver(capabilities: [.cellExpressions]) + let refused = refusal { _ = try factory(databaseType: .postgresql, driver: driver).statements(for: [change]) } + #expect(refused?.kind == .update) + #expect(driver.changes.isEmpty) + } + + @Test("Every cell change of a new row's marked column is checked, not only the first") + func everyInsertCellChangeInMarkedColumnIsChecked() { + let rowID = RowID.inserted(UUID()) + let change = RowChange( + rowID: rowID, + type: .insert, + cellChanges: [ + CellChange(columnIndex: 2, columnName: "stamped_at", oldValue: .null, newValue: "now()", newIsExpression: true), + CellChange(columnIndex: 2, columnName: "stamped_at", oldValue: "now()", newValue: "1 + 1"), + ], + expressionColumns: [2] + ) + let refused = refusal { _ = try factory().statements(for: [change], insertedRowIDs: [rowID]) } + #expect(refused?.kind == .insert) + } + + @Test("The engine's own spelling goes through") + func canonicalTextIsWritten() throws { + let statements = try factory().statements(for: [edit(to: "now()")]) + #expect(statements.first?.sql.contains("\"stamped_at\" = now()") == true) + } + + @Test("A driver that writes its own statements and does not read the mark is not handed one") + func pluginWithoutCapabilityIsRefused() { + let driver = ExpressionRecordingDriver(capabilities: []) + let refused = refusal { + _ = try factory(databaseType: .mssql, driver: driver).statements(for: [edit(to: "GETDATE()")]) + } + #expect(refused?.kind == .update) + #expect(driver.changes.isEmpty) + } + + @Test("A driver that reads the mark is handed the marked columns") + func pluginWithCapabilityGetsExpressionColumns() throws { + let driver = ExpressionRecordingDriver(capabilities: [.cellExpressions]) + let rowID = RowID.inserted(UUID()) + _ = try factory(databaseType: .mssql, driver: driver).statements( + for: [ + edit(to: "GETDATE()"), + RowChange(rowID: rowID, type: .insert, expressionColumns: [2], sequence: 1), + ], + insertedRowData: [rowID: ["2", "Grace", "SYSDATETIME()"]], + insertedRowIDs: [rowID] + ) + + #expect(driver.changes.count == 2) + #expect(driver.changes.first { $0.type == .update }?.expressionColumns == [2]) + #expect(driver.changes.first { $0.type == .insert }?.expressionColumns == [2]) + } + + @Test("A change with no marked cell hands the driver nil") + func noMarksIsNil() throws { + let driver = ExpressionRecordingDriver(capabilities: [.cellExpressions]) + _ = try factory(databaseType: .mssql, driver: driver).statements( + for: [edit(to: "Grace", isExpression: false, column: 1)] + ) + #expect(driver.changes.first?.expressionColumns == nil) + } + + @Test("A text value that reads like a function reaches a driver as text, whatever it declares") + func unmarkedTextNeedsNoCapability() throws { + let driver = ExpressionRecordingDriver(capabilities: []) + _ = try factory(databaseType: .mssql, driver: driver).statements( + for: [edit(to: "GETDATE()", isExpression: false, column: 1)] + ) + #expect(driver.changes.count == 1) + } + + @Test("SQLite refuses DEFAULT on a saved row, naming the column") + func sqliteDefaultUpdateIsRefused() { + let change = edit(to: .text(PluginCellValue.defaultMarkerText), isExpression: false, column: 1) + let refused = refusal { _ = try factory(databaseType: .sqlite).statements(for: [change]) } + #expect(refused?.kind == .update) + #expect(refused?.reason.contains("name") == true) + } + + @Test("A new SQLite row still leaves a defaulted column out") + func sqliteDefaultInsertIsWritten() throws { + let rowID = RowID.inserted(UUID()) + let statements = try factory(databaseType: .sqlite).statements( + for: [RowChange(rowID: rowID, type: .insert)], + insertedRowData: [rowID: ["1", .text(PluginCellValue.defaultMarkerText), "2026-01-01"]], + insertedRowIDs: [rowID] + ) + #expect(statements.count == 1) + #expect(statements.first?.parameters.count == 2) + } + + @Test("PostgreSQL takes DEFAULT on a saved row") + func postgresDefaultUpdate() throws { + let change = edit(to: .text(PluginCellValue.defaultMarkerText), isExpression: false, column: 1) + let statements = try factory().statements(for: [change]) + #expect(statements.first?.sql.contains("\"name\" = DEFAULT") == true) + } +} diff --git a/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift b/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift index 4da0c05f76..17f6ddb827 100644 --- a/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift +++ b/TableProTests/Core/DataWrite/RowWriteOperationBuilderTests.swift @@ -34,12 +34,14 @@ struct RowWriteOperationBuilderTests { ) } - private func cellEdit(column: String, index: Int, from old: PluginCellValue, to new: PluginCellValue) -> RowChange { + private func cellEdit( + column: String, index: Int, from old: PluginCellValue, to new: PluginCellValue, isExpression: Bool = false + ) -> RowChange { RowChange( rowID: .existing(0), type: .update, cellChanges: [ - CellChange(columnIndex: index, columnName: column, oldValue: old, newValue: new), + CellChange(columnIndex: index, columnName: column, oldValue: old, newValue: new, newIsExpression: isExpression), ], originalRow: ["7", "Ada", "2026-01-01"] ) @@ -68,7 +70,9 @@ struct RowWriteOperationBuilderTests { @Test("A value the server computes leaves no after-image, so the row is refused") func serverComputedValueIsRefused() { - let now = operations(changes: [cellEdit(column: "updated_at", index: 2, from: "2026-01-01", to: "NOW()")]) + let now = operations( + changes: [cellEdit(column: "updated_at", index: 2, from: "2026-01-01", to: "NOW()", isExpression: true)] + ) #expect(now.first?.refusal == .serverComputedValue) let marker = operations( @@ -136,6 +140,40 @@ struct RowWriteOperationBuilderTests { #expect(withNull.first?.refusal == .serverAssignedKey) } + @Test("Text that reads like a function but is not marked as SQL is a value, so the edit stays reversible") + func unmarkedFunctionTextIsAValue() { + let result = operations(changes: [cellEdit(column: "name", index: 1, from: "Ada", to: "NOW()")]) + #expect(result.first?.refusal == nil) + #expect(result.first?.postImage == ["7", "NOW()", "2026-01-01"]) + } + + @Test("A new row whose key is SQL gets its key from the server") + func expressionKeyIsServerAssigned() { + let result = operations( + changes: [RowChange(rowID: .existing(0), type: .insert, expressionColumns: [0])], + insertedRowData: [.existing(0): ["now()", "Ada", "2026-01-01"]], + insertedRowIDs: [.existing(0)] + ) + #expect(result.first?.refusal == .serverAssignedKey) + } + + @Test("A new row with SQL in another column is server computed") + func expressionValueIsServerComputed() { + let result = operations( + changes: [RowChange(rowID: .existing(0), type: .insert, expressionColumns: [2])], + insertedRowData: [.existing(0): ["7", "Ada", "now()"]], + insertedRowIDs: [.existing(0)] + ) + #expect(result.first?.refusal == .serverComputedValue) + + let literal = operations( + changes: [RowChange(rowID: .existing(0), type: .insert)], + insertedRowData: [.existing(0): ["7", "Ada", "now()"]], + insertedRowIDs: [.existing(0)] + ) + #expect(literal.first?.refusal == nil) + } + @Test("An insert carrying its own key is reversible") func userSuppliedKeyIsReversible() { let result = operations( diff --git a/TableProTests/Core/Database/SQLEscapingTests.swift b/TableProTests/Core/Database/SQLEscapingTests.swift index 6f400f62e6..497c056c2f 100644 --- a/TableProTests/Core/Database/SQLEscapingTests.swift +++ b/TableProTests/Core/Database/SQLEscapingTests.swift @@ -172,69 +172,4 @@ struct SQLEscapingTests { let result = SQLEscaping.escapeLikeWildcards(input) #expect(result == "\\\\\\%") } - - // MARK: - isTemporalFunction Tests - - @Test("NOW() is recognized as temporal function") - func nowIsTemporalFunction() { - #expect(SQLEscaping.isTemporalFunction("NOW()") == true) - } - - @Test("CURRENT_TIMESTAMP without parens is recognized") - func currentTimestampNoParens() { - #expect(SQLEscaping.isTemporalFunction("CURRENT_TIMESTAMP") == true) - } - - @Test("CURRENT_TIMESTAMP() with parens is recognized") - func currentTimestampWithParens() { - #expect(SQLEscaping.isTemporalFunction("CURRENT_TIMESTAMP()") == true) - } - - @Test("Case-insensitive matching") - func caseInsensitive() { - #expect(SQLEscaping.isTemporalFunction("now()") == true) - #expect(SQLEscaping.isTemporalFunction("Now()") == true) - #expect(SQLEscaping.isTemporalFunction("cUrDaTe()") == true) - } - - @Test("Leading/trailing whitespace is trimmed") - func whitespaceIsTrimmed() { - #expect(SQLEscaping.isTemporalFunction(" NOW() ") == true) - } - - @Test("Non-temporal functions are rejected") - func nonTemporalRejected() { - #expect(SQLEscaping.isTemporalFunction("COUNT(*)") == false) - #expect(SQLEscaping.isTemporalFunction("UPPER(name)") == false) - #expect(SQLEscaping.isTemporalFunction("hello") == false) - } - - @Test("Empty string is rejected") - func emptyStringRejected() { - #expect(SQLEscaping.isTemporalFunction("") == false) - } - - @Test("All 18 known temporal functions are recognized") - func allKnownFunctions() { - for function in SQLEscaping.temporalFunctionExpressions { - #expect(SQLEscaping.isTemporalFunction(function) == true) - } - } - - @Test("CURDATE, CURTIME, UTC variants recognized") - func dateTimeVariants() { - #expect(SQLEscaping.isTemporalFunction("CURDATE()") == true) - #expect(SQLEscaping.isTemporalFunction("CURTIME()") == true) - #expect(SQLEscaping.isTemporalFunction("UTC_TIMESTAMP()") == true) - #expect(SQLEscaping.isTemporalFunction("UTC_DATE()") == true) - #expect(SQLEscaping.isTemporalFunction("UTC_TIME()") == true) - } - - @Test("LOCALTIME and LOCALTIMESTAMP variants recognized") - func localVariants() { - #expect(SQLEscaping.isTemporalFunction("LOCALTIME") == true) - #expect(SQLEscaping.isTemporalFunction("LOCALTIME()") == true) - #expect(SQLEscaping.isTemporalFunction("LOCALTIMESTAMP") == true) - #expect(SQLEscaping.isTemporalFunction("LOCALTIMESTAMP()") == true) - } } diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 7077c273d1..6b9c08e830 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -1200,3 +1200,131 @@ struct MainMenuBuilderTests { } } } + +@MainActor +struct EditSetValueMenuTests { + private struct Leaf { + let command: SetValueCommand + let action: Selector + let shortcut: ShortcutAction + } + + private static let leaves: [Leaf] = [ + Leaf(command: .empty, action: #selector(SetValueCommandResponding.setValueEmpty(_:)), shortcut: .setValueEmpty), + Leaf(command: .null, action: #selector(SetValueCommandResponding.setValueNull(_:)), shortcut: .setValueNull), + Leaf( + command: .defaultValue, + action: #selector(SetValueCommandResponding.setValueDefault(_:)), + shortcut: .setValueDefault + ), + Leaf( + command: .uuid(.v4), + action: #selector(SetValueCommandResponding.generateUUIDv4(_:)), + shortcut: .generateUUIDv4 + ), + Leaf( + command: .uuid(.v7), + action: #selector(SetValueCommandResponding.generateUUIDv7(_:)), + shortcut: .generateUUIDv7 + ), + ] + + private func editMenu() throws -> NSMenu { + try #require(EditMenuBuilder.build(keyboard: KeyboardSettings()).submenu) + } + + private func setValueItem(in edit: NSMenu) throws -> NSMenuItem { + try #require(edit.items.first { $0.title == String(localized: "Set Value") }) + } + + @Test("Set Value sits between Duplicate Row and Edit Document, outside Edit > Data") + func placement() throws { + let edit = try editMenu() + let titles = edit.items.map(\.title) + let duplicate = try #require(titles.firstIndex(of: String(localized: "Duplicate Row"))) + let setValue = try #require(titles.firstIndex(of: String(localized: "Set Value"))) + let editDocument = try #require(titles.firstIndex(of: String(localized: "Edit Document…"))) + #expect(duplicate + 1 == setValue) + #expect(setValue + 1 == editDocument) + + let item = edit.items[setValue] + #expect(item.submenu != nil) + #expect(item.identifier != EditDataMenuGroup.submenuIdentifier) + #expect(item.identifier != EditDataMenuGroup.separatorIdentifier) + } + + @Test("Set Value says what the grid's context menu says, in its order") + func leavesMatchTheContextMenu() throws { + let setValue = try setValueItem(in: editMenu()) + let items = try #require(setValue.submenu).items + let separators = items.map { $0.isSeparatorItem } + #expect(separators == [false, false, false, true, false, false]) + + let commands = items.filter { !$0.isSeparatorItem } + let titles = commands.map { $0.title } + let actions = commands.map { $0.action } + let identifiers = commands.map { $0.identifier } + let expectedTitles = Self.leaves.map { SetValueOptions.title(for: $0.command) } + let expectedActions: [Selector?] = Self.leaves.map { $0.action } + let expectedIdentifiers: [NSUserInterfaceItemIdentifier?] = Self.leaves.map { + MenuItemFactory.identifier(for: $0.shortcut) + } + #expect(titles == expectedTitles) + #expect(actions == expectedActions) + #expect(identifiers == expectedIdentifiers) + for item in commands { + #expect(item.target == nil, "\(item.title) bypasses the responder chain") + #expect(item.keyEquivalent.isEmpty, "\(item.title) ships with a key") + } + } + + @Test("Only the data grid answers Set Value, so the items dim wherever else focus is") + func onlyTheGridAnswers() { + for leaf in Self.leaves { + let name = NSStringFromSelector(leaf.action) + #expect(KeyHandlingTableView.instancesRespond(to: leaf.action), "\(name) reaches no grid") + #expect(!MainSplitViewController.instancesRespond(to: leaf.action), "\(name) answered by the window") + #expect(!DataFileSplitViewController.instancesRespond(to: leaf.action), "\(name) answered by the window") + } + } + + @Test("Showing or hiding Edit > Data leaves Set Value in place") + func dataGroupLeavesSetValueVisible() throws { + let edit = try editMenu() + let setValue = try setValueItem(in: edit) + let children = setValue.submenu?.items ?? [] + #expect(children.count == 6) + for shown in [true, false] { + EditDataMenuGroup.setShown(shown, in: edit) + #expect(!setValue.isHidden, "data group shown: \(shown)") + #expect(children.allSatisfy { !$0.isHidden }, "data group shown: \(shown)") + } + } + + @Test("Each Set Value command is a Data Grid row in Settings with no default key") + func shortcutRowsShipUnbound() { + for leaf in Self.leaves { + let action = leaf.shortcut + #expect(action.category == .dataGrid, "\(action.rawValue)") + #expect(action.context == .dataGrid, "\(action.rawValue)") + #expect(KeyboardSettings.defaultShortcuts[action] == nil, "\(action.rawValue)") + #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") + #expect(!action.displayName.isEmpty, "\(action.rawValue)") + } + #expect(ShortcutAction.generateUUIDv4.displayName == SetValueOptions.title(for: .uuid(.v4))) + #expect(ShortcutAction.generateUUIDv7.displayName == SetValueOptions.title(for: .uuid(.v7))) + } + + @Test("A key the user gives a Set Value command reaches its menu item") + func reboundSetValueCommandApplies() { + let menu = buildMenu() + var keyboard = KeyboardSettings() + keyboard.setShortcut(.character("u", command: true, option: true, control: true), for: .generateUUIDv4) + MainMenuKeyEquivalentSync.apply(keyboard: keyboard, to: menu) + + let item = flatten(menu).first { $0.identifier == MenuItemFactory.identifier(for: .generateUUIDv4) } + #expect(item?.title == SetValueOptions.title(for: .uuid(.v4))) + #expect(item?.keyEquivalent == "u") + #expect(item?.keyEquivalentModifierMask == [.command, .option, .control]) + } +} diff --git a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift index 144af01c39..4406d466ca 100644 --- a/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift +++ b/TableProTests/Core/Plugins/MySQLVariantSupportTests.swift @@ -118,15 +118,24 @@ struct MySQLVariantSupportTests { #expect(PluginManager.shared.rowMatchTextTypePrefixes(for: .sqlite).isEmpty) } - @Test("The inspector's function menu offers only what each engine has") + /// The inspector's SQL Functions submenu reads the vocabulary for the column's kind, so `UUID()` and every + /// function the column cannot hold are gone, and Databend keeps only what it runs. + @Test("The inspector's function menu offers only what each engine runs on the column") func functionMenu() { - let tidb = SQLFunctionProvider.functions(for: .tidb).map(\.expression) - #expect(tidb.contains("CURDATE()")) - #expect(tidb.contains("UTC_TIMESTAMP()")) - let oceanbase = SQLFunctionProvider.functions(for: .oceanbase).map(\.expression) - #expect(oceanbase.contains("CURDATE()")) - let databend = SQLFunctionProvider.functions(for: .databend).map(\.expression) - #expect(databend == ["NOW()", "CURRENT_TIMESTAMP()", "UUID()"]) + func functions(_ type: DatabaseType, _ columnType: ColumnType) -> [String] { + CellExpressionVocabulary.expressions(for: type, columnType: columnType).map(\.sql) + } + let date = ColumnType.date(rawType: "DATE") + let timestamp = ColumnType.timestamp(rawType: "TIMESTAMP") + #expect(functions(.tidb, date).contains("CURDATE()")) + #expect(functions(.tidb, timestamp).contains("UTC_TIMESTAMP()")) + #expect(functions(.oceanbase, date).contains("CURDATE()")) + #expect(functions(.databend, timestamp) == ["NOW()"]) + #expect(functions(.databend, date) == ["TODAY()"]) + for type in [DatabaseType.mysql, .mariadb, .tidb, .oceanbase, .databend] { + #expect(!functions(type, timestamp).contains("UUID()")) + #expect(functions(type, .text(rawType: "CHAR(36)")).isEmpty) + } } @Test("A double-quoted name is a string on TiDB and an identifier on Databend") diff --git a/TableProTests/Core/Services/CellValues/CellEntryTests.swift b/TableProTests/Core/Services/CellValues/CellEntryTests.swift new file mode 100644 index 0000000000..2986be5a37 --- /dev/null +++ b/TableProTests/Core/Services/CellValues/CellEntryTests.swift @@ -0,0 +1,64 @@ +// +// CellEntryTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct CellEntryTests { + private static let timestamp = ColumnType.timestamp(rawType: "timestamptz") + + private static func staged( + _ text: String, + _ columnType: ColumnType?, + on databaseType: DatabaseType = .postgresql, + writer: CellWriterSupport = .host + ) -> PluginCellValue? { + CellEntry.typed(text, columnType: columnType, databaseType: databaseType, writer: writer)?.value + } + + @Test("A function typed in any case and spacing stages the vocabulary's own spelling as SQL") + func typedFunctionIsCanonical() throws { + let entry = try #require( + CellEntry.typed(" NOW() ", columnType: Self.timestamp, databaseType: .postgresql, writer: .host) + ) + + #expect(entry.value == .text("now()")) + #expect(entry.isExpression) + } + + @Test("An alias stages the canonical spelling it stands for") + func aliasIsCanonical() { + #expect(Self.staged("CURRENT_DATE", .date(rawType: "DATE"), on: .mysql) == .text("CURDATE()")) + } + + @Test("The same text on a column of another kind is a literal") + func otherKindIsLiteral() { + #expect(Self.staged("now()", .text(rawType: "text")) == nil) + #expect(Self.staged("CURRENT_DATE", Self.timestamp) == nil) + #expect(Self.staged("now()", nil) == nil) + } + + @Test("A writer that does not take SQL in a cell gets nothing, so its own handling of the text stays") + func writerWithoutExpressions() { + let uuidOnly = CellWriterSupport(acceptsExpressions: false, acceptsUUIDText: true) + + #expect(Self.staged("now()", Self.timestamp, writer: .none) == nil) + #expect(Self.staged("now()", Self.timestamp, writer: uuidOnly) == nil) + } + + @Test("An engine the vocabulary does not list takes no function") + func unknownEngine() { + #expect(Self.staged("now()", Self.timestamp, on: DatabaseType(rawValue: "SomePluginEngine")) == nil) + } + + @Test("Text that only contains a function name is a literal") + func partialMatchIsLiteral() { + #expect(Self.staged("now() + 1", Self.timestamp) == nil) + #expect(Self.staged("", Self.timestamp) == nil) + } +} diff --git a/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift b/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift new file mode 100644 index 0000000000..bfcf0bb9c7 --- /dev/null +++ b/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift @@ -0,0 +1,144 @@ +// +// CellExpressionVocabularyTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +struct CellExpressionVocabularyTests { + private static func sql(_ databaseType: DatabaseType, _ columnType: ColumnType) -> [String] { + CellExpressionVocabulary.expressions(for: databaseType, columnType: columnType).map(\.sql) + } + + @Test("A column's kind comes from its type, time-only and timeuuid included") + func kinds() { + #expect(CellExpressionKind(columnType: .timestamp(rawType: "TIMESTAMP")) == .timestamp) + #expect(CellExpressionKind(columnType: .datetime(rawType: "DATETIME")) == .datetime) + #expect(CellExpressionKind(columnType: .date(rawType: "DATE")) == .date) + #expect(CellExpressionKind(columnType: .timestamp(rawType: "TIME")) == .time) + #expect(CellExpressionKind(columnType: .datetime(rawType: "time(7)")) == .time) + #expect(CellExpressionKind(columnType: .text(rawType: "timeuuid")) == .timeUUID) + #expect(CellExpressionKind(columnType: .text(rawType: "uuid")) == nil) + #expect(CellExpressionKind(columnType: .text(rawType: "VARCHAR(20)")) == nil) + } + + @Test("PostgreSQL offers only spellings it parses, per column kind") + func postgreSQL() { + #expect(Self.sql(.postgresql, .timestamp(rawType: "timestamptz")) == ["now()", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP"]) + #expect(Self.sql(.postgresql, .date(rawType: "date")) == ["CURRENT_DATE"]) + #expect(Self.sql(.postgresql, .timestamp(rawType: "time")) == ["CURRENT_TIME", "LOCALTIME"]) + #expect(Self.sql(.pglite, .date(rawType: "date")) == ["CURRENT_DATE"]) + } + + @Test("A text or UUID column takes no expression") + func nonTemporalColumns() { + #expect(Self.sql(.postgresql, .text(rawType: "varchar")).isEmpty) + #expect(Self.sql(.postgresql, .text(rawType: "uuid")).isEmpty) + #expect(CellExpressionVocabulary.expressions(for: .postgresql, columnType: nil).isEmpty) + } + + @Test("An engine with no vocabulary gets nothing", arguments: [ + DatabaseType.mongodb, .redis, DatabaseType(rawValue: "SomeFuturePlugin") + ]) + func unknownEngines(databaseType: DatabaseType) { + #expect(CellExpressionVocabulary.expressions(for: databaseType).isEmpty) + #expect(!CellExpressionVocabulary.isCanonical("NOW()", for: databaseType)) + } + + @Test("Typed text matches case-insensitively and trimmed, and resolves to the canonical spelling") + func typedMatch() { + let match = CellExpressionVocabulary.expression( + typed: " NOW() ", databaseType: .postgresql, columnType: .timestamp(rawType: "timestamp") + ) + #expect(match?.sql == "now()") + } + + @Test("A spelling that saved before keeps working on MySQL through its alias", arguments: [ + ("CURRENT_DATE", ColumnType.date(rawType: "DATE"), "CURDATE()"), + ("current_date()", .date(rawType: "DATE"), "CURDATE()"), + ("CURRENT_TIME", .timestamp(rawType: "TIME"), "CURTIME()"), + ("LOCALTIMESTAMP", .datetime(rawType: "DATETIME"), "NOW()"), + ("sysdate()", .timestamp(rawType: "TIMESTAMP"), "SYSDATE()"), + ("CURRENT_TIMESTAMP()", .datetime(rawType: "DATETIME"), "CURRENT_TIMESTAMP"), + ]) + func mySQLAliases(typed: String, columnType: ColumnType, canonical: String) { + let match = CellExpressionVocabulary.expression(typed: typed, databaseType: .mysql, columnType: columnType) + #expect(match?.sql == canonical) + } + + @Test("A spelling the engine rejects is not an expression there") + func rejectedSpellings() { + #expect(CellExpressionVocabulary.expression( + typed: "CURRENT_TIMESTAMP()", databaseType: .postgresql, columnType: .timestamp(rawType: "timestamp") + ) == nil) + #expect(CellExpressionVocabulary.expression( + typed: "NOW()", databaseType: .sqlite, columnType: .datetime(rawType: "DATETIME") + ) == nil) + #expect(CellExpressionVocabulary.expression( + typed: "UNIX_TIMESTAMP()", databaseType: .mysql, columnType: .timestamp(rawType: "TIMESTAMP") + ) == nil) + } + + @Test("Typed text must fit the column's kind") + func kindGate() { + #expect(CellExpressionVocabulary.expression( + typed: "CURRENT_TIME", databaseType: .sqlite, columnType: .datetime(rawType: "DATETIME") + ) == nil) + #expect(CellExpressionVocabulary.expression( + typed: "now()", databaseType: .postgresql, columnType: .text(rawType: "varchar") + ) == nil) + } + + @Test("ClickHouse is spelled in lowercase, the only case today() resolves in") + func clickHouse() { + #expect(Self.sql(.clickhouse, .datetime(rawType: "DateTime")) == ["now()"]) + #expect(Self.sql(.clickhouse, .date(rawType: "Date")) == ["today()"]) + #expect(CellExpressionVocabulary.expression( + typed: "CURDATE()", databaseType: .clickhouse, columnType: .date(rawType: "Date") + )?.sql == "today()") + } + + @Test("SQL Server fills a date or time column from the current moment too") + func sqlServer() { + #expect(Self.sql(.mssql, .date(rawType: "date")) == ["SYSDATETIME()", "GETDATE()"]) + #expect(Self.sql(.mssql, .timestamp(rawType: "time")) == ["SYSDATETIME()", "GETDATE()"]) + #expect(Self.sql(.mssql, .datetime(rawType: "datetime2")).count == 5) + } + + @Test("Cassandra fills a timeuuid with now()") + func cassandraTimeUUID() { + #expect(Self.sql(.cassandra, .text(rawType: "timeuuid")) == ["now()", "currentTimeUUID()"]) + #expect(Self.sql(.scylladb, .timestamp(rawType: "timestamp")) == ["currentTimestamp()"]) + #expect(CellExpressionVocabulary.expression( + typed: "toTimestamp(now())", databaseType: .cassandra, columnType: .timestamp(rawType: "timestamp") + )?.sql == "currentTimestamp()") + } + + @Test("BigQuery has its own function for each of timestamp and datetime") + func bigQuery() { + #expect(Self.sql(.bigQuery, .timestamp(rawType: "TIMESTAMP")) == ["CURRENT_TIMESTAMP()"]) + #expect(Self.sql(.bigQuery, .datetime(rawType: "DATETIME")) == ["CURRENT_DATETIME()"]) + } + + @Test("Canonical means exactly the vocabulary's spelling") + func canonical() { + #expect(CellExpressionVocabulary.isCanonical("now()", for: .postgresql)) + #expect(!CellExpressionVocabulary.isCanonical("NOW()", for: .postgresql)) + #expect(!CellExpressionVocabulary.isCanonical("now(); DROP TABLE t", for: .postgresql)) + #expect(CellExpressionVocabulary.isCanonical("CURDATE()", for: .mariadb)) + #expect(!CellExpressionVocabulary.isCanonical("CURRENT_DATE", for: .mariadb)) + } + + @Test("UPDATE takes DEFAULT except where its grammar has none", arguments: [ + (DatabaseType.sqlite, false), (.libsql, false), (.turso, false), (.cloudflareD1, false), + (.clickhouse, false), (.databend, false), (.snowflake, false), (.trino, false), + (.cassandra, false), (.scylladb, false), + (.postgresql, true), (.mysql, true), (.mssql, true), (.oracle, true), (.duckdb, true), + ]) + func updateTakesDefault(databaseType: DatabaseType, expected: Bool) { + #expect(CellExpressionVocabulary.updateTakesDefault(databaseType) == expected) + } +} diff --git a/TableProTests/Core/Services/CellValues/SetValueOptionsTests.swift b/TableProTests/Core/Services/CellValues/SetValueOptionsTests.swift new file mode 100644 index 0000000000..d89e072150 --- /dev/null +++ b/TableProTests/Core/Services/CellValues/SetValueOptionsTests.swift @@ -0,0 +1,133 @@ +// +// SetValueOptionsTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +struct SetValueOptionsTests { + private static func context( + columnType: ColumnType?, + isNullable: Bool = true, + serverAssignsValue: Bool = false, + isForeignKey: Bool = false, + isWritable: Bool = true, + displayFormat: ValueDisplayFormat? = nil, + databaseType: DatabaseType = .postgresql, + rowIsInserted: Bool = false, + writer: CellWriterSupport = .host + ) -> SetValueContext { + SetValueContext( + columnType: columnType, + isNullable: isNullable, + serverAssignsValue: serverAssignsValue, + isForeignKey: isForeignKey, + isWritable: isWritable, + displayFormat: displayFormat, + databaseType: databaseType, + rowIsInserted: rowIsInserted, + writer: writer + ) + } + + @Test("A PostgreSQL uuid key with no default, written by the host, offers both UUIDs and nothing else") + func reportersUUIDKey() { + let context = Self.context(columnType: .text(rawType: "uuid"), isNullable: false) + + #expect(SetValueOptions.commands(for: context) == [.uuid(.v4), .uuid(.v7)]) + #expect(SetValueOptions.uuidForm(for: context) == .hyphenated) + } + + @Test("A driver built before the capabilities existed gets no UUIDs and no expressions") + func oldPluginWriter() { + let uuid = Self.context(columnType: .text(rawType: "UUID"), databaseType: .mongodb, writer: .none) + #expect(SetValueOptions.commands(for: uuid) == [.null]) + #expect(SetValueOptions.uuidForm(for: uuid) == nil) + + let date = Self.context(columnType: .date(rawType: "DATE"), databaseType: .oracle, writer: .none) + #expect(SetValueOptions.commands(for: date) == [.null]) + } + + @Test("A driver that declares the capabilities gets them") + func capablePluginWriter() { + let writer = CellWriterSupport(acceptsExpressions: true, acceptsUUIDText: true) + let date = Self.context(columnType: .date(rawType: "DATE"), databaseType: .oracle, writer: writer) + let titles = SetValueOptions.commands(for: date).map(SetValueOptions.title(for:)) + #expect(titles == ["NULL", "SYSTIMESTAMP", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP", "SYSDATE", "CURRENT_DATE"]) + } + + @Test("The order is Empty, NULL, Default, expressions, then the UUIDs") + func order() { + let context = Self.context( + columnType: .timestamp(rawType: "timestamptz"), serverAssignsValue: true, rowIsInserted: true + ) + let titles = SetValueOptions.commands(for: context).map(SetValueOptions.title(for:)) + #expect(titles == ["NULL", "Default", "now()", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP"]) + + let text = Self.context(columnType: .text(rawType: "varchar"), serverAssignsValue: true) + #expect(SetValueOptions.commands(for: text) == [.empty, .null, .defaultValue]) + } + + @Test("Nothing at all for a column the app may not write") + func notWritable() { + let context = Self.context(columnType: .text(rawType: "uuid"), serverAssignsValue: true, isWritable: false) + #expect(SetValueOptions.commands(for: context).isEmpty) + #expect(SetValueOptions.uuidForm(for: context) == nil) + } + + @Test("A foreign key never offers a new UUID") + func foreignKey() { + let context = Self.context(columnType: .text(rawType: "uuid"), isForeignKey: true) + #expect(SetValueOptions.commands(for: context) == [.null]) + } + + @Test("timeuuid offers its server functions where the writer takes them, never a client UUID") + func timeUUID() { + let context = Self.context(columnType: .text(rawType: "timeuuid"), isNullable: false, databaseType: .cassandra) + let titles = SetValueOptions.commands(for: context).map(SetValueOptions.title(for:)) + #expect(titles == ["now()", "currentTimeUUID()"]) + } + + @Test("Default on a saved row only where UPDATE takes DEFAULT") + func defaultOnSavedRow() { + let saved = Self.context(columnType: .text(rawType: "TEXT"), serverAssignsValue: true, databaseType: .sqlite) + #expect(!SetValueOptions.commands(for: saved).contains(.defaultValue)) + + let inserted = Self.context( + columnType: .text(rawType: "TEXT"), serverAssignsValue: true, databaseType: .sqlite, rowIsInserted: true + ) + #expect(SetValueOptions.commands(for: inserted).contains(.defaultValue)) + + let postgres = Self.context(columnType: .text(rawType: "text"), serverAssignsValue: true) + #expect(SetValueOptions.commands(for: postgres).contains(.defaultValue)) + } + + @Test("A text column the UUID format marks takes the form its values have") + func formatOnlyColumn() { + var context = Self.context(columnType: .text(rawType: "CHAR"), displayFormat: .uuid, databaseType: .mysql) + #expect(SetValueOptions.uuidForm(for: context) == .hyphenated) + + context.sampleText = "0192f3a47b2c7d1e8f001a2b3c4d5e6f" + #expect(SetValueOptions.uuidForm(for: context) == .compact) + #expect(Array(SetValueOptions.commands(for: context).suffix(2)) == [.uuid(.v4), .uuid(.v7)]) + } + + @Test("An empty CHAR(32) column the UUID format marks takes the compact form from its schema") + func formatOnlyColumnWithoutValues() { + var context = Self.context(columnType: .text(rawType: "CHAR"), displayFormat: .uuid, databaseType: .mysql) + context.declaredType = "CHAR(32)" + #expect(SetValueOptions.uuidForm(for: context) == .compact) + } + + @Test("Titles", arguments: [ + (SetValueCommand.empty, "Empty"), (.null, "NULL"), (.defaultValue, "Default"), + (.uuid(.v4), "Generate UUID"), (.uuid(.v7), "Generate UUIDv7"), + (.expression(CellExpression("now()", kinds: [.timestamp])), "now()"), + ]) + func titles(command: SetValueCommand, title: String) { + #expect(SetValueOptions.title(for: command) == title) + } +} diff --git a/TableProTests/Core/Services/CellValues/UUIDCellFormTests.swift b/TableProTests/Core/Services/CellValues/UUIDCellFormTests.swift new file mode 100644 index 0000000000..f8caa6aff2 --- /dev/null +++ b/TableProTests/Core/Services/CellValues/UUIDCellFormTests.swift @@ -0,0 +1,97 @@ +// +// UUIDCellFormTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct UUIDCellFormTests { + private static let sample = UUID(uuidString: "0192F3A4-7B2C-7D1E-8F00-1A2B3C4D5E6F") ?? UUID() + + @Test( + "A column typed as a UUID takes the canonical text", + arguments: ["uuid", "UUID", "Nullable(UUID)", "uniqueidentifier"] + ) + func nativeUUIDTypes(rawType: String) { + let form = UUIDCellForm(columnType: .text(rawType: rawType), displayFormat: nil) + #expect(form == .hyphenated) + #expect(form?.value(for: Self.sample) == .text("0192f3a4-7b2c-7d1e-8f00-1a2b3c4d5e6f")) + } + + @Test("timeuuid takes no generated value") + func timeUUIDIsExcluded() { + #expect(UUIDCellForm(columnType: .text(rawType: "timeuuid"), displayFormat: nil) == nil) + #expect(UUIDCellForm(columnType: .text(rawType: "timeuuid"), displayFormat: .uuid) == nil) + } + + @Test("A text column holds UUIDs only when its display format says so") + func textNeedsTheDisplayFormat() { + #expect(UUIDCellForm(columnType: .text(rawType: "CHAR"), displayFormat: nil) == nil) + #expect(UUIDCellForm(columnType: .text(rawType: "CHAR"), displayFormat: .raw) == nil) + #expect(UUIDCellForm(columnType: .text(rawType: "CHAR"), displayFormat: .uuid) == .hyphenated) + } + + @Test("A CHAR(32) UUID column takes 32 hex digits") + func compactText() { + let form = UUIDCellForm(columnType: .text(rawType: "char(32)"), displayFormat: .uuid) + #expect(form == .compact) + #expect(form?.value(for: Self.sample) == .text("0192f3a47b2c7d1e8f001a2b3c4d5e6f")) + } + + @Test("A text column typed with no length takes the form its values have") + func compactFromSample() { + let compact = UUIDCellForm( + columnType: .text(rawType: "CHAR"), displayFormat: .uuid, sampleText: "0192F3A47B2C7D1E8F001A2B3C4D5E6F" + ) + #expect(compact == .compact) + let hyphenated = UUIDCellForm( + columnType: .text(rawType: "CHAR"), displayFormat: .uuid, sampleText: "0192f3a4-7b2c-7d1e-8f00-1a2b3c4d5e6f" + ) + #expect(hyphenated == .hyphenated) + let declared = UUIDCellForm( + columnType: .text(rawType: "char(36)"), displayFormat: .uuid, sampleText: "0192f3a47b2c7d1e8f001a2b3c4d5e6f" + ) + #expect(declared == .hyphenated) + #expect(UUIDCellForm(columnType: .text(rawType: "Nullable(FixedString(32))"), displayFormat: .uuid) == .compact) + } + + @Test("A text column typed with no length takes the schema's length first, then its values") + func lengthFromDeclaredType() { + let lengthless = ColumnType.text(rawType: "CHAR") + let compactSample = "0192f3a47b2c7d1e8f001a2b3c4d5e6f" + #expect(UUIDCellForm(columnType: lengthless, displayFormat: .uuid, declaredType: "CHAR(32)") == .compact) + #expect( + UUIDCellForm(columnType: lengthless, displayFormat: .uuid, sampleText: compactSample, declaredType: "CHAR(36)") + == .hyphenated + ) + #expect( + UUIDCellForm(columnType: lengthless, displayFormat: .uuid, sampleText: compactSample, declaredType: "CHAR") + == .compact + ) + #expect( + UUIDCellForm(columnType: .text(rawType: "char(36)"), displayFormat: .uuid, declaredType: "CHAR(32)") + == .hyphenated + ) + } + + @Test("A BINARY(16) or RAW(16) UUID column takes the 16 bytes") + func binary() { + let form = UUIDCellForm(columnType: .blob(rawType: "BINARY"), displayFormat: .uuid) + #expect(form == .bytes) + let expected = Data([0x01, 0x92, 0xF3, 0xA4, 0x7B, 0x2C, 0x7D, 0x1E, 0x8F, 0x00, 0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F]) + #expect(form?.value(for: Self.sample) == .bytes(expected)) + #expect(UUIDCellForm(columnType: .blob(rawType: "RAW(16)"), displayFormat: nil) == nil) + } + + @Test("Other types never hold a generated UUID", arguments: [ + ColumnType.integer(rawType: "INT"), .timestamp(rawType: "TIMESTAMP"), .json(rawType: "JSON"), + .array(rawType: "uuid[]", element: .text(rawType: "uuid")) + ]) + func otherTypes(columnType: ColumnType) { + #expect(UUIDCellForm(columnType: columnType, displayFormat: .uuid) == nil) + } +} diff --git a/TableProTests/Core/Services/CellValues/UUIDValueGeneratorTests.swift b/TableProTests/Core/Services/CellValues/UUIDValueGeneratorTests.swift new file mode 100644 index 0000000000..ba642cee70 --- /dev/null +++ b/TableProTests/Core/Services/CellValues/UUIDValueGeneratorTests.swift @@ -0,0 +1,59 @@ +// +// UUIDValueGeneratorTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +struct UUIDValueGeneratorTests { + private static func bytes(of uuid: UUID) -> [UInt8] { + withUnsafeBytes(of: uuid.uuid) { Array($0) } + } + + @Test("Version 4 carries version 4 and the RFC variant") + func version4Bits() { + let bytes = Self.bytes(of: UUIDValueGenerator().make(.v4)) + #expect(bytes[6] >> 4 == 4) + #expect(bytes[8] >> 6 == 0b10) + } + + @Test("Version 7 carries version 7, the RFC variant and the Unix millisecond time") + func version7Bits() { + let instant = Date(timeIntervalSince1970: 1_760_600_000.123) + let bytes = Self.bytes(of: UUIDValueGenerator(now: { instant }).make(.v7)) + let milliseconds = bytes[0..<6].reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + #expect(milliseconds == 1_760_600_000_123) + #expect(bytes[6] >> 4 == 7) + #expect(bytes[8] >> 6 == 0b10) + } + + @Test("Version 7 values made inside one millisecond still sort in creation order") + func version7BurstIsMonotonic() { + let instant = Date(timeIntervalSince1970: 1_760_600_000) + let generator = UUIDValueGenerator(now: { instant }) + let values = (0..<5_000).map { _ in generator.make(.v7).uuidString.lowercased() } + #expect(values == values.sorted()) + #expect(Set(values).count == values.count) + } + + @Test("Version 7 never goes backwards when the clock does") + func version7SurvivesClockStepBack() { + let clock = SteppingClock(Date(timeIntervalSince1970: 1_760_600_000)) + let generator = UUIDValueGenerator(now: { clock.current }) + let first = generator.make(.v7).uuidString.lowercased() + clock.current = Date(timeIntervalSince1970: 1_760_599_000) + let second = generator.make(.v7).uuidString.lowercased() + #expect(first < second) + } +} + +private final class SteppingClock: @unchecked Sendable { + var current: Date + + init(_ current: Date) { + self.current = current + } +} diff --git a/TableProTests/Core/Services/ClipboardServiceTests.swift b/TableProTests/Core/Services/ClipboardServiceTests.swift index 4408a42e86..a0877b345b 100644 --- a/TableProTests/Core/Services/ClipboardServiceTests.swift +++ b/TableProTests/Core/Services/ClipboardServiceTests.swift @@ -68,6 +68,30 @@ struct ClipboardServiceTests { #expect(provider.readGridCells() == cells) } + @Test("Expression marks round-trip, and a copy written before them decodes with none") + func expressionMarksAreOptional() throws { + let rows = GridRowsClipboardPayload(columns: ["at"], rows: [[.text("now()")]], expressionCells: [0: [0]]) + let cells = GridCellsClipboardPayload(text: "now()", fields: [["now()"]], expressionFields: [0: [0]]) + #expect(try JSONDecoder().decode(GridRowsClipboardPayload.self, from: JSONEncoder().encode(rows)) == rows) + #expect(try JSONDecoder().decode(GridCellsClipboardPayload.self, from: JSONEncoder().encode(cells)) == cells) + + var olderRows = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(rows)) as? [String: Any] + ) + olderRows.removeValue(forKey: "expressionCells") + let decodedRows = try JSONDecoder().decode( + GridRowsClipboardPayload.self, from: JSONSerialization.data(withJSONObject: olderRows) + ) + #expect(decodedRows.rows == rows.rows) + #expect(decodedRows.expressionCells == nil) + + let decodedCells = try JSONDecoder().decode( + GridCellsClipboardPayload.self, from: Data(#"{"text":"now()","fields":[["now()"]]}"#.utf8) + ) + #expect(decodedCells.fields == [["now()"]]) + #expect(decodedCells.expressionFields == nil) + } + @Test("A later plain write drops the fields with the text they described") func writeTextClearsFields() { let provider = NSPasteboardClipboardProvider() diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index 1bc786781d..6aa185cbba 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -500,6 +500,15 @@ struct ColumnTypeClassifierTests { #expect(isText(classifier.classify(rawTypeName: "UUID"))) } + @Test("Cassandra timeuuid is a UUID, not a time") + func timeuuidIsText() { + let type = classifier.classify(rawTypeName: "timeuuid") + #expect(type == .text(rawType: "timeuuid")) + #expect(type.isUUIDType) + #expect(type.isTimeUUIDType) + #expect(!type.isDateType) + } + @Test("FixedString(36) classifies as text") func fixedString() { #expect(isText(classifier.classify(rawTypeName: "FixedString(36)"))) diff --git a/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift b/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift index 29810cc2d5..821e8b1b42 100644 --- a/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift +++ b/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift @@ -36,6 +36,25 @@ struct ParseSchemaMetadataTests { #expect(parsed.columnComments == ["email": "Contact"]) } + @Test("Keeps each column's declared type, which a result set can report without its length") + func keepsDeclaredTypes() { + let schema = FetchedTableSchema( + columns: [ + ColumnInfo(name: "id", dataType: "CHAR(32)", isNullable: false, isPrimaryKey: true), + ColumnInfo(name: "name", dataType: "VARCHAR(80)", isNullable: true, isPrimaryKey: false), + ], + foreignKeys: nil, + approximateRowCount: nil + ) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.columnDeclaredTypes == ["id": "CHAR(32)", "name": "VARCHAR(80)"]) + + var rows = TableRows.from(queryRows: [], columns: ["id", "name"], columnTypes: [.text(rawType: "CHAR"), .text(rawType: "VARCHAR")]) + #expect(rows.updateDisplayMetadata(columnDeclaredTypes: parsed.columnDeclaredTypes) == .columnsReplaced) + #expect(rows.declaredType(at: 0) == "CHAR(32)") + #expect(ParsedSchemaMetadata.cached(rows: rows, primaryKeyColumns: ["id"]).columnDeclaredTypes == parsed.columnDeclaredTypes) + } + @Test("Returns an empty dictionary when no column has a comment") func emptyWhenNoComments() { let schema = FetchedTableSchema( diff --git a/TableProTests/Core/Services/RowOperationsManagerUUIDKeyTests.swift b/TableProTests/Core/Services/RowOperationsManagerUUIDKeyTests.swift new file mode 100644 index 0000000000..e104198bee --- /dev/null +++ b/TableProTests/Core/Services/RowOperationsManagerUUIDKeyTests.swift @@ -0,0 +1,294 @@ +// +// RowOperationsManagerUUIDKeyTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct RowOperationsManagerUUIDKeyTests { + private static let columns = ["id", "name", "created_at"] + private static let columnTypes: [ColumnType] = [ + .text(rawType: "uuid"), .text(rawType: "text"), .timestamp(rawType: "timestamptz") + ] + private static let uuidKey: [Int: UUIDCellForm] = [0: .hyphenated] + + private func makeManager() -> (RowOperationsManager, DataChangeManager) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "events", + columns: Self.columns, + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + return (RowOperationsManager(changeManager: changeManager), changeManager) + } + + private func makeRows(_ queryRows: [[PluginCellValue]] = [], columnDefaults: [String: String?] = [:]) -> TableRows { + TableRows.from( + queryRows: queryRows, + columns: Self.columns, + columnTypes: Self.columnTypes, + columnDefaults: columnDefaults, + hasAuthoritativeSchema: true + ) + } + + private func uuidText(_ value: PluginCellValue?) -> String? { + guard let text = value?.asText, UUID(uuidString: text) != nil, text == text.lowercased() else { return nil } + return text + } + + // MARK: - Add Row + + @Test("Add Row gives a UUID key the server does not fill a fresh UUID and reports it as prefilled") + func addRowPrefillsTheKey() throws { + let (manager, changeManager) = makeManager() + var rows = makeRows() + + let first = try #require(manager.addNewRow(tableRows: &rows, uuidKeyForms: Self.uuidKey)) + let second = try #require(manager.addNewRow(tableRows: &rows, uuidKeyForms: Self.uuidKey)) + + let firstKey = try #require(uuidText(first.values[0])) + #expect(uuidText(second.values[0]) != firstKey) + #expect(first.values[1] == .null) + #expect(first.prefilledColumns == [0]) + #expect(rows.rows[0].values[0] == .text(firstKey)) + #expect(changeManager.isRowInserted(first.rowID)) + } + + @Test("Add Row without UUID keys leaves the key NULL and prefills nothing") + func addRowWithoutUUIDKeys() throws { + let (manager, _) = makeManager() + var rows = makeRows() + + let result = try #require(manager.addNewRow(tableRows: &rows)) + + #expect(result.values[0] == .null) + #expect(result.prefilledColumns.isEmpty) + } + + @Test("A key the server fills keeps DEFAULT even when it holds UUIDs") + func addRowKeepsServerDefault() throws { + let (manager, _) = makeManager() + var rows = makeRows(columnDefaults: ["id": "gen_random_uuid()"]) + + let result = try #require(manager.addNewRow(tableRows: &rows, uuidKeyForms: Self.uuidKey)) + + #expect(result.values[0] == .text("__DEFAULT__")) + #expect(result.prefilledColumns.isEmpty) + } + + // MARK: - Duplicate Row + + @Test("Duplicate gives the copy a fresh UUID key instead of DEFAULT") + func duplicatePutsAFreshUUID() throws { + let (manager, _) = makeManager() + let sourceKey = "0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001" + var rows = makeRows([[.text(sourceKey), .text("a"), .text("2024-01-01 00:00:00+00")]]) + + let copy = try #require(manager.duplicateRow(sourceRowIndex: 0, tableRows: &rows, uuidKeyForms: Self.uuidKey)) + let plain = try #require(manager.duplicateRow(sourceRowIndex: 0, tableRows: &rows)) + + let copyKey = try #require(uuidText(copy.values[0])) + #expect(copyKey != sourceKey) + #expect(copy.values[1] == .text("a")) + #expect(copy.prefilledColumns == [0]) + #expect(plain.values[0] == .text("__DEFAULT__")) + } + + @Test("Duplicating a pending row keeps the SQL staged in it, except in the columns the copy resets") + func duplicateKeepsExpressionMarks() throws { + let (manager, changeManager) = makeManager() + var rows = makeRows() + let sourceID = RowID.inserted(UUID()) + let values: [PluginCellValue] = [.text("now()"), .text("a"), .text("now()")] + _ = rows.appendInsertedRow(id: sourceID, values: values) + changeManager.recordRowInsertion(rowID: sourceID, values: values, expressionColumns: [0, 2]) + + let copy = try #require(manager.duplicateRow(sourceRowIndex: 0, tableRows: &rows, uuidKeyForms: Self.uuidKey)) + + #expect(changeManager.expressionColumns(forRow: copy.rowID) == [2]) + #expect(copy.values[2] == .text("now()")) + #expect(uuidText(copy.values[0]) != nil) + } + + @Test("Duplicating a saved row stages no SQL") + func duplicateOfASavedRowHasNoMarks() throws { + let (manager, changeManager) = makeManager() + var rows = makeRows([[.text("0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001"), .text("now()"), .null]]) + + let copy = try #require(manager.duplicateRow(sourceRowIndex: 0, tableRows: &rows)) + + #expect(changeManager.expressionColumns(forRow: copy.rowID).isEmpty) + } + + // MARK: - Paste rows + + @Test("Pasted rows each get their own fresh UUID key") + func pasteGivesEachRowAUUID() throws { + let (manager, _) = makeManager() + var rows = makeRows() + let clipboard = RecordingClipboard() + clipboard.text = "k1\tAnn\tNULL\nk2\tBob\tNULL" + + let result = manager.pasteRowsFromClipboard( + columns: Self.columns, + primaryKeyColumns: ["id"], + tableRows: &rows, + uuidKeyForms: Self.uuidKey, + clipboard: clipboard + ) + + let keys = result.pastedRows.compactMap { uuidText($0.values[0]) } + #expect(keys.count == 2) + #expect(Set(keys).count == 2) + #expect(result.pastedRows.map { $0.values[1] } == [.text("Ann"), .text("Bob")]) + } + + @Test("Rows copied from the grid paste with fresh UUID keys too") + func pasteStructuredRowsGivesUUIDs() { + let (manager, _) = makeManager() + var rows = makeRows() + let clipboard = RecordingClipboard() + clipboard.gridRows = GridRowsClipboardPayload( + columns: Self.columns, + rows: [[.text("0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001"), .text("Ann"), .null]] + ) + + let result = manager.pasteRowsFromClipboard( + columns: Self.columns, + primaryKeyColumns: ["id"], + tableRows: &rows, + uuidKeyForms: Self.uuidKey, + clipboard: clipboard + ) + + let key = uuidText(result.pastedRows.first?.values[0]) + #expect(key != nil) + #expect(key != "0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001") + } + + @Test("Pasted rows on a key that is not a UUID key still hand it to the server") + func pasteWithoutUUIDKeysKeepsDefault() { + let (manager, _) = makeManager() + var rows = makeRows() + let clipboard = RecordingClipboard() + clipboard.text = "k1\tAnn\tNULL" + + let result = manager.pasteRowsFromClipboard( + columns: Self.columns, + primaryKeyColumns: ["id"], + tableRows: &rows, + clipboard: clipboard + ) + + #expect(result.pastedRows.first?.values[0] == .text("__DEFAULT__")) + } +} + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct RowOperationsExpressionClipboardTests { + private static let columns = ["id", "name", "created_at"] + + private func makeManager() -> (RowOperationsManager, DataChangeManager) { + let changeManager = DataChangeManager() + changeManager.configureForTable( + tableName: "events", + columns: Self.columns, + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + return (RowOperationsManager(changeManager: changeManager), changeManager) + } + + private func makeRows(_ queryRows: [[PluginCellValue]] = []) -> TableRows { + TableRows.from( + queryRows: queryRows, + columns: Self.columns, + columnTypes: [.text(rawType: "text"), .text(rawType: "text"), .timestamp(rawType: "timestamptz")], + hasAuthoritativeSchema: true + ) + } + + private func paste( + _ manager: RowOperationsManager, + into rows: inout TableRows + ) throws -> RowOperationsManager.PasteRowsResult { + let clipboard = try #require(RecordingClipboard.current) + return manager.pasteRowsFromClipboard( + columns: Self.columns, + primaryKeyColumns: ["id"], + tableRows: &rows, + clipboard: clipboard + ) + } + + @Test("Rows copied from the grid keep their staged SQL as SQL, and stored text that spells a function as text") + func copiedRowsKeepTheirMarks() throws { + let (manager, changeManager) = makeManager() + var rows = makeRows([[.text("1"), .text("saved"), .text("CURRENT_TIMESTAMP")]]) + let pendingID = RowID.inserted(UUID()) + let pendingValues: [PluginCellValue] = [.text("2"), .text("pending"), .text("now()")] + _ = rows.appendInsertedRow(id: pendingID, values: pendingValues) + changeManager.recordRowInsertion(rowID: pendingID, values: pendingValues, expressionColumns: [2]) + + manager.copySelectedRowsToClipboard(selectedIndices: [0, 1], tableRows: rows) + let payload = try #require(RecordingClipboard.current?.gridRows) + #expect(payload.expressionCells == [1: [2]]) + + let result = try paste(manager, into: &rows) + + let pasted = result.pastedRows.map(\.rowID) + #expect(pasted.count == 2) + #expect(changeManager.expressionColumns(forRow: pasted[0]).isEmpty) + #expect(changeManager.expressionColumns(forRow: pasted[1]) == [2]) + #expect(result.pastedRows[0].values[2] == .text("CURRENT_TIMESTAMP")) + #expect(result.pastedRows[1].values[2] == .text("now()")) + } + + @Test("A copied mark on the key goes with the key's value, which the server assigns") + func keyMarkIsDropped() throws { + let (manager, changeManager) = makeManager() + var rows = makeRows() + let clipboard = try #require(RecordingClipboard.current) + clipboard.gridRows = GridRowsClipboardPayload( + columns: Self.columns, + rows: [[.text("now()"), .text("a"), .text("now()")]], + expressionCells: [0: [0, 2]] + ) + + let result = try paste(manager, into: &rows) + + let rowID = try #require(result.pastedRows.first?.rowID) + #expect(changeManager.expressionColumns(forRow: rowID) == [2]) + } + + @Test("Cells the grid copied as wide as the table paste as rows with their marks") + func copiedCellsKeepTheirMarksAsRows() throws { + let (manager, changeManager) = makeManager() + var rows = makeRows() + let clipboard = try #require(RecordingClipboard.current) + clipboard.writeCells(GridCellsClipboardPayload( + text: "7\tAnn\tnow()\n8\tBob\tnow()", + fields: [["7", "Ann", "now()"], ["8", "Bob", "now()"]], + expressionFields: [0: [2]] + )) + + let result = try paste(manager, into: &rows) + + let pasted = result.pastedRows.map(\.rowID) + #expect(pasted.count == 2) + #expect(changeManager.expressionColumns(forRow: pasted[0]) == [2]) + #expect(changeManager.expressionColumns(forRow: pasted[1]).isEmpty) + } + } +} diff --git a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 87daa98ff2..64c3aae58e 100644 --- a/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -409,6 +409,50 @@ struct SQLRowToStatementConverterTests { #expect(converter.generateUpdates(rows: [staged]) == "UPDATE [Enterprise_App_Approved] SET [Status] = DEFAULT WHERE [code] = N'a';") } + @Test + func insertWritesAnExpressionCellAsTheSQL() throws { + let converter = try mssqlConverter(columns: ["ID", "Name", "At"], unwritableColumns: ["ID"]) + let staged = SQLRowToStatementConverter.SourceRow( + values: ["1761", "GETDATE()", "GETDATE()"], isPendingInsert: true, expressionColumns: [2] + ) + #expect( + converter.generateInserts(rows: [staged]) + == "INSERT INTO [Enterprise_App_Approved] ([Name], [At]) VALUES (N'GETDATE()', GETDATE());" + ) + } + + @Test + func updateWritesAnExpressionCellAsTheSQL() throws { + let converter = try mssqlConverter(columns: ["code", "At"], primaryKeyColumns: ["code"], unwritableColumns: []) + let staged = SQLRowToStatementConverter.SourceRow(values: ["a", "SYSDATETIME()"], expressionColumns: [1]) + #expect( + converter.generateUpdates(rows: [staged]) + == "UPDATE [Enterprise_App_Approved] SET [At] = SYSDATETIME() WHERE [code] = N'a';" + ) + } + + @Test + func aKeySetToAnExpressionThatSpellsItsStoredTextIsAssigned() throws { + let converter = try mssqlConverter(columns: ["At"], primaryKeyColumns: ["At"], unwritableColumns: []) + let staged = SQLRowToStatementConverter.SourceRow( + values: ["SYSDATETIME()"], storedValues: ["SYSDATETIME()"], expressionColumns: [0] + ) + #expect( + converter.generateUpdates(rows: [staged]) + == "UPDATE [Enterprise_App_Approved] SET [At] = SYSDATETIME() WHERE [At] = N'SYSDATETIME()';" + ) + } + + @Test + func anExpressionCellOutsideTheVocabularyIsQuoted() throws { + let converter = try mssqlConverter(columns: ["code", "At"], primaryKeyColumns: ["code"], unwritableColumns: []) + let staged = SQLRowToStatementConverter.SourceRow(values: ["a", "1; DROP TABLE x"], expressionColumns: [1]) + #expect( + converter.generateUpdates(rows: [staged]) + == "UPDATE [Enterprise_App_Approved] SET [At] = N'1; DROP TABLE x' WHERE [code] = N'a';" + ) + } + @Test func aStoredValueThatReadsLikeTheDefaultMarkerIsCopiedAsData() throws { let converter = try mssqlConverter(columns: ["code", "Status"], primaryKeyColumns: ["code"], unwritableColumns: []) diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index f569003152..a04c453dc9 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -117,6 +117,19 @@ struct ReservedShortcutTests { let key = BoundKey.special(.delete, command: true) #expect(ShortcutAction.reservedConflict(for: key, context: .dataGrid) == nil) } + + // A disabled menu item still takes its key, so a binding here would starve the inspector's field shortcuts. + @Test("Control-Option-N and D stay with the inspector, so no action can be bound to them") + func inspectorStateChordsAreReserved() { + let setNull = BoundKey.character("n", option: true, control: true) + let setDefault = BoundKey.character("d", option: true, control: true) + for context in [ShortcutContext.global, .editor, .dataGrid] { + #expect(ShortcutAction.reservedConflict(for: setNull, context: context) == String(localized: "Set NULL")) + #expect( + ShortcutAction.reservedConflict(for: setDefault, context: context) == String(localized: "Set DEFAULT") + ) + } + } } struct StandardTextEditingBindingTests { diff --git a/TableProTests/Models/MultiRowEditStateBinaryRevertTests.swift b/TableProTests/Models/MultiRowEditStateBinaryRevertTests.swift index 74e44a9f4a..d28e06936b 100644 --- a/TableProTests/Models/MultiRowEditStateBinaryRevertTests.swift +++ b/TableProTests/Models/MultiRowEditStateBinaryRevertTests.swift @@ -74,7 +74,7 @@ struct MultiRowEditStateBinaryRevertTests { columnTypes: [.spatial(rawType: "POINT")] ) var reverted: [RowID: PluginCellValue] = [:] - sut.onFieldReverted = { _, values, _ in reverted = values } + sut.onFieldReverted = { _, values, _, _ in reverted = values } sut.setFieldToNull(at: 0) clear(sut, at: 0) @@ -109,7 +109,7 @@ struct MultiRowEditStateBinaryRevertTests { var sent: [PluginCellValue] = [] var reverted: [RowID: PluginCellValue] = [:] sut.onFieldChanged = { _, value, _ in sent.append(value) } - sut.onFieldReverted = { _, values, _ in reverted = values } + sut.onFieldReverted = { _, values, _, _ in reverted = values } sut.setFieldToNull(at: 0) clear(sut, at: 0) diff --git a/TableProTests/Models/MultiRowEditStateClearedFieldTests.swift b/TableProTests/Models/MultiRowEditStateClearedFieldTests.swift index 2f09f522ff..b419004bb4 100644 --- a/TableProTests/Models/MultiRowEditStateClearedFieldTests.swift +++ b/TableProTests/Models/MultiRowEditStateClearedFieldTests.swift @@ -39,7 +39,7 @@ struct MultiRowEditStateClearedFieldTests { nonNullableColumns: ["created_at"] ) state.onFieldChanged = { index, value, _ in recorder.values[index] = value } - state.onDetachedFieldChanged = { index, value, _ in recorder.detachedValues[index] = value } + state.onDetachedFieldChanged = { index, value, _, _ in recorder.detachedValues[index] = value } return state } diff --git a/TableProTests/Models/MultiRowEditStateFieldAbsenceTests.swift b/TableProTests/Models/MultiRowEditStateFieldAbsenceTests.swift index 9331385fee..7bb457ddd7 100644 --- a/TableProTests/Models/MultiRowEditStateFieldAbsenceTests.swift +++ b/TableProTests/Models/MultiRowEditStateFieldAbsenceTests.swift @@ -105,7 +105,7 @@ struct MultiRowEditStateFieldAbsenceTests { let state = configured(rows: [["1", nil]], absentCells: [[1]]) var reverted: Set? state.onFieldChanged = { _, _, _ in } - state.onFieldReverted = { _, _, absentRows in reverted = absentRows } + state.onFieldReverted = { _, _, absentRows, _ in reverted = absentRows } state.updateField(at: 1, value: "Ada") state.updateField(at: 1, value: nil) diff --git a/TableProTests/Models/MultiRowEditStateSetValueTests.swift b/TableProTests/Models/MultiRowEditStateSetValueTests.swift new file mode 100644 index 0000000000..7f86424148 --- /dev/null +++ b/TableProTests/Models/MultiRowEditStateSetValueTests.swift @@ -0,0 +1,380 @@ +// +// MultiRowEditStateSetValueTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct MultiRowEditStateSetValueTests { + private static let columns = ["id", "created_at", "name"] + private static let columnTypes: [ColumnType] = [ + .text(rawType: "uuid"), .timestamp(rawType: "timestamptz"), .text(rawType: "varchar") + ] + + private func makeState( + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [[nil, nil, "Alice"]], + rowIDs: [RowID] = [.existing(0)], + expressionCells: [Set] = [], + externallyModifiedColumns: Set = [], + foreignKeyColumns: Set = [], + nonNullableColumns: Set = [], + serverAssignedColumns: Set = [], + displayFormats: [ValueDisplayFormat?] = [], + declaredTypes: [String: String] = [:], + databaseType: DatabaseType? = .postgresql, + cellWriter: CellWriterSupport = .host + ) -> MultiRowEditState { + let state = MultiRowEditState() + state.configure( + selectedRowIndices: Set(rows.indices), + rowIDs: rowIDs, + allRows: rows, + expressionCells: expressionCells, + columns: Self.columns, + columnTypes: columnTypes ?? Self.columnTypes, + externallyModifiedColumns: externallyModifiedColumns, + foreignKeyColumns: foreignKeyColumns, + nonNullableColumns: nonNullableColumns, + serverAssignedColumns: serverAssignedColumns, + displayFormats: displayFormats, + declaredTypes: declaredTypes, + databaseType: databaseType, + cellWriter: cellWriter + ) + return state + } + + private func commands(_ state: MultiRowEditState, at index: Int) -> [SetValueCommand] { + state.setValueCommands(for: state.fields[index], isWritable: true) + } + + private func expressionSQL(_ commands: [SetValueCommand]) -> [String] { + commands.compactMap { command in + guard case .expression(let expression) = command else { return nil } + return expression.sql + } + } + + private static let now = CellExpression("now()", kinds: [.timestamp, .datetime]) + + // MARK: - Gating + + @Test("Set NULL and Control-Option-N are offered only on a nullable column") + func nullFollowsNullability() { + let nullable = makeState() + let notNull = makeState(nonNullableColumns: ["name"]) + + #expect(commands(nullable, at: 2).contains(.null)) + #expect(!commands(notNull, at: 2).contains(.null)) + #expect(InspectorFieldListView.stateShortcutCommand(for: "n", offered: commands(nullable, at: 2)) == .null) + #expect(InspectorFieldListView.stateShortcutCommand(for: "n", offered: commands(notNull, at: 2)) == nil) + } + + @Test("Set DEFAULT and Control-Option-D are offered only where the server fills the column") + func defaultFollowsTheServer() { + let noDefault = makeState() + let withDefault = makeState(serverAssignedColumns: ["created_at"]) + + #expect(!commands(noDefault, at: 1).contains(.defaultValue)) + #expect(commands(withDefault, at: 1).contains(.defaultValue)) + #expect(InspectorFieldListView.stateShortcutCommand(for: "d", offered: commands(noDefault, at: 1)) == nil) + #expect( + InspectorFieldListView.stateShortcutCommand(for: "d", offered: commands(withDefault, at: 1)) + == .defaultValue + ) + } + + @Test("On SQLite, Set DEFAULT is offered on a new row and not on a saved one") + func defaultOnSQLiteNeedsANewRow() { + let saved = makeState(serverAssignedColumns: ["created_at"], databaseType: .sqlite) + let inserted = makeState( + rowIDs: [.inserted(UUID())], serverAssignedColumns: ["created_at"], databaseType: .sqlite + ) + + #expect(!commands(saved, at: 1).contains(.defaultValue)) + #expect(commands(inserted, at: 1).contains(.defaultValue)) + } + + @Test("Generate UUID is offered on a uuid column, and not on a foreign key, text, or a writer without UUID text") + func generateUUIDFollowsTheColumn() { + let state = makeState() + #expect(commands(state, at: 0).contains(.uuid(.v4))) + #expect(commands(state, at: 0).contains(.uuid(.v7))) + #expect(!commands(state, at: 2).contains(.uuid(.v4))) + + let foreignKey = makeState(foreignKeyColumns: ["id"]) + #expect(!commands(foreignKey, at: 0).contains(.uuid(.v4))) + + let oldDriver = makeState(cellWriter: .none) + #expect(!commands(oldDriver, at: 0).contains(.uuid(.v4))) + } + + @Test("SQL Functions are the vocabulary's for the column, and none for a text column or a writer without SQL") + func functionsFollowTheVocabulary() { + let state = makeState() + #expect(expressionSQL(commands(state, at: 1)) == ["now()", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP"]) + #expect(expressionSQL(commands(state, at: 2)).isEmpty) + + let noSQL = makeState(cellWriter: CellWriterSupport(acceptsExpressions: false, acceptsUUIDText: true)) + #expect(expressionSQL(commands(noSQL, at: 1)).isEmpty) + } + + @Test("A read-only field, or one with no engine to ask, offers nothing") + func nothingWithoutAWritableField() { + let state = makeState() + #expect(state.setValueCommands(for: state.fields[2], isWritable: false).isEmpty) + + let dataFile = makeState(databaseType: nil, cellWriter: .none) + #expect(commands(dataFile, at: 2).isEmpty) + } + + // MARK: - Expressions + + @Test("A function picked from the menu goes out as SQL, and the inspector-only save is told so") + func pickedFunctionIsAnExpression() { + let state = makeState() + var sent: [(index: Int, sql: String, continuity: FieldEditContinuity)] = [] + state.onFieldExpressionChanged = { sent.append(($0, $1, $2)) } + state.onFieldChanged = { _, _, _ in Issue.record("a function must not go out as a literal") } + + state.setValue(.expression(Self.now), at: 1) + + #expect(sent.count == 1) + #expect(sent.first?.index == 1) + #expect(sent.first?.sql == "now()") + #expect(sent.first?.continuity == .discrete) + #expect(state.fields[1].isPendingExpression) + #expect(FieldValueState.resolve(state.fields[1]) == .value("now()")) + #expect(state.getEditedFields() == [ + InspectorFieldEdit(columnIndex: 1, columnName: "created_at", newValue: "now()", isExpression: true) + ]) + } + + @Test("Set NULL over a picked function is no longer SQL") + func nullClearsTheExpression() { + let state = makeState() + state.onFieldExpressionChanged = { _, _, _ in } + state.onFieldChanged = { _, _, _ in } + + state.setValue(.expression(Self.now), at: 1) + state.setValue(.null, at: 1) + + #expect(!state.fields[1].isPendingExpression) + #expect(state.fields[1].isPendingNull) + } + + @Test("A function name typed on a timestamp column stages the vocabulary's SQL and keeps the typed text") + func typedFunctionIsPromoted() { + let state = makeState( + columnTypes: [.text(rawType: "CHAR(36)"), .datetime(rawType: "DATETIME"), .text(rawType: "VARCHAR(20)")], + databaseType: .mysql + ) + var literals: [PluginCellValue] = [] + var expressions: [(sql: String, continuity: FieldEditContinuity)] = [] + state.onFieldChanged = { _, value, _ in literals.append(value) } + state.onFieldExpressionChanged = { _, sql, continuity in expressions.append((sql, continuity)) } + + state.updateField(at: 1, value: "now(") + state.updateField(at: 1, value: "now()") + + #expect(literals == [.text("now(")]) + #expect(expressions.map { $0.sql } == ["NOW()"]) + #expect(expressions.first?.continuity == .typing) + #expect(state.fields[1].pendingValue == "now()") + #expect(!state.fields[1].isPendingExpression) + } + + @Test( + "A function name typed on a text column, or for a writer without SQL, stays text", + arguments: zip([2, 1], [CellWriterSupport.host, CellWriterSupport.none]) + ) + func typedFunctionStaysTextWhereItCannotRun(column: Int, writer: CellWriterSupport) { + let state = makeState(cellWriter: writer) + var literals: [PluginCellValue] = [] + state.onFieldChanged = { _, value, _ in literals.append(value) } + state.onFieldExpressionChanged = { _, _, _ in Issue.record("typed text must stay text here") } + + state.updateField(at: column, value: "now()") + + #expect(literals == [.text("now()")]) + } + + @Test("A value window over rows that are no longer selected promotes typed text the same way") + func detachedTypedFunctionIsPromoted() { + let state = makeState() + var detached: [(value: PluginCellValue, rowIDs: [RowID], isExpression: Bool)] = [] + state.onDetachedFieldChanged = { _, value, rowIDs, isExpression in detached.append((value, rowIDs, isExpression)) } + + state.updateDetachedField(columnIndex: 1, rowIDs: [.existing(5)], value: "CURRENT_TIMESTAMP") + state.updateDetachedField(columnIndex: 2, rowIDs: [.existing(5)], value: "now()") + + #expect(detached.count == 2) + #expect(detached.first?.value == .text("CURRENT_TIMESTAMP")) + #expect(detached.first?.isExpression == true) + #expect(detached.last?.value == .text("now()")) + #expect(detached.last?.isExpression == false) + } + + /// The grid staged `now()`; the inspector shows it, the user types over it and then types it back. + @Test("Clearing a field back to the SQL its rows held sends it back as SQL, not as text") + func revertKeepsTheExpression() { + let rowID = RowID.existing(0) + let state = makeState( + rows: [[nil, "now()", "Alice"]], rowIDs: [rowID], expressionCells: [[1]], externallyModifiedColumns: [1] + ) + #expect(state.fields[1].isPendingExpression) + #expect(state.fields[1].pendingValue == "now()") + + var reverted: (values: [RowID: PluginCellValue], expressionRows: Set)? + state.onFieldChanged = { _, _, _ in } + state.onFieldReverted = { _, values, _, expressionRows in reverted = (values, expressionRows) } + + state.updateField(at: 1, value: "2024-01-01 00:00:00") + state.updateField(at: 1, value: "now()") + + #expect(reverted?.values == [rowID: .text("now()")]) + #expect(reverted?.expressionRows == [rowID]) + } + + @Test("Rows that hold the same text, one as SQL and one not, read as Multiple values") + func expressionStateIsPartOfTheValue() { + let state = makeState( + rows: [[nil, "now()", "a"], [nil, "now()", "a"]], + rowIDs: [.existing(0), .existing(1)], + expressionCells: [[1], []] + ) + + #expect(state.fields[1].hasMultipleValues) + #expect(!state.fields[2].hasMultipleValues) + } + + // MARK: - Generate UUID + + @Test("Generate UUID on one row stages one lowercase UUID as a discrete step") + func generateUUIDOnOneRow() throws { + let state = makeState() + var sent: [(value: PluginCellValue, continuity: FieldEditContinuity)] = [] + state.onFieldChanged = { _, value, continuity in sent.append((value, continuity)) } + + state.setValue(.uuid(.v4), at: 0) + + let text = try #require(sent.first?.value.asText) + #expect(UUID(uuidString: text) != nil) + #expect(text == text.lowercased()) + #expect(sent.first?.continuity == .discrete) + #expect(state.fields[0].pendingValue == text) + } + + @Test("Generate UUID on a lengthless CHAR column with no values takes the length its schema declares") + func generateUUIDTakesTheSchemaLength() throws { + let state = makeState( + columnTypes: [.text(rawType: "CHAR"), .datetime(rawType: "DATETIME"), .text(rawType: "VARCHAR(20)")], + displayFormats: [.uuid, nil, nil], + declaredTypes: ["id": "CHAR(32)"], + databaseType: .mysql + ) + var sent: [PluginCellValue] = [] + state.onFieldChanged = { _, value, _ in sent.append(value) } + + state.setValue(.uuid(.v4), at: 0) + + let text = try #require(sent.first?.asText) + #expect(text.count == 32) + #expect(text.allSatisfy { $0.isHexDigit }) + } + + @Test("Generate UUID on a BINARY(16) column shown as UUID stages 16 bytes") + func generateUUIDInBytes() { + let state = makeState( + columnTypes: [.blob(rawType: "BINARY(16)"), .datetime(rawType: "DATETIME"), .text(rawType: "VARCHAR(20)")], + displayFormats: [.uuid, nil, nil], + databaseType: .mysql + ) + var sent: [PluginCellValue] = [] + state.onFieldChanged = { _, value, _ in sent.append(value) } + + state.setValue(.uuid(.v4), at: 0) + + guard case .bytes(let data) = sent.first else { + Issue.record("expected bytes, got \(String(describing: sent.first))") + return + } + #expect(data.count == 16) + } + + @Test("Generate UUID over several rows gives each row its own, and the field reads Multiple values") + func generateUUIDPerRow() throws { + let rowIDs: [RowID] = [.existing(0), .existing(1), .existing(2)] + let state = makeState(rows: [[nil, nil, "a"], [nil, nil, "b"], [nil, nil, "c"]], rowIDs: rowIDs) + var perRow: [RowID: PluginCellValue] = [:] + state.onFieldValuesChanged = { _, values in perRow = values } + state.onFieldChanged = { _, _, _ in Issue.record("one value must not go to every row") } + + state.setValue(.uuid(.v7), at: 0) + + #expect(Set(perRow.keys) == Set(rowIDs)) + let texts = perRow.values.compactMap { $0.asText } + #expect(Set(texts).count == 3) + for text in texts { + let uuid = try #require(UUID(uuidString: text)) + #expect(uuid.uuid.6 >> 4 == 7) + } + #expect(FieldValueState.resolve(state.fields[0]) == .multipleValues) + #expect(state.fields[0].pendingValue == nil) + #expect(state.fields[0].hasCommittedEdit) + #expect(state.getEditedFields().isEmpty) + } + + @Test("Typing into the field after a per-row generate writes the typed value for every row") + func typingAfterPerRowGenerate() { + let state = makeState(rows: [[nil, nil, "a"], [nil, nil, "b"]], rowIDs: [.existing(0), .existing(1)]) + state.onFieldValuesChanged = { _, _ in } + var typed: [PluginCellValue] = [] + state.onFieldChanged = { _, value, _ in typed.append(value) } + + state.setValue(.uuid(.v4), at: 0) + state.updateField(at: 0, value: "6f1c2a7e-0d3b-4c5f-9a8e-1b2c3d4e5f60") + + #expect(typed == [.text("6f1c2a7e-0d3b-4c5f-9a8e-1b2c3d4e5f60")]) + #expect(state.fields[0].pendingValue == "6f1c2a7e-0d3b-4c5f-9a8e-1b2c3d4e5f60") + } + + @Test("Clearing a value typed over per-row UUIDs puts each row's own UUID back") + func clearingAfterPerRowGenerate() { + let state = makeState(rows: [[nil, nil, "a"], [nil, nil, "b"]], rowIDs: [.existing(0), .existing(1)]) + var generated: [RowID: PluginCellValue] = [:] + var reverted: [RowID: PluginCellValue] = [:] + state.onFieldValuesChanged = { _, values in generated = values } + state.onFieldChanged = { _, _, _ in } + state.onFieldReverted = { _, values, _, _ in reverted = values } + + state.setValue(.uuid(.v4), at: 0) + state.updateField(at: 0, value: "x") + state.updateField(at: 0, value: "") + + #expect(generated.count == 2) + #expect(reverted == generated) + } + + /// After a per-row edit the inspector is rebuilt with the column modified and its rows disagreeing. + /// `configure` wrote `originalValue ?? ""` as the pending value, so the field showed an empty value. + @Test("A modified column the rows disagree on reads Multiple values, not an empty pending value") + func modifiedMultiValuedFieldReadsMultipleValues() { + let state = makeState( + rows: [[nil, nil, "a"], [nil, nil, "b"]], + rowIDs: [.existing(0), .existing(1)], + externallyModifiedColumns: [2] + ) + + #expect(FieldValueState.resolve(state.fields[2]) == .multipleValues) + #expect(state.fields[2].pendingValue == nil) + #expect(state.fields[2].hasCommittedEdit) + #expect(state.getEditedFields().isEmpty) + } +} diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift index 507414b760..47b2dfedd8 100644 --- a/TableProTests/Models/MultiRowEditStateTests.swift +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -457,7 +457,7 @@ struct MultiRowEditStateTests { } } - // MARK: - setFieldToNull / setFieldToDefault / setFieldToFunction / setFieldToEmpty + // MARK: - setFieldToNull / setFieldToDefault / setFieldToExpression / setFieldToEmpty @MainActor @Suite("Set Field Special Values") struct SetFieldSpecialValuesTests { @@ -498,11 +498,12 @@ struct MultiRowEditStateTests { #expect(sut.fields[0].pendingValue == nil) } - @Test("setFieldToFunction sets pendingValue to function string") - func setFieldToFunctionSetsPendingValue() { + @Test("setFieldToExpression holds the SQL as a pending expression") + func setFieldToExpressionSetsPendingValue() { let sut = makeSUT() - sut.setFieldToFunction(at: 0, function: "NOW()") + sut.setFieldToExpression(at: 0, expression: CellExpression("NOW()", kinds: [.timestamp])) #expect(sut.fields[0].pendingValue == "NOW()") + #expect(sut.fields[0].isPendingExpression == true) #expect(sut.fields[0].isPendingNull == false) #expect(sut.fields[0].isPendingDefault == false) } @@ -535,7 +536,7 @@ struct MultiRowEditStateTests { sut.setFieldToDefault(at: 1) #expect(sut.fields[1].hasEdit == true) - sut.setFieldToFunction(at: 2, function: "UUID()") + sut.setFieldToExpression(at: 2, expression: CellExpression("NOW()", kinds: [.timestamp])) #expect(sut.fields[2].hasEdit == true) let sut2 = makeSUT() @@ -787,18 +788,21 @@ struct MultiRowEditStateTests { #expect(callbackCalls[0].value == "__DEFAULT__") } - @Test("setFieldToFunction fires callback with function string") - func setFieldToFunctionFiresCallback() { + /// `onFieldChanged` carries a literal, so an expression sent through it would be saved as quoted text. + @Test("setFieldToExpression sends the SQL through the expression callback, not as a literal") + func setFieldToExpressionFiresExpressionCallback() { let sut = makeSUT() - var callbackCalls: [(index: Int, value: String?)] = [] - sut.onFieldChanged = { index, value, _ in - callbackCalls.append((index, value.asText)) - } - - sut.setFieldToFunction(at: 0, function: "NOW()") - #expect(callbackCalls.count == 1) - #expect(callbackCalls[0].index == 0) - #expect(callbackCalls[0].value == "NOW()") + var literals: [PluginCellValue] = [] + var expressions: [(index: Int, sql: String, continuity: FieldEditContinuity)] = [] + sut.onFieldChanged = { _, value, _ in literals.append(value) } + sut.onFieldExpressionChanged = { index, sql, continuity in expressions.append((index, sql, continuity)) } + + sut.setFieldToExpression(at: 0, expression: CellExpression("NOW()", kinds: [.timestamp])) + #expect(literals.isEmpty) + #expect(expressions.count == 1) + #expect(expressions.first?.index == 0) + #expect(expressions.first?.sql == "NOW()") + #expect(expressions.first?.continuity == .discrete) } @Test("setFieldToEmpty fires callback with empty string") diff --git a/TableProTests/Models/ShortcutUniquenessTests.swift b/TableProTests/Models/ShortcutUniquenessTests.swift index 3147430765..ac00283776 100644 --- a/TableProTests/Models/ShortcutUniquenessTests.swift +++ b/TableProTests/Models/ShortcutUniquenessTests.swift @@ -49,15 +49,11 @@ struct ShortcutUniquenessTests { } } - /// Sixteen actions ship with nothing bound: the six that always did, the eight the connection - /// window's revamp made rebindable for the first time, and the two window-tab commands, whose - /// Control-Tab went to the recent-tab switcher. Counted rather than listed, because the number is - /// the claim: adding a default to one of them is a decision about a combo that is already taken, - /// and it has to be made on purpose. - @Test("Sixteen actions ship unbound") + // Counted, not listed: a default for any of these takes a combo already in use, so it has to be on purpose. + @Test("Twenty-one actions ship unbound") func unboundActionsAreCounted() { let unbound = ShortcutAction.allCases.filter { KeyboardSettings.defaultShortcuts[$0] == nil } - #expect(unbound.count == 16, "Unbound: \(unbound.map(\.rawValue).sorted())") + #expect(unbound.count == 21, "Unbound: \(unbound.map(\.rawValue).sorted())") for action in unbound { #expect(KeyboardSettings.default.shortcut(for: action) == nil, "\(action.rawValue)") } diff --git a/TableProTests/Models/UI/MultiRowEditStateDetachedCommitTests.swift b/TableProTests/Models/UI/MultiRowEditStateDetachedCommitTests.swift index d597a1af71..065cc9b87a 100644 --- a/TableProTests/Models/UI/MultiRowEditStateDetachedCommitTests.swift +++ b/TableProTests/Models/UI/MultiRowEditStateDetachedCommitTests.swift @@ -29,7 +29,7 @@ struct MultiRowEditStateDetachedCommitTests { var fieldEdits: [(Int, PluginCellValue)] = [] var detached: [(Int, PluginCellValue, [RowID])] = [] state.onFieldChanged = { columnIndex, value, _ in fieldEdits.append((columnIndex, value)) } - state.onDetachedFieldChanged = { detached.append(($0, $1, $2)) } + state.onDetachedFieldChanged = { columnIndex, value, rowIDs, _ in detached.append((columnIndex, value, rowIDs)) } state.updateDetachedField(columnIndex: 1, rowIDs: [.existing(1)], value: "Zed") @@ -45,7 +45,7 @@ struct MultiRowEditStateDetachedCommitTests { let state = makeState(rowIDs: [.existing(1)], values: [["2", "Bob"]]) var detached: [(Int, PluginCellValue, [RowID])] = [] state.onFieldChanged = { _, _, _ in Issue.record("the field path must not take a moved selection") } - state.onDetachedFieldChanged = { detached.append(($0, $1, $2)) } + state.onDetachedFieldChanged = { columnIndex, value, rowIDs, _ in detached.append((columnIndex, value, rowIDs)) } state.configure( selectedRowIndices: [0], @@ -66,7 +66,7 @@ struct MultiRowEditStateDetachedCommitTests { func commitWithoutRowsDoesNothing() { let state = makeState(rowIDs: [.existing(1)], values: [["2", "Bob"]]) var detached = 0 - state.onDetachedFieldChanged = { _, _, _ in detached += 1 } + state.onDetachedFieldChanged = { _, _, _, _ in detached += 1 } state.updateDetachedField(columnIndex: 1, rowIDs: [], value: "Zed") diff --git a/TableProTests/Views/Main/InspectorExpressionStagingTests.swift b/TableProTests/Views/Main/InspectorExpressionStagingTests.swift new file mode 100644 index 0000000000..5f655076f7 --- /dev/null +++ b/TableProTests/Views/Main/InspectorExpressionStagingTests.swift @@ -0,0 +1,227 @@ +// +// InspectorExpressionStagingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +struct InspectorExpressionStagingTests { + private static let columns = ["id", "token", "created_at", "name"] + private static let columnTypes: [ColumnType] = [ + .integer(rawType: "int4"), .text(rawType: "uuid"), .timestamp(rawType: "timestamptz"), .text(rawType: "text") + ] + + private let fixture = CoordinatorFixture(connection: TestFixtures.makeConnection(type: .postgresql)) + + private var coordinator: MainContentCoordinator { fixture.coordinator } + private var changeManager: DataChangeManager { fixture.coordinator.changeManager } + + private func makeTable() -> UUID { + var tab = QueryTab(title: "events", query: "SELECT * FROM events", tabType: .table, tableName: "events") + tab.execution.lastExecutedAt = Date() + tab.display.resultsViewMode = .data + fixture.addTab( + tab, + rows: TableRows.from( + queryRows: [ + [.text("1"), .text("0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a001"), .text("2024-01-01 00:00:00+00"), .text("a")], + [.text("2"), .text("0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a002"), .text("2024-01-02 00:00:00+00"), .text("b")], + [.text("3"), .text("0b1e7c9a-58f4-4b8e-9c55-1f1c86f0a003"), .text("2024-01-03 00:00:00+00"), .text("c")] + ], + columns: Self.columns, + columnTypes: Self.columnTypes, + hasAuthoritativeSchema: true + ) + ) + changeManager.configureForTable( + tableName: "events", + columns: Self.columns, + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + return tab.id + } + + private func value(_ tabId: UUID, row: Int, column: Int) -> PluginCellValue { + coordinator.tabSessionRegistry.tableRows(for: tabId).rows[row].values[column] + } + + /// Wired the way `MainContentView.updateSidebarEditState` wires the inspector. + private func configuredEditState(_ tabId: UUID, rows rowIDs: [RowID]) -> MultiRowEditState { + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tabId) + let selected = rowIDs.compactMap { tableRows.row(withID: $0) } + let state = MultiRowEditState() + state.configure( + selectedRowIndices: Set(selected.indices), + rowIDs: selected.map(\.id), + allRows: selected.map { $0.values.map(\.asText) }, + expressionCells: selected.map { changeManager.expressionColumns(forRow: $0.id) }, + columns: tableRows.columns, + columnTypes: Self.columnTypes, + externallyModifiedColumns: Set(selected.flatMap { changeManager.getModifiedColumnsForRow($0.id) }), + databaseType: .postgresql, + cellWriter: changeManager.cellWriterSupport + ) + let rowEditing: RowEditingCoordinator = coordinator.rowEditingCoordinator + state.onFieldChanged = { [rowEditing] columnIndex, value, continuity in + rowEditing.stageInspectorFieldEdit( + columnIndex: columnIndex, value: value, rowIDs: rowIDs, continuity: continuity + ) + } + state.onFieldExpressionChanged = { [rowEditing] columnIndex, sql, continuity in + rowEditing.stageInspectorFieldEdit( + columnIndex: columnIndex, value: .text(sql), rowIDs: rowIDs, continuity: continuity, isExpression: true + ) + } + state.onFieldValuesChanged = { [rowEditing] columnIndex, valuesByRow in + rowEditing.stageInspectorFieldValues(columnIndex: columnIndex, valuesByRow: valuesByRow) + } + state.onFieldReverted = { [rowEditing] columnIndex, valuesByRow, absentRowIDs, expressionRowIDs in + rowEditing.revertInspectorFieldEdit( + columnIndex: columnIndex, + valuesByRow: valuesByRow, + absentRowIDs: absentRowIDs, + expressionRowIDs: expressionRowIDs + ) + } + return state + } + + @Test("A function picked in the inspector is pending as SQL and saved unquoted") + func pickedFunctionIsStagedAsSQL() throws { + let tabId = makeTable() + let state = configuredEditState(tabId, rows: [.existing(0)]) + + state.setValue(.expression(CellExpression("now()", kinds: [.timestamp, .datetime])), at: 2) + + #expect(changeManager.isExpression(rowID: .existing(0), columnIndex: 2)) + #expect(value(tabId, row: 0, column: 2) == .text("now()")) + let statement = try #require(try changeManager.generateSQL().first) + #expect(statement.sql.contains("now()")) + #expect(!statement.parameters.contains { ($0 as? String) == "now()" }) + } + + @Test("A function name typed into the field is staged as the vocabulary's SQL") + func typedFunctionIsStagedAsSQL() { + let tabId = makeTable() + let state = configuredEditState(tabId, rows: [.existing(0)]) + + state.updateField(at: 2, value: "NOW()") + + #expect(changeManager.isExpression(rowID: .existing(0), columnIndex: 2)) + #expect(value(tabId, row: 0, column: 2) == .text("now()")) + } + + @Test("The same text staged as a literal over the SQL is a change of its own") + func literalOverExpressionIsAChange() { + let tabId = makeTable() + let rowEditing: RowEditingCoordinator = coordinator.rowEditingCoordinator + + rowEditing.stageInspectorFieldEdit( + columnIndex: 3, value: .text("now()"), rowIDs: [.existing(0)], continuity: .discrete, isExpression: true + ) + rowEditing.stageInspectorFieldEdit( + columnIndex: 3, value: .text("now()"), rowIDs: [.existing(0)], continuity: .discrete + ) + + #expect(!changeManager.isExpression(rowID: .existing(0), columnIndex: 3)) + #expect(changeManager.hasChanges) + #expect(value(tabId, row: 0, column: 3) == .text("now()")) + } + + /// The grid staged `now()` on two rows. Typing over it in the inspector and clearing the field again used to + /// re-stage the configured text as a literal, which then saved as the string `'now()'`. + @Test("Clearing an inspector edit over SQL the grid staged puts the SQL back") + func revertRestoresTheExpression() { + let tabId = makeTable() + let rows: [RowID] = [.existing(0), .existing(1)] + coordinator.rowEditingCoordinator.stageInspectorFieldEdit( + columnIndex: 2, value: .text("now()"), rowIDs: rows, continuity: .discrete, isExpression: true + ) + let state = configuredEditState(tabId, rows: rows) + #expect(state.fields[2].isPendingExpression) + + state.updateField(at: 2, value: "2024-05-05 00:00:00+00") + #expect(!changeManager.isExpression(rowID: .existing(0), columnIndex: 2)) + + state.updateField(at: 2, value: "now()") + + #expect(changeManager.isExpression(rowID: .existing(0), columnIndex: 2)) + #expect(changeManager.isExpression(rowID: .existing(1), columnIndex: 2)) + #expect(value(tabId, row: 0, column: 2) == .text("now()")) + #expect(value(tabId, row: 1, column: 2) == .text("now()")) + } + + @Test("Generate UUID across the selected rows gives each row its own") + func generateUUIDAcrossRows() { + let tabId = makeTable() + let rows: [RowID] = [.existing(0), .existing(1), .existing(2)] + let state = configuredEditState(tabId, rows: rows) + let before = (0..<3).map { value(tabId, row: $0, column: 1) } + + state.setValue(.uuid(.v4), at: 1) + + let after = (0..<3).map { value(tabId, row: $0, column: 1) } + #expect(Set(after).count == 3) + #expect(Set(after).isDisjoint(with: before)) + #expect(after.allSatisfy { $0.asText.flatMap(UUID.init(uuidString:)) != nil }) + #expect(FieldValueState.resolve(state.fields[1]) == .multipleValues) + + let reconfigured = configuredEditState(tabId, rows: rows) + #expect(FieldValueState.resolve(reconfigured.fields[1]) == .multipleValues) + } + + @Test("Typing into the field after a per-row generate writes that value to every row") + func typingAfterGenerateOverwritesEveryRow() { + let tabId = makeTable() + let rows: [RowID] = [.existing(0), .existing(1)] + let state = configuredEditState(tabId, rows: rows) + let typed = "6f1c2a7e-0d3b-4c5f-9a8e-1b2c3d4e5f60" + + state.setValue(.uuid(.v7), at: 1) + state.updateField(at: 1, value: typed) + + #expect(value(tabId, row: 0, column: 1) == .text(typed)) + #expect(value(tabId, row: 1, column: 1) == .text(typed)) + } + + // MARK: - Inspector-only save + + @Test("An inspector-only save writes a picked function unquoted") + func sidebarSaveWritesPickedFunction() throws { + _ = makeTable() + coordinator.selectionState.indices = [0] + + let statements = try coordinator.sidebarEditStatements(editedFields: [ + InspectorFieldEdit(columnIndex: 2, columnName: "created_at", newValue: "now()", isExpression: true) + ]) + + let statement = try #require(statements.first) + #expect(statement.sql.contains("now()")) + #expect(!statement.parameters.contains { ($0 as? String) == "now()" }) + } + + @Test("An inspector-only save reads a typed function name as the function on a timestamp, as text elsewhere") + func sidebarSavePromotesTypedText() throws { + _ = makeTable() + coordinator.selectionState.indices = [0] + + let timestamp = try #require(try coordinator.sidebarEditStatements(editedFields: [ + InspectorFieldEdit(columnIndex: 2, columnName: "created_at", newValue: "NOW()") + ]).first) + #expect(timestamp.sql.contains("now()")) + #expect(!timestamp.parameters.contains { ($0 as? String) == "NOW()" }) + + let text = try #require(try coordinator.sidebarEditStatements(editedFields: [ + InspectorFieldEdit(columnIndex: 3, columnName: "name", newValue: "NOW()") + ]).first) + #expect(!text.sql.contains("NOW()")) + #expect(text.parameters.contains { ($0 as? String) == "NOW()" }) + } +} diff --git a/TableProTests/Views/Main/InspectorFieldEditStagingTests.swift b/TableProTests/Views/Main/InspectorFieldEditStagingTests.swift index 473595e33b..20707e5759 100644 --- a/TableProTests/Views/Main/InspectorFieldEditStagingTests.swift +++ b/TableProTests/Views/Main/InspectorFieldEditStagingTests.swift @@ -56,9 +56,12 @@ struct InspectorFieldEditStagingTests { columnIndex: columnIndex, value: value, rowIDs: rows, continuity: continuity ) } - state.onFieldReverted = { [coordinator] columnIndex, valuesByRow, absentRowIDs in - coordinator.revertInspectorFieldEdit( - columnIndex: columnIndex, valuesByRow: valuesByRow, absentRowIDs: absentRowIDs + state.onFieldReverted = { [coordinator] columnIndex, valuesByRow, absentRowIDs, expressionRowIDs in + coordinator.rowEditingCoordinator.revertInspectorFieldEdit( + columnIndex: columnIndex, + valuesByRow: valuesByRow, + absentRowIDs: absentRowIDs, + expressionRowIDs: expressionRowIDs ) } return state diff --git a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift index bc28f5f515..7fc0f2ef12 100644 --- a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift +++ b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift @@ -107,6 +107,26 @@ struct DataGridCellAppearanceTests { #expect(appearance.textColor == .systemBlue) } + @Test("A pending SQL expression draws like a pending DEFAULT and shows its SQL") + func expressionDrawsLikeDefault() { + let expression = resolve( + text: "now()", + rawValue: "now()", + visualState: RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [0], expressionColumns: [0]) + ) + let literal = resolve( + text: "now()", + rawValue: "now()", + visualState: RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [0]) + ) + + #expect(expression.text == "now()") + #expect(expression.font == palette.mediumFont) + #expect(expression.textColor == .systemBlue) + #expect(literal.font == palette.regularFont) + #expect(literal.textColor == .labelColor) + } + /// A large result blanks its placeholders rather than formatting every one of them. @Test("A large result draws no placeholder text") func largeDatasetBlanksPlaceholders() { diff --git a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift index 580f421f5a..9e4fcf772e 100644 --- a/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewSetValueTests.swift @@ -1,55 +1,197 @@ +// +// DataGridRowViewSetValueTests.swift +// TableProTests +// + +import AppKit import Foundation +import SwiftUI @testable import TablePro import TableProPluginKit import Testing +@MainActor +private final class SetValueMenuLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class SetValueMenuRowStore { + var tableRows: TableRows + + init(_ tableRows: TableRows) { + self.tableRows = tableRows + } +} + @MainActor struct DataGridRowViewSetValueTests { - @Test("date column offers CURRENT_DATE only") - func dateColumnOffersCurrentDate() { - let presets = DataGridRowView.dateValueFunctions(for: .date(rawType: "DATE")) - #expect(presets == ["CURRENT_DATE"]) - } - - @Test("datetime column offers NOW and CURRENT_TIMESTAMP") - func datetimeColumnOffersNowAndTimestamp() { - let presets = DataGridRowView.dateValueFunctions(for: .datetime(rawType: "DATETIME")) - #expect(presets == ["NOW()", "CURRENT_TIMESTAMP"]) - } - - @Test("timestamp column offers NOW and CURRENT_TIMESTAMP") - func timestampColumnOffersNowAndTimestamp() { - let presets = DataGridRowView.dateValueFunctions(for: .timestamp(rawType: "TIMESTAMP")) - #expect(presets == ["NOW()", "CURRENT_TIMESTAMP"]) - } - - @Test("TIME and TIMETZ columns offer CURRENT_TIME only", arguments: ["TIME", "TIMETZ"]) - func timeColumnOffersCurrentTime(rawType: String) { - let presets = DataGridRowView.dateValueFunctions(for: .timestamp(rawType: rawType)) - #expect(presets == ["CURRENT_TIME"]) - } - - @Test("non-date column returns empty list") - func textColumnReturnsEmpty() { - let presets = DataGridRowView.dateValueFunctions(for: .text(rawType: "VARCHAR(255)")) - #expect(presets.isEmpty) - } - - @Test("all presets are recognized temporal functions") - func allPresetsRoundTripThroughTemporalDetector() { - let cases: [ColumnType] = [ - .date(rawType: "DATE"), - .timestamp(rawType: "TIMESTAMP"), - .datetime(rawType: "DATETIME"), - .timestamp(rawType: "TIME"), - ] - for columnType in cases { - for preset in DataGridRowView.dateValueFunctions(for: columnType) { - #expect( - SQLEscaping.isTemporalFunction(preset), - "preset \(preset) for \(columnType) must be recognized by SQLEscaping" - ) - } - } + private struct Fixture { + let manager: DataChangeManager + let coordinator: TableViewCoordinator + let store: SetValueMenuRowStore + } + + private func makeFixture( + columns: [String], + columnTypes: [ColumnType], + rows: [[PluginCellValue]], + columnNullable: [String: Bool] = [:], + columnDefaults: [String: String?] = [:], + columnForeignKeys: [String: ForeignKeyInfo] = [:], + databaseType: DatabaseType = .postgresql + ) -> Fixture { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "t", + columns: columns, + primaryKeyColumns: [columns[0]], + databaseType: databaseType, + generatedColumns: [] + ) + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: SetValueMenuLayoutPersister() + ) + let store = SetValueMenuRowStore(TableRows.from( + queryRows: rows, + columns: columns, + columnTypes: columnTypes, + columnDefaults: columnDefaults, + columnForeignKeys: columnForeignKeys, + columnNullable: columnNullable + )) + coordinator.tableRowsProvider = { store.tableRows } + coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } + coordinator.databaseType = databaseType + coordinator.updateCache() + return Fixture(manager: manager, coordinator: coordinator, store: store) + } + + private func setValueMenu(_ fixture: Fixture, row: Int = 0, column: Int) -> (DataGridRowView, NSMenu) { + let rowView = DataGridRowView() + rowView.coordinator = fixture.coordinator + rowView.rowIndex = row + let targets = fixture.coordinator.setValueTargets(clicked: SetValueTarget(displayRow: row, columnIndex: column)) + let menu = rowView.buildSetValueMenu( + commands: fixture.coordinator.setValueCommands(for: targets), + clickedColumn: column + ) + return (rowView, menu) + } + + private func titles(_ menu: NSMenu) -> [String] { + menu.items.map { $0.isSeparatorItem ? "-" : $0.title } + } + + private func uuidKeyFixture() -> Fixture { + makeFixture( + columns: ["id", "name"], + columnTypes: [.text(rawType: "uuid"), .text(rawType: "text")], + rows: [[.null, .text("a")]], + columnNullable: ["id": false, "name": true] + ) + } + + @Test("A PostgreSQL uuid key, NOT NULL with no default, offers Generate UUID and Generate UUIDv7") + func reportersUUIDKey() { + let (_, menu) = setValueMenu(uuidKeyFixture(), column: 0) + + #expect(titles(menu) == ["Generate UUID", "Generate UUIDv7"]) + } + + @Test("A date column lists the engine's functions after the placeholders, behind a separator") + func dateColumnFunctions() { + let fixture = makeFixture( + columns: ["id", "born"], + columnTypes: [.integer(rawType: "INT"), .date(rawType: "DATE")], + rows: [[.text("1"), .text("2024-01-01")]], + columnNullable: ["born": true], + databaseType: .mysql + ) + + let (_, menu) = setValueMenu(fixture, column: 1) + + #expect(titles(menu) == ["NULL", "-", "CURDATE()", "UTC_DATE()"]) + } + + @Test("A nullable uuid column with a default keeps NULL and Default apart from the UUIDs") + func uuidWithDefaultGroups() { + let fixture = makeFixture( + columns: ["id", "ref"], + columnTypes: [.integer(rawType: "int4"), .text(rawType: "uuid")], + rows: [[.text("1"), .null]], + columnNullable: ["ref": true], + columnDefaults: ["ref": "gen_random_uuid()"] + ) + + let (_, menu) = setValueMenu(fixture, column: 1) + + #expect(titles(menu) == ["NULL", "Default", "-", "Generate UUID", "Generate UUIDv7"]) + } + + @Test("A uuid foreign key offers no new UUID, which would point at no row") + func foreignKeyOffersNoUUID() { + let fixture = makeFixture( + columns: ["id", "owner"], + columnTypes: [.integer(rawType: "int4"), .text(rawType: "uuid")], + rows: [[.text("1"), .null]], + columnNullable: ["owner": true], + columnForeignKeys: [ + "owner": ForeignKeyInfo(name: "fk", column: "owner", referencedTable: "users", referencedColumn: "id") + ] + ) + + let (_, menu) = setValueMenu(fixture, column: 1) + + #expect(titles(menu) == ["NULL"]) + } + + @Test("Choosing Generate UUID writes a new lowercase UUID into the clicked cell") + func choosingGenerateUUIDWritesIt() throws { + let fixture = uuidKeyFixture() + let (rowView, menu) = setValueMenu(fixture, column: 0) + let item = try #require(menu.items.first { $0.title == "Generate UUID" }) + let action = try #require(item.action) + + _ = rowView.perform(action, with: item) + + let staged = try #require( + fixture.manager.pending.change(forRow: .existing(0), type: .update)?.cellChanges.first?.newValue.asText + ) + #expect(staged == staged.lowercased()) + #expect(UUID(uuidString: staged) != nil) + #expect(fixture.store.tableRows.rows[0].values[0] == .text(staged)) + } + + @Test("The cell's context menu carries Set Value on the reporter's uuid key") + func contextMenuCarriesSetValue() throws { + let fixture = uuidKeyFixture() + let tableView = KeyHandlingTableView() + tableView.coordinator = fixture.coordinator + fixture.coordinator.tableView = tableView + let rowView = DataGridRowView() + rowView.coordinator = fixture.coordinator + rowView.rowIndex = 0 + + let menu = try #require(rowView.contextMenu(target: .cell(dataColumn: 0))) + let setValue = try #require(menu.items.first { $0.title == "Set Value" }?.submenu) + + #expect(titles(setValue) == ["Generate UUID", "Generate UUIDv7"]) + } + + @Test("A read-only grid has no Set Value at all") + func readOnlyGridOffersNothing() { + let fixture = uuidKeyFixture() + fixture.coordinator.isEditable = false + + let (_, menu) = setValueMenu(fixture, column: 1) + + #expect(menu.items.isEmpty) } } diff --git a/TableProTests/Views/Results/Extensions/ClearedCellCommitTests.swift b/TableProTests/Views/Results/Extensions/ClearedCellCommitTests.swift index c5b56bcc53..f1d77c3d05 100644 --- a/TableProTests/Views/Results/Extensions/ClearedCellCommitTests.swift +++ b/TableProTests/Views/Results/Extensions/ClearedCellCommitTests.swift @@ -43,12 +43,12 @@ private struct ClearedCellFixture { let coordinator: TableViewCoordinator let store: ClearedCellRowStore - init() { + init(tableRows: TableRows = Self.usersRows) { manager = DataChangeManager() manager.configureForTable( tableName: "users", - columns: Self.columns, - primaryKeyColumns: ["id"], + columns: tableRows.columns, + primaryKeyColumns: [tableRows.columns[0]], databaseType: .mysql, generatedColumns: [] ) @@ -59,15 +59,24 @@ private struct ClearedCellFixture { delegate: nil, layoutPersister: NoopClearedCellLayoutPersister() ) + coordinator.databaseType = .mysql + store = ClearedCellRowStore(tableRows) + let store = store + coordinator.tableRowsProvider = { store.tableRows } + coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } + coordinator.updateCache() + } + + static var usersRows: TableRows { let created: PluginCellValue = .text("2023-12-01 09:00:00") let doc: PluginCellValue = .text("{\"a\":1}") - store = ClearedCellRowStore(TableRows.from( + return TableRows.from( queryRows: [ [.text("1"), .text("2024-01-01 10:00:00"), .text("Alice"), .text("3"), created, doc], [.text("2"), .text("2024-02-02 10:00:00"), .text("Bob"), .text("4"), created, doc], [.text("3"), .text("2024-03-03 10:00:00"), .text("Carol"), .text("5"), created, doc] ], - columns: Self.columns, + columns: columns, columnTypes: [ .integer(rawType: "INT"), .datetime(rawType: "DATETIME"), @@ -77,11 +86,12 @@ private struct ClearedCellFixture { .json(rawType: "JSON") ], columnNullable: ["deleted_at": true, "name": true, "created_at": false] - )) - let store = store - coordinator.tableRowsProvider = { store.tableRows } - coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } - coordinator.updateCache() + ) + } + + func setValueTitles(row: Int = 0, column: Int) -> [String] { + let targets = coordinator.setValueTargets(clicked: SetValueTarget(displayRow: row, columnIndex: column)) + return coordinator.setValueCommands(for: targets).map { SetValueOptions.title(for: $0) } } func value(row: Int, column: Int) -> PluginCellValue { @@ -153,11 +163,11 @@ struct ClearedCellCommitTests { @Test("An empty Fill Column on a date writes NULL into every row") func emptyFillOnDateIsNull() throws { let fixture = ClearedCellFixture() - let value = try #require( - fixture.coordinator.fillColumnValue(text: "", setNull: false, columnIndex: ClearedCellFixture.deletedAt) + let content = try #require( + fixture.coordinator.fillColumnContent(choice: .value, text: "", columnIndex: ClearedCellFixture.deletedAt) ) - fixture.coordinator.applyFillColumn(columnIndex: ClearedCellFixture.deletedAt, value: value) + fixture.coordinator.applyFillColumn(columnIndex: ClearedCellFixture.deletedAt, content: content) for row in 0..<3 { #expect(fixture.stagedValue(row: row, column: ClearedCellFixture.deletedAt) == .null) @@ -168,27 +178,21 @@ struct ClearedCellCommitTests { func emptyFillOnNotNullTimestampIsRefused() { let fixture = ClearedCellFixture() - #expect(fixture.coordinator.fillColumnValue(text: "", setNull: false, columnIndex: ClearedCellFixture.createdAt) == nil) + let coordinator = fixture.coordinator + #expect(coordinator.fillColumnContent(choice: .value, text: "", columnIndex: ClearedCellFixture.createdAt) == nil) #expect( - fixture.coordinator.fillColumnValue(text: "2024-05-05 00:00:00", setNull: false, columnIndex: ClearedCellFixture.createdAt) - == .text("2024-05-05 00:00:00") + coordinator.fillColumnContent(choice: .value, text: "2024-05-05 00:00:00", columnIndex: ClearedCellFixture.createdAt) + == .value(.text("2024-05-05 00:00:00")) ) } @Test("Set Value offers Empty on a text column and not on a date or a number") func setValueEmptyFollowsColumnType() { let fixture = ClearedCellFixture() - let rowView = DataGridRowView() - - let dateTitles = rowView.buildSetValueMenu( - dataColumnIndex: ClearedCellFixture.deletedAt, tableRows: fixture.store.tableRows - ).items.map { $0.title } - let numberTitles = rowView.buildSetValueMenu( - dataColumnIndex: ClearedCellFixture.visits, tableRows: fixture.store.tableRows - ).items.map { $0.title } - let textTitles = rowView.buildSetValueMenu( - dataColumnIndex: ClearedCellFixture.name, tableRows: fixture.store.tableRows - ).items.map { $0.title } + + let dateTitles = fixture.setValueTitles(column: ClearedCellFixture.deletedAt) + let numberTitles = fixture.setValueTitles(column: ClearedCellFixture.visits) + let textTitles = fixture.setValueTitles(column: ClearedCellFixture.name) #expect(!dateTitles.contains("Empty")) #expect(dateTitles.contains("NULL")) @@ -198,28 +202,27 @@ struct ClearedCellCommitTests { @Test("Set Value offers Empty on an enum only when one of its labels is empty") func setValueEmptyOnEnumFollowsLabels() { - let tableRows = TableRows.from( + let fixture = ClearedCellFixture(tableRows: TableRows.from( queryRows: [[.text("a"), .text("x")]], columns: ["plain", "withEmpty"], columnTypes: [.enumType(rawType: "ENUM", values: nil), .enumType(rawType: "ENUM", values: nil)], columnEnumValues: ["plain": ["a", "b"], "withEmpty": ["", "x"]] - ) - let rowView = DataGridRowView() + )) - #expect(!rowView.buildSetValueMenu(dataColumnIndex: 0, tableRows: tableRows).items.map { $0.title }.contains("Empty")) - #expect(rowView.buildSetValueMenu(dataColumnIndex: 1, tableRows: tableRows).items.map { $0.title }.contains("Empty")) + #expect(!fixture.setValueTitles(column: 0).contains("Empty")) + #expect(fixture.setValueTitles(column: 1).contains("Empty")) } @Test("A NOT NULL number with no default offers no Set Value items at all") func setValueMenuEmptyForNotNullNumber() { - let tableRows = TableRows.from( + let fixture = ClearedCellFixture(tableRows: TableRows.from( queryRows: [[.text("1")]], columns: ["visits"], columnTypes: [.integer(rawType: "INT")], columnNullable: ["visits": false] - ) + )) - #expect(DataGridRowView().buildSetValueMenu(dataColumnIndex: 0, tableRows: tableRows).items.isEmpty) + #expect(fixture.setValueTitles(column: 0).isEmpty) } @Test("Pasted lines keep an empty line as a value and drop the closing line breaks") diff --git a/TableProTests/Views/Results/Extensions/DataGridSetValueTests.swift b/TableProTests/Views/Results/Extensions/DataGridSetValueTests.swift new file mode 100644 index 0000000000..439c610558 --- /dev/null +++ b/TableProTests/Views/Results/Extensions/DataGridSetValueTests.swift @@ -0,0 +1,355 @@ +// +// DataGridSetValueTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class SetValueLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class SetValueRowStore { + var tableRows: TableRows + + init(_ tableRows: TableRows) { + self.tableRows = tableRows + } +} + +/// The app hands the change manager the window's undo manager, so the grid's group and the cells' steps share one. +@MainActor +private final class SetValueUndoWindow: NSWindow { + let undo = UndoManager() + + override var undoManager: UndoManager? { undo } +} + +private final class NoCapabilityDriver: PluginDriverStub, @unchecked Sendable {} + +@MainActor +private struct SetValueFixture { + static let columns = ["id", "ref", "note", "label", "seen"] + static let id = 0 + static let ref = 1 + static let note = 2 + static let label = 3 + static let seen = 4 + + let manager: DataChangeManager + let coordinator: TableViewCoordinator + let store: SetValueRowStore + + init(selectedRows: Set = [], rowCount: Int = 3) { + manager = DataChangeManager() + manager.configureForTable( + tableName: "things", + columns: Self.columns, + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: true, + selectedRowIndices: .constant(selectedRows), + delegate: nil, + layoutPersister: SetValueLayoutPersister() + ) + let rows: [[PluginCellValue]] = (0.. PluginCellValue? { + manager.pending.change(forRow: .existing(row), type: .update)? + .cellChanges.first { $0.columnIndex == column }?.newValue + } + + func select(rows: ClosedRange, columns: ClosedRange) { + coordinator.selectionController.update(.single( + GridRect(rows: rows, columns: columns), + anchor: GridCoord(row: rows.lowerBound, displayColumn: columns.lowerBound), + active: GridCoord(row: rows.upperBound, displayColumn: columns.upperBound) + )) + } + + func cell(_ row: Int, _ column: Int) -> SetValueTarget { + SetValueTarget(displayRow: row, columnIndex: column) + } + + func attachUndoWindow() -> SetValueUndoWindow { + let window = SetValueUndoWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + window.undo.groupsByEvent = false + let tableView = KeyHandlingTableView() + window.contentView?.addSubview(tableView) + tableView.coordinator = coordinator + coordinator.tableView = tableView + let undo = window.undo + manager.undoManagerProvider = { undo } + return window + } +} + +@MainActor +struct DataGridSetValueTests { + @Test("Generate UUID over a cell selection writes a different UUID into every cell") + func uuidPerSelectedCell() throws { + let fixture = SetValueFixture() + fixture.select(rows: 0...2, columns: SetValueFixture.id...SetValueFixture.ref) + + fixture.coordinator.applySetValue(.uuid(.v4), to: fixture.coordinator.setValueTargets(clicked: nil)) + + var written: [String] = [] + for row in 0..<3 { + for column in [SetValueFixture.id, SetValueFixture.ref] { + let text = try #require(fixture.staged(row: row, column: column)?.asText) + #expect(UUID(uuidString: text) != nil) + #expect(text == text.lowercased()) + written.append(text) + } + } + #expect(Set(written).count == 6) + } + + @Test("Generate UUID on an empty CHAR column the UUID format marks takes the length its schema declares") + func uuidTakesTheSchemaLength() throws { + let fixture = SetValueFixture() + fixture.store.tableRows.columnTypes[SetValueFixture.ref] = .text(rawType: "CHAR") + fixture.store.tableRows.columnDeclaredTypes = ["ref": "CHAR(32)"] + fixture.coordinator.syncDisplayFormats([nil, .uuid, nil, nil, nil]) + + fixture.coordinator.applySetValue(.uuid(.v4), to: [fixture.cell(0, SetValueFixture.ref)]) + + let text = try #require(fixture.staged(row: 0, column: SetValueFixture.ref)?.asText) + #expect(text.count == 32) + #expect(text.allSatisfy { $0.isHexDigit }) + } + + @Test("A Set Value over many cells is one undo step, named for the command") + func oneUndoStep() { + let fixture = SetValueFixture() + let window = fixture.attachUndoWindow() + defer { window.orderOut(nil) } + let targets = (0..<3).map { fixture.cell($0, SetValueFixture.id) } + + fixture.coordinator.applySetValue(.uuid(.v7), to: targets) + + #expect(window.undo.undoActionName == "Generate UUIDv7") + window.undo.undo() + #expect(!window.undo.canUndo) + #expect(!fixture.manager.hasChanges) + } + + @Test("NULL over a selection is an undo step named Set Value") + func nullUndoName() { + let fixture = SetValueFixture() + let window = fixture.attachUndoWindow() + defer { window.orderOut(nil) } + + fixture.coordinator.applySetValue( + .null, to: [fixture.cell(0, SetValueFixture.note), fixture.cell(1, SetValueFixture.note)] + ) + + #expect(window.undo.undoActionName == "Set Value") + } + + @Test("Without a cell selection the clicked column is written on every selected row") + func clickedColumnAcrossSelectedRows() { + let fixture = SetValueFixture(selectedRows: [0, 2]) + + let onSelectedRow = fixture.coordinator.setValueTargets(clicked: fixture.cell(2, SetValueFixture.note)) + let onOtherRow = fixture.coordinator.setValueTargets(clicked: fixture.cell(1, SetValueFixture.note)) + + #expect(onSelectedRow == [fixture.cell(0, SetValueFixture.note), fixture.cell(2, SetValueFixture.note)]) + #expect(onOtherRow == [fixture.cell(1, SetValueFixture.note)]) + } + + @Test("A click inside the cell selection acts on the selection, a click outside it on the clicked cell") + func clickInsideOrOutsideSelection() { + let fixture = SetValueFixture() + fixture.select(rows: 0...1, columns: SetValueFixture.note...SetValueFixture.note) + + let inside = fixture.coordinator.setValueTargets(clicked: fixture.cell(1, SetValueFixture.note)) + let outside = fixture.coordinator.setValueTargets(clicked: fixture.cell(2, SetValueFixture.label)) + + #expect(inside == [fixture.cell(0, SetValueFixture.note), fixture.cell(1, SetValueFixture.note)]) + #expect(outside == [fixture.cell(2, SetValueFixture.label)]) + } + + @Test("Cells that do not take the command, and deleted rows, are left as they were") + func skipsCellsTheCommandDoesNotFit() { + let fixture = SetValueFixture() + fixture.manager.recordRowDeletion( + rowID: .existing(1), originalRow: Array(fixture.store.tableRows.rows[1].values) + ) + fixture.select(rows: 0...2, columns: SetValueFixture.note...SetValueFixture.label) + + fixture.coordinator.applySetValue(.null, to: fixture.coordinator.setValueTargets(clicked: nil)) + + #expect(fixture.staged(row: 0, column: SetValueFixture.note) == .null) + #expect(fixture.staged(row: 2, column: SetValueFixture.note) == .null) + #expect(fixture.staged(row: 0, column: SetValueFixture.label) == nil) + #expect(fixture.store.tableRows.rows[1].values[SetValueFixture.note] == .text("n1")) + #expect(fixture.store.tableRows.rows[0].values[SetValueFixture.label] == .text("l0")) + } + + @Test("A selection offers every command one of its cells takes, in menu order") + func offeredCommandsAreTheUnion() { + let fixture = SetValueFixture() + fixture.select(rows: 0...0, columns: SetValueFixture.id...SetValueFixture.note) + let targets = fixture.coordinator.setValueTargets(clicked: nil) + + #expect(fixture.coordinator.setValueCommands(for: targets) == [.empty, .null, .uuid(.v4), .uuid(.v7)]) + #expect(fixture.coordinator.isSetValueOffered(.uuid(.v4), for: targets)) + #expect(!fixture.coordinator.isSetValueOffered(.defaultValue, for: targets)) + } + + @Test("A function chosen from Set Value is staged as SQL, not as text") + func expressionIsMarked() throws { + let fixture = SetValueFixture() + let now = try #require( + CellExpressionVocabulary.expressions(for: .postgresql, columnType: .timestamp(rawType: "timestamptz")) + .first { $0.sql == "now()" } + ) + + fixture.coordinator.applySetValue(.expression(now), to: [fixture.cell(0, SetValueFixture.seen)]) + + #expect(fixture.staged(row: 0, column: SetValueFixture.seen) == .text("now()")) + #expect(fixture.manager.isExpression(rowID: .existing(0), columnIndex: SetValueFixture.seen)) + } + + @Test("Typing a function into a timestamp stages its SQL, and the same text in a text cell stays text") + func typedFunctionIsPromotedOnlyWhereItFits() { + let fixture = SetValueFixture() + + fixture.coordinator.commitCellEdit(row: 0, columnIndex: SetValueFixture.seen, newValue: " NOW() ") + fixture.coordinator.commitCellEdit(row: 0, columnIndex: SetValueFixture.note, newValue: "NOW()") + + #expect(fixture.staged(row: 0, column: SetValueFixture.seen) == .text("now()")) + #expect(fixture.manager.isExpression(rowID: .existing(0), columnIndex: SetValueFixture.seen)) + #expect(fixture.staged(row: 0, column: SetValueFixture.note) == .text("NOW()")) + #expect(!fixture.manager.isExpression(rowID: .existing(0), columnIndex: SetValueFixture.note)) + } + + @Test("A driver that does not write SQL into cells gets typed text as it was typed") + func typedFunctionStaysLiteralForAnOldDriver() { + let fixture = SetValueFixture() + fixture.manager.pluginDriver = NoCapabilityDriver() + + fixture.coordinator.commitCellEdit(row: 0, columnIndex: SetValueFixture.seen, newValue: "NOW()") + + #expect(fixture.staged(row: 0, column: SetValueFixture.seen) == .text("NOW()")) + #expect(!fixture.manager.isExpression(rowID: .existing(0), columnIndex: SetValueFixture.seen)) + } + + @Test("The same text as a literal over a staged function is an edit") + func literalOverExpressionIsAnEdit() throws { + let fixture = SetValueFixture() + fixture.coordinator.commitCellEdit(row: 0, columnIndex: SetValueFixture.seen, newValue: "now()") + #expect(fixture.manager.isExpression(rowID: .existing(0), columnIndex: SetValueFixture.seen)) + + fixture.manager.pluginDriver = NoCapabilityDriver() + fixture.coordinator.commitCellEdit(row: 0, columnIndex: SetValueFixture.seen, newValue: "now()") + + #expect(!fixture.manager.isExpression(rowID: .existing(0), columnIndex: SetValueFixture.seen)) + #expect(fixture.staged(row: 0, column: SetValueFixture.seen) == .text("now()")) + } +} + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct DataGridSetValuePasteTests { + @Test("A function name pasted into a timestamp cell stages its SQL") + func pastedFunctionIsPromoted() throws { + let clipboard = try #require(RecordingClipboard.current) + clipboard.text = "current_timestamp" + let fixture = SetValueFixture() + + #expect(fixture.coordinator.pasteCellsFromClipboard(anchorRow: 1, anchorColumn: SetValueFixture.seen)) + + #expect(fixture.staged(row: 1, column: SetValueFixture.seen) == .text("CURRENT_TIMESTAMP")) + #expect(fixture.manager.isExpression(rowID: .existing(1), columnIndex: SetValueFixture.seen)) + } + + @Test("A staged function copied from the grid pastes as SQL, whichever column it lands in") + func copiedExpressionPastesAsSQL() throws { + let clipboard = try #require(RecordingClipboard.current) + let fixture = SetValueFixture() + let now = try #require( + CellExpressionVocabulary.expressions(for: .postgresql, columnType: .timestamp(rawType: "timestamptz")) + .first { $0.sql == "now()" } + ) + fixture.coordinator.applySetValue(.expression(now), to: [fixture.cell(0, SetValueFixture.seen)]) + + fixture.coordinator.copyCellValue(at: 0, columnIndex: SetValueFixture.seen) + #expect(clipboard.gridCells?.expressionFields == [0: [0]]) + #expect(fixture.coordinator.pasteCellsFromClipboard(anchorRow: 1, anchorColumn: SetValueFixture.note)) + + #expect(fixture.staged(row: 1, column: SetValueFixture.note) == .text("now()")) + #expect(fixture.manager.isExpression(rowID: .existing(1), columnIndex: SetValueFixture.note)) + } + + @Test("Stored text that spells a function, copied from the grid, pastes as the same text") + func copiedLiteralIsNotPromoted() throws { + let fixture = SetValueFixture() + _ = fixture.store.tableRows.edit(row: 0, column: SetValueFixture.seen, value: .text("CURRENT_TIMESTAMP")) + + fixture.coordinator.copyCellValue(at: 0, columnIndex: SetValueFixture.seen) + #expect(try #require(RecordingClipboard.current).gridCells?.expressionFields == nil) + #expect(fixture.coordinator.pasteCellsFromClipboard(anchorRow: 1, anchorColumn: SetValueFixture.seen)) + + #expect(fixture.staged(row: 1, column: SetValueFixture.seen) == .text("CURRENT_TIMESTAMP")) + #expect(!fixture.manager.isExpression(rowID: .existing(1), columnIndex: SetValueFixture.seen)) + } + + @Test("A copied block marks only the fields that held staged SQL") + func copiedBlockMarksEachField() throws { + let clipboard = try #require(RecordingClipboard.current) + let fixture = SetValueFixture() + fixture.coordinator.commitCellEdit(row: 0, columnIndex: SetValueFixture.seen, newValue: "now()") + fixture.select(rows: 0...0, columns: SetValueFixture.note...SetValueFixture.seen) + + fixture.coordinator.copyGridSelection(fixture.coordinator.selectionController.selection) + #expect(clipboard.gridCells?.fields == [["n0", "l0", "now()"]]) + #expect(clipboard.gridCells?.expressionFields == [0: [2]]) + #expect(fixture.coordinator.pasteCellsFromClipboard(anchorRow: 2, anchorColumn: SetValueFixture.note)) + + #expect(fixture.manager.isExpression(rowID: .existing(2), columnIndex: SetValueFixture.seen)) + #expect(!fixture.manager.isExpression(rowID: .existing(2), columnIndex: SetValueFixture.note)) + } + } +} diff --git a/TableProTests/Views/Results/Extensions/FillColumnTests.swift b/TableProTests/Views/Results/Extensions/FillColumnTests.swift index 8bd1d1baa1..1960d40177 100644 --- a/TableProTests/Views/Results/Extensions/FillColumnTests.swift +++ b/TableProTests/Views/Results/Extensions/FillColumnTests.swift @@ -2,11 +2,6 @@ // FillColumnTests.swift // TableProTests // -// Locks Fill Column behaviour at two levels: the pure decisions (which loaded -// rows a fill targets, how the dialog resolves NULL vs an empty string) and -// the effect (applyFillColumn records one staged change per loaded row through -// DataChangeManager, skipping deleted rows and read-only result sets). -// import Foundation import SwiftUI @@ -28,6 +23,9 @@ struct FillColumnTests { columns: [String], rowCount: Int, isEditable: Bool = true, + columnTypes: [ColumnType]? = nil, + columnNullable: [String: Bool] = [:], + databaseType: DatabaseType? = nil, manager: DataChangeManager ) -> TableViewCoordinator { let coordinator = TableViewCoordinator( @@ -37,18 +35,27 @@ struct FillColumnTests { delegate: nil, layoutPersister: NoopColumnLayoutPersister() ) - let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) + let types = columnTypes ?? Array(repeating: .text(rawType: nil), count: columns.count) let rows = (0..) -> [PluginCellValue?] { + rows.map { row in + manager.pending.change(forRow: .existing(row), type: .update)? + .cellChanges.first { $0.columnIndex == column }?.newValue + } + } + // MARK: - Target selection @Test("Targets every loaded row when editable and none are deleted") @@ -96,9 +103,42 @@ struct FillColumnTests { @Test("Resolves NULL distinctly from an empty string") func resolvesNullDistinctFromEmpty() { let coordinator = makeCoordinator(columns: ["a"], rowCount: 1, manager: DataChangeManager()) - #expect(coordinator.fillColumnValue(text: "ignored", setNull: true, columnIndex: 0) == .null) - #expect(coordinator.fillColumnValue(text: "", setNull: false, columnIndex: 0) == .text("")) - #expect(coordinator.fillColumnValue(text: "active", setNull: false, columnIndex: 0) == .text("active")) + #expect(coordinator.fillColumnContent(choice: .null, text: "ignored", columnIndex: 0) == .value(.null)) + #expect(coordinator.fillColumnContent(choice: .value, text: "", columnIndex: 0) == .value(.text(""))) + #expect(coordinator.fillColumnContent(choice: .value, text: "active", columnIndex: 0) == .value(.text("active"))) + } + + @Test("Offers NULL where the column takes it, and the UUIDs only on a column that holds them") + func choicesFollowTheColumn() { + let coordinator = makeCoordinator( + columns: ["id", "name", "code"], + rowCount: 1, + columnTypes: [.text(rawType: "uuid"), .text(rawType: "text"), .text(rawType: "text")], + columnNullable: ["id": false, "name": true, "code": false], + databaseType: .postgresql, + manager: DataChangeManager() + ) + + #expect(coordinator.fillColumnChoices(columnIndex: 0) == [.value, .uuid(.v4), .uuid(.v7)]) + #expect(coordinator.fillColumnChoices(columnIndex: 1) == [.value, .null]) + #expect(coordinator.fillColumnChoices(columnIndex: 2) == [.value]) + } + + @Test("A typed function on a date column fills as SQL, and the same text on a text column as text") + func typedFunctionFillsAsExpression() { + let coordinator = makeCoordinator( + columns: ["seen", "note"], + rowCount: 1, + columnTypes: [.timestamp(rawType: "timestamptz"), .text(rawType: "text")], + databaseType: .postgresql, + manager: DataChangeManager() + ) + + #expect( + coordinator.fillColumnContent(choice: .value, text: " NOW() ", columnIndex: 0) + == .value(.text("now()"), isExpression: true) + ) + #expect(coordinator.fillColumnContent(choice: .value, text: "NOW()", columnIndex: 1) == .value(.text("NOW()"))) } @Test("Impact description reflects singular and plural counts") @@ -114,7 +154,7 @@ struct FillColumnTests { let manager = DataChangeManager() let coordinator = makeCoordinator(columns: ["a", "b"], rowCount: 4, manager: manager) - coordinator.applyFillColumn(columnIndex: 0, value: .text("X")) + coordinator.applyFillColumn(columnIndex: 0, content: .value(.text("X"))) for row in 0..<4 { #expect(manager.pending.isCellModified(rowID: .existing(row), columnIndex: 0)) @@ -128,7 +168,7 @@ struct FillColumnTests { let coordinator = makeCoordinator(columns: ["a"], rowCount: 4, manager: manager) manager.recordRowDeletion(rowID: .existing(2), originalRow: [.text("r2c0")]) - coordinator.applyFillColumn(columnIndex: 0, value: .text("X")) + coordinator.applyFillColumn(columnIndex: 0, content: .value(.text("X"))) #expect(manager.pending.isCellModified(rowID: .existing(0), columnIndex: 0)) #expect(manager.pending.isCellModified(rowID: .existing(2), columnIndex: 0) == false) @@ -139,7 +179,7 @@ struct FillColumnTests { let manager = DataChangeManager() let coordinator = makeCoordinator(columns: ["a"], rowCount: 4, isEditable: false, manager: manager) - coordinator.applyFillColumn(columnIndex: 0, value: .text("X")) + coordinator.applyFillColumn(columnIndex: 0, content: .value(.text("X"))) #expect(manager.hasChanges == false) } @@ -149,7 +189,7 @@ struct FillColumnTests { let manager = DataChangeManager() let coordinator = makeCoordinator(columns: ["a"], rowCount: 2, manager: manager) - coordinator.applyFillColumn(columnIndex: 0, value: .null) + coordinator.applyFillColumn(columnIndex: 0, content: .value(.null)) let change = manager.pending.change(forRow: .existing(0), type: .update) #expect(change?.cellChanges.first?.newValue == .null) @@ -160,9 +200,60 @@ struct FillColumnTests { let manager = DataChangeManager() let coordinator = makeCoordinator(columns: ["a"], rowCount: 2, manager: manager) - coordinator.applyFillColumn(columnIndex: 0, value: .text("r0c0")) + coordinator.applyFillColumn(columnIndex: 0, content: .value(.text("r0c0"))) #expect(manager.pending.isCellModified(rowID: .existing(0), columnIndex: 0) == false) #expect(manager.pending.isCellModified(rowID: .existing(1), columnIndex: 0)) } + + @Test("New UUID writes a different UUID into every row") + func uuidFillDrawsOnePerRow() throws { + let manager = DataChangeManager() + let coordinator = makeCoordinator( + columns: ["id"], + rowCount: 3, + columnTypes: [.text(rawType: "uuid")], + databaseType: .postgresql, + manager: manager + ) + let content = try #require(coordinator.fillColumnContent(choice: .uuid(.v7), text: "", columnIndex: 0)) + + coordinator.applyFillColumn(columnIndex: 0, content: content) + + let staged = stagedValues(manager, column: 0, rows: 0..<3).compactMap { $0?.asText } + #expect(staged.count == 3) + #expect(Set(staged).count == 3) + #expect(staged.allSatisfy { UUID(uuidString: $0) != nil && $0 == $0.lowercased() }) + #expect(staged == staged.sorted()) + } + + @Test("A fill function is staged as SQL on every row") + func expressionFillIsMarked() { + let manager = DataChangeManager() + let coordinator = makeCoordinator( + columns: ["seen"], + rowCount: 2, + columnTypes: [.timestamp(rawType: "timestamptz")], + databaseType: .postgresql, + manager: manager + ) + + coordinator.applyFillColumn(columnIndex: 0, content: .value(.text("now()"), isExpression: true)) + + #expect(manager.isExpression(rowID: .existing(0), columnIndex: 0)) + #expect(manager.isExpression(rowID: .existing(1), columnIndex: 0)) + } + + @Test("No UUID content on a column that holds none") + func uuidFillRefusedOnText() { + let coordinator = makeCoordinator( + columns: ["name"], + rowCount: 1, + columnTypes: [.text(rawType: "text")], + databaseType: .postgresql, + manager: DataChangeManager() + ) + + #expect(coordinator.fillColumnContent(choice: .uuid(.v4), text: "", columnIndex: 0) == nil) + } } diff --git a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift index c7809ceddc..6cda37d347 100644 --- a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift +++ b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift @@ -4,6 +4,7 @@ // import AppKit +import Foundation import SwiftUI @testable import TablePro import TableProPluginKit @@ -52,4 +53,37 @@ struct InlineEditEligibilityTests { let coordinator = makeCoordinator(columnType: .text(rawType: "TEXT"), value: "hello") #expect(coordinator.canStartInlineEdit(row: 0, columnIndex: 0)) } + + private func makeNewRowCoordinator() -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: StubColumnLayoutPersister() + ) + let tableRows = TableRows.from( + queryRows: [[.text("0b8f6c1e-4a7e-4d43-9d0c-2f4c5b6a7e81"), .bytes(Data([1])), .null, .null]], + columns: ["id", "photo", "name", "note"], + columnTypes: [.text(rawType: "uuid"), .blob(rawType: "bytea"), .text(rawType: "text"), .text(rawType: "text")] + ) + coordinator.tableRowsProvider = { tableRows } + return coordinator + } + + @Test("The caret on a new row passes over the columns it was given a value for") + func caretSkipsPrefilledColumns() { + let coordinator = makeNewRowCoordinator() + + #expect(coordinator.firstEditableColumn(displayRow: 0) == 0) + #expect(coordinator.firstEditableColumn(displayRow: 0, skipping: [0]) == 2) + #expect(coordinator.firstEditableColumn(displayRow: 0, skipping: [0, 2]) == 3) + } + + @Test("When every editable column was given a value the caret still opens on the first of them") + func caretFallsBackToAPrefilledColumn() { + let coordinator = makeNewRowCoordinator() + + #expect(coordinator.firstEditableColumn(displayRow: 0, skipping: [0, 2, 3]) == 0) + } } diff --git a/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift b/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift new file mode 100644 index 0000000000..b23bcd984d --- /dev/null +++ b/TableProTests/Views/Results/KeyHandlingTableViewSetValueTests.swift @@ -0,0 +1,175 @@ +// +// KeyHandlingTableViewSetValueTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class MenuBarSetValueLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class MenuBarSetValueRowStore { + var tableRows: TableRows + + init(_ tableRows: TableRows) { + self.tableRows = tableRows + } +} + +@MainActor +struct KeyHandlingTableViewSetValueTests { + private static let id = 0 + private static let note = 1 + + private struct SUT { + let manager: DataChangeManager + let coordinator: TableViewCoordinator + let tableView: KeyHandlingTableView + let store: MenuBarSetValueRowStore + } + + /// Built through the column pool, so the focused positions are the ones the grid really has. + private func makeSUT(isEditable: Bool = true) -> SUT { + let manager = DataChangeManager() + manager.configureForTable( + tableName: "things", + columns: ["id", "note"], + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: isEditable, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: MenuBarSetValueLayoutPersister() + ) + let store = MenuBarSetValueRowStore(TableRows.from( + queryRows: [[.null, .text("a")], [.null, .text("b")]], + columns: ["id", "note"], + columnTypes: [.text(rawType: "uuid"), .text(rawType: "text")], + columnNullable: ["id": false, "note": true] + )) + coordinator.tableRowsProvider = { store.tableRows } + coordinator.tableRowsMutator = { mutation in mutation(&store.tableRows) } + coordinator.databaseType = .postgresql + coordinator.rebuildColumnMetadataCache(from: store.tableRows) + coordinator.updateCache() + + let tableView = KeyHandlingTableView() + tableView.coordinator = coordinator + tableView.addTableColumn(DataGridView.makeRowNumberColumn()) + coordinator.tableView = tableView + coordinator.columnPool.reconcile( + tableView: tableView, + schema: coordinator.identitySchema, + columnTypes: [], + savedLayout: nil, + isEditable: isEditable, + hiddenColumnNames: [], + firstClickSortDirection: .ascending, + widthCalculator: { _, _ in 100 } + ) + return SUT(manager: manager, coordinator: coordinator, tableView: tableView, store: store) + } + + private func focus(_ sut: SUT, row: Int, dataColumn: Int) { + guard let identifier = sut.coordinator.identitySchema.identifier(for: dataColumn) else { return } + sut.tableView.focusedRow = row + sut.tableView.focusedColumn = sut.tableView.column(withIdentifier: identifier) + } + + private func isEnabled(_ selector: Selector, in sut: SUT) -> Bool { + sut.tableView.validateUserInterfaceItem(NSMenuItem(title: "", action: selector, keyEquivalent: "")) + } + + private static let empty = #selector(SetValueCommandResponding.setValueEmpty(_:)) + private static let null = #selector(SetValueCommandResponding.setValueNull(_:)) + private static let defaultValue = #selector(SetValueCommandResponding.setValueDefault(_:)) + private static let uuidV4 = #selector(SetValueCommandResponding.generateUUIDv4(_:)) + private static let uuidV7 = #selector(SetValueCommandResponding.generateUUIDv7(_:)) + + @Test("On the focused uuid key only the UUID items are enabled") + func focusedUUIDKey() { + let sut = makeSUT() + focus(sut, row: 0, dataColumn: Self.id) + + #expect(isEnabled(Self.uuidV4, in: sut)) + #expect(isEnabled(Self.uuidV7, in: sut)) + #expect(!isEnabled(Self.null, in: sut)) + #expect(!isEnabled(Self.empty, in: sut)) + #expect(!isEnabled(Self.defaultValue, in: sut)) + } + + @Test("On a focused nullable text cell Empty and NULL are enabled and the UUIDs are not") + func focusedTextCell() { + let sut = makeSUT() + focus(sut, row: 0, dataColumn: Self.note) + + #expect(isEnabled(Self.empty, in: sut)) + #expect(isEnabled(Self.null, in: sut)) + #expect(!isEnabled(Self.uuidV4, in: sut)) + } + + @Test("With no focused cell and no selection every item is dimmed") + func nothingFocused() { + let sut = makeSUT() + + for selector in [Self.empty, Self.null, Self.defaultValue, Self.uuidV4, Self.uuidV7] { + #expect(!isEnabled(selector, in: sut)) + } + } + + @Test("A read-only grid dims every item") + func readOnlyGrid() { + let sut = makeSUT(isEditable: false) + focus(sut, row: 0, dataColumn: Self.note) + + #expect(!isEnabled(Self.null, in: sut)) + #expect(!isEnabled(Self.empty, in: sut)) + } + + @Test("A selection across both columns enables what either one takes, and writes each only where it fits") + func selectionAcrossColumns() { + let sut = makeSUT() + sut.coordinator.selectionController.update(.single( + GridRect(rows: 0...1, columns: 0...1), + anchor: GridCoord(row: 0, displayColumn: 0), + active: GridCoord(row: 1, displayColumn: 1) + )) + + #expect(isEnabled(Self.uuidV4, in: sut)) + #expect(isEnabled(Self.null, in: sut)) + + sut.tableView.generateUUIDv4(nil) + + let ids = (0..<2).compactMap { sut.store.tableRows.rows[$0].values[Self.id].asText } + #expect(ids.count == 2) + #expect(Set(ids).count == 2) + #expect(ids.allSatisfy { UUID(uuidString: $0) != nil }) + #expect(sut.store.tableRows.rows[0].values[Self.note] == .text("a")) + #expect(sut.store.tableRows.rows[1].values[Self.note] == .text("b")) + } + + @Test("Set Value NULL from the menu bar writes the focused cell") + func menuBarNullWritesFocusedCell() { + let sut = makeSUT() + focus(sut, row: 1, dataColumn: Self.note) + + sut.tableView.setValueNull(nil) + + #expect(sut.store.tableRows.rows[1].values[Self.note] == .null) + #expect(sut.store.tableRows.rows[0].values[Self.note] == .text("a")) + } +} diff --git a/TableProTests/Views/Results/RowVisualIndexTests.swift b/TableProTests/Views/Results/RowVisualIndexTests.swift index 0be66ecc07..671a41f240 100644 --- a/TableProTests/Views/Results/RowVisualIndexTests.swift +++ b/TableProTests/Views/Results/RowVisualIndexTests.swift @@ -86,4 +86,27 @@ struct RowVisualIndexTests { #expect(index.visualState(for: .existing(0)) == .empty) #expect(index.visualState(for: .inserted(UUID())) == .empty) } + + @Test("A cell staged as SQL is marked, on a saved row's edit and on a new row alike") + func expressionCellsAreMarked() { + let manager = makeManager() + manager.recordCellChange( + rowID: .existing(1), columnIndex: 1, columnName: "name", + oldValue: "a", newValue: "now()", originalRow: ["1", "a"], + absence: FieldAbsence(), isExpression: true + ) + manager.recordCellChange( + rowID: .existing(2), columnIndex: 1, columnName: "name", + oldValue: "b", newValue: "now()", originalRow: ["2", "b"] + ) + let inserted = RowID.inserted(UUID()) + manager.recordRowInsertion(rowID: inserted, values: [.null, "now()"], expressionColumns: [1]) + let index = RowVisualIndex() + + index.rebuild(from: AnyChangeManager(manager)) + + #expect(index.visualState(for: .existing(1)).expressionColumns == [1]) + #expect(index.visualState(for: .existing(2)).expressionColumns.isEmpty) + #expect(index.visualState(for: inserted).expressionColumns == [1]) + } } diff --git a/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift b/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift index ec788afe1f..509f1e6e6b 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorRowIdentityTests.swift @@ -152,7 +152,7 @@ struct TableViewCoordinatorRowIdentityTests { filterToInactive(coordinator) manager.recordRowDeletion(rowID: .existing(3), originalRow: ["4", "inactive"]) - coordinator.applyFillColumn(columnIndex: 1, value: .text("gone")) + coordinator.applyFillColumn(columnIndex: 1, content: .value(.text("gone"))) #expect(manager.isCellModified(rowID: .existing(1), columnIndex: 1)) #expect(!manager.isCellModified(rowID: .existing(3), columnIndex: 1))