diff --git a/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+EmptyValue.swift b/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+EmptyValue.swift index 1972a01e21..86ca184f52 100644 --- a/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+EmptyValue.swift +++ b/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+EmptyValue.swift @@ -1,13 +1,7 @@ import Foundation public extension ColumnInfo { - /// Whether `''` is a value of this column's type, read from the declared type name alone. - /// - /// A date, number, boolean, JSON, UUID or geometry has no empty value: sent `''`, MySQL without - /// strict mode stores its zero value (`0000-00-00 00:00:00`, `0`), SQL Server stores - /// `1900-01-01` or `0`, and a strict MySQL or PostgreSQL refuses the write. Every other name, - /// including one this does not know, such as PostgreSQL's `USER-DEFINED` for a `citext` or a - /// domain, keeps `''`. + // Sent '', a typed column stores its zero value (lax MySQL, SQL Server) or refuses the write; an unknown name such as USER-DEFINED keeps ''. var holdsEmptyString: Bool { Self.holdsEmptyString(typeName: typeName) } @@ -21,8 +15,7 @@ public extension ColumnInfo { return !typedStems.contains(stem(of: base)) } - /// Type names by their first word with any width digits dropped, so `INT4`, `Int64`, - /// `DATETIME2`, `DateTime64` and `FLOAT8` read as their family. + // Matched on the first word with width digits dropped, so INT4, Int64, DATETIME2 and FLOAT8 read as their family. private static let typedStems: Set = [ "DATE", "TIME", "TIMETZ", "TIMESTAMP", "TIMESTAMPTZ", "TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_TZ", "DATETIME", "DATETIMEOFFSET", "SMALLDATETIME", "YEAR", "INTERVAL", @@ -35,7 +28,7 @@ public extension ColumnInfo { "MULTIPOLYGON", "GEOMETRYCOLLECTION" ] - private static func stem(of base: String) -> String { + internal static func stem(of base: String) -> String { var word = String(base.prefix { $0 != " " }) while word.count > 1, word.last?.isNumber == true { word.removeLast() @@ -43,7 +36,7 @@ public extension ColumnInfo { return word } - private static func unwrapped(_ typeName: String) -> String { + internal static func unwrapped(_ typeName: String) -> String { var name = typeName.trimmingCharacters(in: .whitespaces) while let wrapper = ["NULLABLE(", "LOWCARDINALITY("].first(where: { name.hasPrefix($0) }), name.hasSuffix(")") { name = String(name.dropFirst(wrapper.count).dropLast()) diff --git a/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+TypeFamily.swift b/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+TypeFamily.swift new file mode 100644 index 0000000000..4385b1e5dc --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProModels/ColumnInfo+TypeFamily.swift @@ -0,0 +1,46 @@ +import Foundation + +public extension ColumnInfo { + var isJSONType: Bool { + Self.isJSONType(typeName: typeName) + } + + var isNumericType: Bool { + Self.isNumericType(typeName: typeName) + } + + var mayHoldJSONText: Bool { + Self.mayHoldJSONText(typeName: typeName) + } + + static func isJSONType(typeName: String) -> Bool { + guard let stem = scalarStem(of: typeName) else { return false } + return stem == "JSON" || stem == "JSONB" + } + + static func isNumericType(typeName: String) -> Bool { + guard let stem = scalarStem(of: typeName) else { return false } + return numericStems.contains(stem) + } + + // DuckDB prints a list, struct or map as text that can parse as JSON, and PostgreSQL types an array result unknown. + static func mayHoldJSONText(typeName: String) -> Bool { + guard holdsEmptyString(typeName: typeName), let stem = scalarStem(of: typeName) else { return false } + return !nestedStems.contains(stem) + } + + private static let numericStems: Set = [ + "INT", "UINT", "INTEGER", "UINTEGER", "TINYINT", "UTINYINT", "SMALLINT", "USMALLINT", "MEDIUMINT", + "BIGINT", "UBIGINT", "HUGEINT", "UHUGEINT", "SERIAL", "SMALLSERIAL", "BIGSERIAL", + "DECIMAL", "DEC", "NUMERIC", "NUMBER", "FLOAT", "DOUBLE", "REAL", "MONEY", "SMALLMONEY", + "BINARY_FLOAT", "BINARY_DOUBLE" + ] + + private static let nestedStems: Set = ["UNKNOWN", "LIST", "STRUCT", "MAP"] + + private static func scalarStem(of typeName: String) -> String? { + let upper = unwrapped(typeName.uppercased()) + if upper.hasSuffix("[]") || upper.hasPrefix("ARRAY") { return nil } + return stem(of: upper.prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces)) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProTabularIO/JSONOutline.swift b/apple/Packages/TableProCore/Sources/TableProTabularIO/JSONOutline.swift new file mode 100644 index 0000000000..17ee56f92d --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProTabularIO/JSONOutline.swift @@ -0,0 +1,277 @@ +import Foundation + +public struct JSONOutlineNode: Identifiable, Sendable { + public enum Key: Sendable, Hashable { + case root + case member(String) + case index(Int) + } + + public enum Value: Sendable, Hashable { + case object(count: Int) + case array(count: Int) + case string(String) + case number(String) + case bool(Bool) + case null + } + + // Nodes index one flat store, so a deep document neither builds nor frees a deep chain of arrays. + private let storage: JSONOutlineStorage + public let id: Int + + fileprivate init(storage: JSONOutlineStorage, id: Int) { + self.storage = storage + self.id = id + } + + public var key: Key { + storage.records[id].key + } + + public var value: Value { + storage.records[id].value + } + + public var children: [JSONOutlineNode]? { + let childIDs = storage.records[id].children + guard !childIDs.isEmpty else { return nil } + return childIDs.map { JSONOutlineNode(storage: storage, id: $0) } + } + + public var keyPath: String { + var keys: [Key] = [] + var current: Int? = id + while let node = current { + keys.append(storage.records[node].key) + current = storage.records[node].parent + } + var path = "$" + for key in keys.reversed() { + switch key { + case .root: + continue + case .member(let name): + path += "." + JSONOutline.pathMember(name) + case .index(let position): + path += "[" + String(position) + "]" + } + } + return path + } + + public var copyText: String { + let record = storage.records[id] + return storage.source.withUnsafeBufferPointer { buffer -> String in + let bytes = UnsafeBufferPointer(rebasing: buffer[record.range]) + switch record.value { + case .object, .array: + var compact: [UInt8] = [] + compact.reserveCapacity(bytes.count) + JSONText.appendCompact(bytes, into: &compact) + return compact.withUnsafeBufferPointer(JSONText.lossyString) + case .string: + return JSONText.decodedString(UnsafeBufferPointer(rebasing: bytes.dropFirst().dropLast())) + case .number, .bool, .null: + return JSONText.lossyString(bytes) + } + } + } +} + +public enum JSONOutline { + public enum Result: Sendable { + case tree(root: JSONOutlineNode, omitted: Int) + case tooLarge + case invalid + } + + private static let maxLength = 100_000 + private static let maxNodes = 5_000 + private static let maxStringLength = 300 + + public static func build(_ text: String) -> Result { + guard text.utf16.count <= maxLength else { return .tooLarge } + let source = Array(text.utf8) + let walked = source.withUnsafeBufferPointer { bytes -> (records: [JSONOutlineRecord], omitted: Int)? in + guard let base = bytes.baseAddress, (try? JSONRowParser.validateValue(in: bytes)) != nil else { return nil } + return try? walk(JSONCursor(base: base, count: bytes.count, row: 0)) + } + guard let walked else { return .invalid } + let storage = JSONOutlineStorage(source: source, records: walked.records) + return .tree(root: JSONOutlineNode(storage: storage, id: 0), omitted: walked.omitted) + } + + // $."a.b" is the form SQLite, DuckDB, MariaDB and PostgreSQL all resolve to the key a.b. + static func pathMember(_ key: String) -> String { + guard !isIdentifier(key) else { return key } + var quoted = "\"" + for scalar in key.unicodeScalars { + if scalar == "\"" || scalar == "\\" { quoted.unicodeScalars.append("\\") } + quoted.unicodeScalars.append(scalar) + } + quoted.unicodeScalars.append("\"") + return quoted + } + + private static func isIdentifier(_ key: String) -> Bool { + var isFirst = true + for byte in key.utf8 { + switch byte { + case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "_"): + break + case UInt8(ascii: "0")...UInt8(ascii: "9") where !isFirst: + break + default: + return false + } + isFirst = false + } + return !isFirst + } + + private struct Frame { + let record: Int? + let isObject: Bool + let start: Int + var count = 0 + } + + // Runs on validated bytes, so it reads structure without re-checking it, and keeps counting past the node cap. + private static func walk(_ cursor: JSONCursor) throws -> (records: [JSONOutlineRecord], omitted: Int) { + var records: [JSONOutlineRecord] = [] + var stack: [Frame] = [] + var omitted = 0 + var key = JSONOutlineNode.Key.root + var index = cursor.skipWhitespace(from: 0) + while true { + let parent = stack.last?.record + let builds = records.count < maxNodes + let byte = try peek(cursor, at: index) + if byte == JSONByte.openBrace || byte == JSONByte.openBracket { + let isObject = byte == JSONByte.openBrace + var recordID: Int? + if builds { + recordID = records.count + let empty: JSONOutlineNode.Value = isObject ? .object(count: 0) : .array(count: 0) + append(JSONOutlineRecord(key: key, value: empty, parent: parent, range: index.. UInt8 { + guard index < cursor.count else { throw JSONTableError.truncated(row: cursor.row, byteOffset: cursor.count) } + return cursor.base[index] + } + + private static func member(_ cursor: JSONCursor, at start: Int) throws -> (key: JSONOutlineNode.Key, valueStart: Int) { + let string = try cursor.string(at: start) + let content = cursor.bytes((start + 1)..<(string.end - 1)) + let name = string.hasEscapes ? JSONText.decodedString(content) : JSONText.lossyString(content) + let colon = cursor.skipWhitespace(from: string.end) + return (.member(name), cursor.skipWhitespace(from: colon + 1)) + } + + private static func leafValue(_ cursor: JSONCursor, token: JSONValueToken, at start: Int) -> JSONOutlineNode.Value { + switch token.kind { + case .text: + let content = cursor.bytes((start + 1)..<(token.end - 1)) + let text = token.needsRewrite ? JSONText.decodedString(content) : JSONText.lossyString(content) + return .string(cut(text)) + case .number: + return .number(JSONText.lossyString(cursor.bytes(start.. String { + var index = text.startIndex + var units = 0 + while index < text.endIndex { + let next = text.index(after: index) + units += text.utf16.distance(from: index, to: next) + if units > maxStringLength { return String(text[.. + var children: [Int] = [] +} + +private final class JSONOutlineStorage: Sendable { + let source: [UInt8] + let records: [JSONOutlineRecord] + + init(source: [UInt8], records: [JSONOutlineRecord]) { + self.source = source + self.records = records + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProTabularIO/JSONPrettyPrinter.swift b/apple/Packages/TableProCore/Sources/TableProTabularIO/JSONPrettyPrinter.swift new file mode 100644 index 0000000000..99f57cdc0e --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProTabularIO/JSONPrettyPrinter.swift @@ -0,0 +1,90 @@ +import Foundation + +public enum JSONPrettyPrinter { + private static let maxLength = 500_000 + // Indentation grows with depth, so deep nesting under the length cap could print gigabytes. + private static let maxOutputBytes = 8 << 20 + private static let indentWidth = 2 + + public static func format(_ text: String) -> String? { + guard text.utf16.count <= maxLength else { return nil } + var source = text + return source.withUTF8 { bytes -> String? in + guard let base = bytes.baseAddress else { return nil } + let cursor = JSONCursor(base: base, count: bytes.count, row: 0) + let start = cursor.skipWhitespace(from: 0) + guard let token = try? cursor.value(at: start), cursor.skipWhitespace(from: token.end) == bytes.count else { + return nil + } + var output: [UInt8] = [] + output.reserveCapacity(bytes.count + bytes.count / 2) + guard write(cursor, from: start, to: token.end, into: &output) else { return nil } + return output.withUnsafeBufferPointer(JSONText.lossyString) + } + } + + private static func write(_ cursor: JSONCursor, from start: Int, to end: Int, into output: inout [UInt8]) -> Bool { + let base = cursor.base + var depth = 0 + var index = start + while index < end { + let byte = base[index] + switch byte { + case JSONByte.quote: + guard let string = try? cursor.string(at: index) else { return false } + output.append(contentsOf: cursor.bytes(index.. Bool { + let width = depth * indentWidth + guard output.count + 1 + width <= maxOutputBytes else { return false } + output.append(JSONByte.lineFeed) + output.append(contentsOf: repeatElement(JSONByte.space, count: width)) + return true + } + + @inline(__always) + private static func endsScalar(_ byte: UInt8) -> Bool { + byte == JSONByte.comma || byte == JSONByte.closeBrace || byte == JSONByte.closeBracket || JSONByte.isWhitespace(byte) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/ColumnInfoTypeFamilyTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/ColumnInfoTypeFamilyTests.swift new file mode 100644 index 0000000000..68ae8396fa --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/ColumnInfoTypeFamilyTests.swift @@ -0,0 +1,79 @@ +import Foundation +@testable import TableProModels +import Testing + +struct ColumnInfoTypeFamilyTests { + @Test( + "JSON and JSONB columns read as JSON, wrapped or sized", + arguments: ["json", "jsonb", "JSON", "JSONB", " json ", "Nullable(JSON)", "LowCardinality(JSON)", "json(max)"] + ) + func jsonColumnsAreJSON(_ typeName: String) { + #expect(ColumnInfo.isJSONType(typeName: typeName)) + } + + @Test( + "Arrays of JSON, JSON paths and text types are not JSON", + arguments: [ + "json[]", "jsonb[]", "_jsonb", "ARRAY", "Array(JSON)", "jsonpath", "JSONPATH", "longtext", "VARCHAR", + "text", "unknown", "" + ] + ) + func otherColumnsAreNotJSON(_ typeName: String) { + #expect(ColumnInfo.isJSONType(typeName: typeName) == false) + } + + @Test( + "Integer and decimal families are numeric", + arguments: [ + "INT", "int4", "integer", "BIGINT UNSIGNED", "smallint", "TINYINT(1)", "serial", "Int64", "UInt8", + "Nullable(Int32)", "HUGEINT", "DECIMAL(10,2)", "Decimal128(3)", "numeric", "NUMBER(10)", "DOUBLE", + "double precision", "FLOAT8", "Float32", "REAL", "money", "BINARY_DOUBLE" + ] + ) + func numericColumnsAreNumeric(_ typeName: String) { + #expect(ColumnInfo.isNumericType(typeName: typeName)) + } + + @Test( + "Types whose name only contains INT, and arrays of numbers, are not numeric", + arguments: [ + "INTERVAL", "interval day to second", "POINT", "MULTIPOINT", "integer[]", "ARRAY", "Array(Int32)", + "VARCHAR(10)", "TEXT", "BIT", "BOOLEAN", "DATE", "JSON", "unknown", "" + ] + ) + func otherColumnsAreNotNumeric(_ typeName: String) { + #expect(ColumnInfo.isNumericType(typeName: typeName) == false) + } + + @Test( + "Text columns and names this does not know may hold JSON text", + arguments: ["text", "varchar(255)", "nvarchar(max)", "LONGTEXT", "CLOB", "citext", "Nullable(String)", "USER-DEFINED", ""] + ) + func textColumnsMayHoldJSONText(_ typeName: String) { + #expect(ColumnInfo.mayHoldJSONText(typeName: typeName)) + } + + @Test( + "Arrays, lists, structs, maps, unknown types and typed columns do not", + arguments: [ + "unknown", "int[]", "ARRAY", "INTEGER[]", "VARCHAR[]", "LIST", "STRUCT(a INTEGER)", "MAP(VARCHAR, INTEGER)", + "Map(String, UInt64)", "jsonb", "JSON", "integer", "uuid", "timestamp" + ] + ) + func nestedAndTypedColumnsDoNotHoldJSONText(_ typeName: String) { + #expect(ColumnInfo.mayHoldJSONText(typeName: typeName) == false) + } + + @Test("The properties read the column's own type name") + func propertiesReadTypeName() { + let document = ColumnInfo(name: "doc", typeName: "jsonb", ordinalPosition: 0) + let count = ColumnInfo(name: "count", typeName: "INTEGER", ordinalPosition: 1) + let note = ColumnInfo(name: "note", typeName: "TEXT", ordinalPosition: 2) + #expect(document.isJSONType) + #expect(document.mayHoldJSONText == false) + #expect(count.isNumericType) + #expect(count.isJSONType == false) + #expect(note.mayHoldJSONText) + #expect(note.isNumericType == false) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProTabularIOTests/JSONOutlineTests.swift b/apple/Packages/TableProCore/Tests/TableProTabularIOTests/JSONOutlineTests.swift new file mode 100644 index 0000000000..ce7f4bd155 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProTabularIOTests/JSONOutlineTests.swift @@ -0,0 +1,195 @@ +import Foundation +@testable import TableProTabularIO +import Testing + +struct JSONOutlineTests { + private func outline(_ text: String) -> (root: JSONOutlineNode, omitted: Int)? { + guard case .tree(let root, let omitted) = JSONOutline.build(text) else { return nil } + return (root, omitted) + } + + private func child(_ node: JSONOutlineNode, _ position: Int) throws -> JSONOutlineNode { + let children = try #require(node.children) + try #require(position < children.count) + return children[position] + } + + private func allNodes(_ root: JSONOutlineNode) -> [JSONOutlineNode] { + var nodes: [JSONOutlineNode] = [] + var pending = [root] + while let node = pending.popLast() { + nodes.append(node) + pending.append(contentsOf: node.children ?? []) + } + return nodes + } + + private func isTooLarge(_ result: JSONOutline.Result) -> Bool { + if case .tooLarge = result { return true } + return false + } + + private func isInvalid(_ result: JSONOutline.Result) -> Bool { + if case .invalid = result { return true } + return false + } + + @Test func namesEachValueByItsKeyPath() throws { + let tree = try #require(outline(#"{"user":{"name":"a","emails":["x@y.z"]},"tags":[1,2,3]}"#)) + let root = tree.root + #expect(root.key == .root) + #expect(root.keyPath == "$") + #expect(root.value == .object(count: 2)) + #expect(tree.omitted == 0) + + let user = try child(root, 0) + #expect(user.key == .member("user")) + #expect(user.keyPath == "$.user") + let emails = try child(user, 1) + #expect(emails.keyPath == "$.user.emails") + #expect(emails.value == .array(count: 1)) + let email = try child(emails, 0) + #expect(email.key == .index(0)) + #expect(email.keyPath == "$.user.emails[0]") + #expect(email.value == .string("x@y.z")) + #expect(email.children == nil) + + let tags = try child(root, 1) + let three = try child(tags, 2) + #expect(three.keyPath == "$.tags[2]") + #expect(three.value == .number("3")) + #expect(Set(allNodes(root).map { $0.id }) == Set(0..<9)) + } + + @Test func quotesKeysThatAreNotIdentifiers() throws { + let tree = try #require(outline(#"{"content-type":1,"a.b":2,"say \"hi\"":3,"_ok9":4,"9lives":5,"":6,"é":7,"back\\slash":8}"#)) + let members = try #require(tree.root.children) + #expect(members.map { $0.keyPath } == [ + #"$."content-type""#, #"$."a.b""#, #"$."say \"hi\"""#, "$._ok9", #"$."9lives""#, "$.\"\"", #"$."é""#, #"$."back\\slash""# + ]) + #expect(members[2].key == .member(#"say "hi""#)) + #expect(members[7].key == .member(#"back\slash"#)) + } + + @Test func duplicateKeysGetTheirOwnNodes() throws { + let tree = try #require(outline(#"{"a":1,"a":{"a":2}}"#)) + let members = try #require(tree.root.children) + #expect(members.map { $0.keyPath } == ["$.a", "$.a"]) + #expect(members[0].id != members[1].id) + #expect(members[0].value == .number("1")) + #expect(members[1].value == .object(count: 1)) + #expect(Set(allNodes(tree.root).map { $0.id }).count == 4) + } + + @Test func idsFollowPositionSoReindentedTextKeepsThem() throws { + let compact = #"{"user":{"name":"a","emails":["x@y.z"]},"tags":[1,2,3]}"# + let pretty = try #require(JSONPrettyPrinter.format(compact)) + let first = try #require(outline(compact)) + let second = try #require(outline(pretty)) + #expect(allNodes(first.root).map { $0.id } == allNodes(second.root).map { $0.id }) + #expect(allNodes(first.root).map { $0.keyPath } == allNodes(second.root).map { $0.keyPath }) + } + + @Test func stopsBuildingAtFiveThousandNodesAndCountsTheRest() throws { + let text = "[" + (0..<6_000).map { String($0) }.joined(separator: ",") + "]" + let tree = try #require(outline(text)) + #expect(tree.root.value == .array(count: 6_000)) + #expect(tree.root.children?.count == 4_999) + #expect(tree.root.children?.last?.value == .number("4998")) + #expect(tree.omitted == 1_001) + } + + @Test func countsTheDescendantsOfEveryValueLeftOut() throws { + let text = "[" + Array(repeating: "[0,0]", count: 3_000).joined(separator: ",") + "]" + let tree = try #require(outline(text)) + let pairs = try #require(tree.root.children) + #expect(pairs.count == 1_667) + #expect(pairs.last?.value == .array(count: 2)) + #expect(pairs.last?.children == nil) + #expect(allNodes(tree.root).count == 5_000) + #expect(tree.omitted == 4_001) + } + + @Test func walksSixHundredLevels() throws { + let text = String(repeating: "[", count: 600) + "1" + String(repeating: "]", count: 600) + let tree = try #require(outline(text)) + var node = tree.root + for _ in 0..<600 { + node = try child(node, 0) + } + #expect(node.value == .number("1")) + #expect(node.keyPath == "$" + String(repeating: "[0]", count: 600)) + #expect(allNodes(tree.root).count == 601) + } + + @Test func capsADeepChainAtTheNodeLimit() throws { + let text = String(repeating: "[", count: 50_000) + String(repeating: "]", count: 50_000) + let tree = try #require(outline(text)) + #expect(allNodes(tree.root).count == 5_000) + #expect(tree.omitted == 45_000) + } + + @Test func cutsLongStringsAtThreeHundredUnitsAndCopiesThemWhole() throws { + let long = String(repeating: "a", count: 400) + let cut = try #require(outline("\"" + long + "\"")) + #expect(cut.root.value == .string(String(repeating: "a", count: 300) + "…")) + #expect(cut.root.copyText == long) + + let exact = String(repeating: "b", count: 300) + let whole = try #require(outline("\"" + exact + "\"")) + #expect(whole.root.value == .string(exact)) + + let emoji = try #require(outline("\"" + String(repeating: "c", count: 299) + "\u{1F44D}\u{1F3FD}\"")) + #expect(emoji.root.value == .string(String(repeating: "c", count: 299) + "…")) + } + + @Test func decodesEscapesInKeysAndStrings() throws { + let tree = try #require(outline(#"{"k\u00e9y":"line\nbreak \ud83d\ude00"}"#)) + let member = try child(tree.root, 0) + #expect(member.key == .member("k\u{E9}y")) + #expect(member.keyPath == "$.\"k\u{E9}y\"") + #expect(member.value == .string("line\nbreak \u{1F600}")) + #expect(member.copyText == "line\nbreak \u{1F600}") + } + + @Test func copiesContainersCompactWithTheirLexemes() throws { + let tree = try #require(outline(#"{ "a" : [ 1.50 , 1E+2 , true , null ] , "s" : "x y" }"#)) + #expect(tree.root.copyText == #"{"a":[1.50,1E+2,true,null],"s":"x y"}"#) + let array = try child(tree.root, 0) + #expect(array.copyText == "[1.50,1E+2,true,null]") + #expect(try child(array, 0).copyText == "1.50") + #expect(try child(array, 1).value == .number("1E+2")) + #expect(try child(array, 2).value == .bool(true)) + #expect(try child(array, 2).copyText == "true") + #expect(try child(array, 3).value == .null) + #expect(try child(array, 3).copyText == "null") + #expect(try child(tree.root, 1).copyText == "x y") + } + + @Test func emptyContainersHaveNoChildren() throws { + let tree = try #require(outline(#"{"a":{},"b":[ ]}"#)) + let members = try #require(tree.root.children) + #expect(members.map { $0.value } == [.object(count: 0), .array(count: 0)]) + #expect(members.allSatisfy { $0.children == nil }) + } + + @Test func aScalarDocumentIsOneNode() throws { + let tree = try #require(outline(" true \n")) + #expect(tree.root.id == 0) + #expect(tree.root.key == .root) + #expect(tree.root.value == .bool(true)) + #expect(tree.root.keyPath == "$") + #expect(tree.root.children == nil) + } + + @Test func refusesTextAboveOneHundredThousandCharacters() { + #expect(outline("\"" + String(repeating: "a", count: 99_998) + "\"") != nil) + #expect(isTooLarge(JSONOutline.build("\"" + String(repeating: "a", count: 99_999) + "\""))) + #expect(isTooLarge(JSONOutline.build(String(repeating: "x", count: 100_001)))) + } + + @Test(arguments: ["", " ", #"{"a":}"#, "01", "[1,]", #"{"a":1}x"#, "[\"a\tb\"]", #"{"a" 1}"#, "[1}"]) + func refusesInvalidText(_ text: String) { + #expect(isInvalid(JSONOutline.build(text))) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProTabularIOTests/JSONPrettyPrinterTests.swift b/apple/Packages/TableProCore/Tests/TableProTabularIOTests/JSONPrettyPrinterTests.swift new file mode 100644 index 0000000000..d460e370d8 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProTabularIOTests/JSONPrettyPrinterTests.swift @@ -0,0 +1,90 @@ +import Foundation +@testable import TableProTabularIO +import Testing + +struct JSONPrettyPrinterTests { + @Test func keepsKeyOrderAndIndentsByTwoSpaces() { + let text = #"{"b":1,"a":{"d":true,"c":null},"e":["x",2]}"# + #expect(JSONPrettyPrinter.format(text) == """ + { + "b": 1, + "a": { + "d": true, + "c": null + }, + "e": [ + "x", + 2 + ] + } + """) + } + + @Test func copiesNumberLexemes() { + let text = "[1.50,1E+2,-0,123456789012345678901234567890,1e-7,0.1]" + #expect(JSONPrettyPrinter.format(text) == "[\n 1.50,\n 1E+2,\n -0,\n 123456789012345678901234567890,\n 1e-7,\n 0.1\n]") + } + + @Test func copiesEscapesAsWritten() { + let text = #"{"k\"ey":"a\\b\n\u00e9\ud83d\ude00\/","raw":"é 😀"}"# + #expect(JSONPrettyPrinter.format(text) == #""" + { + "k\"ey": "a\\b\n\u00e9\ud83d\ude00\/", + "raw": "é 😀" + } + """#) + } + + @Test func keepsEmptyContainersOnOneLine() { + #expect(JSONPrettyPrinter.format(#"{"a":{},"b":[ ],"c":[{ }]}"#) == """ + { + "a": {}, + "b": [], + "c": [ + {} + ] + } + """) + #expect(JSONPrettyPrinter.format("[]") == "[]") + #expect(JSONPrettyPrinter.format(" { } ") == "{}") + } + + @Test func dropsWhitespaceOutsideStringsOnly() { + #expect(JSONPrettyPrinter.format(" [ \"a b\" ,\r\n\t\"c\" ] ") == "[\n \"a b\",\n \"c\"\n]") + } + + @Test func printsAScalarDocumentAsWritten() { + #expect(JSONPrettyPrinter.format(" \"x\" ") == "\"x\"") + #expect(JSONPrettyPrinter.format("42") == "42") + #expect(JSONPrettyPrinter.format("null") == "null") + } + + @Test func formattingItsOwnOutputChangesNothing() throws { + let pretty = try #require(JSONPrettyPrinter.format(#"{"user":{"name":"a","emails":["x@y.z"]},"tags":[1,2,3]}"#)) + #expect(JSONPrettyPrinter.format(pretty) == pretty) + } + + @Test(arguments: ["", " ", #"{"a":}"#, "[1,]", "01", #"{"a" 1}"#, #""\q""#, "\"a\tb\"", "[1] x", "{'a':1}", "NaN", "[1,2", #"{"a":1}}"#]) + func returnsNilForInvalidText(_ text: String) { + #expect(JSONPrettyPrinter.format(text) == nil) + } + + @Test func returnsNilAboveFiveHundredThousandCharacters() { + let fits = "\"" + String(repeating: "a", count: 499_998) + "\"" + #expect(JSONPrettyPrinter.format(fits) == fits) + #expect(JSONPrettyPrinter.format("\"" + String(repeating: "a", count: 499_999) + "\"") == nil) + } + + @Test func indentsSixHundredLevels() throws { + let deep = String(repeating: "[", count: 600) + String(repeating: "]", count: 600) + let pretty = try #require(JSONPrettyPrinter.format(deep)) + let lines = pretty.split(separator: "\n", omittingEmptySubsequences: false) + #expect(lines.count == 1_199) + #expect(lines[599] == String(repeating: " ", count: 1_198) + "[]") + } + + @Test func returnsNilWhenIndentationOutgrowsTheOutputBudget() { + let deep = String(repeating: "[", count: 200_000) + String(repeating: "]", count: 200_000) + #expect(JSONPrettyPrinter.format(deep) == nil) + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/Cell+KeyText.swift b/apple/TableProMobile/TableProMobile/Helpers/Cell+KeyText.swift new file mode 100644 index 0000000000..912f8f3d97 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/Cell+KeyText.swift @@ -0,0 +1,17 @@ +import Foundation +import TableProModels + +nonisolated extension Cell { + // A long key is cut for display, but its ref carries the whole key the driver read. A binary key's bytes are not + // text, so it gives none. + func keyText(column: String) -> String? { + switch self { + case .text(let value): + value + case .truncatedText(_, _, let ref?): + ref.primaryKey.first { $0.column == column }?.value + case .truncatedText, .binary, .null: + nil + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/CellLookupError.swift b/apple/TableProMobile/TableProMobile/Helpers/CellLookupError.swift new file mode 100644 index 0000000000..7cb20d1972 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/CellLookupError.swift @@ -0,0 +1,12 @@ +import Foundation + +nonisolated enum CellLookupError: LocalizedError, Equatable { + case rowNotFound + + var errorDescription: String? { + switch self { + case .rowNotFound: + String(localized: "This row is no longer in the table. Another session may have changed its key or deleted it.") + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift b/apple/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift index e40529cf0d..e8a00f17a9 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ClipboardExporter.swift @@ -1,6 +1,7 @@ import Foundation import TableProDatabase import TableProModels +import TableProTabularIO import UIKit import UniformTypeIdentifiers @@ -12,6 +13,57 @@ nonisolated enum ExportFormat: String, CaseIterable, Identifiable { } nonisolated enum ClipboardExporter { + nonisolated struct IncompleteValues: LocalizedError, Equatable { + let columns: [String] + + var errorDescription: String? { + String( + format: String(localized: "Binary values and values not loaded in full cannot be exported: %@."), + ListFormatter.localizedString(byJoining: columns) + ) + } + } + + nonisolated struct TooLarge: LocalizedError, Equatable { + var errorDescription: String? { + String(localized: "The full values on this page need more memory than this device has free. Choose a smaller page size and try again.") + } + } + + static func incompleteColumns(columns: [ColumnInfo], cells: [Cell]) -> [String] { + incompleteColumns(columns: columns, rows: [cells]) + } + + static func exportRow( + columns: [ColumnInfo], + cells: [Cell], + format: ExportFormat, + tableName: String? = nil, + databaseType: DatabaseType, + driver: (any DatabaseDriver)? + ) throws(IncompleteValues) -> String { + let values = try completeValues(columns: columns, rows: [cells]) + return exportRow( + columns: columns, row: values[0], + format: format, tableName: tableName, databaseType: databaseType, driver: driver + ) + } + + static func exportRows( + columns: [ColumnInfo], + rows: [[Cell]], + format: ExportFormat, + tableName: String? = nil, + databaseType: DatabaseType, + driver: (any DatabaseDriver)? + ) throws(IncompleteValues) -> String { + let values = try completeValues(columns: columns, rows: rows) + return exportRows( + columns: columns, rows: values, + format: format, tableName: tableName, databaseType: databaseType, driver: driver + ) + } + static func exportRow( columns: [ColumnInfo], row: [String?], @@ -83,18 +135,42 @@ nonisolated enum ClipboardExporter { // MARK: - Private + private static func incompleteColumns(columns: [ColumnInfo], rows: [[Cell]]) -> [String] { + columns.indices.compactMap { index in + let isIncomplete = rows.contains { cells in + guard index < cells.count else { return false } + switch cells[index] { + case .null, .text: return false + case .truncatedText, .binary: return true + } + } + return isIncomplete ? columns[index].name : nil + } + } + + private static func completeValues(columns: [ColumnInfo], rows: [[Cell]]) throws(IncompleteValues) -> [[String?]] { + let incomplete = incompleteColumns(columns: columns, rows: rows) + guard incomplete.isEmpty else { throw IncompleteValues(columns: incomplete) } + return rows.map { cells in + cells.map { cell -> String? in + if case .text(let value) = cell { return value } + return nil + } + } + } + private static func rowToJson(columns: [ColumnInfo], row: [String?]) -> String { var pairs: [String] = [] for (i, col) in columns.enumerated() { let value = i < row.count ? row[i] : nil - let key = " \"\(escapeJsonString(col.name))\"" + let key = " " + JSONText.stringLiteral(col.name) if let value { if isJsonNumber(value) { pairs.append("\(key): \(value)") } else if value == "true" || value == "false" { pairs.append("\(key): \(value)") } else { - pairs.append("\(key): \"\(escapeJsonString(value))\"") + pairs.append("\(key): \(JSONText.stringLiteral(value))") } } else { pairs.append("\(key): null") @@ -152,8 +228,7 @@ nonisolated enum ClipboardExporter { return escapeCsvField(value) } - /// The JSON number grammar over ASCII digits only, so a VARCHAR like "01234", "+5", "0x10" or - /// "\u{0661}\u{0662}\u{0663}" stays a quoted string rather than a token no JSON parser accepts. + // ASCII digits only, so "01234", "+5" or Arabic-Indic digits stay quoted strings. private static func isJsonNumber(_ value: String) -> Bool { var characters = Substring(value) if characters.first == "-" { characters = characters.dropFirst() } @@ -206,12 +281,4 @@ nonisolated enum ClipboardExporter { } return field } - - private static func escapeJsonString(_ str: String) -> String { - str.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/JSONValidation.swift b/apple/TableProMobile/TableProMobile/Helpers/JSONValidation.swift new file mode 100644 index 0000000000..c4121d464b --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/JSONValidation.swift @@ -0,0 +1,82 @@ +import Foundation +import TableProTabularIO + +nonisolated enum JSONValidation { + nonisolated struct Failure: Error, Equatable, Sendable { + let byteOffset: Int + let line: Int + let column: Int + let utf16Offset: Int + let reason: String + + var message: String { + String(format: String(localized: "Line %lld, column %lld: %@"), line, column, reason) + } + } + + private static let lineBreaks: Set = ["\n", "\r", "\u{85}", "\u{2028}", "\u{2029}"] + + static func check(_ text: String) -> Result { + var source = text + do { + let kind = try source.withUTF8 { try JSONRowParser.validateValue(in: $0) } + return .success(kind) + } catch let error as JSONTableError { + return .failure(failure(in: text, byteOffset: error.byteOffset, reason: reason(for: error))) + } catch { + return .failure(failure(in: text, byteOffset: 0, reason: String(localized: "Not valid JSON."))) + } + } + + private static func failure(in text: String, byteOffset: Int, reason: String) -> Failure { + let scalars = text.unicodeScalars + let utf8 = text.utf8 + var position = utf8.index(utf8.startIndex, offsetBy: min(max(byteOffset, 0), utf8.count)) + while position > utf8.startIndex, position.samePosition(in: scalars) == nil { + position = utf8.index(before: position) + } + var line = 1 + var lineStart = scalars.startIndex + var previous: Unicode.Scalar? + var index = scalars.startIndex + while index < position { + let scalar = scalars[index] + index = scalars.index(after: index) + if lineBreaks.contains(scalar) { + if !(scalar == "\n" && previous == "\r") { line += 1 } + lineStart = index + } + previous = scalar + } + return Failure( + byteOffset: byteOffset, + line: line, + column: text[lineStart.. String { + switch error { + case .truncated: + String(localized: "The JSON ends before it is complete.") + case .unexpectedByte: + String(localized: "Unexpected character.") + case .mismatchedBracket: + String(localized: "This bracket does not close the one that is open.") + case .trailingContent: + String(localized: "Extra text after the JSON value.") + case .invalidString: + String(localized: "A string cannot hold a line break, tab or other control character.") + case .invalidEscape: + String(localized: "Invalid escape sequence.") + case .invalidNumber: + String(localized: "Invalid number.") + case .invalidLiteral: + String(localized: "Expected true, false or null.") + default: + String(localized: "Not valid JSON.") + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 2f64406e83..87a72b8f57 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -818,6 +818,15 @@ }, "%lld changes could not be uploaded to iCloud. They stay on this device and go again on the next sync." : { + }, + "%lld items" : { + + }, + "%lld keys" : { + + }, + "%lld more not shown" : { + }, "%lld of %lld" : { "localizations" : { @@ -1023,6 +1032,12 @@ } } } + }, + "1 item" : { + + }, + "1 key" : { + }, "1 row" : { "localizations" : { @@ -1164,6 +1179,9 @@ } } } + }, + "A string cannot hold a line break, tab or other control character." : { + }, "A sync conflict was detected and needs to be resolved." : { "extractionState" : "stale", @@ -2606,6 +2624,9 @@ } } } + }, + "Binary values and values not loaded in full cannot be exported: %@." : { + }, "Blue" : { "localizations" : { @@ -2857,6 +2878,9 @@ } } } + }, + "Cannot Export" : { + }, "Cannot reach iCloud. Changes sync when this device is back online." : { @@ -4587,6 +4611,9 @@ "Copies" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Copy" : { + }, "Copy Column Name" : { "localizations" : { @@ -4615,6 +4642,12 @@ } } } + }, + "Copy Key" : { + + }, + "Copy Key Path" : { + }, "Copy Name" : { "localizations" : { @@ -6366,6 +6399,9 @@ } } } + }, + "Edit JSON" : { + }, "Edit Tag" : { "localizations" : { @@ -6974,6 +7010,9 @@ } } } + }, + "Expected true, false or null." : { + }, "Explains why it can't open here." : { @@ -7070,6 +7109,9 @@ }, "External Table" : { + }, + "Extra text after the JSON value." : { + }, "Eye" : { "extractionState" : "manual", @@ -7763,6 +7805,9 @@ "Fork and Knife" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Format" : { + }, "FreeTDS could not set up the login." : { @@ -9359,6 +9404,15 @@ } } } + }, + "Invalid escape sequence." : { + + }, + "Invalid JSON" : { + + }, + "Invalid number." : { + }, "is not null" : { "localizations" : { @@ -9475,6 +9529,9 @@ "JSON" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "JSON Too Large" : { + }, "Jump Hosts" : { @@ -9875,6 +9932,16 @@ } } }, + "Line %lld, column %lld: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Line %1$lld, column %2$lld: %3$@" + } + } + } + }, "Line Chart" : { "extractionState" : "manual", "generatesSymbol" : false @@ -10697,6 +10764,9 @@ } } } + }, + "Moves the cursor to the error." : { + }, "Music" : { "extractionState" : "manual", @@ -11895,6 +11965,9 @@ }, "Not Signed In to iCloud" : { + }, + "Not valid JSON." : { + }, "Nothing to Run" : { @@ -14258,6 +14331,9 @@ } } } + }, + "Reload the table to see the row as it is now." : { + }, "Remove" : { "localizations" : { @@ -14807,6 +14883,12 @@ } } } + }, + "Row Not Deleted" : { + + }, + "Row Not Found" : { + }, "Row %d of %d" : { "localizations" : { @@ -18105,6 +18187,9 @@ } } } + }, + "Text" : { + }, "Text Search" : { "extractionState" : "manual", @@ -18653,6 +18738,9 @@ "The FreeTDS configuration could not be written: %@" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "The full values on this page need more memory than this device has free. Choose a smaller page size and try again." : { + }, "The host cannot be passed to FreeTDS. Use a host name or an IP address, without spaces, brackets, “=”, “;” or “#”." : { "extractionState" : "manual", @@ -18725,6 +18813,9 @@ } } } + }, + "The JSON ends before it is complete." : { + }, "The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a shorter host name." : { "extractionState" : "manual", @@ -19312,6 +19403,9 @@ } } } + }, + "The row is still in the table. A trigger, rule or row security policy may have stopped the delete." : { + }, "The sample database is missing from the app." : { "localizations" : { @@ -19613,6 +19707,9 @@ } } } + }, + "The text could not be parsed as JSON. Use text mode to read it." : { + }, "The text encoding is invalid." : { "localizations" : { @@ -19647,6 +19744,9 @@ }, "The user name is longer than the 128 bytes FreeTDS takes." : { + }, + "The value of %@ is not valid JSON." : { + }, "The workbook is missing %@." : { "extractionState" : "manual", @@ -19692,6 +19792,9 @@ } } } + }, + "This bracket does not close the one that is open." : { + }, "This build has no driver for that database type." : { "localizations" : { @@ -20279,6 +20382,9 @@ "This is a Redis Sentinel, not a data node, so it only answers SENTINEL commands. Set Connection Mode to Sentinel and list this address under Sentinel Nodes." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "This JSON document is too large for tree view. Use text mode instead." : { + }, "This Oracle server is older than release 11.1, which the database driver does not support." : { "extractionState" : "manual", @@ -20341,6 +20447,9 @@ } } } + }, + "This row is no longer in the table. Another session may have changed its key or deleted it." : { + }, "This row's primary key is not plain text, so it cannot identify the row." : { @@ -20872,6 +20981,9 @@ "Transfer" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Tree" : { + }, "Triangle" : { "extractionState" : "manual", @@ -21146,6 +21258,9 @@ } } } + }, + "Unexpected character." : { + }, "unbalanced quotes" : { @@ -21860,6 +21975,9 @@ } } } + }, + "View as JSON" : { + }, "Views" : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 0351de3f79..75fb8aedbc 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -443,8 +443,9 @@ final class DataBrowserViewModel { func deleteRow(pkValues: [(column: String, value: String)]) async -> Bool { guard let session, let table, !pkValues.isEmpty else { return false } + let schema = schema do { - try await session.driver.executeWrite([ + let deleted = try await session.driver.executeWrite([ SQLBuilder.buildDelete( table: table.name, schema: schema, @@ -453,7 +454,22 @@ final class DataBrowserViewModel { primaryKeys: pkValues ) ]) + // SQL Server under SET NOCOUNT ON reports 0 for every delete, so a zero is checked against the table. + var isGone = deleted > 0 + if !isGone { + isGone = try await !rowExists(table: table.name, schema: schema, primaryKey: pkValues, driver: session.driver) + } await load() + guard isGone else { + operationError = AppError( + category: .query, + title: String(localized: "Row Not Deleted"), + message: String(localized: "The row is still in the table. A trigger, rule or row security policy may have stopped the delete."), + recovery: nil, + underlying: nil + ) + return false + } return true } catch { operationError = ErrorClassifier.classify( @@ -464,6 +480,11 @@ final class DataBrowserViewModel { } } + func cells(at index: Int) -> [Cell] { + let rows = window.rows + return rows.indices.contains(index) ? rows[index].cells : [] + } + func primaryKeyValues(forRowAt index: Int) -> [(column: String, value: String)]? { let rows = window.rows guard rows.indices.contains(index) else { return nil } @@ -474,7 +495,24 @@ final class DataBrowserViewModel { // MARK: - Lazy Cell Loading - func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType) async throws -> String? { + private func rowExists( + table: String, + schema: String?, + primaryKey: [(column: String, value: String)], + driver: any DatabaseDriver + ) async throws -> Bool { + let components = primaryKey.map { PrimaryKeyComponent(column: $0.column, value: $0.value) } + let ref = CellRef(table: table, column: components[0].column, primaryKey: components) + do { + _ = try await loadFullValue(driver: driver, ref: ref, databaseType: databaseType, schema: schema) + return true + } catch CellLookupError.rowNotFound { + return false + } + } + + // The caller passes the schema its rows came from: a schema switch while a read is in flight must not move it. + func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType, schema: String?) async throws -> String? { let predicates = ref.primaryKey.map { component in "\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(driver.escapeStringLiteral(component.value))'" } @@ -492,9 +530,56 @@ final class DataBrowserViewModel { } let result = try await driver.execute(query: query) - return result.rows.first?.first ?? nil + guard let row = result.rows.first else { throw CellLookupError.rowNotFound } + return row.first ?? nil } + nonisolated static func canComplete(_ cells: [Cell]) -> Bool { + cells.allSatisfy { cell in + switch cell { + case .null, .text: true + case .truncatedText(_, _, let ref): ref != nil + case .binary: false + } + } + } + + func completeRows(_ rows: [[Cell]], driver: any DatabaseDriver) async throws -> [[Cell]] { + let pendingBytes = rows.joined().reduce(0) { total, cell in + guard case .truncatedText(_, let bytes, _?) = cell else { return total } + return total + bytes + } + guard Self.exportFits( + pendingBytes: pendingBytes, + availableBytes: MemoryPressureMonitor.shared.availableMemoryBytes() + ) else { + throw ClipboardExporter.TooLarge() + } + let schema = schema + var complete: [[Cell]] = [] + complete.reserveCapacity(rows.count) + for cells in rows { + var row = cells + for (index, cell) in cells.enumerated() { + guard case .truncatedText(_, _, let ref?) = cell else { continue } + try Task.checkCancellation() + let value = try await loadFullValue(driver: driver, ref: ref, databaseType: databaseType, schema: schema) + row[index] = value.map(Cell.text) ?? .null + } + complete.append(row) + } + return complete + } + + // The full values, the export text and the pasteboard's copy of it are all held at once. + nonisolated static func exportFits(pendingBytes: Int, availableBytes: Int) -> Bool { + guard pendingBytes > 0, availableBytes > 0 else { return true } + let (copies, overflow) = pendingBytes.multipliedReportingOverflow(by: 4) + return !overflow && availableBytes - exportMemoryMarginBytes > copies + } + + private nonisolated static let exportMemoryMarginBytes = 64 * 1_024 * 1_024 + // MARK: - Memory Pressure nonisolated func handlePressure(_ level: MemoryPressureMonitor.Level) async { diff --git a/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index a18e0f93d0..9b9ed962d5 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -2,6 +2,7 @@ import Foundation import os import TableProDatabase import TableProModels +import TableProTabularIO @MainActor @Observable @@ -79,14 +80,14 @@ final class RowDetailViewModel { var canEdit: Bool { table != nil && session != nil && !columnDetails.isEmpty && allowsRowEditing && !safeModeLevel.blocksWrites - && currentPrimaryKeyValues != nil + && primaryKeyValues(at: currentIndex) != nil } - private var currentPrimaryKeyValues: [(column: String, value: String)]? { - Self.primaryKeyValues(cells: cells(at: currentIndex), columns: columns, columnDetails: columnDetails) + private func primaryKeyValues(at rowIndex: Int) -> [(column: String, value: String)]? { + Self.primaryKeyValues(cells: cells(at: rowIndex), columns: columns, columnDetails: columnDetails) } - // A binary or cut-off key cell holds placeholder text, which as a predicate matches no row, so the row has no key. + // A placeholder key cell, such as `[BLOB 16 bytes]`, matches no row as a predicate, so the row has no key. static func primaryKeyValues( cells: [Cell], columns: [ColumnInfo], columnDetails: [ColumnInfo] ) -> [(column: String, value: String)]? { @@ -96,7 +97,7 @@ final class RowDetailViewModel { for key in keyColumns { guard let index = columns.firstIndex(where: { $0.name == key.name }), index < cells.count, - case .text(let value) = cells[index] else { return nil } + let value = cells[index].keyText(column: key.name) else { return nil } values.append((column: key.name, value: value)) } return values @@ -121,6 +122,54 @@ final class RowDetailViewModel { return rows[index].cells } + // A binary value stays a placeholder even when loaded: what the driver reads back is not its bytes. + func completeCells(at rowIndex: Int) -> [Cell] { + let overrides = fullValueOverrides[rowIndex] ?? [:] + return cells(at: rowIndex).enumerated().map { index, cell in + guard case .truncatedText = cell, let loaded = overrides[index] else { return cell } + return loaded.map(Cell.text) ?? .null + } + } + + func completeValue(forRow rowIndex: Int, at index: Int) -> String? { + let rowCells = cells(at: rowIndex) + guard index >= 0, index < rowCells.count else { return nil } + switch rowCells[index] { + case .text(let value): + return value + case .truncatedText: + return fullValueOverrides[rowIndex]?[index] ?? nil + case .null, .binary: + return nil + } + } + + // MariaDB's catalog names a JSON column longtext while its result column says JSON, so either type decides. + func isJSON(at index: Int) -> Bool { + guard index >= 0, index < columns.count else { return false } + let column = columns[index] + if ColumnInfo.isJSONType(typeName: column.typeName) { return true } + return columnDetail(for: column.name).map { ColumnInfo.isJSONType(typeName: $0.typeName) } ?? false + } + + func jsonText(forRow rowIndex: Int, at index: Int) -> String? { + guard index < columns.count, let value = completeValue(forRow: rowIndex, at: index) else { return nil } + if isJSON(at: index) { return value } + let typeName = (columnDetail(for: columns[index].name) ?? columns[index]).typeName + guard ColumnInfo.mayHoldJSONText(typeName: typeName), Self.opensContainer(value) else { return nil } + switch JSONValidation.check(value) { + case .success(.object), .success(.array): + return value + default: + return nil + } + } + + private static func opensContainer(_ value: String) -> Bool { + let first = value.utf8.first { $0 != 0x20 && $0 != 0x09 && $0 != 0x0A && $0 != 0x0D } + return first == UInt8(ascii: "{") || first == UInt8(ascii: "[") + } + func columnDetail(for name: String) -> ColumnInfo? { columnDetails.first { $0.name == name } } @@ -248,9 +297,25 @@ final class RowDetailViewModel { private func holdsEmptyString(at index: Int) -> Bool { guard index >= 0, index < columns.count else { return true } + if isJSON(at: index) { return false } return (columnDetail(for: columns[index].name) ?? columns[index]).holdsEmptyString } + var hasInvalidJSONEdit: Bool { + firstInvalidJSONEdit() != nil + } + + private func firstInvalidJSONEdit() -> (column: String, failure: JSONValidation.Failure)? { + let original = currentRow + for index in editedColumnIndices where isJSON(at: index) { + let stored = index < original.count ? original[index] : nil + guard let value = writtenValue(at: index, stored: stored), !value.isEmpty, + case .failure(let failure) = JSONValidation.check(value) else { continue } + return (columns[index].name, failure) + } + return nil + } + // MARK: - Save func saveChanges() async -> Bool { @@ -270,7 +335,7 @@ final class RowDetailViewModel { return false } - guard let pkValues = currentPrimaryKeyValues else { + guard let pkValues = primaryKeyValues(at: currentIndex) else { operationError = AppError( category: .config, title: String(localized: "Cannot Save"), @@ -289,6 +354,17 @@ final class RowDetailViewModel { return true } + if let invalid = firstInvalidJSONEdit() { + operationError = AppError( + category: .query, + title: String(localized: "Invalid JSON"), + message: String(format: String(localized: "The value of %@ is not valid JSON."), invalid.column), + recovery: invalid.failure.message, + underlying: nil + ) + return false + } + let sql = SQLBuilder.buildUpdate( table: table.name, schema: schema, @@ -319,26 +395,83 @@ final class RowDetailViewModel { private func execute(sql: String, session: ConnectionSession) async -> Bool { isSaving = true defer { isSaving = false } + let rowIndex = currentIndex let original = currentRow - let savedCells = Dictionary(uniqueKeysWithValues: editedColumnIndices.map { index in + let savedIndices = editedColumnIndices + let typedCells = Dictionary(uniqueKeysWithValues: savedIndices.map { index in let stored = index < original.count ? original[index] : nil return (index, writtenValue(at: index, stored: stored).map { Cell.text($0) } ?? .null) }) + let affected: Int do { - try await session.driver.executeWrite([sql]) - guard currentIndex >= 0, currentIndex < rows.count else { return false } - commitSavedCells(savedCells, toRow: currentIndex) - isEditing = false - showSaveSuccess = true - onSaved?() - scheduleSuccessDismiss() - return true + affected = try await session.driver.executeWrite([sql]) } catch { let context = ErrorContext(operation: "saveChanges", databaseType: databaseType) operationError = ErrorClassifier.classify(error, context: context) return false } + guard rowIndex >= 0, rowIndex < rows.count else { return false } + + let savedCells: [Int: Cell] + do { + savedCells = try await storedCells(at: savedIndices, inRow: rowIndex) ?? typedCells + } catch CellLookupError.rowNotFound where affected > 0 { + // The UPDATE landed; a trigger or another session has since changed the row's key. + Self.logger.error("The saved row is no longer under its primary key") + savedCells = typedCells + } catch CellLookupError.rowNotFound { + // A count of 0 proves nothing on SQL Server under SET NOCOUNT ON, so this never claims the UPDATE did not run. + operationError = AppError( + category: .query, + title: String(localized: "Row Not Found"), + message: CellLookupError.rowNotFound.localizedDescription, + recovery: String(localized: "Reload the table to see the row as it is now."), + underlying: CellLookupError.rowNotFound + ) + return false + } catch { + Self.logger.error("Reading the saved row back failed: \(error.localizedDescription, privacy: .private)") + savedCells = typedCells + } + commitSavedCells(savedCells, toRow: rowIndex) + isEditing = false + showSaveSuccess = true + onSaved?() + scheduleSuccessDismiss() + return true + } + + // The server can store other text than was sent: jsonb drops duplicate keys and reorders the rest. MySQL also + // counts only changed rows, so this read, not the affected count, tells a missing row from an unchanged one. + private func storedCells(at columnIndices: [Int], inRow rowIndex: Int) async throws -> [Int: Cell]? { + guard let loadFullValueProvider, let table, let pkValues = primaryKeyValues(at: rowIndex) else { return nil } + let keys = pkValues.map { PrimaryKeyComponent(column: $0.column, value: $0.value) } + var stored: [Int: Cell] = [:] + for index in columnIndices { + let ref = CellRef(table: table.name, column: columns[index].name, primaryKey: keys) + stored[index] = try await loadFullValueProvider(ref).map { Cell.text($0) } ?? .null + } + return stored + } + + func exportCurrentRow(as format: ExportFormat) -> String? { + do { + return try ClipboardExporter.exportRow( + columns: columns, cells: completeCells(at: currentIndex), + format: format, tableName: table?.name, + databaseType: databaseType, driver: session?.driver + ) + } catch { + operationError = AppError( + category: .query, + title: String(localized: "Cannot Export"), + message: error.localizedDescription, + recovery: nil, + underlying: error + ) + return nil + } } private func commitSavedCells(_ savedCells: [Int: Cell], toRow rowIndex: Int) { diff --git a/apple/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift b/apple/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift index 6c510bcaa9..9e7717d92f 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/FilterSheetView.swift @@ -59,9 +59,7 @@ struct FilterSheetView: View { } if filter.filterOperator.needsValue { - TextField("Value", text: binding.value) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + LiteralTextField("Value", text: binding.value) } if filter.filterOperator.supportsCaseSensitivity, isCaseSensitivityAdjustable { @@ -69,9 +67,7 @@ struct FilterSheetView: View { } if filter.filterOperator == .between { - TextField("Second value", text: binding.secondValue) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + LiteralTextField("Second value", text: binding.secondValue) } } } diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JSONFieldPreview.swift b/apple/TableProMobile/TableProMobile/Views/Components/JSONFieldPreview.swift new file mode 100644 index 0000000000..6a9772c4db --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JSONFieldPreview.swift @@ -0,0 +1,49 @@ +import SwiftUI +import TableProTabularIO + +struct JSONFieldPreview: View { + private static let lineLimit = 12 + private static let scalarLimit = 4_000 + + let text: String + + @State private var cache = ExcerptCache() + + var body: some View { + Text(verbatim: cache.excerpt(for: text)) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(Self.lineLimit) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // One more line than the limit, so the text shows its truncation mark when the value goes on. + static func excerpt(of text: String) -> String { + let pretty = JSONPrettyPrinter.format(text) ?? text + let scalars = pretty.unicodeScalars + var index = scalars.startIndex + var lineBreaks = 0 + var count = 0 + while index != scalars.endIndex, count < scalarLimit { + if scalars[index] == "\n" { + lineBreaks += 1 + if lineBreaks > lineLimit { break } + } + count += 1 + scalars.formIndex(after: &index) + } + return String(scalars[.. String { + if source != text { + source = text + excerpt = JSONFieldPreview.excerpt(of: text) + } + return excerpt + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JSONTextView.swift b/apple/TableProMobile/TableProMobile/Views/Components/JSONTextView.swift new file mode 100644 index 0000000000..b06ea92b52 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JSONTextView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import UIKit + +struct JSONTextView: UIViewRepresentable { + struct CaretRequest: Equatable { + let utf16Offset: Int + let serial: Int + } + + @Binding private var text: String + private let isEditable: Bool + private let caretRequest: CaretRequest? + + init(text: Binding, caretRequest: CaretRequest? = nil) { + self._text = text + self.isEditable = true + self.caretRequest = caretRequest + } + + init(readOnlyText: String) { + self._text = .constant(readOnlyText) + self.isEditable = false + self.caretRequest = nil + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.applyLiteralInputTraits() + textView.font = UIFontMetrics(forTextStyle: .body) + .scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) + textView.adjustsFontForContentSizeCategory = true + textView.textColor = .label + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) + textView.alwaysBounceVertical = true + textView.keyboardDismissMode = .interactive + textView.isFindInteractionEnabled = true + textView.isEditable = isEditable + textView.isSelectable = true + textView.text = text + textView.delegate = context.coordinator + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + context.coordinator.text = $text + if textView.text != text { + textView.text = text + } + if textView.isEditable != isEditable { + textView.isEditable = isEditable + } + if let caretRequest, caretRequest != context.coordinator.appliedCaretRequest { + context.coordinator.appliedCaretRequest = caretRequest + placeCaret(at: caretRequest.utf16Offset, in: textView) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + private func placeCaret(at utf16Offset: Int, in textView: UITextView) { + let location = min(max(utf16Offset, 0), textView.textStorage.length) + if textView.isEditable, !textView.isFirstResponder { + textView.becomeFirstResponder() + } + textView.selectedRange = NSRange(location: location, length: 0) + textView.scrollRangeToVisible(textView.selectedRange) + } + + final class Coordinator: NSObject, UITextViewDelegate { + var text: Binding + var appliedCaretRequest: CaretRequest? + + init(text: Binding) { + self.text = text + } + + func textViewDidChange(_ textView: UITextView) { + text.wrappedValue = textView.text + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JSONTreeView.swift b/apple/TableProMobile/TableProMobile/Views/Components/JSONTreeView.swift new file mode 100644 index 0000000000..e7a9cc950c --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JSONTreeView.swift @@ -0,0 +1,156 @@ +import SwiftUI +import TableProTabularIO + +struct JSONTreeView: View { + let result: JSONOutline.Result + @Binding var expanded: Set + + var body: some View { + switch result { + case .tree(let root, let omitted): + List { + ForEach(Self.topLevelNodes(of: root)) { node in + JSONTreeRow(node: node, expanded: $expanded) + } + if omitted > 0 { + Text(String(format: String(localized: "%lld more not shown"), Int64(omitted))) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .listStyle(.plain) + case .tooLarge: + ContentUnavailableView( + "JSON Too Large", + systemImage: "doc.text", + description: Text("This JSON document is too large for tree view. Use text mode instead.") + ) + case .invalid: + ContentUnavailableView( + "Invalid JSON", + systemImage: "exclamationmark.triangle", + description: Text("The text could not be parsed as JSON. Use text mode to read it.") + ) + } + } + + static func topLevelNodes(of root: JSONOutlineNode) -> [JSONOutlineNode] { + root.children ?? [root] + } + + static func defaultExpansion(of root: JSONOutlineNode) -> Set { + Set(topLevelNodes(of: root).filter { $0.children != nil }.map(\.id)) + } +} + +private struct JSONTreeRow: View { + let node: JSONOutlineNode + @Binding var expanded: Set + + var body: some View { + if let children = node.children { + DisclosureGroup(isExpanded: isExpanded) { + ForEach(children) { child in + JSONTreeRow(node: child, expanded: $expanded) + } + } label: { + JSONTreeLabel(node: node) + } + } else { + JSONTreeLabel(node: node) + } + } + + private var isExpanded: Binding { + let id = node.id + return Binding( + get: { expanded.contains(id) }, + set: { isOpen in + if isOpen { + expanded.insert(id) + } else { + expanded.remove(id) + } + } + ) + } +} + +private struct JSONTreeLabel: View { + let node: JSONOutlineNode + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let key = keyText { + Text(verbatim: key) + .foregroundStyle(isIndex ? HierarchicalShapeStyle.secondary : .primary) + } + Text(verbatim: valueText) + .foregroundStyle(.secondary) + .lineLimit(6) + } + .font(.system(.body, design: .monospaced)) + .accessibilityElement(children: .combine) + .contextMenu { + Button("Copy Value", systemImage: "doc.on.doc", action: copyValue) + if memberName != nil { + Button("Copy Key", systemImage: "textformat", action: copyKey) + } + Button("Copy Key Path", systemImage: "arrow.turn.down.right", action: copyKeyPath) + } + } + + private var memberName: String? { + guard case .member(let name) = node.key else { return nil } + return name + } + + private var isIndex: Bool { + guard case .index = node.key else { return false } + return true + } + + private var keyText: String? { + switch node.key { + case .root: return nil + case .member(let name): return name + case .index(let index): return "[\(index)]" + } + } + + private var valueText: String { + switch node.value { + case .object(let count): + let summary = count == 1 + ? String(localized: "1 key") + : String(format: String(localized: "%lld keys"), Int64(count)) + return "{\(summary)}" + case .array(let count): + let summary = count == 1 + ? String(localized: "1 item") + : String(format: String(localized: "%lld items"), Int64(count)) + return "[\(summary)]" + case .string(let string): + return "\"\(string)\"" + case .number(let lexeme): + return lexeme + case .bool(let flag): + return flag ? "true" : "false" + case .null: + return "null" + } + } + + private func copyValue() { + ClipboardExporter.copyToClipboard(node.copyText) + } + + private func copyKey() { + guard let memberName else { return } + ClipboardExporter.copyToClipboard(memberName) + } + + private func copyKeyPath() { + ClipboardExporter.copyToClipboard(node.keyPath) + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JSONValueEditorSheet.swift b/apple/TableProMobile/TableProMobile/Views/Components/JSONValueEditorSheet.swift new file mode 100644 index 0000000000..de5e3cfad4 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JSONValueEditorSheet.swift @@ -0,0 +1,91 @@ +import SwiftUI +import TableProTabularIO + +struct JSONValueEditorSheet: View { + let title: String + let onDone: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var draft: String + @State private var caretRequest: JSONTextView.CaretRequest? + private let original: String + + init(title: String, text: String, onDone: @escaping (String) -> Void) { + self.title = title + self.onDone = onDone + self.original = text + _draft = State(initialValue: text) + } + + private var hasChanges: Bool { draft != original } + + var body: some View { + let failure = Self.failure(of: draft) + NavigationStack { + JSONTextView(text: $draft, caretRequest: caretRequest) + .safeAreaInset(edge: .bottom, spacing: 0) { + if let failure { + errorLine(failure) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + DiscardChangesCancelButton(hasChanges: hasChanges) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + ConfirmButton(title: "Done", action: finish) + .disabled(failure != nil) + } + ToolbarItem(placement: .bottomBar) { + Button("Format", action: format) + .disabled(draft.isEmpty || failure != nil) + } + } + } + .presentationSizing(.page) + .interactiveDismissDisabled(hasChanges) + .holdsScene(withUnsavedChanges: hasChanges) + } + + private func errorLine(_ failure: JSONValidation.Failure) -> some View { + Button { + showError(failure) + } label: { + Label(failure.message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 10) + .contentShape(.rect) + } + .buttonStyle(.plain) + .background(.bar) + .accessibilityHint("Moves the cursor to the error.") + } + + // Empty text is not checked: the row's NULL rule decides what an empty value saves. + private static func failure(of text: String) -> JSONValidation.Failure? { + guard !text.isEmpty, case .failure(let failure) = JSONValidation.check(text) else { return nil } + return failure + } + + private func showError(_ failure: JSONValidation.Failure) { + caretRequest = JSONTextView.CaretRequest( + utf16Offset: failure.utf16Offset, + serial: (caretRequest?.serial ?? 0) + 1 + ) + } + + private func format() { + guard let pretty = JSONPrettyPrinter.format(draft) else { return } + draft = pretty + } + + private func finish() { + onDone(draft) + dismiss() + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/JSONValueViewer.swift b/apple/TableProMobile/TableProMobile/Views/Components/JSONValueViewer.swift new file mode 100644 index 0000000000..87f500952d --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/JSONValueViewer.swift @@ -0,0 +1,88 @@ +import SwiftUI +import TableProTabularIO + +struct JSONValueViewer: View { + let title: String + let text: String + + @State private var mode: Mode = .text + @State private var displayText: String? + @State private var outline: JSONOutline.Result? + @State private var expanded: Set = [] + + var body: some View { + Group { + switch mode { + case .text: + if let displayText { + JSONTextView(readOnlyText: displayText) + } + case .tree: + if let outline { + JSONTreeView(result: outline, expanded: $expanded) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Mode", selection: $mode) { + Text("Text").tag(Mode.text) + Text("Tree").tag(Mode.tree) + } + .pickerStyle(.segmented) + .fixedSize() + } + ToolbarItem(placement: .primaryAction) { + Button("Copy", systemImage: "doc.on.doc", action: copy) + } + } + .onChange(of: text, initial: true) { _, newText in + render(newText) + } + .onChange(of: mode) { _, newMode in + if newMode == .tree, outline == nil { + buildOutline(text) + } + } + } + + private func render(_ text: String) { + displayText = JSONPrettyPrinter.format(text) ?? text + if mode == .tree { + buildOutline(text) + } else { + outline = nil + } + } + + private func buildOutline(_ text: String) { + let result = JSONOutline.build(text) + if case .tree(let root, _) = result, outline == nil, expanded.isEmpty { + expanded = JSONTreeView.defaultExpansion(of: root) + } + outline = result + } + + private func copy() { + ClipboardExporter.copyToClipboard(text) + } + + private enum Mode: Hashable { + case text + case tree + } +} + +extension JSONValueViewer { + struct Route: Hashable { + let title: String + let text: String + } + + init(route: Route) { + self.init(title: route.title, text: route.text) + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/LiteralInputTraits.swift b/apple/TableProMobile/TableProMobile/Views/Components/LiteralInputTraits.swift new file mode 100644 index 0000000000..7ebbf7c185 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/LiteralInputTraits.swift @@ -0,0 +1,30 @@ +import UIKit + +// SwiftUI cannot turn smart quotes off, and one curly quote breaks a JSON value or a filter. +extension UITextField { + func applyLiteralInputTraits() { + autocorrectionType = .no + autocapitalizationType = .none + spellCheckingType = .no + smartQuotesType = .no + smartDashesType = .no + smartInsertDeleteType = .no + inlinePredictionType = .no + mathExpressionCompletionType = .no + writingToolsBehavior = .none + } +} + +extension UITextView { + func applyLiteralInputTraits() { + autocorrectionType = .no + autocapitalizationType = .none + spellCheckingType = .no + smartQuotesType = .no + smartDashesType = .no + smartInsertDeleteType = .no + inlinePredictionType = .no + mathExpressionCompletionType = .no + writingToolsBehavior = .none + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/LiteralTextField.swift b/apple/TableProMobile/TableProMobile/Views/Components/LiteralTextField.swift new file mode 100644 index 0000000000..52512a57f6 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/LiteralTextField.swift @@ -0,0 +1,79 @@ +import SwiftUI +import UIKit + +struct LiteralTextField: UIViewRepresentable { + private let placeholder: String + @Binding private var text: String + private let keyboardType: UIKeyboardType + + init(_ titleKey: LocalizedStringResource, text: Binding, keyboardType: UIKeyboardType = .default) { + self.init(placeholder: String(localized: titleKey), text: text, keyboardType: keyboardType) + } + + @_disfavoredOverload + init(_ title: Title, text: Binding, keyboardType: UIKeyboardType = .default) { + self.init(placeholder: String(title), text: text, keyboardType: keyboardType) + } + + private init(placeholder: String, text: Binding, keyboardType: UIKeyboardType) { + self.placeholder = placeholder + self._text = text + self.keyboardType = keyboardType + } + + func makeUIView(context: Context) -> UITextField { + let field = UITextField() + field.applyLiteralInputTraits() + field.font = .preferredFont(forTextStyle: .body) + field.adjustsFontForContentSizeCategory = true + field.textColor = .label + field.delegate = context.coordinator + field.addTarget(context.coordinator, action: #selector(Coordinator.textChanged(_:)), for: .editingChanged) + field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return field + } + + func updateUIView(_ field: UITextField, context: Context) { + context.coordinator.text = $text + if field.text != text { + field.text = text + } + if field.placeholder != placeholder { + field.placeholder = placeholder + } + if field.keyboardType != keyboardType { + field.keyboardType = keyboardType + if field.isFirstResponder { + field.reloadInputViews() + } + } + field.isEnabled = context.environment.isEnabled + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView field: UITextField, context: Context) -> CGSize? { + let fitting = field.intrinsicContentSize + guard let width = proposal.width, width.isFinite else { return fitting } + return CGSize(width: width, height: fitting.height) + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var text: Binding + + init(text: Binding) { + self.text = text + } + + @objc func textChanged(_ field: UITextField) { + text.wrappedValue = field.text ?? "" + } + + func textFieldShouldReturn(_ field: UITextField) -> Bool { + field.resignFirstResponder() + return true + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 8e36da9eb8..6ea7f1057e 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -34,6 +34,7 @@ struct DataBrowserView: View { @State private var deleteTarget: [(column: String, value: String)]? @State private var fkPreviewItem: FKPreviewItem? @State private var shareText = "" + @State private var isCompletingCells = false @State private var hapticSuccess = false @State private var hapticError = false @@ -313,15 +314,18 @@ struct DataBrowserView: View { safeModeLevel: { [coordinator] in coordinator.connection.safeModeLevel }, foreignKeys: viewModel.foreignKeys, onSaved: { Task { await viewModel.load() } }, - loadFullValue: { ref in + loadFullValue: { [schema = viewModel.schema] ref in guard let session else { return nil } - return try await viewModel.loadFullValue(driver: session.driver, ref: ref, databaseType: connection.type) + return try await viewModel.loadFullValue( + driver: session.driver, ref: ref, databaseType: connection.type, schema: schema + ) }, coordinator: coordinator ) } private func rowLink(index: Int, row: [String?]) -> some View { + let cells = viewModel.cells(at: index) let deleteKeys = canDeleteRows ? viewModel.primaryKeyValues(forRowAt: index) : nil return NavigationLink { rowDetail(index: index) @@ -334,7 +338,7 @@ struct DataBrowserView: View { ) } .hoverEffect() - .contextMenu { rowContextMenu(index: index, row: row) } + .contextMenu { rowContextMenu(cells: cells) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if let deleteKeys { Button { @@ -358,32 +362,39 @@ struct DataBrowserView: View { } @ViewBuilder - private func rowContextMenu(index: Int, row: [String?]) -> some View { - Menu("Share Row") { - ForEach(ExportFormat.allCases) { format in - Button(format.rawValue) { - shareText = ClipboardExporter.exportRow( - columns: columns, row: row, - format: format, tableName: table.name, - databaseType: connection.type, driver: session?.driver - ) - showShareSheet = true + private func rowContextMenu(cells: [Cell]) -> some View { + if DataBrowserViewModel.canComplete(cells) { + Menu("Share Row") { + ForEach(ExportFormat.allCases) { format in + Button(format.rawValue) { + exportCompleted([cells]) { exportColumns, completed in + shareText = try ClipboardExporter.exportRow( + columns: exportColumns, cells: completed[0], + format: format, tableName: table.name, + databaseType: connection.type, driver: session?.driver + ) + showShareSheet = true + } + } } } - } - Menu("Copy Row") { - ForEach(ExportFormat.allCases) { format in - Button(format.rawValue) { - let text = ClipboardExporter.exportRow( - columns: columns, row: row, - format: format, tableName: table.name, - databaseType: connection.type, driver: session?.driver - ) - ClipboardExporter.copyToClipboard(text) + .disabled(isCompletingCells) + Menu("Copy Row") { + ForEach(ExportFormat.allCases) { format in + Button(format.rawValue) { + exportCompleted([cells]) { exportColumns, completed in + let text = try ClipboardExporter.exportRow( + columns: exportColumns, cells: completed[0], + format: format, tableName: table.name, + databaseType: connection.type, driver: session?.driver + ) + ClipboardExporter.copyToClipboard(text) + } + } } } + .disabled(isCompletingCells) } - let cells = index < viewModel.window.rows.count ? viewModel.window.rows[index].cells : [] let references = ForeignKeyReference.all( foreignKeys: viewModel.foreignKeys, columns: columns, cells: cells, sourceSchema: viewModel.schema @@ -416,15 +427,18 @@ struct DataBrowserView: View { Section("Export") { ForEach(ExportFormat.allCases) { format in Button { - let text = ClipboardExporter.exportRows( - columns: columns, rows: rows, - format: format, tableName: table.name, - databaseType: connection.type, driver: session?.driver - ) - ClipboardExporter.copyToClipboard(text) + exportCompleted(viewModel.window.rows.map(\.cells)) { exportColumns, completed in + let text = try ClipboardExporter.exportRows( + columns: exportColumns, rows: completed, + format: format, tableName: table.name, + databaseType: connection.type, driver: session?.driver + ) + ClipboardExporter.copyToClipboard(text) + } } label: { Label(format.rawValue, systemImage: "doc.on.clipboard") } + .disabled(isCompletingCells) } } } label: { @@ -542,6 +556,43 @@ struct DataBrowserView: View { ) } + // Rows that cannot all be completed skip the reads: the exporter refuses them anyway. + private func exportCompleted( + _ pageRows: [[Cell]], + export: @escaping @MainActor ([ColumnInfo], [[Cell]]) throws -> Void + ) { + guard !isCompletingCells else { return } + let exportColumns = columns + let driver = session?.driver + isCompletingCells = true + Task { + defer { isCompletingCells = false } + do { + var completed = pageRows + if let driver, pageRows.allSatisfy(DataBrowserViewModel.canComplete) { + completed = try await viewModel.completeRows(pageRows, driver: driver) + } + try export(exportColumns, completed) + } catch let error where error is ClipboardExporter.IncompleteValues || error is ClipboardExporter.TooLarge + || error is CellLookupError { + viewModel.operationError = AppError( + category: .query, + title: String(localized: "Cannot Export"), + message: error.localizedDescription, + recovery: nil, + underlying: error + ) + hapticError.toggle() + } catch { + viewModel.operationError = ErrorClassifier.classify( + error, + context: ErrorContext(operation: "exportRows", databaseType: connection.type, host: connection.host) + ) + hapticError.toggle() + } + } + } + private func performDelete(_ pkValues: [(column: String, value: String)]) async { let success = await viewModel.deleteRow(pkValues: pkValues) if success { diff --git a/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift b/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift index 7984621ab9..465f662259 100644 --- a/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -24,6 +24,7 @@ struct InsertRowView: View { @State private var writeGate = ConfirmedWriteGate() @State private var hapticSuccess = false @State private var hapticError = false + @State private var jsonEditorColumn: IndexedRow? private var columnNames: [String] { columnDetails.map(\.name) } private var hasChanges: Bool { !fields.isEmpty } @@ -95,6 +96,9 @@ struct InsertRowView: View { } message: { Text(String(format: String(localized: "This will insert a row into %@. Continue?"), table.name)) } + .sheet(item: $jsonEditorColumn) { field in + jsonEditor(for: field.values) + } } } @@ -119,14 +123,15 @@ struct InsertRowView: View { .font(.body) .foregroundStyle(.secondary) .italic() + } else if ColumnInfo.isJSONType(typeName: column.typeName) { + jsonPreview(for: column) } else { - TextField(text: literalBinding(for: column), prompt: placeholder(for: column)) { - Text(verbatim: column.name) - } - .font(.body) - .keyboardType(keyboardType(for: column)) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + LiteralTextField( + placeholder(for: column), + text: literalBinding(for: column), + keyboardType: keyboardType(for: column) + ) + .accessibilityLabel(Text(verbatim: column.name)) } Spacer() @@ -134,6 +139,33 @@ struct InsertRowView: View { stateMenu(for: column) } } + if ColumnInfo.isJSONType(typeName: column.typeName), !column.isGenerated, !column.rejectsWrittenValues { + Button { + guard let index = columnDetails.firstIndex(where: { $0.name == column.name }) else { return } + jsonEditorColumn = IndexedRow(id: index, values: column) + } label: { + Label("Edit JSON", systemImage: "pencil") + } + } + } + + @ViewBuilder + private func jsonPreview(for column: ColumnInfo) -> some View { + if case .text(let value) = fields[column.name], !value.isEmpty { + JSONFieldPreview(text: value) + } else { + Text(placeholder(for: column)) + .font(.body) + .foregroundStyle(Color(.placeholderText)) + } + } + + private func jsonEditor(for column: ColumnInfo) -> some View { + let literal = literalBinding(for: column) + return JSONValueEditorSheet(title: column.name, text: literal.wrappedValue, onDone: { text in + guard text != literal.wrappedValue else { return } + literal.wrappedValue = text + }) } private func stateMenu(for column: ColumnInfo) -> some View { @@ -238,20 +270,15 @@ struct InsertRowView: View { ) } - private func placeholder(for column: ColumnInfo) -> Text { - if column.isAutoIncrement { return Text("Auto") } - if let defaultValue = column.defaultValue { return Text("Default: \(defaultValue)") } - return Text("Default") + private func placeholder(for column: ColumnInfo) -> String { + if column.isAutoIncrement { return String(localized: "Auto") } + if let defaultValue = column.defaultValue { return String(format: String(localized: "Default: %@"), defaultValue) } + return String(localized: "Default") } + // The decimal pad has no minus sign, so a negative number could not be typed. private func keyboardType(for column: ColumnInfo) -> UIKeyboardType { - let type = column.typeName.uppercased() - if type.contains("INT") || type.contains("REAL") || type.contains("FLOAT") - || type.contains("DOUBLE") || type.contains("NUMERIC") || type.contains("DECIMAL") - { - return .decimalPad - } - return .default + ColumnInfo.isNumericType(typeName: column.typeName) ? .numbersAndPunctuation : .default } // MARK: - Insert diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index b29d800f28..25ffbdd074 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -31,6 +31,7 @@ struct QueryEditorView: View { @State private var showClearConfirmation = false @State private var showShareSheet = false @State private var shareText = "" + @State private var resultsExportError: ClipboardExporter.IncompleteValues? @State private var hapticSuccess = false @State private var hapticError = false @State private var containerSize: CGSize = .zero @@ -398,7 +399,7 @@ struct QueryEditorView: View { resultRowCard(columns: viewModel.columns, row: row) } .contextMenu { - resultRowContextMenu(columns: viewModel.columns, row: row) + resultRowContextMenu(columns: viewModel.columns, rowIndex: rowIndex) } } } @@ -431,23 +432,27 @@ struct QueryEditorView: View { } @ViewBuilder - private func resultRowContextMenu(columns: [ColumnInfo], row: [String?]) -> some View { - if let firstValue = row.first, let value = firstValue { + private func resultRowContextMenu(columns: [ColumnInfo], rowIndex: Int) -> some View { + let rows = viewModel.window.rows + let cells = rows.indices.contains(rowIndex) ? rows[rowIndex].cells : [] + if case .text(let value)? = cells.first { Button { ClipboardExporter.copyToClipboard(value) } label: { Label("Copy Value", systemImage: "doc.on.doc") } } - Menu("Copy Row") { - ForEach(ExportFormat.allCases) { format in - Button(format.rawValue) { - let text = ClipboardExporter.exportRow( - columns: columns, row: row, - format: format, - databaseType: databaseType, driver: coordinator.session?.driver - ) - ClipboardExporter.copyToClipboard(text) + if ClipboardExporter.incompleteColumns(columns: columns, cells: cells).isEmpty { + Menu("Copy Row") { + ForEach(ExportFormat.allCases) { format in + Button(format.rawValue) { + guard let text = try? ClipboardExporter.exportRow( + columns: columns, cells: cells, + format: format, + databaseType: databaseType, driver: coordinator.session?.driver + ) else { return } + ClipboardExporter.copyToClipboard(text) + } } } } @@ -490,12 +495,16 @@ struct QueryEditorView: View { Section("Share Results") { ForEach(ExportFormat.allCases) { format in Button { - shareText = ClipboardExporter.exportRows( - columns: viewModel.columns, rows: viewModel.legacyRows, - format: format, - databaseType: databaseType, driver: coordinator.session?.driver - ) - showShareSheet = true + do throws(ClipboardExporter.IncompleteValues) { + shareText = try ClipboardExporter.exportRows( + columns: viewModel.columns, rows: viewModel.window.rows.map(\.cells), + format: format, + databaseType: databaseType, driver: coordinator.session?.driver + ) + showShareSheet = true + } catch { + resultsExportError = error + } } label: { Label(format.rawValue, systemImage: "square.and.arrow.up") } @@ -504,12 +513,16 @@ struct QueryEditorView: View { Section("Copy Results") { ForEach(ExportFormat.allCases) { format in Button { - let text = ClipboardExporter.exportRows( - columns: viewModel.columns, rows: viewModel.legacyRows, - format: format, - databaseType: databaseType, driver: coordinator.session?.driver - ) - ClipboardExporter.copyToClipboard(text) + do throws(ClipboardExporter.IncompleteValues) { + let text = try ClipboardExporter.exportRows( + columns: viewModel.columns, rows: viewModel.window.rows.map(\.cells), + format: format, + databaseType: databaseType, driver: coordinator.session?.driver + ) + ClipboardExporter.copyToClipboard(text) + } catch { + resultsExportError = error + } } label: { Label(format.rawValue, systemImage: "doc.on.clipboard") } @@ -529,6 +542,18 @@ struct QueryEditorView: View { .font(.body) .foregroundStyle(.secondary) } + .alert( + String(localized: "Cannot Export"), + isPresented: Binding( + get: { resultsExportError != nil }, + set: { if !$0 { resultsExportError = nil } } + ), + presenting: resultsExportError + ) { _ in + Button("OK", role: .cancel) {} + } message: { error in + Text(verbatim: error.localizedDescription) + } } // MARK: - Execution diff --git a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift index 28b0e74c67..640db5007a 100644 --- a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -11,6 +11,8 @@ struct RowDetailView: View { @State private var hapticError = false @State private var hapticSelection = 0 @State private var showSaveConfirmation = false + @State private var jsonEditorField: IndexedRow? + @State private var jsonViewerRoute: JSONValueViewer.Route? private let coordinator: ConnectionCoordinator? init( @@ -83,6 +85,10 @@ struct RowDetailView: View { .holdsScene(withUnsavedChanges: viewModel.hasUnsavedEdits) .navigationTitle(viewModel.table?.name ?? String(format: String(localized: "Row %d of %d"), viewModel.currentIndex + 1, viewModel.rows.count)) .navigationBarTitleDisplayMode(.inline) + // A NavigationLink inside the paged TabView cannot reach the stack: it pushes a blank screen. + .navigationDestination(item: $jsonViewerRoute) { route in + JSONValueViewer(route: route) + } .toolbar { rowDetailToolbar } .sensoryFeedback(.success, trigger: hapticSuccess) .sensoryFeedback(.error, trigger: hapticError) @@ -120,6 +126,12 @@ struct RowDetailView: View { .sheet(isPresented: $showShareSheet) { ActivityViewController(items: [shareText]) } + .sheet(item: $jsonEditorField) { field in + JSONValueEditorSheet(title: viewModel.columns[field.id].name, text: field.values, onDone: { text in + guard text != field.values else { return } + viewModel.setEditedValue(text, at: field.id) + }) + } } @ToolbarContentBuilder @@ -138,7 +150,7 @@ struct RowDetailView: View { ConfirmButton(title: "Save", isInProgress: viewModel.isSaving) { Task { await handleSave() } } - .disabled(viewModel.isSaving) + .disabled(viewModel.isSaving || viewModel.hasInvalidJSONEdit) } else { Button { viewModel.startEditing() } label: { Label("Edit", systemImage: "pencil") @@ -179,11 +191,11 @@ struct RowDetailView: View { Section("Share") { ForEach(ExportFormat.allCases) { format in Button { - shareText = ClipboardExporter.exportRow( - columns: viewModel.columns, row: viewModel.currentRow, - format: format, tableName: viewModel.table?.name, - databaseType: viewModel.databaseType, driver: viewModel.session?.driver - ) + guard let text = viewModel.exportCurrentRow(as: format) else { + hapticError.toggle() + return + } + shareText = text showShareSheet = true } label: { Label(format.rawValue, systemImage: "square.and.arrow.up") @@ -193,11 +205,10 @@ struct RowDetailView: View { Section("Copy to Clipboard") { ForEach(ExportFormat.allCases) { format in Button { - let text = ClipboardExporter.exportRow( - columns: viewModel.columns, row: viewModel.currentRow, - format: format, tableName: viewModel.table?.name, - databaseType: viewModel.databaseType, driver: viewModel.session?.driver - ) + guard let text = viewModel.exportCurrentRow(as: format) else { + hapticError.toggle() + return + } ClipboardExporter.copyToClipboard(text) } label: { Label(format.rawValue, systemImage: "doc.on.clipboard") @@ -220,10 +231,14 @@ struct RowDetailView: View { Section { if takesEdits { editableField(index: index, value: value) + if viewModel.isJSON(at: index) { + editJSONButton(index: index) + } } else { - fieldContent(value: value) + let jsonText = viewModel.jsonText(forRow: rowIndex, at: index) + fieldContent(value: value, jsonPreview: viewModel.isJSON(at: index) ? jsonText : nil) .contextMenu { - if let value { + if let value = viewModel.completeValue(forRow: rowIndex, at: index) { Button { ClipboardExporter.copyToClipboard(value) } label: { @@ -236,6 +251,13 @@ struct RowDetailView: View { Label("Copy Column Name", systemImage: "textformat") } } + if let jsonText { + Button { + jsonViewerRoute = JSONValueViewer.Route(title: column.name, text: jsonText) + } label: { + Label("View as JSON", systemImage: "curlybraces") + } + } if index < cells.count, cells[index].isLoadable, !viewModel.hasOverride(forRow: rowIndex, cellIndex: index) { lazyLoadButton(cell: cells[index], rowIndex: rowIndex, cellIndex: index) @@ -326,9 +348,14 @@ struct RowDetailView: View { .font(.body) .foregroundStyle(.secondary) .italic() + } else if viewModel.isJSON(at: index) { + if let value, !value.isEmpty { + JSONFieldPreview(text: value) + } else { + Spacer(minLength: 0) + } } else { - TextField("Value", text: textBinding) - .font(.body) + LiteralTextField("Value", text: textBinding) } if offersNull { @@ -348,9 +375,20 @@ struct RowDetailView: View { } } + private func editJSONButton(index: Int) -> some View { + Button { + let text = index < viewModel.editedValues.count ? viewModel.editedValues[index] : nil + jsonEditorField = IndexedRow(id: index, values: text ?? "") + } label: { + Label("Edit JSON", systemImage: "pencil") + } + } + @ViewBuilder - private func fieldContent(value: String?) -> some View { - if let value { + private func fieldContent(value: String?, jsonPreview: String? = nil) -> some View { + if let jsonPreview { + JSONFieldPreview(text: jsonPreview) + } else if let value { Text(verbatim: value) .font(.body) .textSelection(.enabled) diff --git a/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index cc306eba75..49b6190d37 100644 --- a/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -214,7 +214,7 @@ struct DataBrowserViewModelTests { ) let ref = CellRef(table: "notes", column: "body", primaryKey: [.init(column: "id", value: "7")]) - let value = try await vm.loadFullValue(driver: driver, ref: ref, databaseType: .postgresql) + let value = try await vm.loadFullValue(driver: driver, ref: ref, databaseType: .postgresql, schema: vm.schema) #expect(value == "the whole body") #expect(driver.executedQueries == [#"SELECT "body" FROM "crm"."notes" WHERE "id" = '7' LIMIT 1"#]) @@ -397,6 +397,88 @@ struct DataBrowserViewModelTests { #expect(driver.executedQueries.contains(where: { $0.uppercased().hasPrefix("DELETE") })) } + @Test("A binary, NULL or ref-less cut key cell gives no key, and a cut one with a ref gives the whole key") + func nonTextKeyGivesNoKey() async { + let columns = [ + ColumnInfo(name: "tenant", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "id", typeName: "BINARY", isPrimaryKey: true, isNullable: false, ordinalPosition: 1), + ColumnInfo(name: "name", typeName: "VARCHAR(64)", ordinalPosition: 2) + ] + let driver = MockDatabaseDriver() + driver.scriptedColumns = columns + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: columns, rows: [["1", "x", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + let keyValues = { (cells: [Cell]) in + RowDetailViewModel.primaryKeyValues(cells: cells, columns: vm.columns, columnDetails: vm.columnDetails) + } + #expect(keyValues([.text("1"), .binary(byteCount: 16, ref: nil), .text("Alice")]) == nil) + #expect(keyValues([.text("1"), .null, .text("Alice")]) == nil) + #expect(keyValues([.text("1"), .truncatedText(prefix: "a", totalBytes: 5_000, ref: nil), .text("Alice")]) == nil) + let wholeKey = CellRef(table: "users", column: "id", primaryKey: [ + PrimaryKeyComponent(column: "tenant", value: "1"), PrimaryKeyComponent(column: "id", value: "long-key") + ]) + let cutKey = keyValues([.text("1"), .truncatedText(prefix: "lo", totalBytes: 5_000, ref: wholeKey), .text("Alice")]) + #expect(cutKey?.map { $0.value } == ["1", "long-key"]) + + let keys = keyValues([.text("1"), .text("7"), .text("Alice")]) + #expect(keys?.map { $0.column } == ["tenant", "id"]) + #expect(keys?.map { $0.value } == ["1", "7"]) + } + + @Test("A delete that leaves the row in the table reports it instead of succeeding") + func deleteThatKeepsTheRowFails() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)) + ] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == false) + #expect(vm.operationError?.title == String(localized: "Row Not Deleted")) + #expect(driver.executedQueries.contains { $0.hasPrefix("SELECT `id` FROM `users` WHERE `id` = '1'") }) + #expect(vm.legacyRows == [["1", "Alice"]]) + } + + @Test("A delete whose count reads 0, as under SET NOCOUNT ON, succeeds once the row is gone") + func uncountedDeleteOfAGoneRowSucceeds() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)) + ] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == true) + #expect(vm.operationError == nil) + } + @Test("deleteRow returns false and sets operationError on driver failure") func deleteFailure() async { let driver = MockDatabaseDriver() @@ -487,6 +569,131 @@ struct DataBrowserViewModelTests { #expect(!driver.didCommitTransaction) } + private func bodyRef(_ column: String) -> CellRef { + CellRef(table: "users", column: column, primaryKey: [PrimaryKeyComponent(column: "id", value: "1")]) + } + + @Test("Loading a full value from a row that is gone throws, so it never reads as NULL") + func loadFullValueOfMissingRowThrows() async throws { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [[nil]], rowsAffected: 0, executionTime: 0)) + ] + + await #expect(throws: CellLookupError.rowNotFound) { + try await vm.loadFullValue(driver: driver, ref: bodyRef("bio"), databaseType: .mysql, schema: nil) + } + let storedNull = try await vm.loadFullValue(driver: driver, ref: bodyRef("bio"), databaseType: .mysql, schema: nil) + #expect(storedNull == nil) + } + + @Test("A full value is read from the schema's table, the one Save and Delete write to") + func loadFullValueReadsTheBrowsedSchema() async throws { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + driver.scriptedExecuteResults = [.success(QueryResult(columns: [], rows: [["full"]], rowsAffected: 0, executionTime: 0))] + + #expect(try await vm.loadFullValue(driver: driver, ref: bodyRef("bio"), databaseType: .postgresql, schema: "sales") == "full") + #expect(driver.executedQueries.first?.contains(#"FROM "sales"."users" WHERE"#) == true) + } + + @Test("Completing rows keeps reading the schema they came from after the browser switches schema") + func completeRowsKeepsTheirSchema() async throws { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .postgresql, host: "localhost", schema: "sales") + driver.beforeExecute = { @MainActor in + vm.attach(session: nil, table: TableInfo(name: "users"), databaseType: .postgresql, host: "localhost", schema: "archive") + } + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [["a"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["b"]], rowsAffected: 0, executionTime: 0)) + ] + let rows: [[Cell]] = [ + [.text("1"), .truncatedText(prefix: "a", totalBytes: 5_000, ref: bodyRef("bio"))], + [.text("1"), .truncatedText(prefix: "b", totalBytes: 5_000, ref: bodyRef("notes"))] + ] + + _ = try await vm.completeRows(rows, driver: driver) + + #expect(vm.schema == "archive") + #expect(driver.executedQueries.count == 2) + #expect(driver.executedQueries.allSatisfy { $0.contains(#"FROM "sales"."users" WHERE"#) }) + } + + @Test("A page export reads full values only while the device has room for them, the text and its copy") + func exportFitsInFreeMemory() { + let margin = 64 * 1_024 * 1_024 + #expect(DataBrowserViewModel.exportFits(pendingBytes: 0, availableBytes: 1)) + #expect(DataBrowserViewModel.exportFits(pendingBytes: 10_000_000, availableBytes: 0)) + #expect(DataBrowserViewModel.exportFits(pendingBytes: 10_000_000, availableBytes: margin + 40_000_001)) + #expect(!DataBrowserViewModel.exportFits(pendingBytes: 10_000_000, availableBytes: margin + 40_000_000)) + #expect(!DataBrowserViewModel.exportFits(pendingBytes: 200_000_000, availableBytes: 500_000_000)) + #expect(!DataBrowserViewModel.exportFits(pendingBytes: .max, availableBytes: .max)) + } + + @Test("Completing a row reads each cut value that has a ref, one query per value") + func completeRowsReadsCutValues() async throws { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + let full = String(repeating: "a", count: 5_000) + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [[full]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [[nil]], rowsAffected: 0, executionTime: 0)) + ] + let cells: [Cell] = [ + .text("1"), + .truncatedText(prefix: "aaa", totalBytes: 5_000, ref: bodyRef("bio")), + .truncatedText(prefix: "bbb", totalBytes: 5_000, ref: bodyRef("notes")), + .null + ] + #expect(DataBrowserViewModel.canComplete(cells)) + + let complete = try await vm.completeRows([cells], driver: driver)[0] + + #expect(driver.executedQueries.count == 2) + #expect(driver.executedQueries.first?.contains("`bio`") == true) + #expect(Row(cells: complete).legacyValues == ["1", full, nil, nil]) + } + + @Test("A binary value or a cut value with no ref cannot be completed, so the export names its column") + func uncompletableCellsBlockExport() async throws { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + let columns = [ + ColumnInfo(name: "id", typeName: "INT", ordinalPosition: 0), + ColumnInfo(name: "avatar", typeName: "BLOB", ordinalPosition: 1) + ] + let binary: [Cell] = [.text("1"), .binary(byteCount: 16, ref: bodyRef("avatar"))] + let keyless: [Cell] = [.text("1"), .truncatedText(prefix: "a", totalBytes: 5_000, ref: nil)] + #expect(!DataBrowserViewModel.canComplete(binary)) + #expect(!DataBrowserViewModel.canComplete(keyless)) + + let complete = try await vm.completeRows([binary], driver: driver)[0] + + #expect(driver.executedQueries.isEmpty) + #expect(throws: ClipboardExporter.IncompleteValues(columns: ["avatar"])) { + try ClipboardExporter.exportRow(columns: columns, cells: complete, format: .json, databaseType: .mysql, driver: nil) + } + } + + @Test("A row that is gone stops the completion instead of exporting NULL") + func completeRowsOfMissingRowThrows() async { + let driver = MockDatabaseDriver() + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + driver.scriptedExecuteResults = [.success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0))] + let cells: [Cell] = [.text("1"), .truncatedText(prefix: "a", totalBytes: 5_000, ref: bodyRef("bio"))] + + await #expect(throws: CellLookupError.rowNotFound) { + try await vm.completeRows([cells], driver: driver) + } + } + @Test("changePageSize resets currentPage and totalRows") func changePageSizeResets() async { let driver = MockDatabaseDriver() diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/ClipboardExporterFidelityTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/ClipboardExporterFidelityTests.swift index bd00e89e25..b8513a2766 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/ClipboardExporterFidelityTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/ClipboardExporterFidelityTests.swift @@ -87,6 +87,92 @@ struct ClipboardExporterFidelityTests { #expect(json(["1", nil, "x"]).contains("\"zip\": null")) } + @Test("control characters are escaped, so the JSON parses and keeps them") + func controlCharactersEscape() throws { + let values = ["form\u{0C}feed", "bell\u{07}", "esc\u{1B}[0m", "unit\u{1F}separator"] + for value in values { + let text = json(["1", value, "x"]) + let data = try #require(text.data(using: .utf8)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["zip"] as? String == value) + } + } + + // MARK: - Cells + + @Test("a cut or binary cell throws and names its column, in every format") + func incompleteCellsThrow() { + let cells: [Cell] = [ + .text("1"), + .truncatedText(prefix: "abc", totalBytes: 5_000, ref: nil), + .binary(byteCount: 16, ref: nil) + ] + for format in ExportFormat.allCases { + #expect(throws: ClipboardExporter.IncompleteValues(columns: ["zip", "note"])) { + try ClipboardExporter.exportRow( + columns: columns, cells: cells, format: format, + tableName: "t", databaseType: .postgresql, driver: nil + ) + } + } + } + + @Test("a cut cell that carries a ref still throws, since only its prefix is here") + func cutCellWithRefThrows() { + let ref = CellRef(table: "t", column: "note", primaryKey: [PrimaryKeyComponent(column: "id", value: "1")]) + let cells: [Cell] = [.text("1"), .text("a"), .truncatedText(prefix: "abc", totalBytes: 5_000, ref: ref)] + #expect(ClipboardExporter.incompleteColumns(columns: columns, cells: cells) == ["note"]) + #expect(throws: ClipboardExporter.IncompleteValues(columns: ["note"])) { + try ClipboardExporter.exportRow( + columns: columns, cells: cells, format: .sqlInsert, + tableName: "t", databaseType: .postgresql, driver: nil + ) + } + } + + @Test("rows name each incomplete column once, in column order") + func incompleteRowsNameColumnsOnce() { + let rows: [[Cell]] = [ + [.text("1"), .text("a"), .binary(byteCount: 4, ref: nil)], + [.text("2"), .truncatedText(prefix: "a", totalBytes: 5_000, ref: nil), .binary(byteCount: 4, ref: nil)] + ] + #expect(throws: ClipboardExporter.IncompleteValues(columns: ["zip", "note"])) { + try ClipboardExporter.exportRows( + columns: columns, rows: rows, format: .csv, + tableName: "t", databaseType: .postgresql, driver: nil + ) + } + } + + @Test("the error message names the columns") + func incompleteValuesNamesColumns() throws { + let message = try #require(ClipboardExporter.IncompleteValues(columns: ["zip", "note"]).errorDescription) + #expect(message.contains("zip")) + #expect(message.contains("note")) + } + + @Test("a loaded long value exports whole, with no size suffix") + func loadedValueExportsWhole() throws { + let long = String(repeating: "x", count: 5_000) + let cells: [Cell] = [.text("1"), .null, .text(long)] + #expect(ClipboardExporter.incompleteColumns(columns: columns, cells: cells).isEmpty) + + let insert = try ClipboardExporter.exportRow( + columns: columns, cells: cells, format: .sqlInsert, + tableName: "t", databaseType: .postgresql, driver: nil + ) + #expect(insert == "INSERT INTO \"t\" (\"id\", \"zip\", \"note\") VALUES ('1', NULL, '\(long)');") + + let text = try ClipboardExporter.exportRows( + columns: columns, rows: [cells], format: .json, + tableName: "t", databaseType: .postgresql, driver: nil + ) + let data = try #require(text.data(using: .utf8)) + let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + #expect(objects.first?["note"] as? String == long) + #expect(objects.first?["zip"] is NSNull) + } + // MARK: - SQL INSERT @Test("identifiers are quoted for the dialect, not always ANSI") diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/JSONValidationTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/JSONValidationTests.swift new file mode 100644 index 0000000000..318538d461 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Helpers/JSONValidationTests.swift @@ -0,0 +1,80 @@ +import Foundation +import TableProTabularIO +import Testing +@testable import TableProMobile + +struct JSONValidationTests { + private func failing(_ text: String) throws -> JSONValidation.Failure { + let reported: JSONValidation.Failure? = if case .failure(let found) = JSONValidation.check(text) { found } else { nil } + return try #require(reported, "\(text) passed") + } + + @Test("valid text passes with the kind of its top value") + func validTextPasses() { + #expect(JSONValidation.check(#"{"a":[1,2]}"#) == .success(.object)) + #expect(JSONValidation.check(" [ ] ") == .success(.array)) + #expect(JSONValidation.check("-1.5e3") == .success(.number)) + #expect(JSONValidation.check(#""x""#) == .success(.text)) + #expect(JSONValidation.check("null") == .success(.null)) + } + + @Test("the issue's document without its last brace fails at line 1, column 55") + func missingBraceReportsTheEnd() throws { + let failure = try failing(#"{"user":{"name":"a","emails":["x@y.z"]},"tags":[1,2,3]"#) + #expect(failure.byteOffset == 54) + #expect(failure.line == 1) + #expect(failure.column == 55) + #expect(failure.utf16Offset == 54) + #expect(failure.reason == String(localized: "The JSON ends before it is complete.")) + #expect(failure.message == String(format: String(localized: "Line %lld, column %lld: %@"), 1, 55, failure.reason)) + } + + @Test("a line counts from the last line break before the error") + func multiLineText() throws { + let failure = try failing("{\n \"a\": 1,\n \"b\": tru\n}") + #expect(failure.byteOffset == 19) + #expect(failure.line == 3) + #expect(failure.column == 8) + #expect(failure.reason == String(localized: "Expected true, false or null.")) + } + + @Test("CRLF is one line break") + func carriageReturnLineFeed() throws { + let failure = try failing("[\r\n1,\r\n]") + #expect(failure.line == 3) + #expect(failure.column == 1) + #expect(failure.byteOffset == 7) + #expect(failure.utf16Offset == 7) + } + + @Test("the column counts characters, the offsets count bytes and UTF-16 units") + func nonASCIIColumns() throws { + let wide = try failing("{\"\u{540D}\u{524D}\": \"\u{E9}\", x}") + #expect(wide.byteOffset == 17) + #expect(wide.column == 13) + #expect(wide.utf16Offset == 12) + + let emoji = try failing("[\"\u{1F44D}\u{1F3FD}\", ]") + #expect(emoji.byteOffset == 13) + #expect(emoji.column == 7) + #expect(emoji.utf16Offset == 9) + #expect(emoji.reason == String(localized: "Unexpected character.")) + } + + @Test("each error names its own reason") + func reasons() throws { + #expect(try failing("01").reason == String(localized: "Invalid number.")) + #expect(try failing("[1}").reason == String(localized: "This bracket does not close the one that is open.")) + #expect(try failing("{} x").reason == String(localized: "Extra text after the JSON value.")) + #expect(try failing("\"a\tb\"").reason == String(localized: "A string cannot hold a line break, tab or other control character.")) + #expect(try failing(#""\q""#).reason == String(localized: "Invalid escape sequence.")) + #expect(try failing("{} x").column == 4) + #expect(try failing("\"a\tb\"").column == 3) + } + + @Test("empty and blank text fail at the end") + func emptyText() throws { + #expect(try failing("").column == 1) + #expect(try failing(" \n").line == 2) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/LiteralInputTraitsTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/LiteralInputTraitsTests.swift new file mode 100644 index 0000000000..4094fa3120 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Helpers/LiteralInputTraitsTests.swift @@ -0,0 +1,55 @@ +@testable import TableProMobile +import Testing +import UIKit + +@MainActor +struct LiteralInputTraitsTests { + @Test("A literal text field turns off every automatic text change") + func textFieldTraits() { + let field = UITextField() + #expect(field.smartQuotesType == .default) + + field.applyLiteralInputTraits() + + #expect(field.autocorrectionType == .no) + #expect(field.autocapitalizationType == UITextAutocapitalizationType.none) + #expect(field.spellCheckingType == .no) + #expect(field.smartQuotesType == .no) + #expect(field.smartDashesType == .no) + #expect(field.smartInsertDeleteType == .no) + #expect(field.inlinePredictionType == .no) + #expect(field.mathExpressionCompletionType == .no) + #expect(field.writingToolsBehavior == UIWritingToolsBehavior.none) + } + + @Test("A literal text view turns off every automatic text change") + func textViewTraits() { + let textView = UITextView() + #expect(textView.smartQuotesType == .default) + + textView.applyLiteralInputTraits() + + #expect(textView.autocorrectionType == .no) + #expect(textView.autocapitalizationType == UITextAutocapitalizationType.none) + #expect(textView.spellCheckingType == .no) + #expect(textView.smartQuotesType == .no) + #expect(textView.smartDashesType == .no) + #expect(textView.smartInsertDeleteType == .no) + #expect(textView.inlinePredictionType == .no) + #expect(textView.mathExpressionCompletionType == .no) + #expect(textView.writingToolsBehavior == UIWritingToolsBehavior.none) + } + + @Test("The traits leave the keyboard type the field asked for") + func keyboardTypeKept() { + let field = UITextField() + field.keyboardType = .decimalPad + field.applyLiteralInputTraits() + #expect(field.keyboardType == .decimalPad) + + let textView = UITextView() + textView.keyboardType = .numbersAndPunctuation + textView.applyLiteralInputTraits() + #expect(textView.keyboardType == .numbersAndPunctuation) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index 14144daddb..e497bd98a1 100644 --- a/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -576,6 +576,7 @@ struct RowDetailViewModelTests { lastCell: Cell, lastTypeName: String = "TEXT", driver: MockDatabaseDriver = MockDatabaseDriver(), + safeModeLevel: SafeModeLevel = .off, loadFullValue: ((CellRef) async throws -> String?)? = nil ) -> RowDetailViewModel { let columns = makeColumns(lastTypeName: lastTypeName) @@ -586,6 +587,7 @@ struct RowDetailViewModelTests { table: TableInfo(name: "users"), session: makeSession(driver: driver), columnDetails: columns, + safeModeLevel: { safeModeLevel }, loadFullValue: loadFullValue ) } @@ -683,7 +685,7 @@ struct RowDetailViewModelTests { let vm = makeViewModel( lastCell: .truncatedText(prefix: "abc", totalBytes: 5_000, ref: bodyRef(id: "1")), driver: driver, - loadFullValue: { _ in "abc and the rest" } + loadFullValue: { _ in driver.executedQueries.isEmpty ? "abc and the rest" : "abc and the rest, edited" } ) await vm.loadFullValue(ref: bodyRef(id: "1"), forRow: 0, cellIndex: 2) vm.startEditing() @@ -694,6 +696,7 @@ struct RowDetailViewModelTests { #expect(driver.executedQueries.count == 1) #expect(driver.executedQueries.first?.contains("abc and the rest, edited") == true) #expect(vm.currentRow[2] == "abc and the rest, edited") + #expect(vm.isEditableAsText(at: 2)) } @Test("A full value that arrives while editing becomes the field's value, not an unsaved edit") @@ -767,4 +770,329 @@ struct RowDetailViewModelTests { #expect(vm.row(at: 1)[2] == "the second row's body") #expect(vm.isEditableAsText(at: 2) == false) } + + private func savedUpdate() -> Result { + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)) + } + + private func kind(of cell: Cell) -> String { + switch cell { + case .null: return "null" + case .text(let value): return "text \(value)" + case .truncatedText: return "truncated" + case .binary: return "binary" + } + } + + @Test("A value is offered as JSON when either type says JSON, or when a text column holds an object or array") + func jsonDetectionReadsBothTypes() async { + let jsonb = makeViewModel(lastCell: .text(#"{"a":1}"#), lastTypeName: "jsonb") + #expect(jsonb.isJSON(at: 2)) + #expect(jsonb.isJSON(at: 1) == false) + #expect(jsonb.jsonText(forRow: 0, at: 2) == #"{"a":1}"#) + + let mariaDB = RowDetailViewModel( + columns: makeColumns(lastTypeName: "JSON"), + rows: [Row(cells: [.text("1"), .text("Alice"), .text("[1]")])], + initialIndex: 0, + columnDetails: makeColumns(lastTypeName: "longtext") + ) + #expect(mariaDB.isJSON(at: 2)) + + let catalogOnly = RowDetailViewModel( + columns: makeColumns(lastTypeName: "VARCHAR"), + rows: [Row(cells: [.text("1"), .text("Alice"), .text("42")])], + initialIndex: 0, + columnDetails: makeColumns(lastTypeName: "JSON") + ) + #expect(catalogOnly.isJSON(at: 2)) + #expect(catalogOnly.jsonText(forRow: 0, at: 2) == "42") + + let object = makeViewModel(lastCell: .text(#" {"a": [1, 2]}"#)) + #expect(object.isJSON(at: 2) == false) + #expect(object.jsonText(forRow: 0, at: 2) == #" {"a": [1, 2]}"#) + + #expect(makeViewModel(lastCell: .text("42")).jsonText(forRow: 0, at: 2) == nil) + #expect(makeViewModel(lastCell: .text(#"{"a":"#)).jsonText(forRow: 0, at: 2) == nil) + #expect(makeViewModel(lastCell: .text("{}"), lastTypeName: "unknown").jsonText(forRow: 0, at: 2) == nil) + #expect(makeViewModel(lastCell: .null, lastTypeName: "jsonb").jsonText(forRow: 0, at: 2) == nil) + + let truncated = makeViewModel( + lastCell: .truncatedText(prefix: #"{"a": "#, totalBytes: 5_000, ref: bodyRef(id: "1")), + lastTypeName: "jsonb", + loadFullValue: { _ in #"{"a": "the rest"}"# } + ) + #expect(truncated.jsonText(forRow: 0, at: 2) == nil) + await truncated.loadFullValue(ref: bodyRef(id: "1"), forRow: 0, cellIndex: 2) + #expect(truncated.jsonText(forRow: 0, at: 2) == #"{"a": "the rest"}"#) + } + + @Test("Save refuses a JSON column's edit that does not parse, before Safe Mode or the database sees it") + func invalidJSONEditIsRefused() async { + let driver = MockDatabaseDriver() + let vm = makeViewModel( + lastCell: .text(#"{"tags": [1, 2, 3]}"#), + lastTypeName: "jsonb", + driver: driver, + safeModeLevel: .confirmWrites + ) + vm.startEditing() + #expect(vm.hasInvalidJSONEdit == false) + + vm.setEditedValue(#"{"tags": [1, 2, 3]"#, at: 2) + #expect(vm.hasInvalidJSONEdit) + #expect(await vm.saveChanges() == false) + #expect(vm.operationError != nil) + #expect(vm.pendingWriteConfirmation == false) + #expect(vm.isEditing) + #expect(driver.executedQueries.isEmpty) + } + + @Test("A JSON value left as it was is no edit, and an edited one is written exactly as typed") + func jsonEditSavesAsTyped() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + let stored = #"{"b": 1, "a": 2}"# + let vm = makeViewModel(lastCell: .text(stored), lastTypeName: "jsonb", driver: driver) + vm.startEditing() + vm.setEditedValue(stored, at: 2) + #expect(vm.hasUnsavedEdits == false) + + let typed = "{\n \"b\":1,\"a\" : 2\n}" + vm.setEditedValue(typed, at: 2) + #expect(vm.hasInvalidJSONEdit == false) + #expect(await vm.saveChanges()) + #expect(driver.executedQueries.count == 1) + #expect(driver.executedQueries.first?.contains(typed) == true) + } + + @Test("Clearing a MariaDB JSON field saves NULL, though its catalog type reads longtext") + func clearedMariaDBJSONSavesNull() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + let vm = RowDetailViewModel( + columns: makeColumns(lastTypeName: "JSON"), + rows: [Row(cells: [.text("1"), .text("Alice"), .text(#"{"a": 1}"#)])], + initialIndex: 0, + table: TableInfo(name: "users"), + session: makeSession(driver: driver), + columnDetails: makeColumns(lastTypeName: "longtext") + ) + vm.startEditing() + vm.setEditedValue("", at: 2) + + #expect(vm.writtenValue(at: 2) == nil) + #expect(vm.hasInvalidJSONEdit == false) + #expect(await vm.saveChanges()) + let query = driver.executedQueries.first ?? "" + #expect(query.contains("`body` = NULL") || query.contains("\"body\" = NULL")) + #expect(!query.contains("''")) + } + + @Test("A text column holding JSON saves an edit that does not parse, since the column takes any text") + func textColumnSavesBrokenJSON() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + let vm = makeViewModel(lastCell: .text(#"{"a": 1}"#), driver: driver) + vm.startEditing() + vm.setEditedValue(#"{"a": "#, at: 2) + + #expect(vm.hasInvalidJSONEdit == false) + #expect(await vm.saveChanges()) + #expect(driver.executedQueries.count == 1) + } + + @Test("After a save the row shows what the database stored, not what was typed") + func savedRowShowsTheStoredValue() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + let stored = #"{"a": 3, "b": 1}"# + var lookups: [CellRef] = [] + let vm = makeViewModel( + lastCell: .text(#"{"a": 1}"#), + lastTypeName: "jsonb", + driver: driver, + loadFullValue: { ref in + lookups.append(ref) + return stored + } + ) + vm.startEditing() + vm.setEditedValue(#"{"b":1,"a":2,"a":3}"#, at: 2) + + #expect(await vm.saveChanges()) + #expect(lookups == [bodyRef(id: "1")]) + #expect(vm.currentRow[2] == stored) + #expect(vm.isEditing == false) + #expect(vm.showSaveSuccess) + } + + @Test("A save whose row is gone says so and stays in edit mode with the typed values") + func saveOfAMissingRowStaysEditing() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [.success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0))] + let vm = makeViewModel( + lastCell: .text("old"), + driver: driver, + loadFullValue: { _ in throw CellLookupError.rowNotFound } + ) + vm.startEditing() + vm.setEditedValue("new", at: 2) + + #expect(await vm.saveChanges() == false) + #expect(vm.operationError?.title == String(localized: "Row Not Found")) + #expect(vm.isEditing) + #expect(vm.editedValues[2] == "new") + #expect(vm.hasUnsavedEdits) + #expect(vm.showSaveSuccess == false) + #expect(vm.currentRow[2] == "old") + } + + @Test("A save that updated a row whose key then changed is saved, with the typed values") + func saveOfARowThatLeftItsKeyIsSaved() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + var savedCount = 0 + let columns = makeColumns(lastTypeName: "TEXT") + let vm = RowDetailViewModel( + columns: columns, + rows: [Row(cells: [.text("1"), .text("Alice"), .text("old")])], + initialIndex: 0, + table: TableInfo(name: "users"), + session: makeSession(driver: driver), + columnDetails: columns, + onSaved: { savedCount += 1 }, + loadFullValue: { _ in throw CellLookupError.rowNotFound } + ) + vm.startEditing() + vm.setEditedValue("new", at: 2) + + #expect(await vm.saveChanges()) + #expect(vm.operationError == nil) + #expect(vm.isEditing == false) + #expect(vm.currentRow[2] == "new") + #expect(savedCount == 1) + } + + @Test("A save whose read-back fails for another reason keeps the typed values") + func failedReadBackKeepsTypedValues() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + let vm = makeViewModel( + lastCell: .text("old"), + driver: driver, + loadFullValue: { _ in throw MockDatabaseDriver.MockError.scripted } + ) + vm.startEditing() + vm.setEditedValue("new", at: 2) + + #expect(await vm.saveChanges()) + #expect(vm.operationError == nil) + #expect(vm.isEditing == false) + #expect(vm.currentRow[2] == "new") + } + + @Test("A save reads back only the saved columns, and an unsaved long value keeps its reference") + func readBackLeavesUnsavedCellsAlone() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + var lookedUp: [String] = [] + let vm = makeViewModel( + lastCell: .truncatedText(prefix: "abc", totalBytes: 5_000, ref: bodyRef(id: "1")), + driver: driver, + loadFullValue: { ref in + lookedUp.append(ref.column) + return "Charlie" + } + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + #expect(await vm.saveChanges()) + #expect(lookedUp == ["name"]) + #expect(vm.currentRow[1] == "Charlie") + guard case .truncatedText(_, _, let ref) = vm.cells(at: 0)[2] else { + Issue.record("the unsaved truncated value became \(vm.cells(at: 0)[2])") + return + } + #expect(ref == bodyRef(id: "1")) + } + + @Test("Complete cells carry a loaded long value in full and leave every placeholder as it is") + func completeCellsApplyLoadedValues() async { + let columns = makeColumns() + [ + ColumnInfo(name: "body", typeName: "TEXT", ordinalPosition: 2), + ColumnInfo(name: "note", typeName: "TEXT", ordinalPosition: 3), + ColumnInfo(name: "memo", typeName: "TEXT", ordinalPosition: 4), + ColumnInfo(name: "data", typeName: "BLOB", ordinalPosition: 5) + ] + func ref(_ column: String) -> CellRef { + CellRef(table: "users", column: column, primaryKey: [.init(column: "id", value: "1")]) + } + let vm = RowDetailViewModel( + columns: columns, + rows: [Row(cells: [ + .text("1"), + .null, + .truncatedText(prefix: "abc", totalBytes: 5_000, ref: ref("body")), + .truncatedText(prefix: "n", totalBytes: 5_000, ref: ref("note")), + .truncatedText(prefix: "m", totalBytes: 5_000, ref: nil), + .binary(byteCount: 12, ref: ref("data")) + ])], + initialIndex: 0, + table: TableInfo(name: "users"), + columnDetails: columns, + loadFullValue: { ref in ref.column == "note" ? nil : "\(ref.column) in full" } + ) + await vm.loadFullValue(ref: ref("body"), forRow: 0, cellIndex: 2) + await vm.loadFullValue(ref: ref("note"), forRow: 0, cellIndex: 3) + await vm.loadFullValue(ref: ref("data"), forRow: 0, cellIndex: 5) + + let kinds = vm.completeCells(at: 0).map { kind(of: $0) } + #expect(kinds == ["text 1", "null", "text body in full", "null", "truncated", "binary"]) + #expect(vm.completeValue(forRow: 0, at: 2) == "body in full") + #expect(vm.completeValue(forRow: 0, at: 4) == nil) + #expect(vm.completeValue(forRow: 0, at: 5) == nil) + } + + @Test("A long text key cut for display still names its row, through the whole key its ref carries") + func cutTextKeyEditsThroughItsRef() async { + let columns = [ + ColumnInfo(name: "slug", typeName: "TEXT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "doc", typeName: "TEXT", ordinalPosition: 1) + ] + let slug = String(repeating: "k", count: 5_000) + let ref = CellRef(table: "t", column: "slug", primaryKey: [PrimaryKeyComponent(column: "slug", value: slug)]) + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [savedUpdate()] + let vm = RowDetailViewModel( + columns: columns, + rows: [Row(cells: [.truncatedText(prefix: "kkk", totalBytes: 5_000, ref: ref), .text("a")])], + initialIndex: 0, + table: TableInfo(name: "t"), + session: makeSession(driver: driver), + columnDetails: columns + ) + #expect(vm.canEdit) + + vm.startEditing() + vm.setEditedValue("b", at: 1) + #expect(await vm.saveChanges()) + #expect(driver.executedQueries.first?.hasSuffix("WHERE \"slug\" = '\(slug)'") == true) + } + + @Test("Sharing a row reports a long value not loaded yet, and exports it in full once loaded") + func rowExportNeedsCompleteValues() async { + let vm = makeViewModel( + lastCell: .truncatedText(prefix: "abc", totalBytes: 5_000, ref: nil), + loadFullValue: { _ in "abc and the rest" } + ) + #expect(vm.exportCurrentRow(as: .csv) == nil) + #expect(vm.operationError != nil) + + vm.operationError = nil + await vm.loadFullValue(ref: bodyRef(id: "1"), forRow: 0, cellIndex: 2) + #expect(vm.exportCurrentRow(as: .csv)?.contains("abc and the rest") == true) + #expect(vm.operationError == nil) + } } diff --git a/apple/changelog.d/3686.added.md b/apple/changelog.d/3686.added.md new file mode 100644 index 0000000000..de8516b979 --- /dev/null +++ b/apple/changelog.d/3686.added.md @@ -0,0 +1 @@ +iPhone and iPad show JSON values indented, open them as text or a tree, and check JSON edits before Save. diff --git a/apple/changelog.d/3686.fixed.1.md b/apple/changelog.d/3686.fixed.1.md new file mode 100644 index 0000000000..b0b88fe211 --- /dev/null +++ b/apple/changelog.d/3686.fixed.1.md @@ -0,0 +1 @@ +Typing a quote in a value or filter field on iPhone and iPad inserted a curly quote that broke JSON and SQL. diff --git a/apple/changelog.d/3686.fixed.2.md b/apple/changelog.d/3686.fixed.2.md new file mode 100644 index 0000000000..be26be8cce --- /dev/null +++ b/apple/changelog.d/3686.fixed.2.md @@ -0,0 +1 @@ +Copying or exporting rows on iPhone and iPad wrote placeholder text for long and binary values. diff --git a/apple/changelog.d/3686.fixed.3.md b/apple/changelog.d/3686.fixed.3.md new file mode 100644 index 0000000000..098caf2911 --- /dev/null +++ b/apple/changelog.d/3686.fixed.3.md @@ -0,0 +1 @@ +After a save on iPhone and iPad, the row showed the typed values instead of what the database stored. diff --git a/apple/changelog.d/3686.fixed.4.md b/apple/changelog.d/3686.fixed.4.md new file mode 100644 index 0000000000..cfb49ed8f6 --- /dev/null +++ b/apple/changelog.d/3686.fixed.4.md @@ -0,0 +1 @@ +Loading a full value on iPhone and iPad showed NULL when the row had been deleted elsewhere. diff --git a/apple/changelog.d/3686.fixed.5.md b/apple/changelog.d/3686.fixed.5.md new file mode 100644 index 0000000000..47af66a758 --- /dev/null +++ b/apple/changelog.d/3686.fixed.5.md @@ -0,0 +1 @@ +Rows copied as JSON on iPhone and iPad were invalid when a value held a tab or other control character. diff --git a/apple/changelog.d/3686.fixed.6.md b/apple/changelog.d/3686.fixed.6.md new file mode 100644 index 0000000000..ea0d71d31a --- /dev/null +++ b/apple/changelog.d/3686.fixed.6.md @@ -0,0 +1 @@ +Clearing a MariaDB JSON field on iPhone and iPad saved an empty string instead of NULL. diff --git a/apple/changelog.d/3686.fixed.7.md b/apple/changelog.d/3686.fixed.7.md new file mode 100644 index 0000000000..95baf9db36 --- /dev/null +++ b/apple/changelog.d/3686.fixed.7.md @@ -0,0 +1 @@ +The number keyboard in Add Row on iPhone and iPad had no minus sign, so negative numbers could not be typed. diff --git a/apple/changelog.d/3686.fixed.md b/apple/changelog.d/3686.fixed.md new file mode 100644 index 0000000000..4bf9a2f909 --- /dev/null +++ b/apple/changelog.d/3686.fixed.md @@ -0,0 +1 @@ +Deleting a row with a binary or long text primary key on iPhone and iPad reported success without deleting it.