diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d3be896..8af555412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- JSON tab in the inspector, showing the selected row as JSON, with Show Row as JSON on a row's right-click menu. +- Foreign key expansion in the JSON tab, fetching the referenced row on click, five levels deep. +- Filter field in the JSON tab, taking text or a regular expression in slashes. +- Always Expand Foreign Keys in the JSON tab, off until turned on. - JavaScript shell for MongoDB queries, with mongosh's `db` API, cursors, variables, functions and `print`. - Per-connection MongoDB shell state, so a variable or function survives from one statement to the next. - Cursor method autocomplete after `find()` and `aggregate()`. @@ -37,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Row inspector lag on hover, on a tab switch and on every keystroke, from re-parsing each field's value. - The data grid's row commands on a column's right-click menu in Structure, when the column was already selected. - Wrong keyboard shortcuts shown beside Copy Name and Duplicate in the Structure right-click menu. - Plugin download reporting no progress at all when a connection or a file needs a driver installed. diff --git a/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift b/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift new file mode 100644 index 000000000..a0851de08 --- /dev/null +++ b/TablePro/Core/Services/Query/ForeignKeyRowFetcher.swift @@ -0,0 +1,105 @@ +// +// ForeignKeyRowFetcher.swift +// TablePro +// +// The single-row lookup behind Preview Referenced Row and the JSON inspector's +// foreign key expansion. +// + +import Foundation +import os +import TableProPluginKit + +@MainActor +enum ForeignKeyRowFetcher { + struct FetchedRow: Sendable { + let columns: [String] + let columnTypes: [ColumnType] + let values: [PluginCellValue] + let foreignKeys: [String: JSONForeignKeyRef] + } + + enum FetchFailure: Error { + case noConnection + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeyRowFetcher") + + /// The referenced row, or nil when the key matches nothing. Two callers share this so the + /// popover and the inspector cannot end up reading a foreign key two different ways. + /// + /// `includeForeignKeys` costs a metadata read for the referenced table's own constraints, which + /// only the inspector needs: it is what makes a nested key clickable in turn. + static func fetch( + connectionId: UUID, + databaseType: DatabaseType, + reference: JSONForeignKeyRef, + value: String, + includeForeignKeys: Bool = false + ) async throws -> FetchedRow? { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw FetchFailure.noConnection + } + + let quotedTable: String + if let schema = reference.referencedSchema, !schema.isEmpty { + quotedTable = "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(reference.referencedTable))" + } else { + quotedTable = driver.quoteIdentifier(reference.referencedTable) + } + + let query = ForeignKeyPreviewQuery.singleRow( + quotedTable: quotedTable, + quotedColumn: driver.quoteIdentifier(reference.referencedColumn), + escapedValue: driver.escapeStringLiteral(value), + dialect: PluginManager.shared.sqlDialect(for: databaseType) + ) + + let result = try await driver.execute(query: query) + guard let firstRow = result.rows.first else { return nil } + + let foreignKeys = includeForeignKeys + ? await referencedTableForeignKeys(connectionId: connectionId, reference: reference) + : [:] + + return FetchedRow( + columns: result.columns, + columnTypes: result.columnTypes, + values: firstRow, + foreignKeys: foreignKeys + ) + } + + /// Answers from the schema prefetch when it covers the table, so following a chain of keys in + /// the same schema costs no extra round trips. + private static func referencedTableForeignKeys( + connectionId: UUID, + reference: JSONForeignKeyRef + ) async -> [String: JSONForeignKeyRef] { + guard let scope = DatabaseManager.shared.browseScope(for: connectionId) else { return [:] } + let targetScope = reference.referencedSchema.map { + DatabaseScope(connectionId: connectionId, database: scope.database, schema: $0) + } ?? scope + + if let cached = SchemaForeignKeyStore.shared.foreignKeysByColumn( + for: targetScope, + table: reference.referencedTable + ) { + return cached.mapValues(JSONForeignKeyRef.init) + } + + do { + let table = reference.referencedTable + let fetched = try await DatabaseManager.shared.withMetadataDriver(scope: targetScope) { driver in + try await driver.fetchForeignKeys(table: table) + } + return Dictionary( + fetched.map { ($0.column, JSONForeignKeyRef($0)) }, + uniquingKeysWith: { first, _ in first } + ) + } catch { + logger.error("Nested foreign key metadata fetch failed: \(error.localizedDescription)") + return [:] + } + } +} diff --git a/TablePro/Models/UI/InspectorContext.swift b/TablePro/Models/UI/InspectorContext.swift index 1fa2eea2b..f47cafaa5 100644 --- a/TablePro/Models/UI/InspectorContext.swift +++ b/TablePro/Models/UI/InspectorContext.swift @@ -17,6 +17,8 @@ struct InspectorContext { let isRowDeleted: Bool let currentQuery: String? let queryResults: String? + /// The same row the details tab shows, carried as raw cell values for the JSON tab. + let jsonRow: JSONRowSnapshot? static let empty = InspectorContext( tableName: nil, @@ -25,6 +27,7 @@ struct InspectorContext { isEditable: false, isRowDeleted: false, currentQuery: nil, - queryResults: nil + queryResults: nil, + jsonRow: nil ) } diff --git a/TablePro/Models/UI/JSON/JSONDisplayRow.swift b/TablePro/Models/UI/JSON/JSONDisplayRow.swift new file mode 100644 index 000000000..f74d3488d --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONDisplayRow.swift @@ -0,0 +1,61 @@ +// +// JSONDisplayRow.swift +// TablePro +// +// One printed line of the JSON inspector. +// + +import Foundation + +enum JSONForeignKeyFailure: Equatable, Sendable { + case notFound + case cycle + case depthLimit + case failed(String) +} + +struct JSONForeignKeyStates: Sendable { + var fetched: [JSONNodePath: JSONRowNode] = [:] + var loading: Set = [] + var failures: [JSONNodePath: JSONForeignKeyFailure] = [:] +} + +struct JSONDisplayRow: Identifiable, Equatable, Sendable { + enum Token: Equatable, Sendable { + case scalar(JSONScalar) + case openObject + case closeObject + case openArray + case closeArray + case collapsedObject(count: Int) + case collapsedArray(count: Int) + } + + enum Status: Equatable, Sendable { + case none + case loading + case failure(JSONForeignKeyFailure) + } + + let id: String + let path: JSONNodePath + let depth: Int + let key: JSONNodeKey + let token: Token + let needsComma: Bool + /// The node's own value, carried beside the token rather than read out of it. An expanded + /// foreign key draws as `{`, and taking the value from the token alone took Copy Value and + /// Open off the line the moment it was opened. + let scalar: JSONScalar? + let foreignKey: JSONForeignKeyRef? + let isExpandable: Bool + let isExpanded: Bool + let status: Status + + var showsKey: Bool { + switch token { + case .closeObject, .closeArray: false + default: key.text != nil + } + } +} diff --git a/TablePro/Models/UI/JSON/JSONForeignKeyExpansionPolicy.swift b/TablePro/Models/UI/JSON/JSONForeignKeyExpansionPolicy.swift new file mode 100644 index 000000000..96c25eb04 --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONForeignKeyExpansionPolicy.swift @@ -0,0 +1,58 @@ +// +// JSONForeignKeyExpansionPolicy.swift +// TablePro +// +// How far the JSON inspector follows a chain of foreign keys. +// + +import Foundation + +struct JSONForeignKeyVisit: Hashable, Sendable { + let table: String + let schema: String? + let column: String + let value: String? + + init(table: String, schema: String?, column: String, value: String?) { + self.table = table + self.schema = schema + self.column = column + self.value = value + } + + init(ref: JSONForeignKeyRef, value: String?) { + self.init( + table: ref.referencedTable, + schema: ref.referencedSchema, + column: ref.referencedColumn, + value: value + ) + } +} + +enum JSONForeignKeyExpansionDecision: Equatable, Sendable { + case allowed + case cycle + case depthLimit +} + +/// A row that references itself, directly or through another table, is ordinary schema design, so +/// the chain has to be checked rather than trusted: `employee.manager_id → employee` expands +/// forever otherwise. The depth cap covers the chains that do terminate but only after more +/// round trips than a reader wants. +enum JSONForeignKeyExpansionPolicy { + static let maxChainDepth = 5 + + /// How many levels "Always Expand Foreign Keys" fetches without being asked. One: the setting + /// exists to save the first click, not to walk the schema on every selection change. + static let autoExpandDepth = 1 + + static func decide( + chain: [JSONForeignKeyVisit], + next: JSONForeignKeyVisit + ) -> JSONForeignKeyExpansionDecision { + if chain.contains(next) { return .cycle } + guard chain.count < maxChainDepth else { return .depthLimit } + return .allowed + } +} diff --git a/TablePro/Models/UI/JSON/JSONRowFilter.swift b/TablePro/Models/UI/JSON/JSONRowFilter.swift new file mode 100644 index 000000000..402bf7c92 --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONRowFilter.swift @@ -0,0 +1,124 @@ +// +// JSONRowFilter.swift +// TablePro +// +// Text and /regex/ filtering over a row's JSON tree. +// + +import Foundation + +struct JSONRowMatcher: Sendable { + private enum Kind { + case substring(String) + case regex(NSRegularExpression) + } + + private let kind: Kind + + /// `/…/` is read as a regular expression, anything else as a case-insensitive substring. A + /// trailing slash is required, so a reader typing a path such as `a/b` still gets a substring. + static func make(query: String) -> JSONRowFilterQuery { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .empty } + if trimmed.count >= 2, trimmed.hasPrefix("/"), trimmed.hasSuffix("/") { + let pattern = String(trimmed.dropFirst().dropLast()) + guard !pattern.isEmpty else { return .empty } + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return .invalidRegex + } + return .matcher(JSONRowMatcher(kind: .regex(regex))) + } + return .matcher(JSONRowMatcher(kind: .substring(trimmed))) + } + + func matches(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + switch kind { + case .substring(let needle): + return text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil + case .regex(let regex): + let range = NSRange(location: 0, length: (text as NSString).length) + return regex.firstMatch(in: text, options: [], range: range) != nil + } + } +} + +enum JSONRowFilterQuery: Sendable { + case empty + case invalidRegex + case matcher(JSONRowMatcher) +} + +enum JSONRowFilter { + /// Paths to keep for a filter: every node that matches, plus every ancestor of one, so a match + /// nested three levels down still arrives with the keys that lead to it. + static func visiblePaths( + root: JSONRowNode, + fetchedForeignKeys: [JSONNodePath: JSONRowNode], + matcher: JSONRowMatcher + ) -> Set { + var visible: Set = [] + _ = collect( + node: root, + fetched: fetchedForeignKeys, + matcher: matcher, + keepsEverything: false, + into: &visible + ) + return visible + } + + /// One walk, one visit per node. + /// + /// A key that matches keeps what it holds, decided before descending and carried down as + /// `keepsEverything`, so a chain of matching ancestors costs one pass rather than one pass per + /// ancestor over the same leaves. Keeping the container alone left it drawn as `{…}` with a + /// disclosure control that could not open it, because a filtered tree takes its expansion from + /// what survived the filter rather than from the reader's expanded set. + /// + /// Only the *key* keeps a subtree. A value that matches keeps its own line and the keys that + /// lead to it: an expanded foreign key carries both its own scalar and the fetched row's + /// fields, so treating a value match the same way answered a search for `1` with every column + /// of the referenced row. + private static func collect( + node: JSONRowNode, + fetched: [JSONNodePath: JSONRowNode], + matcher: JSONRowMatcher, + keepsEverything: Bool, + into visible: inout Set + ) -> Bool { + let keyMatches = node.key.text.map(matcher.matches) ?? false + let keepsDescendants = keepsEverything || keyMatches + var subtreeMatched = false + + for child in children(of: node, fetched: fetched) { + if collect( + node: child, + fetched: fetched, + matcher: matcher, + keepsEverything: keepsDescendants, + into: &visible + ) { + subtreeMatched = true + } + } + + guard keepsDescendants || subtreeMatched || scalarMatches(node, matcher: matcher) else { + return false + } + visible.insert(node.path) + return true + } + + static func children(of node: JSONRowNode, fetched: [JSONNodePath: JSONRowNode]) -> [JSONRowNode] { + if node.foreignKey != nil, let expansion = fetched[node.path] { + return expansion.children + } + return node.children + } + + private static func scalarMatches(_ node: JSONRowNode, matcher: JSONRowMatcher) -> Bool { + guard let scalar = node.scalar else { return false } + return matcher.matches(scalar.searchableText) + } +} diff --git a/TablePro/Models/UI/JSON/JSONRowFlattener.swift b/TablePro/Models/UI/JSON/JSONRowFlattener.swift new file mode 100644 index 000000000..c20f31f89 --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONRowFlattener.swift @@ -0,0 +1,161 @@ +// +// JSONRowFlattener.swift +// TablePro +// +// Turns the node tree, the expanded set and the fetched foreign keys into printed lines. +// + +import Foundation + +enum JSONRowFlattener { + /// `visiblePaths` is the filter's answer. A filter run expands everything it kept, so a match + /// nested inside a collapsed object is on screen without the reader opening its way down. + static func rows( + root: JSONRowNode, + expanded: Set, + states: JSONForeignKeyStates, + visiblePaths: Set? = nil + ) -> [JSONDisplayRow] { + var rows: [JSONDisplayRow] = [] + append( + node: root, + depth: 0, + needsComma: false, + expanded: expanded, + states: states, + visiblePaths: visiblePaths, + into: &rows + ) + return rows + } + + /// Every path a disclosure control can act on, for Expand All. + static func expandablePaths(root: JSONRowNode, states: JSONForeignKeyStates) -> Set { + var paths: Set = [] + collectExpandable(node: root, states: states, into: &paths) + return paths + } + + private static func collectExpandable( + node: JSONRowNode, + states: JSONForeignKeyStates, + into paths: inout Set + ) { + let children = JSONRowFilter.children(of: node, fetched: states.fetched) + guard !children.isEmpty else { return } + paths.insert(node.path) + for child in children { + collectExpandable(node: child, states: states, into: &paths) + } + } + + private static func append( + node: JSONRowNode, + depth: Int, + needsComma: Bool, + expanded: Set, + states: JSONForeignKeyStates, + visiblePaths: Set?, + into rows: inout [JSONDisplayRow] + ) { + if let visiblePaths, !visiblePaths.contains(node.path) { return } + + let children = JSONRowFilter.children(of: node, fetched: states.fetched) + let visibleChildren = children.filter { visiblePaths?.contains($0.path) ?? true } + let isFiltering = visiblePaths != nil + let isExpanded = isFiltering ? !visibleChildren.isEmpty : expanded.contains(node.path) + let status = status(for: node, states: states) + + guard !children.isEmpty, isExpanded else { + rows.append( + JSONDisplayRow( + id: node.path.rawValue, + path: node.path, + depth: depth, + key: node.key, + token: collapsedToken(for: node, childCount: children.count), + needsComma: needsComma, + scalar: node.scalar, + foreignKey: node.foreignKey, + isExpandable: isExpandable(node, states: states), + isExpanded: false, + status: status + ) + ) + return + } + + let isArray: Bool + if case .array = node.value { isArray = true } else { isArray = false } + + rows.append( + JSONDisplayRow( + id: node.path.rawValue, + path: node.path, + depth: depth, + key: node.key, + token: isArray ? .openArray : .openObject, + needsComma: false, + scalar: node.scalar, + foreignKey: node.foreignKey, + isExpandable: true, + isExpanded: true, + status: status + ) + ) + + for (index, child) in visibleChildren.enumerated() { + append( + node: child, + depth: depth + 1, + needsComma: index < visibleChildren.count - 1, + expanded: expanded, + states: states, + visiblePaths: visiblePaths, + into: &rows + ) + } + + rows.append( + JSONDisplayRow( + id: "\(node.path.rawValue)\u{001E}close", + path: node.path, + depth: depth, + key: node.key, + token: isArray ? .closeArray : .closeObject, + needsComma: needsComma, + scalar: nil, + foreignKey: nil, + isExpandable: false, + isExpanded: true, + status: .none + ) + ) + } + + private static func collapsedToken(for node: JSONRowNode, childCount: Int) -> JSONDisplayRow.Token { + if let scalar = node.scalar { return .scalar(scalar) } + switch node.value { + case .array: return .collapsedArray(count: childCount) + case .object: return .collapsedObject(count: childCount) + case .scalar(let scalar), .foreignKey(_, let scalar): return .scalar(scalar) + } + } + + /// A foreign key with a NULL value references nothing, so it offers no control. An empty object + /// or array has nothing to open either. + private static func isExpandable(_ node: JSONRowNode, states: JSONForeignKeyStates) -> Bool { + if let scalar = node.scalar, node.foreignKey != nil { + if case .null = scalar { return false } + return true + } + return !JSONRowFilter.children(of: node, fetched: states.fetched).isEmpty + } + + private static func status(for node: JSONRowNode, states: JSONForeignKeyStates) -> JSONDisplayRow.Status { + guard node.foreignKey != nil else { return .none } + if states.loading.contains(node.path) { return .loading } + if let failure = states.failures[node.path] { return .failure(failure) } + return .none + } +} diff --git a/TablePro/Models/UI/JSON/JSONRowNode.swift b/TablePro/Models/UI/JSON/JSONRowNode.swift new file mode 100644 index 000000000..20f4b7f4f --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONRowNode.swift @@ -0,0 +1,131 @@ +// +// JSONRowNode.swift +// TablePro +// +// Immutable tree a single result row is rendered from in the JSON inspector. +// + +import Foundation + +struct JSONNodePath: Hashable, Sendable { + let components: [String] + + static let root = JSONNodePath(components: []) + + func appending(_ component: String) -> JSONNodePath { + JSONNodePath(components: components + [component]) + } + + var rawValue: String { + components.joined(separator: "\u{001F}") + } + + var depth: Int { components.count } +} + +enum JSONNodeKey: Equatable, Sendable { + case root + case name(String) + case index(Int) + + var text: String? { + switch self { + case .root: nil + case .name(let name): name + case .index: nil + } + } +} + +enum JSONScalar: Equatable, Sendable { + case string(String) + case number(String) + case bool(Bool) + case null + case binary(Data) + + var searchableText: String { + switch self { + case .string(let text): text + case .number(let text): text + case .bool(let flag): flag ? "true" : "false" + case .null: "null" + case .binary: "" + } + } +} + +/// The parts of a `ForeignKeyInfo` an expansion needs, without its per-instance identity. +/// +/// `ForeignKeyInfo` carries a fresh `UUID` and takes it into `==`, so two descriptions of the same +/// constraint never compare equal. A node tree that has to diff cleanly cannot hold one. +struct JSONForeignKeyRef: Hashable, Sendable { + let column: String + let referencedTable: String + let referencedSchema: String? + let referencedColumn: String + + init(column: String, referencedTable: String, referencedSchema: String?, referencedColumn: String) { + self.column = column + self.referencedTable = referencedTable + self.referencedSchema = referencedSchema + self.referencedColumn = referencedColumn + } + + init(_ info: ForeignKeyInfo) { + self.init( + column: info.column, + referencedTable: info.referencedTable, + referencedSchema: info.referencedSchema, + referencedColumn: info.referencedColumn + ) + } + + var qualifiedTable: String { + guard let referencedSchema, !referencedSchema.isEmpty else { return referencedTable } + return "\(referencedSchema).\(referencedTable)" + } +} + +enum JSONNodeValue: Equatable, Sendable { + case scalar(JSONScalar) + case object([JSONRowNode]) + case array([JSONRowNode]) + /// A scalar the referenced row can be fetched for. Children arrive from the fetch, not from here. + case foreignKey(JSONForeignKeyRef, JSONScalar) +} + +struct JSONRowNode: Identifiable, Equatable, Sendable { + let path: JSONNodePath + let key: JSONNodeKey + let value: JSONNodeValue + + var id: String { path.rawValue } + + var children: [JSONRowNode] { + switch value { + case .object(let nodes), .array(let nodes): nodes + case .scalar, .foreignKey: [] + } + } + + var isContainer: Bool { + switch value { + case .object, .array: true + case .scalar, .foreignKey: false + } + } + + var foreignKey: JSONForeignKeyRef? { + guard case .foreignKey(let ref, _) = value else { return nil } + return ref + } + + var scalar: JSONScalar? { + switch value { + case .scalar(let scalar): scalar + case .foreignKey(_, let scalar): scalar + case .object, .array: nil + } + } +} diff --git a/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift b/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift new file mode 100644 index 000000000..2f99c1a2c --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONRowNodeBuilder.swift @@ -0,0 +1,169 @@ +// +// JSONRowNodeBuilder.swift +// TablePro +// +// Turns one result row into the JSON inspector's node tree. +// + +import Foundation +import TableProPluginKit + +enum JSONRowNodeBuilder { + /// Root node for a row. `foreignKeys` is keyed by column name, the shape + /// `TableRows.columnForeignKeys` already holds. + static func build( + path: JSONNodePath = .root, + key: JSONNodeKey = .root, + columns: [String], + values: [PluginCellValue], + columnTypes: [ColumnType], + foreignKeys: [String: JSONForeignKeyRef] + ) -> JSONRowNode { + var children: [JSONRowNode] = [] + children.reserveCapacity(columns.count) + + for (index, column) in columns.enumerated() { + let value = index < values.count ? values[index] : .null + let type = index < columnTypes.count ? columnTypes[index] : nil + /// The column's position leads its path component, because a result set's labels are not + /// unique: an unaliased join selecting two `id` columns gives two of them. Sharing a path + /// gives the two nodes one id in the `ForEach` that draws them, and one entry in the + /// expanded set and the fetched-key map, so neither could be opened on its own. + let childPath = path.appending("\(index).\(column)") + children.append( + JSONRowNode( + path: childPath, + key: .name(column), + value: nodeValue( + for: value, + type: type, + foreignKey: foreignKeys[column], + path: childPath + ) + ) + ) + } + + return JSONRowNode(path: path, key: key, value: .object(children)) + } + + private static func nodeValue( + for value: PluginCellValue, + type: ColumnType?, + foreignKey: JSONForeignKeyRef?, + path: JSONNodePath + ) -> JSONNodeValue { + switch value { + case .null: + guard let foreignKey else { return .scalar(.null) } + return .foreignKey(foreignKey, .null) + case .bytes(let data): + return .scalar(.binary(data)) + case .text(let text): + if let foreignKey { + return .foreignKey(foreignKey, scalar(for: text, type: type)) + } + if let document = parsedDocument(text, type: type) { + return documentValue(document, path: path) + } + return .scalar(scalar(for: text, type: type)) + } + } + + /// The scan cap the cell viewer already applies. A column holding a megabyte of JSON is not a + /// tree anyone reads, and the inspector shows the text instead. + static let maxScannedDocumentLength = 100_000 + + /// A JSON column is parsed whatever it holds; any other column only when its text is shaped + /// like a document, which is what makes a `TEXT` column holding JSON expand too. + /// + /// The parse is `JsonSyntaxParser`, the same one the JSON cell viewer reads with, so a document + /// cannot render one way in a cell and another in the row. + /// + /// A top-level scalar is kept rather than dropped. `42`, `true`, `null` and `"text"` are all + /// valid JSON documents, and a declared JSON column is allowed to hold one; handing them back + /// to the column-type path printed the number as a string and the string with its own quotes + /// still on. The gate below means only a JSON column ever reaches that case: any other column + /// has to start with a brace or a bracket to be parsed at all. + /// + /// A scalar has to be strictly valid before it is retyped, because `JsonSyntaxParser` is a + /// syntax highlighter's parser and not a validator: it drops the backslash from an unknown + /// escape and reads `01` as a number. A column the engine never validated can hold either, and + /// retyping one there would show the reader something the cell does not say. A container keeps + /// the lenient parse it has always had, since its braces are what the tree is built from. + private static func parsedDocument(_ text: String, type: ColumnType?) -> JsonSyntaxNode? { + guard (text as NSString).length <= maxScannedDocumentLength else { return nil } + if type?.isJsonType != true, !looksLikeDocument(text) { return nil } + guard let parsed = JsonSyntaxParser.parse(text) else { return nil } + switch parsed { + case .object, .array: return parsed + case .string, .number, .literal: return isStrictJSON(text) ? parsed : nil + } + } + + private static func isStrictJSON(_ text: String) -> Bool { + (try? JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed])) != nil + } + + /// A cell holding `42` is a number column, not a JSON document, and treating it as one would + /// nest every integer a level deeper than it belongs. + static func looksLikeDocument(_ text: String) -> Bool { + guard let first = text.first(where: { !$0.isWhitespace && !$0.isNewline }) else { return false } + return first == "{" || first == "[" + } + + private static func documentValue(_ document: JsonSyntaxNode, path: JSONNodePath) -> JSONNodeValue { + switch document { + case .object(let members): + let children = members.enumerated().map { index, member in + let key = JsonSyntaxParser.decodeStringLiteral(member.key) + let childPath = path.appending("\(index).\(key)") + return JSONRowNode( + path: childPath, + key: .name(key), + value: documentValue(member.value, path: childPath) + ) + } + return .object(children) + case .array(let elements): + let children = elements.enumerated().map { index, element in + let childPath = path.appending("[\(index)]") + return JSONRowNode( + path: childPath, + key: .index(index), + value: documentValue(element, path: childPath) + ) + } + return .array(children) + case .string(let raw): + return .scalar(.string(JsonSyntaxParser.decodeStringLiteral(raw))) + case .number(let literal): + return .scalar(.number(literal)) + case .literal(let literal): + switch literal { + case "true": return .scalar(.bool(true)) + case "false": return .scalar(.bool(false)) + default: return .scalar(.null) + } + } + } + + /// The column's own type decides whether a value is quoted, so a `DECIMAL` arriving as `"4.99"` + /// stays a string the way the grid shows it and an `INT` arriving as `"2"` renders bare. + static func scalar(for text: String, type: ColumnType?) -> JSONScalar { + guard let type else { return .string(text) } + switch type { + case .integer, .decimal: + guard let literal = JsonNumberNormalizer.numberLiteral(from: text) else { return .string(text) } + return .number(literal) + case .boolean: + switch PluginSQLLiteral.booleanSynonym(for: text) { + case .isTrue: return .bool(true) + case .isFalse: return .bool(false) + default: return .string(text) + } + case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array: + return .string(text) + } + } +} diff --git a/TablePro/Models/UI/JSON/JSONRowSnapshot.swift b/TablePro/Models/UI/JSON/JSONRowSnapshot.swift new file mode 100644 index 000000000..ee3cc62e7 --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONRowSnapshot.swift @@ -0,0 +1,30 @@ +// +// JSONRowSnapshot.swift +// TablePro +// +// The selected row, as the JSON inspector needs it. +// + +import Foundation +import TableProPluginKit + +/// Carries the raw cell values rather than the inspector's formatted strings: the JSON view decides +/// whether a value prints quoted from the column's type, and a string that arrived pre-formatted +/// cannot answer that. +struct JSONRowSnapshot: Equatable, Sendable { + /// Which row this is. A change here is a new selection, so the reader's expansions go too. + /// + /// Everything else is compared by value: the whole snapshot is the change test, because a + /// hand-written token of the parts that "matter" gets it wrong. One over the cells and the + /// column names read a late-arriving `columnForeignKeys` as no change at all, so a row selected + /// before the schema fetch landed kept a tree with no keys to expand until the selection moved. + let rowIdentity: String + let columns: [String] + let columnTypes: [ColumnType] + let values: [PluginCellValue] + let foreignKeys: [String: JSONForeignKeyRef] + /// Carried on the snapshot so the panel can hand it to the view model without the view, which + /// is what keeps the model in step with the row a render is about to draw. + let connectionId: UUID + let databaseType: DatabaseType +} diff --git a/TablePro/Models/UI/JSON/JSONRowTextRenderer.swift b/TablePro/Models/UI/JSON/JSONRowTextRenderer.swift new file mode 100644 index 000000000..c4198b2ca --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONRowTextRenderer.swift @@ -0,0 +1,41 @@ +// +// JSONRowTextRenderer.swift +// TablePro +// +// Prints the lines the inspector is showing, for Copy Visible. +// + +import Foundation + +enum JSONRowTextRenderer { + static let indent = " " + + /// Renders exactly what is on screen: a collapsed object prints as `{…}`, a filtered-out key + /// does not print at all, and an expanded foreign key prints its fetched row. + static func render(rows: [JSONDisplayRow]) -> String { + rows.map(line(for:)).joined(separator: "\n") + } + + private static func line(for row: JSONDisplayRow) -> String { + let padding = String(repeating: indent, count: row.depth) + let keyPrefix = row.showsKey ? "\"\(JSONScalarText.escaped(row.key.text ?? ""))\": " : "" + let comma = row.needsComma ? "," : "" + + switch row.token { + case .scalar(let scalar): + return "\(padding)\(keyPrefix)\(JSONScalarText.printed(scalar))\(comma)" + case .openObject: + return "\(padding)\(keyPrefix){" + case .openArray: + return "\(padding)\(keyPrefix)[" + case .closeObject: + return "\(padding)}\(comma)" + case .closeArray: + return "\(padding)]\(comma)" + case .collapsedObject: + return "\(padding)\(keyPrefix){…}\(comma)" + case .collapsedArray: + return "\(padding)\(keyPrefix)[…]\(comma)" + } + } +} diff --git a/TablePro/Models/UI/JSON/JSONScalarText.swift b/TablePro/Models/UI/JSON/JSONScalarText.swift new file mode 100644 index 000000000..908932c15 --- /dev/null +++ b/TablePro/Models/UI/JSON/JSONScalarText.swift @@ -0,0 +1,84 @@ +// +// JSONScalarText.swift +// TablePro +// +// How a scalar prints in the JSON inspector, shared by the view and by Copy Visible. +// + +import Foundation + +enum JSONScalarText { + /// The value as it is printed, quotes included, so the view and the clipboard cannot disagree. + static func printed(_ scalar: JSONScalar) -> String { + switch scalar { + case .string(let text): "\"\(escaped(text))\"" + case .number(let literal): literal + case .bool(let flag): flag ? "true" : "false" + case .null: "null" + case .binary(let data): "\"\(hex(data, limit: maxDisplayedHexBytes))\"" + } + } + + /// The value without its quotes, which is what Copy Value puts on the pasteboard. + /// + /// A blob is capped here as well, at the same 64 bytes `RowValueCopyFormatter` gives the grid's + /// own Copy: one cell copied two ways cannot come back as two different values. Carrying the + /// whole blob instead would also mean hex-encoding an unbounded value on the main actor while + /// the reader waits for the pasteboard. The quotes are what was wrong, not the cap. + static func unquoted(_ scalar: JSONScalar) -> String { + switch scalar { + case .string(let text): text + case .number(let literal): literal + case .bool(let flag): flag ? "true" : "false" + case .null: "NULL" + case .binary(let data): hex(data, limit: maxDisplayedHexBytes) + } + } + + static func escaped(_ text: String) -> String { + var output = "" + output.reserveCapacity(text.count + 2) + for character in text.unicodeScalars { + switch character { + case "\"": output += "\\\"" + case "\\": output += "\\\\" + case "\n": output += "\\n" + case "\r": output += "\\r" + case "\t": output += "\\t" + default: + if character.value < 0x20 { + output += String(format: "\\u%04x", character.value) + } else { + output.unicodeScalars.append(character) + } + } + } + return output + } + + /// How much of a blob a printed line carries. A column holding a megabyte of image data is not + /// a value anyone reads byte by byte, and laying the whole of it out as one line costs more + /// than the reader gets back. + static let maxDisplayedHexBytes = 64 + + private static let hexDigits: [UInt8] = Array("0123456789ABCDEF".utf8) + + /// Written into a byte buffer and decoded once rather than appended a character at a time. + /// Copy Value asks for the whole blob, so this runs over every byte of a value that can be + /// megabytes, on the main thread, while the reader waits for the pasteboard. + private static func hex(_ data: Data, limit: Int?) -> String { + let shown = limit.map { data.prefix($0) } ?? data[...] + var bytes: [UInt8] = [0x30, 0x78] + bytes.reserveCapacity(shown.count * 2 + 5) + for byte in shown { + bytes.append(hexDigits[Int(byte >> 4)]) + bytes.append(hexDigits[Int(byte & 0x0F)]) + } + var output = String(unsafeUninitializedCapacity: bytes.count) { buffer in + _ = buffer.initialize(from: bytes) + return bytes.count + } + if let limit, data.count > limit { output.append("…") } + return output + } +} diff --git a/TablePro/Models/UI/MultiRowEditState.swift b/TablePro/Models/UI/MultiRowEditState.swift index 5d9061d81..0d00005f4 100644 --- a/TablePro/Models/UI/MultiRowEditState.swift +++ b/TablePro/Models/UI/MultiRowEditState.swift @@ -25,6 +25,13 @@ struct FieldEditState: Identifiable { /// Set when the owning grid dictates the editor instead of the column type. var editor: FieldEditorKind? + /// Which editor the field's own type and value ask for, resolved once here. + /// + /// Resolving it costs a full `JSONSerialization` parse of the value and a PHP-serialized parse + /// after it, and `FieldEditorResolver` was reached from two view bodies per field. Every hover, + /// every inspector tab switch and every pending-edit keystroke re-parsed every value in the row. + var resolvedEditor: FieldEditorKind? + /// A schema field has no data type, so it offers no type badge and no NULL or DEFAULT state. var isSchemaField: Bool = false @@ -161,6 +168,11 @@ final class MultiRowEditState { if let preservedId { newField.id = preservedId } + newField.resolvedEditor = FieldEditorResolver.resolve( + for: columnTypeEnum, + isLongText: isLongText, + originalValue: originalValue + ) newFields.append(newField) } @@ -198,6 +210,13 @@ final class MultiRowEditState { if index < reusedIds.count { state.id = reusedIds[index] } + if field.editor == nil { + state.resolvedEditor = FieldEditorResolver.resolve( + for: .text(rawType: nil), + isLongText: false, + originalValue: field.value + ) + } return state } } diff --git a/TablePro/Models/UI/RightPanelState.swift b/TablePro/Models/UI/RightPanelState.swift index 415925f67..d99c515f5 100644 --- a/TablePro/Models/UI/RightPanelState.swift +++ b/TablePro/Models/UI/RightPanelState.swift @@ -20,13 +20,26 @@ import os } } - var inspectorContext: InspectorContext = .empty + /// The JSON tab's model is fed here rather than from the tab's own `onChange`. + /// + /// A view's `onChange` runs after the render that already observed the new value, so the tab + /// drew one frame of the previous record's tree before the model caught up: moving between rows + /// flickered. Writing both in the same turn means every render sees one consistent row. + var inspectorContext: InspectorContext = .empty { + didSet { + jsonViewModel.update(snapshot: inspectorContext.jsonRow) + } + } // Save closure — set by MainContentCommandActions, called by UnifiedRightPanelView var onSave: (() -> Void)? // Owned objects — lifted from MainContentView @StateObject let editState = MultiRowEditState() + + /// Held here rather than as the JSON tab's own `@State` so a switch to Details and back keeps + /// the reader's expansions and the rows already fetched for them. + let jsonViewModel = JSONRowInspectorViewModel() private var _aiViewModel: AIChatViewModel? var aiViewModel: AIChatViewModel { if _aiViewModel == nil { @@ -58,6 +71,7 @@ import os _didTeardown.withLock { $0 = true } onSave = nil _aiViewModel?.clearSessionData() + jsonViewModel.releaseData() editState.releaseData() } } diff --git a/TablePro/Models/UI/RightPanelTab.swift b/TablePro/Models/UI/RightPanelTab.swift index 6a30b1bd2..7976fd216 100644 --- a/TablePro/Models/UI/RightPanelTab.swift +++ b/TablePro/Models/UI/RightPanelTab.swift @@ -9,11 +9,13 @@ import Foundation enum RightPanelTab: String, CaseIterable, Hashable { case details = "Details" + case json = "JSON" case aiChat = "AI Chat" var localizedTitle: String { switch self { case .details: String(localized: "Details") + case .json: String(localized: "JSON") case .aiChat: String(localized: "AI Chat") } } @@ -21,7 +23,23 @@ enum RightPanelTab: String, CaseIterable, Hashable { var systemImage: String { switch self { case .details: "info.circle" + case .json: "curlybraces" case .aiChat: "sparkles" } } + + /// AI Chat is the only tab a setting can take away. + static func available(isAIEnabled: Bool) -> [RightPanelTab] { + allCases.filter { $0 != .aiChat || isAIEnabled } + } + + /// The tab to show for a stored one, which can name a tab the settings no longer offer. + /// + /// The active tab is persisted per connection and restored without asking whether the tab + /// still exists, so a connection last left on AI Chat comes back to it even with the assistant + /// turned off since. Resolving on every read rather than only when the setting changes is what + /// covers the restore, which no change notification ever reaches. + static func resolved(_ tab: RightPanelTab, isAIEnabled: Bool) -> RightPanelTab { + available(isAIEnabled: isAIEnabled).contains(tab) ? tab : .details + } } diff --git a/TablePro/ViewModels/JSONRowInspectorViewModel.swift b/TablePro/ViewModels/JSONRowInspectorViewModel.swift new file mode 100644 index 000000000..fe20a414e --- /dev/null +++ b/TablePro/ViewModels/JSONRowInspectorViewModel.swift @@ -0,0 +1,340 @@ +// +// JSONRowInspectorViewModel.swift +// TablePro +// +// Expansion, filtering and foreign key fetching for the JSON inspector. +// + +import AppKit +import Foundation +import os + +/// The referenced-row lookup a foreign key expansion runs, held as a value so a test can drive the +/// model's cancellation and generation rules without a database behind it. +typealias JSONForeignKeyRowFetch = @MainActor ( + _ connectionId: UUID, + _ databaseType: DatabaseType, + _ reference: JSONForeignKeyRef, + _ value: String +) async throws -> ForeignKeyRowFetcher.FetchedRow? + +@MainActor +@Observable +final class JSONRowInspectorViewModel { + private(set) var root: JSONRowNode? + private(set) var states = JSONForeignKeyStates() + /// Session state, not a setting. Following a key costs a query per key, so the tab opens with + /// them closed however the reader left it last time, and turning it on is a deliberate act. + private(set) var alwaysExpandForeignKeys = false + + var filterText: String = "" + + private var expanded: Set = [] + private var chains: [JSONNodePath: [JSONForeignKeyVisit]] = [:] + private var fetches: [JSONNodePath: Task] = [:] + private var lastSnapshot: JSONRowSnapshot? + /// Bumped by every rebuild and every reset. A fetch that returns after one discards itself, + /// because `Task.cancel()` cannot interrupt a query already in flight. + private var generation = 0 + private var connectionId: UUID? + private var databaseType: DatabaseType? + @ObservationIgnored private let fetchRow: JSONForeignKeyRowFetch + + private static let logger = Logger(subsystem: "com.TablePro", category: "JSONRowInspector") + + init(fetchRow: @escaping JSONForeignKeyRowFetch = JSONRowInspectorViewModel.fetchThroughDatabase) { + self.fetchRow = fetchRow + } + + private static func fetchThroughDatabase( + connectionId: UUID, + databaseType: DatabaseType, + reference: JSONForeignKeyRef, + value: String + ) async throws -> ForeignKeyRowFetcher.FetchedRow? { + try await ForeignKeyRowFetcher.fetch( + connectionId: connectionId, + databaseType: databaseType, + reference: reference, + value: value, + includeForeignKeys: true + ) + } + + // MARK: - Lifecycle + + /// Drops the tree and every fetched row on disconnect, the way the chat and the edit state do. + func releaseData() { + reset() + filterText = "" + alwaysExpandForeignKeys = false + } + + // MARK: - Input + + func update(snapshot: JSONRowSnapshot?) { + guard let snapshot else { + reset() + return + } + connectionId = snapshot.connectionId + databaseType = snapshot.databaseType + + guard snapshot != lastSnapshot else { return } + let isSameRow = snapshot.rowIdentity == lastSnapshot?.rowIdentity + lastSnapshot = snapshot + + /// A fetched row is held against the key node's path, and a rerun or a refresh keeps a row's + /// identity while its values move under it. So any content change drops the fetched keys: + /// leaving them would print the row `artist_id = 1` referenced under a cell that now holds + /// `artist_id = 2`, which is a wrong row presented as this row's own. + cancelFetches() + states = JSONForeignKeyStates() + chains = [:] + generation += 1 + + let rebuilt = JSONRowNodeBuilder.build( + columns: snapshot.columns, + values: snapshot.values, + columnTypes: snapshot.columnTypes, + foreignKeys: snapshot.foreignKeys + ) + root = rebuilt + if isSameRow { + expanded.insert(rebuilt.path) + } else { + expanded = [rebuilt.path] + expandContainers(in: rebuilt) + } + + guard alwaysExpandForeignKeys else { return } + autoExpandForeignKeys(in: rebuilt) + } + + private func reset() { + cancelFetches() + generation += 1 + lastSnapshot = nil + root = nil + expanded = [] + chains = [:] + states = JSONForeignKeyStates() + } + + /// An embedded JSON document arrives expanded, the way the grid's own JSON preview shows it. + /// Only foreign keys cost a round trip, so only they start closed. + private func expandContainers(in node: JSONRowNode) { + for child in node.children where child.isContainer { + expanded.insert(child.path) + expandContainers(in: child) + } + } + + // MARK: - Display + + var displayRows: [JSONDisplayRow] { + guard let root else { return [] } + switch JSONRowMatcher.make(query: filterText) { + case .empty, .invalidRegex: + return JSONRowFlattener.rows(root: root, expanded: expanded, states: states) + case .matcher(let matcher): + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: states.fetched, + matcher: matcher + ) + return JSONRowFlattener.rows( + root: root, + expanded: expanded, + states: states, + visiblePaths: visible + ) + } + } + + var isFilterInvalid: Bool { + if case .invalidRegex = JSONRowMatcher.make(query: filterText) { return true } + return false + } + + var isFiltering: Bool { + if case .matcher = JSONRowMatcher.make(query: filterText) { return true } + return false + } + + // MARK: - Expansion + + func toggle(row: JSONDisplayRow) { + guard let root else { return } + if row.foreignKey != nil, states.fetched[row.path] == nil { + expandForeignKey(at: row.path, in: root) + return + } + if expanded.contains(row.path) { + expanded.remove(row.path) + } else { + expanded.insert(row.path) + } + } + + /// Expands what is already in hand. A foreign key that has not been fetched is left alone: + /// walking every key in a wide row would fire one query per column on a single click. + func expandAll() { + guard let root else { return } + expanded = JSONRowFlattener.expandablePaths(root: root, states: states) + } + + func collapseAll() { + expanded = [] + } + + // MARK: - Preferences + + func setAlwaysExpandForeignKeys(_ expand: Bool) { + alwaysExpandForeignKeys = expand + guard expand, let root else { return } + autoExpandForeignKeys(in: root) + } + + // MARK: - Clipboard + + func copyVisible() { + let text = JSONRowTextRenderer.render(rows: displayRows) + guard !text.isEmpty else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + // MARK: - Foreign Keys + + private func autoExpandForeignKeys(in root: JSONRowNode) { + for child in root.children where child.foreignKey != nil { + guard states.fetched[child.path] == nil, fetches[child.path] == nil else { continue } + expandForeignKey(at: child.path, in: root) + } + } + + private func expandForeignKey(at path: JSONNodePath, in root: JSONRowNode) { + guard fetches[path] == nil, + let connectionId, + let databaseType, + let node = node(at: path, from: root), + let reference = node.foreignKey, + let scalar = node.scalar else { return } + + if case .null = scalar { return } + startFetch( + path: path, + reference: reference, + value: JSONScalarText.unquoted(scalar), + connectionId: connectionId, + databaseType: databaseType + ) + } + + private func startFetch( + path: JSONNodePath, + reference: JSONForeignKeyRef, + value: String, + connectionId: UUID, + databaseType: DatabaseType + ) { + let visit = JSONForeignKeyVisit(ref: reference, value: value) + let chain = chain(endingAt: path) + + switch JSONForeignKeyExpansionPolicy.decide(chain: chain, next: visit) { + case .cycle: + states.failures[path] = .cycle + return + case .depthLimit: + states.failures[path] = .depthLimit + return + case .allowed: + break + } + + states.failures.removeValue(forKey: path) + states.loading.insert(path) + + let generation = generation + fetches[path] = Task { [weak self] in + defer { self?.finishFetch(at: path, generation: generation) } + do { + guard let fetch = self?.fetchRow else { return } + let fetched = try await fetch(connectionId, databaseType, reference, value) + guard let self, !Task.isCancelled, generation == self.generation else { return } + guard let fetched else { + self.states.failures[path] = .notFound + return + } + self.adopt(fetched: fetched, at: path, reference: reference, chain: chain + [visit]) + } catch { + Self.logger.error("Foreign key expansion failed: \(error.localizedDescription)") + guard let self, !Task.isCancelled, generation == self.generation else { return } + self.states.failures[path] = .failed(String(localized: "Failed to load referenced row")) + } + } + } + + private func adopt( + fetched: ForeignKeyRowFetcher.FetchedRow, + at path: JSONNodePath, + reference: JSONForeignKeyRef, + chain: [JSONForeignKeyVisit] + ) { + let expansion = JSONRowNodeBuilder.build( + path: path, + key: .name(reference.column), + columns: fetched.columns, + values: fetched.values, + columnTypes: fetched.columnTypes, + foreignKeys: fetched.foreignKeys + ) + states.fetched[path] = expansion + chains[path] = chain + expanded.insert(path) + expandContainers(in: expansion) + } + + /// A fetch from an earlier generation cleans up nothing. + /// + /// `cancelFetches()` already dropped its handle, and `Task.cancel()` cannot interrupt a query + /// blocked in the driver, so a cancelled fetch still returns, late. Removing its path + /// unconditionally deleted the handle of the fetch the rebuilt tree had started at the same + /// path: that one could no longer be cancelled, and the next click on the key started a + /// second query for it. + private func finishFetch(at path: JSONNodePath, generation: Int) { + guard generation == self.generation else { return } + fetches.removeValue(forKey: path) + states.loading.remove(path) + } + + private func cancelFetches() { + for task in fetches.values { task.cancel() } + fetches = [:] + } + + /// The keys already followed to reach `path`, nearest ancestor first, which is what the cycle + /// check needs: the chain lives on the expansion that produced the subtree, not on the node. + private func chain(endingAt path: JSONNodePath) -> [JSONForeignKeyVisit] { + var components = path.components + while !components.isEmpty { + components.removeLast() + if let chain = chains[JSONNodePath(components: components)] { return chain } + } + return [] + } + + private func node(at path: JSONNodePath, from root: JSONRowNode) -> JSONRowNode? { + if path == root.path { return root } + for child in JSONRowFilter.children(of: root, fetched: states.fetched) { + if path.components.count >= child.path.components.count, + Array(path.components.prefix(child.path.components.count)) == child.path.components, + let found = node(at: path, from: child) { + return found + } + } + return nil + } +} diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index f5148b9fb..c4057d8d9 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -88,6 +88,16 @@ final class DataTabGridDelegate: DataGridViewDelegate { coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo, openInNewTab: openInNewTab) } + /// The panel reads the selection, not a row this is told about. + /// + /// `KeyHandlingTableView.menu(for:)` retargets the selection to a row clicked outside it, so + /// the usual single-row case shows the row the reader asked about. A click inside a multi-row + /// selection keeps that selection on purpose, and the panel then shows its first row, which is + /// the row the Details tab shows too. + func dataGridShowRowAsJSON() { + coordinator?.showJSONPanel() + } + func dataGridHideColumn(_ columnName: String) { coordinator?.hideColumn(columnName) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index f3ca8df6d..9c9425088 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -14,6 +14,23 @@ private let fkNavigationLogger = Logger(subsystem: "com.TablePro", category: "FK extension MainContentCoordinator { // MARK: - Foreign Key Navigation + /// The JSON inspector's own route to the same navigation. It holds a `JSONForeignKeyRef`, + /// which is a `ForeignKeyInfo` without the identity and the referential actions, because a node + /// tree cannot hold a type whose `==` is a fresh `UUID`. + func navigateToFKReference(reference: JSONForeignKeyRef, value: String) { + navigateToFKReference( + value: value, + fkInfo: ForeignKeyInfo( + name: "", + column: reference.column, + referencedTable: reference.referencedTable, + referencedColumn: reference.referencedColumn, + referencedSchema: reference.referencedSchema + ), + openInNewTab: false + ) + } + /// Navigate to the referenced table filtered by the FK value. /// Reuses the current tab when it holds nothing the user authored, and otherwise opens the /// reference in its own tab so the originating query or edits survive. diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 45cb8c7a6..9c3db0d00 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -94,6 +94,36 @@ extension MainContentView { return data } + // MARK: - Selected Row for the JSON Tab + + /// The same selection the details tab reads, carried as raw cell values. + /// + /// The JSON tab decides from the column's own type whether a value prints quoted, which the + /// formatted strings the details tab takes cannot answer. Only the data grid supplies it: the + /// schema grid's rows are label and value pairs with no types and no foreign keys. + var jsonRowSnapshotForSidebar: JSONRowSnapshot? { + guard gridSelectionOwner == .dataGrid, + let tab = coordinator.tabManager.selectedTab, + let firstDisplayIndex = coordinator.selectionState.indices.min() else { return nil } + let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) + guard !tableRows.columns.isEmpty, + let row = DisplayRowMapping.row( + forDisplay: firstDisplayIndex, + displayIDs: coordinator.activeGridDisplayIDs, + in: tableRows + ) else { return nil } + + return JSONRowSnapshot( + rowIdentity: "\(tab.id.uuidString)\u{001F}\(row.id)", + columns: tableRows.columns, + columnTypes: tableRows.columnTypes, + values: Array(row.values), + foreignKeys: tableRows.columnForeignKeys.mapValues(JSONForeignKeyRef.init), + connectionId: coordinator.connection.id, + databaseType: coordinator.connection.type + ) + } + // MARK: - Sidebar Edit State /// Determine if sidebar should be in editable mode diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 8c0fc2a2d..bfae81ed4 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -72,7 +72,8 @@ extension MainContentView { isEditable: isSidebarEditable, isRowDeleted: isSelectedRowDeleted, currentQuery: coordinator.tabManager.selectedTab?.content.query, - queryResults: cachedQueryResultsSummary() + queryResults: cachedQueryResultsSummary(), + jsonRow: jsonRowSnapshotForSidebar ) } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 79af61e76..a0c4afd72 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1037,6 +1037,14 @@ final class MainContentCommandActions { coordinator?.toggleFKPreviewForFocusedCell() } + func showRowAsJSON() { + coordinator?.showJSONPanel() + } + + func openForeignKeyTable(reference: JSONForeignKeyRef, value: String) { + coordinator?.navigateToFKReference(reference: reference, value: value) + } + func exportTables() { coordinator?.openExportDialog() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 523c7d827..d879edbb8 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -764,6 +764,11 @@ final class MainContentCoordinator { rightPanelState?.activeTab = .aiChat } + func showJSONPanel() { + inspectorProxy?.showInspector() + rightPanelState?.activeTab = .json + } + /// Set up the plugin driver for query building dispatch on the query builder and change manager. internal func setupPluginDriver() { guard let driver = services.databaseManager.driver(for: connectionId) else { return } diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index c606d58a3..f1f85b08b 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -475,6 +475,16 @@ class DataGridRowView: NSTableRowView { menu.addItem(pasteItem) } + menu.addItem(NSMenuItem.separator()) + + let jsonViewItem = NSMenuItem( + title: String(localized: "Show Row as JSON"), + action: #selector(showRowAsJSON), + keyEquivalent: "" + ) + jsonViewItem.target = self + menu.addItem(jsonViewItem) + let tableRows = coordinator.tableRowsProvider() addForeignKeyMenuItems(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows) @@ -725,6 +735,10 @@ class DataGridRowView: NSTableRowView { ) } + @objc private func showRowAsJSON() { + coordinator?.delegate?.dataGridShowRowAsJSON() + } + @objc private func previewForeignKey(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int, let coordinator, let tableView = coordinator.tableView, diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index f4c5472ba..44556e08f 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -22,6 +22,7 @@ protocol DataGridViewDelegate: AnyObject { func dataGridSortStateChanged(_ state: SortState) func dataGridFilterColumn(_ columnName: String) func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) + func dataGridShowRowAsJSON() func dataGridDuplicateRow() func dataGridExportResults() func dataGridClearResults() @@ -57,6 +58,7 @@ extension DataGridViewDelegate { func dataGridSortStateChanged(_ state: SortState) {} func dataGridFilterColumn(_ columnName: String) {} func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo, openInNewTab: Bool) {} + func dataGridShowRowAsJSON() {} func dataGridDuplicateRow() {} func dataGridExportResults() {} func dataGridClearResults() {} diff --git a/TablePro/Views/Results/ForeignKeyPreviewView.swift b/TablePro/Views/Results/ForeignKeyPreviewView.swift index 210b9ab54..8a0fc2d62 100644 --- a/TablePro/Views/Results/ForeignKeyPreviewView.swift +++ b/TablePro/Views/Results/ForeignKeyPreviewView.swift @@ -176,35 +176,20 @@ struct ForeignKeyPreviewView: View { return } - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - Self.logger.error("No active driver for FK preview") - errorMessage = String(localized: "No database connection") - isLoading = false - return - } - - let quotedTable: String - if let schema = fkInfo.referencedSchema { - quotedTable = "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(fkInfo.referencedTable))" - } else { - quotedTable = driver.quoteIdentifier(fkInfo.referencedTable) - } - let quotedColumn = driver.quoteIdentifier(fkInfo.referencedColumn) - let escapedValue = driver.escapeStringLiteral(value) - - let query = ForeignKeyPreviewQuery.singleRow( - quotedTable: quotedTable, - quotedColumn: quotedColumn, - escapedValue: escapedValue, - dialect: PluginManager.shared.sqlDialect(for: databaseType) - ) - do { - let result = try await driver.execute(query: query) - if let firstRow = result.rows.first { - columns = result.columns - values = firstRow.map { $0.asText } + let fetched = try await ForeignKeyRowFetcher.fetch( + connectionId: connectionId, + databaseType: databaseType, + reference: JSONForeignKeyRef(fkInfo), + value: value + ) + if let fetched { + columns = fetched.columns + values = fetched.values.map { $0.asText } } + } catch ForeignKeyRowFetcher.FetchFailure.noConnection { + Self.logger.error("No active driver for FK preview") + errorMessage = String(localized: "No database connection") } catch { Self.logger.error("FK preview query failed: \(error.localizedDescription)") errorMessage = String(localized: "Failed to load referenced row") diff --git a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift index 043a3d4c9..c7dca4bb3 100644 --- a/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift +++ b/TablePro/Views/RightSidebar/FieldEditors/FieldEditorContext.swift @@ -14,7 +14,8 @@ internal struct FieldEditorContext { let isReadOnly: Bool let commitBytes: ((Data) -> Void)? - /// Set when the owning grid dictates the editor instead of the column type. + /// The editor to build, already resolved by the caller. `FieldEditorResolver` falls back to the + /// column type and the value only when this is nil, and resolving costs a parse of the value. let editor: FieldEditorKind? /// A schema field has no NULL or DEFAULT state and no data type to badge. diff --git a/TablePro/Views/RightSidebar/JSON/JSONNodeRowView.swift b/TablePro/Views/RightSidebar/JSON/JSONNodeRowView.swift new file mode 100644 index 000000000..d7f885f7c --- /dev/null +++ b/TablePro/Views/RightSidebar/JSON/JSONNodeRowView.swift @@ -0,0 +1,180 @@ +// +// JSONNodeRowView.swift +// TablePro +// +// One printed line of the JSON inspector. +// + +import SwiftUI + +struct JSONNodeRowView: View { + let row: JSONDisplayRow + let colors: JSONRowColors + let onToggle: () -> Void + let onOpenReferencedTable: (JSONForeignKeyRef, String) -> Void + + private static let indentWidth: CGFloat = 14 + private static let controlWidth: CGFloat = 14 + + private var valueFont: Font { ThemeEngine.shared.valueFontSwiftUI } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + Spacer() + .frame(width: CGFloat(row.depth) * Self.indentWidth) + disclosure + content + Spacer(minLength: 0) + } + .padding(.vertical, 1) + .contentShape(Rectangle()) + .contextMenu { menu } + } + + // MARK: - Disclosure + + @ViewBuilder + private var disclosure: some View { + if row.isExpandable { + Button(action: onToggle) { + Image(systemName: row.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: Self.controlWidth, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + row.isExpanded ? String(localized: "Collapse") : String(localized: "Expand") + ) + } else { + Spacer().frame(width: Self.controlWidth) + } + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + if row.showsKey, let key = row.key.text { + Text("\"\(JSONScalarText.escaped(key))\"") + .font(valueFont) + .foregroundStyle(colors.key) + .lineLimit(1) + Text(": ") + .font(valueFont) + .foregroundStyle(colors.punctuation) + } + token + status + } + .textSelection(.enabled) + } + + @ViewBuilder + private var token: some View { + switch row.token { + case .scalar(let scalar): + Text(JSONScalarText.printed(scalar) + (row.needsComma ? "," : "")) + .font(valueFont) + .foregroundStyle(colors.color(for: scalar)) + .fixedSize(horizontal: false, vertical: true) + case .openObject: + punctuation("{") + case .openArray: + punctuation("[") + case .closeObject: + punctuation("}" + (row.needsComma ? "," : "")) + case .closeArray: + punctuation("]" + (row.needsComma ? "," : "")) + case .collapsedObject(let count): + collapsed(open: "{", close: "}", count: count) + case .collapsedArray(let count): + collapsed(open: "[", close: "]", count: count) + } + } + + private func punctuation(_ text: String) -> some View { + Text(text) + .font(valueFont) + .foregroundStyle(colors.punctuation) + } + + private func collapsed(open: String, close: String, count: Int) -> some View { + HStack(spacing: 4) { + punctuation(open) + Text(count == 1 + ? String(localized: "1 item") + : String(format: String(localized: "%d items"), count)) + .font(.caption) + .foregroundStyle(colors.placeholder) + punctuation(close + (row.needsComma ? "," : "")) + } + } + + @ViewBuilder + private var status: some View { + switch row.status { + case .none: + EmptyView() + case .loading: + ProgressView() + .controlSize(.small) + .scaleEffect(0.6) + .frame(width: 16, height: 12) + .padding(.leading, 4) + case .failure(let failure): + Image(systemName: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 4) + .help(Self.message(for: failure)) + .accessibilityLabel(Self.message(for: failure)) + } + } + + static func message(for failure: JSONForeignKeyFailure) -> String { + switch failure { + case .notFound: + String(localized: "Referenced row not found") + case .cycle: + String(localized: "This key references a row already shown above") + case .depthLimit: + String( + format: String(localized: "Foreign keys are followed %d levels deep"), + JSONForeignKeyExpansionPolicy.maxChainDepth + ) + case .failed(let message): + message + } + } + + // MARK: - Menu + + @ViewBuilder + private var menu: some View { + if let scalar = row.scalar { + Button(String(localized: "Copy Value")) { + copy(JSONScalarText.unquoted(scalar)) + } + } + if let key = row.key.text { + Button(String(localized: "Copy Key")) { + copy(key) + } + } + if let reference = row.foreignKey, let scalar = row.scalar, scalar != .null { + Divider() + Button(String(format: String(localized: "Open %@"), reference.qualifiedTable)) { + onOpenReferencedTable(reference, JSONScalarText.unquoted(scalar)) + } + } + } + + private func copy(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } +} diff --git a/TablePro/Views/RightSidebar/JSON/JSONRowColors.swift b/TablePro/Views/RightSidebar/JSON/JSONRowColors.swift new file mode 100644 index 000000000..049a0354b --- /dev/null +++ b/TablePro/Views/RightSidebar/JSON/JSONRowColors.swift @@ -0,0 +1,42 @@ +// +// JSONRowColors.swift +// TablePro +// +// Syntax colours for the JSON inspector, taken from the active editor theme. +// + +import SwiftUI + +/// The inspector shows stored values, so its font is the Data Grid Font (`ThemeEngine.valueFont`) +/// while its colours come from the editor palette the SQL editor and the JSON preview already use. +/// Naming a system text style here is what makes a value read differently in the grid and in the +/// inspector the moment the two font settings differ. +struct JSONRowColors { + let key: Color + let string: Color + let number: Color + let literal: Color + let punctuation: Color + let placeholder: Color + + @MainActor + static func current() -> JSONRowColors { + let colors = ThemeEngine.shared.colors.editor + return JSONRowColors( + key: colors.keywordSwiftUI, + string: colors.stringSwiftUI, + number: colors.numberSwiftUI, + literal: colors.nullSwiftUI, + punctuation: colors.textSwiftUI, + placeholder: colors.commentSwiftUI + ) + } + + func color(for scalar: JSONScalar) -> Color { + switch scalar { + case .string, .binary: string + case .number: number + case .bool, .null: literal + } + } +} diff --git a/TablePro/Views/RightSidebar/JSON/JSONRowInspectorView.swift b/TablePro/Views/RightSidebar/JSON/JSONRowInspectorView.swift new file mode 100644 index 000000000..7e157deac --- /dev/null +++ b/TablePro/Views/RightSidebar/JSON/JSONRowInspectorView.swift @@ -0,0 +1,143 @@ +// +// JSONRowInspectorView.swift +// TablePro +// +// The JSON tab of the right panel: the selected row as a filterable JSON tree +// whose foreign keys expand into the rows they reference. +// + +import SwiftUI + +struct JSONRowInspectorView: View { + @Bindable var viewModel: JSONRowInspectorViewModel + + let snapshot: JSONRowSnapshot? + let onOpenReferencedTable: (JSONForeignKeyRef, String) -> Void + + @State private var colors = JSONRowColors.current() + + var body: some View { + VStack(spacing: 0) { + if snapshot != nil { + toolbar + Divider() + tree + } else { + emptyState + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onReceive(AppEvents.shared.themeChanged) { _ in colors = JSONRowColors.current() } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView( + String(localized: "No Row Selected"), + systemImage: "curlybraces", + description: Text(String(localized: "Select a row to view it as JSON")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Toolbar + + private var toolbar: some View { + HStack(spacing: 6) { + filterField + optionsMenu + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + /// The same `NSSearchField` the Details tab beside it uses. + /// + /// A plain `TextField` implements none of what a search field is: `Escape` clears the term and + /// only then falls through to the window, the cancel button and the magnifier are drawn by + /// AppKit, and assistive software reads the control as a search field rather than as text. + private var filterField: some View { + NativeSearchField( + text: $viewModel.filterText, + placeholder: String(localized: "Filter by text or /regex/"), + controlSize: .small, + accessibilityIdentifier: "json-row-filter" + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.red.opacity(0.6)) + .opacity(viewModel.isFilterInvalid ? 1 : 0) + ) + .help(viewModel.isFilterInvalid + ? String(localized: "Not a valid regular expression") + : String(localized: "Filter keys and values. Wrap in slashes for a regular expression.")) + } + + private var optionsMenu: some View { + Menu { + Button(String(localized: "Copy Visible")) { viewModel.copyVisible() } + Divider() + Button(String(localized: "Collapse All")) { viewModel.collapseAll() } + Button(String(localized: "Expand All")) { viewModel.expandAll() } + Divider() + Toggle( + String(localized: "Always Expand Foreign Keys"), + isOn: Binding( + get: { viewModel.alwaysExpandForeignKeys }, + set: { viewModel.setAlwaysExpandForeignKeys($0) } + ) + ) + } label: { + Image(systemName: "ellipsis") + .font(.subheadline) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 22, height: 20) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(String(localized: "JSON view options")) + } + + // MARK: - Tree + + @ViewBuilder + private var tree: some View { + let rows = viewModel.displayRows + if rows.isEmpty { + /// Only a filter can empty a row that has columns, so anything else that empties the + /// tree is the absence of a row, not the absence of a match. + if viewModel.isFiltering { noMatches } else { emptyState } + } else { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(rows) { row in + JSONNodeRowView( + row: row, + colors: colors, + onToggle: { viewModel.toggle(row: row) }, + onOpenReferencedTable: onOpenReferencedTable + ) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) + .accessibilityLabel(String(localized: "Row as JSON")) + } + } + + private var noMatches: some View { + ContentUnavailableView( + String(localized: "No Matches"), + systemImage: "magnifyingglass", + description: Text(String(localized: "No key or value matches this filter")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index ccaab708f..6a252e98b 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -350,7 +350,7 @@ struct RightSidebarView: View { hasMultipleValues: field.hasMultipleValues, isReadOnly: !isEditable || isPhpField, commitBytes: isEditable ? { data in editState.setFieldToBytes(at: index, data: data) } : nil, - editor: field.editor, + editor: kind, allowsNullAndDefault: !field.isSchemaField, showsTypeBadge: !field.isSchemaField ), diff --git a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift index e4504bd8b..df6e33bee 100644 --- a/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift +++ b/TablePro/Views/RightSidebar/UnifiedRightPanelView.swift @@ -10,21 +10,33 @@ struct UnifiedRightPanelView: View { let connection: DatabaseConnection private let settingsManager = AppSettingsManager.shared + @Environment(\.commandActions) private var commandActions @State private var showClearConfirmation = false + /// AI Chat is the only tab a setting can take away, and a tab that is gone cannot stay + /// selected: the picker would show no selection and the panel no content. + private var availableTabs: [RightPanelTab] { + RightPanelTab.available(isAIEnabled: settingsManager.ai.enabled) + } + + /// Every read of the active tab goes through the resolution, because the stored value is + /// restored per connection without asking whether the tab still exists and no change + /// notification fires for a value that was already wrong when the panel appeared. + private var activeTab: RightPanelTab { + RightPanelTab.resolved(state.activeTab, isAIEnabled: settingsManager.ai.enabled) + } + + /// Writes the resolution back so the stored tab stops naming one the panel cannot show, and + /// only when it differs: every assignment persists, and the panel appears on every switch. + private func normalizeActiveTab() { + guard state.activeTab != activeTab else { return } + state.activeTab = activeTab + } + var body: some View { - Group { - if settingsManager.ai.enabled { - splitContent - } else { - detailsView - } - } - .onChange(of: settingsManager.ai.enabled) { - if !settingsManager.ai.enabled { - state.activeTab = .details - } - } + splitContent + .task { normalizeActiveTab() } + .onChange(of: settingsManager.ai.enabled) { normalizeActiveTab() } .alert( String(localized: "Clear All Conversations?"), isPresented: $showClearConfirmation @@ -42,8 +54,28 @@ struct UnifiedRightPanelView: View { VStack(spacing: 0) { inspectorHeader Divider() - switch state.activeTab { - case .details: detailsView + tabContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Details stays mounted and is hidden rather than rebuilt. + /// + /// Its field list is a `List`, so leaving the tab tears down an `NSTableView` and a field editor + /// per column, and coming back builds them again: the switch cost grows with the row's width. + /// The other two tabs are cheap to rebuild and are left conditional, which also keeps the AI + /// chat's view model and its conversation load off a window that never opens that tab. + private var tabContent: some View { + ZStack(alignment: .topLeading) { + detailsView + .opacity(activeTab == .details ? 1 : 0) + .allowsHitTesting(activeTab == .details) + .disabled(activeTab != .details) + .accessibilityHidden(activeTab != .details) + + switch activeTab { + case .details: EmptyView() + case .json: jsonView case .aiChat: aiChatView } } @@ -54,7 +86,7 @@ struct UnifiedRightPanelView: View { HStack(alignment: .center, spacing: 4) { tabPicker Spacer(minLength: 8) - if state.activeTab == .aiChat { + if activeTab == .aiChat { historyMenu newConversationButton } @@ -64,8 +96,8 @@ struct UnifiedRightPanelView: View { } private var tabPicker: some View { - Picker("", selection: $state.activeTab) { - ForEach(RightPanelTab.allCases, id: \.self) { tab in + Picker("", selection: Binding(get: { activeTab }, set: { state.activeTab = $0 })) { + ForEach(availableTabs, id: \.self) { tab in Text(tab.localizedTitle).tag(tab) } } @@ -145,6 +177,16 @@ struct UnifiedRightPanelView: View { ) } + private var jsonView: some View { + JSONRowInspectorView( + viewModel: state.jsonViewModel, + snapshot: state.inspectorContext.jsonRow, + onOpenReferencedTable: { reference, value in + commandActions?.openForeignKeyTable(reference: reference, value: value) + } + ) + } + private var aiChatView: some View { let ctx = state.inspectorContext return AIChatPanelView( diff --git a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index 08662b864..722af5623 100644 --- a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -6,8 +6,11 @@ import Foundation @MainActor internal enum FieldEditorResolver { + /// Answers from the field's own resolution when it has one. Only a field built before + /// `MultiRowEditState` filled it in pays the detectors here. static func resolve(field: FieldEditState) -> FieldEditorKind { if let editor = field.editor { return editor } + if let resolved = field.resolvedEditor { return resolved } return resolve( for: field.columnTypeEnum, isLongText: field.isLongText, diff --git a/TableProTests/Models/JSON/JSONForeignKeyExpansionPolicyTests.swift b/TableProTests/Models/JSON/JSONForeignKeyExpansionPolicyTests.swift new file mode 100644 index 000000000..4962050cd --- /dev/null +++ b/TableProTests/Models/JSON/JSONForeignKeyExpansionPolicyTests.swift @@ -0,0 +1,59 @@ +// +// JSONForeignKeyExpansionPolicyTests.swift +// TableProTests +// +// A self-referencing key must stop, and a long chain must stop too. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("JSONForeignKeyExpansionPolicy") +struct JSONForeignKeyExpansionPolicyTests { + private func visit(_ table: String, _ value: String) -> JSONForeignKeyVisit { + JSONForeignKeyVisit(table: table, schema: nil, column: "id", value: value) + } + + @Test("A fresh key expands") + func allowsFreshKeys() { + #expect( + JSONForeignKeyExpansionPolicy.decide(chain: [visit("film", "1")], next: visit("language", "1")) + == .allowed + ) + } + + @Test("The same row twice is a cycle") + func stopsOnCycle() { + let repeated = visit("employee", "7") + #expect( + JSONForeignKeyExpansionPolicy.decide(chain: [visit("employee", "9"), repeated], next: repeated) + == .cycle + ) + } + + @Test("The same table with a different row is not a cycle") + func allowsSameTableDifferentRow() { + #expect( + JSONForeignKeyExpansionPolicy.decide(chain: [visit("employee", "7")], next: visit("employee", "8")) + == .allowed + ) + } + + @Test("A chain stops at the depth cap") + func stopsAtDepthCap() { + let chain = (0.. JSONRowNode { + JSONRowNodeBuilder.build( + columns: ["title", "length", "special_features"], + values: [.text("ANYTHING SAVANNAH"), .text("82"), .text("[\"Trailers\", \"Deleted Scenes\"]")], + columnTypes: [.text(rawType: "VARCHAR"), .integer(rawType: "INT"), .json(rawType: "JSON")], + foreignKeys: [:] + ) + } + + /// A column's path carries its position, so a test asks the tree where a key is rather than + /// spelling the path out. + private func path(of column: String, in root: JSONRowNode) throws -> JSONNodePath { + try #require(root.children.first { $0.key == .name(column) }).path + } + + private func matcher(_ query: String) throws -> JSONRowMatcher { + guard case .matcher(let matcher) = JSONRowMatcher.make(query: query) else { + throw FilterTestError.notAMatcher + } + return matcher + } + + private enum FilterTestError: Error { + case notAMatcher + } + + @Test("An empty query is not a filter") + func emptyQuery() { + if case .empty = JSONRowMatcher.make(query: " ") { return } + Issue.record("Whitespace should read as no filter") + } + + @Test("A key match keeps the key") + func matchesKeys() throws { + let root = makeRoot() + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("length") + ) + #expect(visible.contains(try path(of: "length", in: root))) + #expect(visible.contains(try path(of: "title", in: root)) == false) + } + + @Test("A value match keeps the key that holds it") + func matchesValues() throws { + let root = makeRoot() + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("savannah") + ) + #expect(visible.contains(try path(of: "title", in: root))) + } + + @Test("A nested match keeps its ancestors") + func keepsAncestors() throws { + let root = makeRoot() + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("Deleted") + ) + #expect(visible.contains(root.path)) + #expect(visible.contains(try path(of: "special_features", in: root))) + #expect(visible.contains(try path(of: "title", in: root)) == false) + } + + @Test("Slashes make the query a regular expression") + func readsRegex() throws { + let root = makeRoot() + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("/^len/") + ) + #expect(visible.contains(try path(of: "length", in: root))) + #expect(visible.contains(try path(of: "title", in: root)) == false) + } + + @Test("A broken regular expression is reported, not treated as text") + func reportsInvalidRegex() { + if case .invalidRegex = JSONRowMatcher.make(query: "/[/") { return } + Issue.record("An unclosed class should report as invalid") + } + + @Test("Text that only looks like a path stays a substring") + func treatsPathsAsSubstrings() throws { + let root = JSONRowNodeBuilder.build( + columns: ["path"], + values: [.text("a/b")], + columnTypes: [.text(rawType: "TEXT")], + foreignKeys: [:] + ) + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("a/b") + ) + #expect(visible.contains(try path(of: "path", in: root))) + } + + @Test("A fetched foreign key's own keys are searched") + func searchesFetchedForeignKeys() throws { + let reference = JSONForeignKeyRef( + column: "language_id", + referencedTable: "language", + referencedSchema: nil, + referencedColumn: "language_id" + ) + let root = JSONRowNodeBuilder.build( + columns: ["language_id"], + values: [.text("1")], + columnTypes: [.integer(rawType: "INT")], + foreignKeys: ["language_id": reference] + ) + let keyPath = try path(of: "language_id", in: root) + let expansion = JSONRowNodeBuilder.build( + path: keyPath, + key: .name("language_id"), + columns: ["name"], + values: [.text("English")], + columnTypes: [.text(rawType: "CHAR")], + foreignKeys: [:] + ) + + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [keyPath: expansion], + matcher: try matcher("English") + ) + let nestedPath = try #require(expansion.children.first).path + #expect(visible.contains(nestedPath)) + #expect(visible.contains(keyPath)) + } + + /// A container whose own key matches keeps what it holds. Keeping the container alone drew it + /// as `{…}` with a disclosure control that could not open it, because a filtered tree takes its + /// expansion from what survived the filter. + @Test("A container whose key matches keeps its whole subtree") + func matchedContainerKeepsItsContents() throws { + let root = makeRoot() + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("special_features") + ) + + let containerPath = try path(of: "special_features", in: root) + let container = try #require(root.children.first { $0.path == containerPath }) + #expect(visible.contains(containerPath)) + #expect(container.children.allSatisfy { visible.contains($0.path) }) + } + + @Test("A matched container prints open, not as a collapsed placeholder") + func matchedContainerPrintsExpanded() throws { + let root = makeRoot() + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("special_features") + ) + let rows = JSONRowFlattener.rows( + root: root, + expanded: [root.path], + states: JSONForeignKeyStates(), + visiblePaths: visible + ) + + let container = try #require(rows.first { $0.key == .name("special_features") }) + #expect(container.token == .openArray) + #expect(container.isExpanded) + } + + /// A value that matches keeps its own line and the keys that lead to it, never the subtree + /// under it. An expanded foreign key carries both its own scalar and the referenced row's + /// fields, so treating a value match as a key match answered a search for the key's value with + /// every column of the row it points at. + @Test("A value match keeps its own line, not the rows fetched underneath it") + func valueMatchDoesNotKeepAFetchedSubtree() throws { + let root = JSONRowNodeBuilder.build( + columns: ["title", "language_id"], + values: [.text("ANYTHING SAVANNAH"), .text("1")], + columnTypes: [.text(rawType: "VARCHAR"), .integer(rawType: "INT")], + foreignKeys: ["language_id": JSONForeignKeyRef( + column: "language_id", + referencedTable: "language", + referencedSchema: nil, + referencedColumn: "language_id" + )] + ) + let keyPath = try path(of: "language_id", in: root) + let expansion = JSONRowNodeBuilder.build( + path: keyPath, + key: .name("language_id"), + columns: ["name", "country"], + values: [.text("English"), .text("Ireland")], + columnTypes: [.text(rawType: "CHAR"), .text(rawType: "CHAR")], + foreignKeys: [:] + ) + + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [keyPath: expansion], + matcher: try matcher("1") + ) + + #expect(visible.contains(keyPath)) + for child in expansion.children { + #expect(visible.contains(child.path) == false, "A value match must not drag in the fetched row") + } + } + + /// The filter runs on every keystroke, so a chain of matching ancestors over one large subtree + /// has to stay one pass rather than one pass per ancestor. + @Test("Nested matching containers are visited once each") + func nestedMatchesDoNotRewalkTheSubtree() throws { + var document = "{" + for depth in 0..<40 { document += "\"key\(depth)\": {" } + document += "\"leaf\": 1" + document += String(repeating: "}", count: 41) + + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text(document)], + columnTypes: [.json(rawType: "JSON")], + foreignKeys: [:] + ) + + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try matcher("key") + ) + + #expect(visible.contains(try path(of: "payload", in: root))) + #expect(visible.count == 43, "Every node once: the root, payload, 40 keys and the leaf") + } +} diff --git a/TableProTests/Models/JSON/JSONRowFlattenerTests.swift b/TableProTests/Models/JSON/JSONRowFlattenerTests.swift new file mode 100644 index 000000000..34a883d30 --- /dev/null +++ b/TableProTests/Models/JSON/JSONRowFlattenerTests.swift @@ -0,0 +1,195 @@ +// +// JSONRowFlattenerTests.swift +// TableProTests +// +// The printed lines: braces, commas, disclosure state and foreign key status. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("JSONRowFlattener") +struct JSONRowFlattenerTests { + private let reference = JSONForeignKeyRef( + column: "language_id", + referencedTable: "language", + referencedSchema: nil, + referencedColumn: "language_id" + ) + + private func makeRoot(foreignKeys: [String: JSONForeignKeyRef] = [:]) -> JSONRowNode { + JSONRowNodeBuilder.build( + columns: ["film_id", "language_id"], + values: [.text("2"), .text("1")], + columnTypes: [.integer(rawType: "INT"), .integer(rawType: "INT")], + foreignKeys: foreignKeys + ) + } + + private func row(_ rows: [JSONDisplayRow], _ column: String) throws -> JSONDisplayRow { + try #require(rows.first { $0.key == .name(column) && $0.token != .closeObject }) + } + + /// A column's path carries its position, so a test asks the tree for it. + private func path(of column: String, in root: JSONRowNode) throws -> JSONNodePath { + try #require(root.children.first { $0.key == .name(column) }).path + } + + @Test("An expanded root prints its braces around its keys") + func printsBraces() { + let root = makeRoot() + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: JSONForeignKeyStates()) + #expect(rows.count == 4) + #expect(rows.first?.token == .openObject) + #expect(rows.last?.token == .closeObject) + #expect(rows.last?.depth == 0) + } + + @Test("Every key but the last carries a comma") + func printsCommas() throws { + let root = makeRoot() + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: JSONForeignKeyStates()) + #expect(try row(rows, "film_id").needsComma) + #expect(try row(rows, "language_id").needsComma == false) + } + + @Test("A collapsed root prints one line with its key count") + func printsCollapsedRoot() { + let root = makeRoot() + let rows = JSONRowFlattener.rows(root: root, expanded: [], states: JSONForeignKeyStates()) + #expect(rows.count == 1) + #expect(rows.first?.token == .collapsedObject(count: 2)) + #expect(rows.first?.isExpandable == true) + } + + @Test("An unexpanded foreign key prints its value and offers a control") + func printsUnexpandedForeignKey() throws { + let root = makeRoot(foreignKeys: ["language_id": reference]) + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: JSONForeignKeyStates()) + let key = try row(rows, "language_id") + #expect(key.token == .scalar(.number("1"))) + #expect(key.isExpandable) + #expect(key.foreignKey == reference) + } + + @Test("A NULL foreign key offers no control") + func offersNoControlForNullKeys() throws { + let root = JSONRowNodeBuilder.build( + columns: ["original_language_id"], + values: [.null], + columnTypes: [.integer(rawType: "INT")], + foreignKeys: [ + "original_language_id": JSONForeignKeyRef( + column: "original_language_id", + referencedTable: "language", + referencedSchema: nil, + referencedColumn: "language_id" + ), + ] + ) + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: JSONForeignKeyStates()) + #expect(try row(rows, "original_language_id").isExpandable == false) + } + + @Test("A fetched foreign key prints the row it references") + func printsFetchedForeignKey() throws { + let root = makeRoot(foreignKeys: ["language_id": reference]) + let keyPath = try path(of: "language_id", in: root) + var states = JSONForeignKeyStates() + states.fetched[keyPath] = JSONRowNodeBuilder.build( + path: keyPath, + key: .name("language_id"), + columns: ["name"], + values: [.text("English")], + columnTypes: [.text(rawType: "CHAR")], + foreignKeys: [:] + ) + + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path, keyPath], states: states) + #expect(try row(rows, "language_id").token == .openObject) + let nestedPath = try #require(states.fetched[keyPath]?.children.first).path + let nested = try #require(rows.first { $0.path == nestedPath }) + #expect(nested.token == .scalar(.string("English"))) + #expect(nested.depth == 2) + } + + @Test("A key being fetched reports as loading") + func reportsLoading() throws { + let root = makeRoot(foreignKeys: ["language_id": reference]) + var states = JSONForeignKeyStates() + states.loading.insert(try path(of: "language_id", in: root)) + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: states) + #expect(try row(rows, "language_id").status == .loading) + } + + @Test("A key that could not be followed reports why") + func reportsFailure() throws { + let root = makeRoot(foreignKeys: ["language_id": reference]) + var states = JSONForeignKeyStates() + states.failures[try path(of: "language_id", in: root)] = .cycle + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: states) + #expect(try row(rows, "language_id").status == .failure(.cycle)) + } + + @Test("A filter expands what it kept, whatever was collapsed before") + func filterExpandsMatches() throws { + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text("{\"inner\": \"found\"}")], + columnTypes: [.json(rawType: "JSON")], + foreignKeys: [:] + ) + let visible = JSONRowFilter.visiblePaths( + root: root, + fetchedForeignKeys: [:], + matcher: try #require(matcher("found")) + ) + let rows = JSONRowFlattener.rows(root: root, expanded: [], states: JSONForeignKeyStates(), visiblePaths: visible) + #expect(rows.contains { $0.token == .scalar(.string("found")) }) + #expect(rows.first?.token == .openObject) + } + + @Test("Expand All names every container, and no unfetched foreign key") + func expandAllSkipsUnfetchedKeys() { + let root = makeRoot(foreignKeys: ["language_id": reference]) + let paths = JSONRowFlattener.expandablePaths(root: root, states: JSONForeignKeyStates()) + #expect(paths == [root.path]) + } + + private func matcher(_ query: String) -> JSONRowMatcher? { + guard case .matcher(let matcher) = JSONRowMatcher.make(query: query) else { return nil } + return matcher + } + + /// An expanded foreign key draws as `{`, and reading its value out of the token alone took + /// Copy Value and Open off the line the moment the reader opened it. + @Test("An expanded foreign key line still carries the value it was opened from") + func expandedForeignKeyKeepsItsScalar() throws { + let root = makeRoot(foreignKeys: ["language_id": reference]) + let keyPath = try path(of: "language_id", in: root) + var states = JSONForeignKeyStates() + states.fetched[keyPath] = JSONRowNodeBuilder.build( + path: keyPath, + key: .name("language_id"), + columns: ["name"], + values: [.text("English")], + columnTypes: [.text(rawType: "CHAR")], + foreignKeys: [:] + ) + + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path, keyPath], states: states) + let opened = try #require(rows.first { $0.path == keyPath && $0.token == .openObject }) + #expect(opened.scalar == .number("1")) + #expect(opened.foreignKey == reference) + } + + @Test("A collapsed foreign key carries the same value the expanded one does") + func collapsedForeignKeyCarriesItsScalar() throws { + let root = makeRoot(foreignKeys: ["language_id": reference]) + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: JSONForeignKeyStates()) + #expect(try row(rows, "language_id").scalar == .number("1")) + } +} diff --git a/TableProTests/Models/JSON/JSONRowNodeBuilderTests.swift b/TableProTests/Models/JSON/JSONRowNodeBuilderTests.swift new file mode 100644 index 000000000..0141b028e --- /dev/null +++ b/TableProTests/Models/JSON/JSONRowNodeBuilderTests.swift @@ -0,0 +1,293 @@ +// +// JSONRowNodeBuilderTests.swift +// TableProTests +// +// Whether a value prints quoted comes from the column's own type, not from the text. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("JSONRowNodeBuilder") +struct JSONRowNodeBuilderTests { + private func reference(column: String, table: String = "language") -> JSONForeignKeyRef { + JSONForeignKeyRef( + column: column, + referencedTable: table, + referencedSchema: nil, + referencedColumn: "\(table)_id" + ) + } + + private func node(_ root: JSONRowNode, _ column: String) throws -> JSONRowNode { + try #require(root.children.first { $0.key == .name(column) }) + } + + @Test("The column type decides the scalar kind") + func typesScalars() throws { + let root = JSONRowNodeBuilder.build( + columns: ["film_id", "rental_rate", "title", "active", "last_update"], + values: [.text("2"), .text("4.99"), .text("ACE GOLDFINGER"), .text("true"), .text("2006-02-15 05:03:42")], + columnTypes: [ + .integer(rawType: "INT"), + .decimal(rawType: "NUMERIC(5,2)"), + .text(rawType: "VARCHAR"), + .boolean(rawType: "BOOL"), + .timestamp(rawType: "TIMESTAMP"), + ], + foreignKeys: [:] + ) + + #expect(try node(root, "film_id").scalar == .number("2")) + #expect(try node(root, "rental_rate").scalar == .number("4.99")) + #expect(try node(root, "title").scalar == .string("ACE GOLDFINGER")) + #expect(try node(root, "active").scalar == .bool(true)) + #expect(try node(root, "last_update").scalar == .string("2006-02-15 05:03:42")) + } + + @Test("A boolean column reads the spellings the drivers emit") + func typesBooleans() throws { + let root = JSONRowNodeBuilder.build( + columns: ["a", "b", "c"], + values: [.text("false"), .text("0"), .text("maybe")], + columnTypes: [ + .boolean(rawType: "BOOL"), + .boolean(rawType: "TINYINT(1)"), + .boolean(rawType: "BOOL"), + ], + foreignKeys: [:] + ) + #expect(try node(root, "a").scalar == .bool(false)) + #expect(try node(root, "b").scalar == .bool(false)) + #expect(try node(root, "c").scalar == .string("maybe")) + } + + @Test("An integer column holding text that is not a number stays a string") + func keepsUnparsableNumbersAsStrings() throws { + let root = JSONRowNodeBuilder.build( + columns: ["n"], + values: [.text("twelve")], + columnTypes: [.integer(rawType: "INT")], + foreignKeys: [:] + ) + #expect(try node(root, "n").scalar == .string("twelve")) + } + + @Test("An integer wider than a Double keeps its digits") + func preservesWideIntegers() throws { + let root = JSONRowNodeBuilder.build( + columns: ["n"], + values: [.text("9007199254740993")], + columnTypes: [.integer(rawType: "BIGINT")], + foreignKeys: [:] + ) + #expect(try node(root, "n").scalar == .number("9007199254740993")) + } + + @Test("NULL and binary values keep their own kinds") + func typesNullAndBinary() throws { + let root = JSONRowNodeBuilder.build( + columns: ["note", "thumb"], + values: [.null, .bytes(Data([0x4C, 0x65]))], + columnTypes: [.text(rawType: "TEXT"), .blob(rawType: "BLOB")], + foreignKeys: [:] + ) + #expect(try node(root, "note").scalar == .null) + #expect(try node(root, "thumb").scalar == .binary(Data([0x4C, 0x65]))) + } + + @Test("A JSON column expands into a subtree") + func expandsJsonColumns() throws { + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text("{\"a\": [1, 2], \"b\": null}")], + columnTypes: [.json(rawType: "JSONB")], + foreignKeys: [:] + ) + let payload = try node(root, "payload") + #expect(payload.isContainer) + #expect(payload.children.count == 2) + #expect(payload.children[0].children.map(\.scalar) == [.number("1"), .number("2")]) + #expect(payload.children[1].scalar == .null) + } + + @Test("A TEXT column holding a document expands too") + func expandsTextColumnsHoldingDocuments() throws { + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text("[\"Trailers\"]")], + columnTypes: [.text(rawType: "TEXT")], + foreignKeys: [:] + ) + let payload = try node(root, "payload") + #expect(payload.isContainer) + #expect(payload.children.map(\.scalar) == [.string("Trailers")]) + } + + @Test("A TEXT column holding ordinary prose stays a string") + func leavesProseAlone() throws { + let root = JSONRowNodeBuilder.build( + columns: ["description"], + values: [.text("A Epic Story of a Pastry Chef")], + columnTypes: [.text(rawType: "TEXT")], + foreignKeys: [:] + ) + #expect(try node(root, "description").scalar == .string("A Epic Story of a Pastry Chef")) + } + + @Test("A foreign key column becomes an expandable node carrying its value") + func marksForeignKeys() throws { + let root = JSONRowNodeBuilder.build( + columns: ["language_id"], + values: [.text("1")], + columnTypes: [.integer(rawType: "INT")], + foreignKeys: ["language_id": reference(column: "language_id")] + ) + let node = try node(root, "language_id") + #expect(node.foreignKey == reference(column: "language_id")) + #expect(node.scalar == .number("1")) + } + + @Test("A NULL foreign key still reports its reference, so the row can say there is none") + func marksNullForeignKeys() throws { + let root = JSONRowNodeBuilder.build( + columns: ["original_language_id"], + values: [.null], + columnTypes: [.integer(rawType: "INT")], + foreignKeys: ["original_language_id": reference(column: "original_language_id")] + ) + let node = try node(root, "original_language_id") + #expect(node.foreignKey != nil) + #expect(node.scalar == .null) + } + + @Test("A foreign key column is a key first, whatever its text looks like") + func foreignKeyBeatsDocumentParsing() throws { + let root = JSONRowNodeBuilder.build( + columns: ["ref"], + values: [.text("{\"a\": 1}")], + columnTypes: [.json(rawType: "JSON")], + foreignKeys: ["ref": reference(column: "ref")] + ) + let node = try node(root, "ref") + #expect(node.foreignKey != nil) + #expect(node.isContainer == false) + } + + @Test("A document past the scan cap stays a string") + func leavesOversizedDocumentsAlone() throws { + let oversized = "[" + String(repeating: "1,", count: JSONRowNodeBuilder.maxScannedDocumentLength) + "1]" + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text(oversized)], + columnTypes: [.json(rawType: "JSON")], + foreignKeys: [:] + ) + #expect(try node(root, "payload").isContainer == false) + } + + @Test("Broken JSON stays a string rather than becoming a partial tree") + func leavesBrokenDocumentsAlone() throws { + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text("{\"a\": 1,}")], + columnTypes: [.json(rawType: "JSON")], + foreignKeys: [:] + ) + #expect(try node(root, "payload").scalar == .string("{\"a\": 1,}")) + } + + @Test("A document's escapes and nested keys are decoded") + func decodesDocumentStrings() throws { + let root = JSONRowNodeBuilder.build( + columns: ["payload"], + values: [.text(#"{"a\u0041": "line\nbreak"}"#)], + columnTypes: [.json(rawType: "JSON")], + foreignKeys: [:] + ) + let payload = try node(root, "payload") + #expect(payload.children.first?.key == .name("aA")) + #expect(payload.children.first?.scalar == .string("line\nbreak")) + } + + @Test("Two columns with the same label get their own nodes") + func separatesDuplicateColumnLabels() throws { + let root = JSONRowNodeBuilder.build( + columns: ["id", "name", "id"], + values: [.text("1"), .text("Album"), .text("7")], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT"), .integer(rawType: "INT")], + foreignKeys: [:] + ) + + let paths = Set(root.children.map(\.path)) + #expect(paths.count == root.children.count) + #expect(root.children.map(\.key) == [.name("id"), .name("name"), .name("id")]) + #expect(root.children[0].scalar == .number("1")) + #expect(root.children[2].scalar == .number("7")) + } + + @Test("A row with fewer values than columns reads the missing ones as NULL") + func toleratesShortRows() throws { + let root = JSONRowNodeBuilder.build( + columns: ["a", "b"], + values: [.text("1")], + columnTypes: [.integer(rawType: "INT")], + foreignKeys: [:] + ) + #expect(try node(root, "b").scalar == .null) + } + + /// `42`, `true`, `null` and `"text"` are whole JSON documents, and a JSON column is allowed to + /// hold one. Handing them back to the column-type path printed the number as a string and left + /// the string literal's own quotes inside the printed quotes. + @Test("A JSON column holding a top-level scalar keeps that scalar's kind") + func keepsJsonScalarDocuments() throws { + let root = JSONRowNodeBuilder.build( + columns: ["count", "flag", "missing", "label"], + values: [.text("42"), .text("true"), .text("null"), .text("\"ready\"")], + columnTypes: [ + .json(rawType: "JSON"), + .json(rawType: "JSON"), + .json(rawType: "JSON"), + .json(rawType: "JSON"), + ], + foreignKeys: [:] + ) + + #expect(try node(root, "count").scalar == .number("42")) + #expect(try node(root, "flag").scalar == .bool(true)) + #expect(try node(root, "missing").scalar == .null) + #expect(try node(root, "label").scalar == .string("ready")) + } + + @Test("A text column holding a bare number stays a string, because it is not a document") + func leavesNonJsonTextAlone() throws { + let root = JSONRowNodeBuilder.build( + columns: ["note"], + values: [.text("42")], + columnTypes: [.text(rawType: "VARCHAR")], + foreignKeys: [:] + ) + #expect(try node(root, "note").scalar == .string("42")) + } + + /// `JsonSyntaxParser` highlights, it does not validate: it drops the backslash from an unknown + /// escape and reads a leading zero as a number. A JSON column the engine never validated can + /// hold either, and retyping one would show the reader something the cell does not say. + @Test("A JSON column holding text that is not strictly valid JSON stays the text it holds") + func refusesInvalidJsonScalars() throws { + let root = JSONRowNodeBuilder.build( + columns: ["escape", "leadingZero", "negativeZero"], + values: [.text("\"\\q\""), .text("01"), .text("-01")], + columnTypes: [.json(rawType: "JSON"), .json(rawType: "JSON"), .json(rawType: "JSON")], + foreignKeys: [:] + ) + + #expect(try node(root, "escape").scalar == .string("\"\\q\"")) + #expect(try node(root, "leadingZero").scalar == .string("01")) + #expect(try node(root, "negativeZero").scalar == .string("-01")) + } +} diff --git a/TableProTests/Models/JSON/JSONRowSnapshotChangeTests.swift b/TableProTests/Models/JSON/JSONRowSnapshotChangeTests.swift new file mode 100644 index 000000000..db09f4e26 --- /dev/null +++ b/TableProTests/Models/JSON/JSONRowSnapshotChangeTests.swift @@ -0,0 +1,69 @@ +// +// JSONRowSnapshotChangeTests.swift +// TableProTests +// +// What counts as a change to the row the JSON tab is showing. A hand-written token over the +// parts that seemed to matter read a late foreign key fetch as no change, and read a rerun that +// moved a row's values as no reason to drop the rows fetched for its keys. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("JSONRowSnapshot change detection") +struct JSONRowSnapshotChangeTests { + private let reference = JSONForeignKeyRef( + column: "ArtistId", + referencedTable: "Artist", + referencedSchema: nil, + referencedColumn: "ArtistId" + ) + + private func snapshot( + rowIdentity: String = "tab\u{001F}existing(0)", + values: [PluginCellValue] = [.text("1"), .text("2")], + columnTypes: [ColumnType] = [.integer(rawType: "INT"), .integer(rawType: "INT")], + foreignKeys: [String: JSONForeignKeyRef] = [:] + ) -> JSONRowSnapshot { + JSONRowSnapshot( + rowIdentity: rowIdentity, + columns: ["AlbumId", "ArtistId"], + columnTypes: columnTypes, + values: values, + foreignKeys: foreignKeys, + connectionId: UUID(uuidString: "00000000-0000-0000-0000-0000000000AA") ?? UUID(), + databaseType: .sqlite + ) + } + + @Test("Foreign keys arriving after the row is on screen is a change") + func foreignKeyMetadataIsAChange() { + #expect(snapshot() != snapshot(foreignKeys: ["ArtistId": reference])) + } + + @Test("Column types arriving after the row is on screen is a change") + func columnTypesAreAChange() { + #expect(snapshot() != snapshot(columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")])) + } + + @Test("The same row with a different value is a change") + func movedValuesAreAChange() { + #expect(snapshot() != snapshot(values: [.text("1"), .text("3")])) + } + + @Test("The same row unchanged is not a change") + func identicalSnapshotsMatch() { + #expect(snapshot() == snapshot()) + } + + @Test("A rerun keeps a row's identity, so identity alone cannot answer the question") + func identityOutlivesTheValues() { + let before = snapshot(values: [.text("1"), .text("1")]) + let after = snapshot(values: [.text("1"), .text("2")]) + #expect(before.rowIdentity == after.rowIdentity) + #expect(before != after) + } +} diff --git a/TableProTests/Models/JSON/JSONRowTextRendererTests.swift b/TableProTests/Models/JSON/JSONRowTextRendererTests.swift new file mode 100644 index 000000000..b083a1cc8 --- /dev/null +++ b/TableProTests/Models/JSON/JSONRowTextRendererTests.swift @@ -0,0 +1,56 @@ +// +// JSONRowTextRendererTests.swift +// TableProTests +// +// Copy Visible writes the lines that are on screen, not the whole row. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("JSONRowTextRenderer") +struct JSONRowTextRendererTests { + private func makeRoot() -> JSONRowNode { + JSONRowNodeBuilder.build( + columns: ["film_id", "title", "special_features", "note"], + values: [.text("2"), .text("ACE \"GOLDFINGER\""), .text("[\"Trailers\"]"), .null], + columnTypes: [ + .integer(rawType: "INT"), + .text(rawType: "VARCHAR"), + .json(rawType: "JSON"), + .text(rawType: "TEXT"), + ], + foreignKeys: [:] + ) + } + + @Test("Renders the expanded tree as JSON") + func rendersExpandedTree() { + let root = makeRoot() + let rows = JSONRowFlattener.rows( + root: root, + expanded: [root.path, root.children[2].path], + states: JSONForeignKeyStates() + ) + #expect(JSONRowTextRenderer.render(rows: rows) == """ + { + "film_id": 2, + "title": "ACE \\"GOLDFINGER\\"", + "special_features": [ + "Trailers" + ], + "note": null + } + """) + } + + @Test("A collapsed container prints as an ellipsis, the way it is shown") + func rendersCollapsedContainer() { + let root = makeRoot() + let rows = JSONRowFlattener.rows(root: root, expanded: [root.path], states: JSONForeignKeyStates()) + #expect(JSONRowTextRenderer.render(rows: rows).contains("\"special_features\": […],")) + } +} diff --git a/TableProTests/Models/JSON/JSONScalarTextTests.swift b/TableProTests/Models/JSON/JSONScalarTextTests.swift new file mode 100644 index 000000000..7ceaa1bf3 --- /dev/null +++ b/TableProTests/Models/JSON/JSONScalarTextTests.swift @@ -0,0 +1,58 @@ +// +// JSONScalarTextTests.swift +// TableProTests +// +// What a printed line carries and what Copy Value carries are not the same thing for a blob. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("JSONScalarText") +struct JSONScalarTextTests { + private let sample = Data((0..<200).map { UInt8($0 % 256) }) + + @Test("A printed blob is quoted and stops at the display cap") + func printedBlobIsCapped() { + let printed = JSONScalarText.printed(.binary(sample)) + #expect(printed.hasPrefix("\"0x")) + #expect(printed.hasSuffix("…\"")) + #expect(printed.contains("0x000102")) + } + + /// The quotes were the bug, not the cap. Copy Value used to hand the pasteboard the printed + /// form, quotes included, which round-trips as neither the blob nor valid JSON; the 64-byte cap + /// is what `RowValueCopyFormatter` already gives the grid's own Copy for the same cell. + @Test("Copy Value gives unquoted hex, capped the way the grid's own Copy is") + func unquotedBlobIsUnquotedAndCapped() { + let copied = JSONScalarText.unquoted(.binary(sample)) + #expect(copied.hasPrefix("0x")) + #expect(!copied.contains("\"")) + #expect(copied.hasSuffix("…")) + #expect(copied.count == 2 + JSONScalarText.maxDisplayedHexBytes * 2 + 1) + } + + @Test("A blob within the cap copies whole") + func shortBlobCopiesWhole() { + #expect(JSONScalarText.unquoted(.binary(Data([0x4C, 0x65]))) == "0x4C65") + } + + @Test("A blob shorter than the cap prints without an ellipsis") + func shortBlobIsNotMarkedTruncated() { + let printed = JSONScalarText.printed(.binary(Data([0x4C, 0x65]))) + #expect(printed == "\"0x4C65\"") + } + + @Test("An empty blob prints as an empty hex value rather than as an empty string") + func emptyBlob() { + #expect(JSONScalarText.unquoted(.binary(Data())) == "0x") + } + + @Test("A string is escaped when printed and raw when copied") + func stringEscaping() { + #expect(JSONScalarText.printed(.string("a\"b\nc")) == "\"a\\\"b\\nc\"") + #expect(JSONScalarText.unquoted(.string("a\"b\nc")) == "a\"b\nc") + } +} diff --git a/TableProTests/Models/RightPanelTabAvailabilityTests.swift b/TableProTests/Models/RightPanelTabAvailabilityTests.swift new file mode 100644 index 000000000..839e7e125 --- /dev/null +++ b/TableProTests/Models/RightPanelTabAvailabilityTests.swift @@ -0,0 +1,45 @@ +// +// RightPanelTabAvailabilityTests.swift +// TableProTests +// +// The active tab is persisted per connection and restored without asking whether the tab still +// exists, so a connection last left on AI Chat comes back to it with the assistant turned off. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("RightPanelTab availability") +struct RightPanelTabAvailabilityTests { + @Test("AI Chat is the only tab a setting takes away") + func aiChatIsTheOnlyOptionalTab() { + #expect(RightPanelTab.available(isAIEnabled: true) == RightPanelTab.allCases) + #expect(RightPanelTab.available(isAIEnabled: false) == [.details, .json]) + } + + @Test("A restored AI Chat tab resolves to Details while the assistant is off") + func restoredAIChatFallsBack() { + #expect(RightPanelTab.resolved(.aiChat, isAIEnabled: false) == .details) + #expect(RightPanelTab.resolved(.aiChat, isAIEnabled: true) == .aiChat) + } + + @Test("The tabs a setting cannot reach resolve to themselves either way") + func alwaysAvailableTabsAreUntouched() { + for enabled in [true, false] { + #expect(RightPanelTab.resolved(.details, isAIEnabled: enabled) == .details) + #expect(RightPanelTab.resolved(.json, isAIEnabled: enabled) == .json) + } + } + + @Test("A resolved tab is always one the picker offers") + func resolutionIsAlwaysSelectable() { + for enabled in [true, false] { + for tab in RightPanelTab.allCases { + let resolved = RightPanelTab.resolved(tab, isAIEnabled: enabled) + #expect(RightPanelTab.available(isAIEnabled: enabled).contains(resolved)) + } + } + } +} diff --git a/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift b/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift new file mode 100644 index 000000000..6bedd98e8 --- /dev/null +++ b/TableProTests/ViewModels/JSONRowInspectorViewModelTests.swift @@ -0,0 +1,232 @@ +// +// JSONRowInspectorViewModelTests.swift +// TableProTests +// +// What the JSON inspector does with a row that changes under it while a foreign key is still +// being fetched. A rerun keeps a row's identity while its values move, so a fetched row held +// against a node path is the wrong row the moment the values do. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@MainActor +@Suite("JSONRowInspectorViewModel") +struct JSONRowInspectorViewModelTests { + private static let connectionId = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA") ?? UUID() + + private static let artistReference = JSONForeignKeyRef( + column: "ArtistId", + referencedTable: "Artist", + referencedSchema: nil, + referencedColumn: "ArtistId" + ) + + /// Hands a row back when the test says so rather than when the model asks, so a fetch can be + /// held open across a rebuild the way a query blocked in a driver call is. + @MainActor + private final class FetchGate { + private var pending: [CheckedContinuation] = [] + private(set) var callCount = 0 + + func fetch() async throws -> ForeignKeyRowFetcher.FetchedRow? { + callCount += 1 + return try await withCheckedThrowingContinuation { pending.append($0) } + } + + var pendingCount: Int { pending.count } + + func releaseFirst(with row: ForeignKeyRowFetcher.FetchedRow?) { + guard !pending.isEmpty else { return } + pending.removeFirst().resume(returning: row) + } + + func releaseAll(with row: ForeignKeyRowFetcher.FetchedRow?) { + let waiting = pending + pending = [] + for continuation in waiting { continuation.resume(returning: row) } + } + } + + /// Lets the model's fetch task run up to its first suspension point, which is where it hands + /// the gate its continuation. Nothing can be released before that has happened. + private func settle() async { + for _ in 0..<8 { await Task.yield() } + } + + private static func snapshot( + rowIdentity: String = "tab\u{001F}existing(0)", + artistId: PluginCellValue = .text("1"), + foreignKeys: [String: JSONForeignKeyRef] = ["ArtistId": artistReference] + ) -> JSONRowSnapshot { + JSONRowSnapshot( + rowIdentity: rowIdentity, + columns: ["AlbumId", "ArtistId"], + columnTypes: [.integer(rawType: "INT"), .integer(rawType: "INT")], + values: [.text("7"), artistId], + foreignKeys: foreignKeys, + connectionId: connectionId, + databaseType: .sqlite + ) + } + + private static func artistRow(name: String) -> ForeignKeyRowFetcher.FetchedRow { + ForeignKeyRowFetcher.FetchedRow( + columns: ["ArtistId", "Name"], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "TEXT")], + values: [.text("1"), .text(name)], + foreignKeys: [:] + ) + } + + private func makeModel(gate: FetchGate) -> JSONRowInspectorViewModel { + JSONRowInspectorViewModel { _, _, _, _ in try await gate.fetch() } + } + + private func foreignKeyRow(in model: JSONRowInspectorViewModel) throws -> JSONDisplayRow { + try #require(model.displayRows.first { $0.foreignKey != nil }) + } + + @Test("A fetched referenced row is dropped when the values under its key move") + func rerunDropsFetchedRows() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot(artistId: .text("1"))) + let path = try foreignKeyRow(in: model).path + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + gate.releaseFirst(with: Self.artistRow(name: "AC/DC")) + await settle() + #expect(model.states.fetched[path] != nil) + + /// The same row, rerun: the identity survives, the value under the key does not. + model.update(snapshot: Self.snapshot(artistId: .text("2"))) + + #expect(model.states.fetched.isEmpty, "Artist 1 must not stay printed under a key now holding 2") + #expect(model.states.loading.isEmpty) + } + + @Test("A fetch that returns after a rebuild writes nothing into the new tree") + func lateFetchIsDiscarded() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot(artistId: .text("1"))) + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + #expect(gate.pendingCount == 1) + + model.update(snapshot: Self.snapshot(artistId: .text("2"))) + gate.releaseAll(with: Self.artistRow(name: "AC/DC")) + await settle() + + #expect(model.states.fetched.isEmpty) + #expect(model.states.failures.isEmpty) + } + + @Test("A stale fetch completing late leaves the fetch that replaced it in hand") + func staleCompletionKeepsTheReplacementFetch() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot(artistId: .text("1"))) + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + + model.update(snapshot: Self.snapshot(artistId: .text("2"))) + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + #expect(gate.callCount == 2) + + /// Only the cancelled first query comes back. Its cleanup used to drop the second query's + /// handle, which left the key looking unfetched and open to a third query for the same row. + gate.releaseFirst(with: Self.artistRow(name: "Accept")) + await settle() + + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + #expect(gate.callCount == 2, "A key with a fetch already in flight must not start a second one") + } + + @Test("A NULL foreign key never fetches") + func nullForeignKeyDoesNotFetch() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot(artistId: .null)) + + let row = try foreignKeyRow(in: model) + #expect(!row.isExpandable, "A key that references nothing offers no control") + model.toggle(row: row) + await settle() + #expect(gate.callCount == 0) + } + + @Test("Releasing data drops the tree and every row fetched for it") + func releaseDataClearsEverything() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot()) + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + gate.releaseFirst(with: Self.artistRow(name: "AC/DC")) + await settle() + model.filterText = "Artist" + + model.releaseData() + + #expect(model.root == nil) + #expect(model.states.fetched.isEmpty) + #expect(model.filterText.isEmpty) + #expect(model.displayRows.isEmpty) + } + + @Test("An unchanged snapshot keeps the rows already fetched") + func unchangedSnapshotKeepsFetchedRows() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot()) + let path = try foreignKeyRow(in: model).path + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + gate.releaseFirst(with: Self.artistRow(name: "AC/DC")) + await settle() + + model.update(snapshot: Self.snapshot()) + + #expect(model.states.fetched[path] != nil) + } + + @Test("A key that references a row already open in the chain reports the cycle") + func repeatedVisitReportsACycle() async throws { + let gate = FetchGate() + let model = makeModel(gate: gate) + + model.update(snapshot: Self.snapshot(artistId: .text("1"))) + let path = try foreignKeyRow(in: model).path + model.toggle(row: try foreignKeyRow(in: model)) + await settle() + gate.releaseFirst( + with: ForeignKeyRowFetcher.FetchedRow( + columns: ["ArtistId"], + columnTypes: [.integer(rawType: "INT")], + values: [.text("1")], + foreignKeys: ["ArtistId": Self.artistReference] + ) + ) + await settle() + + let nested = try #require(model.displayRows.first { $0.foreignKey != nil && $0.path != path }) + model.toggle(row: nested) + await settle() + + #expect(model.states.failures[nested.path] == .cycle) + #expect(gate.callCount == 1, "A cycle is refused before it costs a query") + } +} diff --git a/TableProUITests/JSONRowInspectorUITests.swift b/TableProUITests/JSONRowInspectorUITests.swift new file mode 100644 index 000000000..df38bc2b5 --- /dev/null +++ b/TableProUITests/JSONRowInspectorUITests.swift @@ -0,0 +1,99 @@ +// +// JSONRowInspectorUITests.swift +// TableProUITests +// +// The JSON tab shows the selected row as JSON, and a foreign key in it fetches the row it +// references. Chinook's Album.ArtistId is the reference this drives. +// + +import AppKit +import XCTest + +final class JSONRowInspectorUITests: UITestCase { + func testShowRowAsJSONOpensTheInspectorOnTheJSONTab() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try albumGrid(in: app, window: window) + + openRowAsJSON(in: app, grid: grid) + + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.jsonTab(in: window).exists }, + "Show Row as JSON must reveal the inspector's JSON tab" + ) + XCTAssertTrue( + waitForPredicate(timeout: 20) { window.staticTexts["\"Title\""].exists }, + "The JSON tab must print the row's own keys; Album has a Title column" + ) + } + + func testExpandingAForeignKeyFetchesTheRowItReferences() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try albumGrid(in: app, window: window) + + openRowAsJSON(in: app, grid: grid) + + /// Album's own keys hold no container, so the only closed disclosure in the tree is the + /// foreign key on ArtistId. Its expansion is a query, which is the whole point of the test. + let expand = window.buttons["Expand"] + XCTAssertTrue( + expand.waitToExist(timeout: 20), + "A foreign key column must offer a disclosure control of its own" + ) + clickAtCenter(expand) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["\"Name\""].exists }, + "Expanding Album.ArtistId must fetch the Artist row, whose columns include Name" + ) + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + private func albumGrid(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { + let row = window.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "Table: Album")) + .firstMatch + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") + clickAtCenter(row) + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") + XCTAssertTrue( + waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, + "Album must load rows before a row can be inspected" + ) + return grid + } + + /// The grid publishes a column as a sibling of its rows, each as tall as every row it spans, so + /// XCUITest reads every row and cell as obscured and refuses to click one. A point offset from + /// the grid itself is the way in, which is what the drawn-cell grid's other suites do too. + private func openRowAsJSON(in app: XCUIApplication, grid: XCUIElement) { + let firstRow = grid.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: 60, dy: 12)) + firstRow.click() + Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + firstRow.rightClick() + + let item = app.menuItems["Show Row as JSON"] + XCTAssertTrue(item.waitToExist(timeout: 15), "The row's context menu must offer Show Row as JSON") + item.click() + } + + /// The tab strip is a segmented control, which AppKit publishes as radio buttons. + private func jsonTab(in window: XCUIElement) -> XCUIElement { + window.radioButtons["JSON"] + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 42a8606d0..e39699cc1 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -50,6 +50,8 @@ Widths, order, and hidden columns are remembered per table, scoped to the connec A foreign key cell carries an arrow on its right edge. Click it to open the referenced table filtered to the matching row, or right-click for **Preview Referenced Row**, which shows that row in a popover. `Cmd`-click always opens a new tab; otherwise the reference takes over the current tab unless that tab holds a query or unsaved edits. +The inspector's **JSON** tab follows a key without leaving the row: see [Row as JSON](/features/json-viewer#row-as-json). + Step back with the Back and Forward buttons at the leading edge of the toolbar, or **View > Back** (`Ctrl+Cmd+[`) and **View > Forward** (`Ctrl+Cmd+]`). Back restores the table you came from as you left it: same filters, sort, page, and selected row. Each tab keeps its own history, Back never closes a tab, and it is unavailable while a tab holds unsaved edits. diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index 2c5983ca5..6174ddb26 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -98,4 +98,23 @@ Right-click a field for its menu; an editable field also shows it on a hover but With no row selected, the inspector describes the table: data, index and total size, row count, average row size, engine, collation, and creation and update dates, as far as the database reports them. On a Structure tab it follows the structure grid. +## Row as JSON + +Right-click a row and choose **Show Row as JSON**, or open the inspector and pick its **JSON** tab. The row prints as one JSON object, keys in the result's own column order: numbers and booleans unquoted, NULL as `null`, binary as hex. Like the **Details** tab beside it, it prints every column the result carries, including any you hid in the grid. A JSON column, and a text column holding a document, arrive as nested keys instead of as a string. + + + Row as JSON + Row as JSON + + +A foreign key carries a disclosure control. Click it and the referenced row is fetched and printed underneath; a foreign key inside that row expands the same way, five levels down. A key pointing back at a row already open in the tree, and one past the fifth level, carry a warning icon that says which. A NULL foreign key has no control. + +**Always Expand Foreign Keys**, in the options menu at the trailing end of the filter field, fetches the first level on every row selected from then on. It starts off and stays off until you turn it on, every session, since each key it follows is a query. The rest of that menu is **Copy Visible**, which puts the printed lines on the pasteboard as they stand, **Collapse All**, and **Expand All**. Long text wraps rather than being cut. + +The filter field takes text, or a regular expression wrapped in slashes such as `/^rental/`. It matches keys and values, keeps the keys that lead to a match along with everything under a key that matches, and opens what was collapsed. `Escape` clears it. An invalid expression outlines the field in red and filters nothing. + +Editing stays on the **Details** tab. Right-click a line for **Copy Value** and **Copy Key**; a foreign key line adds **Open** for the referenced table, filtered to that row. + +Caps: a document over 100,000 characters prints as a string rather than as nested keys, the same cap the [JSON tree](#limits) has. A binary value shows its first 64 bytes followed by an ellipsis, the same cap the grid's own **Copy** applies. + For whole-row JSON, switch the result to JSON mode with the switcher at the leading edge of the status bar, or from **View > Result View**. It shows what the [grid](/features/data-grid) shows, in the same order, minus hidden columns and rows marked for deletion, which the count line reports separately. Select rows in Data mode first to narrow it, then click **Copy JSON**. diff --git a/docs/images/row-as-json-dark.png b/docs/images/row-as-json-dark.png new file mode 100644 index 000000000..42897173c Binary files /dev/null and b/docs/images/row-as-json-dark.png differ diff --git a/docs/images/row-as-json.png b/docs/images/row-as-json.png new file mode 100644 index 000000000..90e914120 Binary files /dev/null and b/docs/images/row-as-json.png differ