From 6a29b4e0a0e7644c044b7ca4c7cd967d9b670fc6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 16 Sep 2026 01:43:16 +0700 Subject: [PATCH] feat(datagrid): per-element JSON editing for PostgreSQL jsonb[] columns --- CHANGELOG.md | 3 + .../PostgresArrayLiteralCodec.swift | 157 +++++++++------- TablePro/Core/Services/ColumnType.swift | 50 +++++- TablePro/Models/UI/FieldEditorKind.swift | 3 + TablePro/Models/UI/InspectorFieldLayout.swift | 7 +- .../MainContentView+EventHandlers.swift | 7 +- .../Results/ArrayJsonElementEditor.swift | 170 ++++++++++++++++++ .../Views/Results/ArrayValueEditorModel.swift | 79 +++++++- .../Views/Results/ArrayValueEditorView.swift | 161 +++++++++++++---- .../Extensions/DataGridView+Popovers.swift | 18 +- .../FieldEditors/ArrayFieldEditorView.swift | 84 +++++++++ .../FieldEditors/FieldEditorContent.swift | 17 +- .../RowInspector/InspectorFieldRow.swift | 4 +- .../Views/RowInspector/RowInspectorView.swift | 2 +- .../FieldEditors/FieldEditorResolver.swift | 25 +++ .../Services/ColumnTypeClassifierTests.swift | 26 ++- .../Models/InspectorFieldLayoutTests.swift | 7 + .../PostgresArrayLiteralCodecTests.swift | 77 ++++++++ .../Views/ArrayValueEditorModelTests.swift | 74 ++++++++ .../Shared/FieldEditorResolverTests.swift | 102 +++++++++++ docs/STYLE.md | 6 +- docs/databases/postgresql.mdx | 9 +- docs/features/change-tracking.mdx | 1 + docs/features/json-viewer.mdx | 8 + 24 files changed, 957 insertions(+), 140 deletions(-) create mode 100644 TablePro/Views/Results/ArrayJsonElementEditor.swift create mode 100644 TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3a7cbeb9..4f950da2f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Query > Clear Query** and **Query > Clear Results**. - Result chooser in the status bar, naming the result on screen and offering Pin, Unpin, Close and Close Others. - A reason on a dimmed Run, Explain, Format or Favorite saying why it cannot run. +- Formatted JSON inspection and per-element editing for PostgreSQL `jsonb[]` and `json[]` columns. (#2897) +- Array element editor in the row inspector. ### Changed @@ -68,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Unicode whitespace dropped from a PostgreSQL array element when a sibling element was edited. - Stale error banner over a pinned result after clearing the results of a failed query. - `DROP TABLE` and `TRUNCATE TABLE` generated for Elasticsearch, Kafka, Weaviate and etcd, which have no SQL. (#2884) - Empty Elasticsearch and Weaviate exports, which asked the engine for `SELECT * FROM`. diff --git a/Plugins/TableProPluginKit/PostgresArrayLiteralCodec.swift b/Plugins/TableProPluginKit/PostgresArrayLiteralCodec.swift index 74923a22a4..42bd681af9 100644 --- a/Plugins/TableProPluginKit/PostgresArrayLiteralCodec.swift +++ b/Plugins/TableProPluginKit/PostgresArrayLiteralCodec.swift @@ -5,175 +5,196 @@ public enum PostgresArrayElement: Hashable, Sendable { case null } +/// Reads and writes the literal PostgreSQL's `array_in` and `array_out` use. +/// +/// It scans Unicode scalars rather than `Character`s because that is what the server does. A Swift +/// grapheme can carry a structural scalar and a combining mark together, and `Character` +/// comparison then misses it: `,` followed by U+0301 is one `Character` that is not `","`, so a +/// grapheme scan would keep an element the server splits in two, and a space followed by U+0301 is +/// one `Character` that is not whitespace, so it would go out unquoted for the server to trim. public enum PostgresArrayLiteralCodec { public static let defaultDelimiter: Character = "," + /// The scalars PostgreSQL's array parser treats as whitespace, its `scanner_isspace`. + /// + /// `Character.isWhitespace` is the whole Unicode set instead, which is wrong in both + /// directions. Measured on PostgreSQL 17.11: `array_out` writes U+00A0 and U+3000 into a + /// literal unquoted and `array_in` reads them back as part of the value, so trimming them here + /// deleted a character from an element the user never edited. + private static let separators: Set = [" ", "\t", "\n", "\r", "\u{0B}", "\u{0C}"] + public static func parse(_ text: String, delimiter: Character = defaultDelimiter) -> [PostgresArrayElement]? { - let characters = Array(text) + guard let delimiter = delimiter.unicodeScalars.first else { return nil } + let scalars = Array(text.unicodeScalars) var index = 0 - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] == "{" else { return nil } + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] == "{" else { return nil } index += 1 - skipWhitespace(characters, &index) + skipWhitespace(scalars, &index) - if index < characters.count, characters[index] == "}" { + if index < scalars.count, scalars[index] == "}" { index += 1 - return isExhausted(characters, from: index) ? [] : nil + return isExhausted(scalars, from: index) ? [] : nil } var elements: [PostgresArrayElement] = [] while true { - skipWhitespace(characters, &index) - guard index < characters.count, characters[index] != "{" else { return nil } - guard let element = parseElement(characters, &index, delimiter: delimiter) else { return nil } + skipWhitespace(scalars, &index) + guard index < scalars.count, scalars[index] != "{" else { return nil } + guard let element = parseElement(scalars, &index, delimiter: delimiter) else { return nil } elements.append(element) - skipWhitespace(characters, &index) - guard index < characters.count else { return nil } - if characters[index] == delimiter { + skipWhitespace(scalars, &index) + guard index < scalars.count else { return nil } + if scalars[index] == delimiter { index += 1 continue } - guard characters[index] == "}" else { return nil } + guard scalars[index] == "}" else { return nil } index += 1 break } - return isExhausted(characters, from: index) ? elements : nil + return isExhausted(scalars, from: index) ? elements : nil } public static func serialize( _ elements: [PostgresArrayElement], delimiter: Character = defaultDelimiter ) -> String { + let scalar = delimiter.unicodeScalars.first ?? "," let body = elements - .map { serializeElement($0, delimiter: delimiter) } - .joined(separator: String(delimiter)) + .map { serializeElement($0, delimiter: scalar) } + .joined(separator: String(Unicode.Scalar(scalar))) return "{\(body)}" } - private static func isExhausted(_ characters: [Character], from index: Int) -> Bool { + private static func text(_ scalars: some Sequence) -> String { + String(String.UnicodeScalarView(scalars)) + } + + private static func isExhausted(_ scalars: [Unicode.Scalar], from index: Int) -> Bool { var cursor = index - skipWhitespace(characters, &cursor) - return cursor == characters.count + skipWhitespace(scalars, &cursor) + return cursor == scalars.count } - private static func skipWhitespace(_ characters: [Character], _ index: inout Int) { - while index < characters.count, characters[index].isWhitespace { + private static func skipWhitespace(_ scalars: [Unicode.Scalar], _ index: inout Int) { + while index < scalars.count, separators.contains(scalars[index]) { index += 1 } } private static func parseElement( - _ characters: [Character], + _ scalars: [Unicode.Scalar], _ index: inout Int, - delimiter: Character + delimiter: Unicode.Scalar ) -> PostgresArrayElement? { - if characters[index] == "\"" { + if scalars[index] == "\"" { index += 1 - return parseQuotedElement(characters, &index) + return parseQuotedElement(scalars, &index) } - return parseUnquotedElement(characters, &index, delimiter: delimiter) + return parseUnquotedElement(scalars, &index, delimiter: delimiter) } private static func parseQuotedElement( - _ characters: [Character], + _ scalars: [Unicode.Scalar], _ index: inout Int ) -> PostgresArrayElement? { - var value: [Character] = [] - while index < characters.count { - let character = characters[index] - if character == "\\" { - guard index + 1 < characters.count else { return nil } - value.append(characters[index + 1]) + var value: [Unicode.Scalar] = [] + while index < scalars.count { + let scalar = scalars[index] + if scalar == "\\" { + guard index + 1 < scalars.count else { return nil } + value.append(scalars[index + 1]) index += 2 continue } - if character == "\"" { + if scalar == "\"" { index += 1 - return .value(String(value)) + return .value(text(value)) } - value.append(character) + value.append(scalar) index += 1 } return nil } private static func parseUnquotedElement( - _ characters: [Character], + _ scalars: [Unicode.Scalar], _ index: inout Int, - delimiter: Character + delimiter: Unicode.Scalar ) -> PostgresArrayElement? { - var value: [Character] = [] + var value: [Unicode.Scalar] = [] var significantCount = 0 var containsEscape = false var startedContent = false - while index < characters.count { - let character = characters[index] - if character == "\\" { - guard index + 1 < characters.count else { return nil } - value.append(characters[index + 1]) + while index < scalars.count { + let scalar = scalars[index] + if scalar == "\\" { + guard index + 1 < scalars.count else { return nil } + value.append(scalars[index + 1]) containsEscape = true startedContent = true index += 2 significantCount = value.count continue } - if character == delimiter || character == "}" { + if scalar == delimiter || scalar == "}" { break } - guard character != "{", character != "\"" else { return nil } - if !startedContent, character.isWhitespace { + guard scalar != "{", scalar != "\"" else { return nil } + if !startedContent, separators.contains(scalar) { index += 1 continue } startedContent = true - value.append(character) + value.append(scalar) index += 1 - if !character.isWhitespace { + if !separators.contains(scalar) { significantCount = value.count } } guard startedContent else { return nil } - let text = String(value.prefix(significantCount)) - if !containsEscape, isUnquotedNullKeyword(text) { + let parsed = text(value.prefix(significantCount)) + if !containsEscape, isUnquotedNullKeyword(parsed) { return .null } - return .value(text) + return .value(parsed) } - private static func isUnquotedNullKeyword(_ text: String) -> Bool { - text.count == 4 && text.lowercased() == "null" + private static func isUnquotedNullKeyword(_ value: String) -> Bool { + value.lowercased() == "null" } - private static func serializeElement(_ element: PostgresArrayElement, delimiter: Character) -> String { + private static func serializeElement(_ element: PostgresArrayElement, delimiter: Unicode.Scalar) -> String { switch element { case .null: return "NULL" case .value(let value): guard needsQuoting(value, delimiter: delimiter) else { return value } - var quoted: [Character] = ["\""] - for character in value { - if character == "\\" || character == "\"" { + var quoted: [Unicode.Scalar] = ["\""] + for scalar in value.unicodeScalars { + if scalar == "\\" || scalar == "\"" { quoted.append("\\") } - quoted.append(character) + quoted.append(scalar) } quoted.append("\"") - return String(quoted) + return text(quoted) } } - private static func needsQuoting(_ value: String, delimiter: Character) -> Bool { + private static func needsQuoting(_ value: String, delimiter: Unicode.Scalar) -> Bool { if value.isEmpty { return true } if isUnquotedNullKeyword(value) { return true } - return value.contains { character in - character == delimiter - || character == "\"" - || character == "\\" - || character == "{" - || character == "}" - || character.isWhitespace + return value.unicodeScalars.contains { scalar in + scalar == delimiter + || scalar == "\"" + || scalar == "\\" + || scalar == "{" + || scalar == "}" + || separators.contains(scalar) } } } diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index cbeb17f481..5a8523a98b 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -8,6 +8,16 @@ import Foundation +/// Which editor an array's elements get. +/// +/// A Bool could only say that an array has a per-element editor, never which one, and the two are +/// not the same control: a scalar element fits a one-line field, a JSON element is a document and +/// needs the JSON viewer. +enum ArrayElementEditor: Equatable, Sendable { + case scalar + case json +} + /// Represents the semantic type of a database column enum ColumnType: Equatable, Sendable { case text(rawType: String?) @@ -161,17 +171,26 @@ enum ColumnType: Equatable, Sendable { } } - /// Whether this array's elements can be edited one at a time - var supportsElementEditing: Bool { - guard let element = arrayElement else { return false } + /// The editor this array's elements get, or nil where they cannot be edited one at a time. + /// + /// A JSON element is included because PostgreSQL's array quoting round-trips it exactly: the + /// literal a `jsonb[]` cell carries parses to its elements and re-serializes byte for byte, + /// SQL NULL and JSON null included. Binary, spatial and nested arrays stay out. + var arrayElementEditor: ArrayElementEditor? { + guard let element = arrayElement else { return nil } switch element { case .text, .integer, .decimal, .date, .timestamp, .datetime, .boolean, .enumType, .set: - return true - case .json, .blob, .spatial, .array: - return false + return .scalar + case .json: + return .json + case .blob, .spatial, .array: + return nil } } + /// Whether this array's elements can be edited one at a time + var supportsElementEditing: Bool { arrayElementEditor != nil } + /// Compact lowercase badge label for sidebar var badgeLabel: String { switch self { @@ -192,6 +211,25 @@ enum ColumnType: Equatable, Sendable { } } + /// The same type with the labels the catalog declared, reaching inside an array to its element. + /// + /// The classifier cannot know them: a column's labels arrive separately in + /// `TableRows.columnEnumValues`. Injecting them on the scalar cases alone left an `ENUM[]` + /// column's element editor with no vocabulary, so it offered free-form text where the grid + /// offers the declared labels. + func withAllowedValues(_ values: [String]) -> ColumnType { + switch self { + case .enumType(let rawType, _): + return .enumType(rawType: rawType, values: values) + case .set(let rawType, _): + return .set(rawType: rawType, values: values) + case .array(let rawType, let element): + return .array(rawType: rawType, element: element.withAllowedValues(values)) + case .text, .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .json, .spatial: + return self + } + } + /// The allowed enum/set values, if known var enumValues: [String]? { switch self { diff --git a/TablePro/Models/UI/FieldEditorKind.swift b/TablePro/Models/UI/FieldEditorKind.swift index e747baf1bb..5cc8d58452 100644 --- a/TablePro/Models/UI/FieldEditorKind.swift +++ b/TablePro/Models/UI/FieldEditorKind.swift @@ -13,6 +13,9 @@ internal enum FieldEditorKind: Equatable { case boolean case enumPicker(values: [String]) case setPicker(values: [String]) + /// An ordered list of elements, each edited on its own. The payload says whether an element is + /// a one-line value or a JSON document, which are two different editors over the same list. + case arrayElements(element: ArrayElementEditor, values: [String]) case typePicker /// A value with suggestions the user may ignore: the menu writes correct SQL, typing writes /// your own. `enumPicker` cannot serve, because its list is the whole set of legal values. diff --git a/TablePro/Models/UI/InspectorFieldLayout.swift b/TablePro/Models/UI/InspectorFieldLayout.swift index 1eda0f5bbc..8f3dd97199 100644 --- a/TablePro/Models/UI/InspectorFieldLayout.swift +++ b/TablePro/Models/UI/InspectorFieldLayout.swift @@ -43,8 +43,8 @@ internal enum InspectorFieldLayout: Equatable { /// the other while the user is typing into it. private static func dataLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .schemaText, .typePicker, .valuePicker, - .multiLine, .json, .phpSerialized, .blobHex, .image: + case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .schemaText, .typePicker, + .valuePicker, .multiLine, .json, .phpSerialized, .blobHex, .image: return .stacked } } @@ -53,7 +53,8 @@ internal enum InspectorFieldLayout: Equatable { /// high. The editors that need the pane's width still take it. private static func schemaLayout(for kind: FieldEditorKind) -> InspectorFieldLayout { switch kind { - case .singleLine, .boolean, .enumPicker, .setPicker, .schemaText, .typePicker, .valuePicker: + case .singleLine, .boolean, .enumPicker, .setPicker, .arrayElements, .schemaText, .typePicker, + .valuePicker: return .inline case .multiLine, .json, .phpSerialized, .blobHex, .image: return .stacked diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 2125c5eb34..3c23fe0edc 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -181,12 +181,7 @@ extension MainContentView { var columnTypes = tableRows.columnTypes for (i, col) in tableRows.columns.enumerated() where i < columnTypes.count { if let values = tableRows.columnEnumValues[col], !values.isEmpty { - let ct = columnTypes[i] - if ct.isEnumType { - columnTypes[i] = .enumType(rawType: ct.rawType, values: values) - } else if ct.isSetType { - columnTypes[i] = .set(rawType: ct.rawType, values: values) - } + columnTypes[i] = columnTypes[i].withAllowedValues(values) } } diff --git a/TablePro/Views/Results/ArrayJsonElementEditor.swift b/TablePro/Views/Results/ArrayJsonElementEditor.swift new file mode 100644 index 0000000000..de495ca188 --- /dev/null +++ b/TablePro/Views/Results/ArrayJsonElementEditor.swift @@ -0,0 +1,170 @@ +// +// ArrayJsonElementEditor.swift +// TablePro +// +// Element list and JSON document pane for an array whose elements are JSON. +// + +import SwiftUI +import TableProPluginKit + +/// The editor a `jsonb[]` or `json[]` cell opens on. +/// +/// The elements are listed above and the selected one opens in the same `JSONViewerView` a scalar +/// JSON cell gets, so an element has the Text and Tree modes, the tree search and the invalid-JSON +/// reporting the rest of the app already has. The array stays a list of documents rather than +/// collapsing into one: `to_jsonb()` writes a SQL NULL element and a JSON null element both as +/// `null`, and that distinction is the point of editing the column in place. +internal struct ArrayJsonElementEditor: View { + @Binding internal var rows: [ArrayEditorRow] + @Binding internal var selection: UUID? + internal let isReadOnly: Bool + + private static let listHeight: CGFloat = 132 + + var body: some View { + VStack(spacing: 0) { + elementList + Divider() + detail + } + .onAppear(perform: selectFirstIfNeeded) + .onChange(of: rows.map(\.id), selectFirstIfNeeded) + } + + private var elementList: some View { + List(selection: $selection) { + /// `ForEach(rows.enumerated(), id:)` is the modern form and does not compile here: + /// `EnumeratedSequence`'s `RandomAccessCollection` conformance is macOS 26, and the + /// deployment target is 14. The copy is a constant factor on the identity walk `ForEach` + /// already does over every row. + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + elementRow(row, index: index) + } + } + .listStyle(.plain) + .frame(height: Self.listHeight) + .overlay { + if rows.isEmpty { + Text("Empty array") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + private func elementRow(_ row: ArrayEditorRow, index: Int) -> some View { + let display = ArrayValueEditorModel.jsonDisplay(of: row.element) + return HStack(spacing: 6) { + Text(verbatim: "\(index + 1)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .trailing) + if let summary = display.summary { + Text(summary) + .font(ThemeEngine.shared.valueFontSwiftUI) + .lineLimit(1) + .truncationMode(.tail) + } else { + Text("NULL") + .italic() + .foregroundStyle(.secondary) + } + Spacer(minLength: 4) + if !display.isValid { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityLabel(Text("Not valid JSON")) + .help(Text("This element is not valid JSON")) + } + } + } + + @ViewBuilder + private var detail: some View { + if let row = selectedRow { + VStack(spacing: 0) { + detailHeader(for: row) + Divider() + if row.element == .null { + nullPlaceholder + } else { + JSONViewerView(text: selectedJsonText, isEditable: !isReadOnly) + .id(row.id) + } + } + } else { + ContentUnavailableView { + Label(String(localized: "No Element Selected"), systemImage: "list.bullet.rectangle") + } description: { + Text("Select an element to read or edit its JSON.") + } + } + } + + private func detailHeader(for row: ArrayEditorRow) -> some View { + HStack(spacing: 8) { + Text(elementLabel(for: row)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Toggle("NULL", isOn: nullBinding) + .toggleStyle(.checkbox) + .font(.caption) + .disabled(isReadOnly) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private var nullPlaceholder: some View { + Text("NULL") + .italic() + .font(ThemeEngine.shared.valueFontSwiftUI) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func elementLabel(for row: ArrayEditorRow) -> String { + guard let index = rows.firstIndex(where: { $0.id == row.id }) else { return "" } + return String(format: String(localized: "Element %d"), index + 1) + } + + private var selectedRow: ArrayEditorRow? { + guard let selection else { return nil } + return rows.first { $0.id == selection } + } + + /// A NULL element keeps no text of its own, so turning NULL off starts from an empty JSON + /// object rather than from an empty string, which is not a JSON document. + private var nullBinding: Binding { + Binding( + get: { selectedRow?.element == .null }, + set: { isNull in + mutateSelected { $0.element = isNull ? .null : .value("{}") } + } + ) + } + + private var selectedJsonText: Binding { + Binding( + get: { + guard let row = selectedRow, case .value(let value) = row.element else { return "" } + return value + }, + set: { newValue in + mutateSelected { $0.element = .value(newValue) } + } + ) + } + + private func mutateSelected(_ transform: (inout ArrayEditorRow) -> Void) { + guard let selection, let index = rows.firstIndex(where: { $0.id == selection }) else { return } + transform(&rows[index]) + } + + private func selectFirstIfNeeded() { + if let selection, rows.contains(where: { $0.id == selection }) { return } + selection = rows.first?.id + } +} diff --git a/TablePro/Views/Results/ArrayValueEditorModel.swift b/TablePro/Views/Results/ArrayValueEditorModel.swift index 4da4052c4b..257ca3db38 100644 --- a/TablePro/Views/Results/ArrayValueEditorModel.swift +++ b/TablePro/Views/Results/ArrayValueEditorModel.swift @@ -10,19 +10,90 @@ struct ArrayEditorRow: Identifiable, Equatable { let id: UUID var element: PostgresArrayElement - init(id: UUID = UUID(), element: PostgresArrayElement) { + /// What the server sent for this element, kept beside the edited copy. + /// + /// A JSON element is shown pretty-printed, and PostgreSQL stores `json` text verbatim, so + /// writing the displayed form back would rewrite every element the user never touched. A row + /// that still means the same JSON emits this instead. Nil on a row the user added. + let originalElement: PostgresArrayElement? + + init(id: UUID = UUID(), element: PostgresArrayElement, originalElement: PostgresArrayElement? = nil) { self.id = id self.element = element + self.originalElement = originalElement } } enum ArrayValueEditorModel { static func rows(from elements: [PostgresArrayElement]) -> [ArrayEditorRow] { - elements.map { ArrayEditorRow(element: $0) } + elements.map { ArrayEditorRow(element: $0, originalElement: $0) } + } + + static func literal( + from rows: [ArrayEditorRow], + delimiter: Character, + elementEditor: ArrayElementEditor = .scalar + ) -> String { + let elements = rows.map { committedElement($0, elementEditor: elementEditor) } + return PostgresArrayLiteralCodec.serialize(elements, delimiter: delimiter) + } + + /// The element a row writes back. + /// + /// An edited JSON element commits compact, which is what the scalar JSON editor does, and an + /// unedited one commits the bytes the server sent. The rule is confined to JSON elements + /// because a scalar array can hold text that happens to parse as JSON, and there retyping the + /// whitespace inside it is a real edit. + static func committedElement( + _ row: ArrayEditorRow, + elementEditor: ArrayElementEditor + ) -> PostgresArrayElement { + guard elementEditor == .json, case .value(let edited) = row.element else { return row.element } + let normalized = JsonReindenter.normalize(edited) + if case .value(let stored) = row.originalElement, normalized == JsonReindenter.normalize(stored) { + return .value(stored) + } + return .value(normalized) + } + + /// What a JSON element shows in the element list. + /// + /// A nil summary is SQL NULL, which the list renders as its own state rather than as text that + /// could be confused with `"null"`. + struct JsonElementDisplay: Equatable { + let summary: String? + let isValid: Bool + } + + /// Past this, an element is previewed from its first characters and never parsed. + /// + /// The list rebuilds on every keystroke in the detail editor, and a parse walks the whole + /// document, so parsing each visible row's element per render is unbounded work on the main + /// actor. A preview needs a couple of hundred characters, so the cost is capped at the prefix + /// rather than at the value. An element too large to check reports no verdict rather than a + /// wrong one; the detail editor still reports invalid JSON authoritatively. + private static let maxInspectableLength = 4_096 + + static func jsonDisplay(of element: PostgresArrayElement, limit: Int = 200) -> JsonElementDisplay { + guard case .value(let value) = element else { + return JsonElementDisplay(summary: nil, isValid: true) + } + let text = value as NSString + guard text.length <= maxInspectableLength else { + return JsonElementDisplay(summary: truncated(text, limit: limit), isValid: true) + } + guard JsonSyntaxParser.parse(value) != nil else { + return JsonElementDisplay(summary: truncated(text, limit: limit), isValid: false) + } + return JsonElementDisplay( + summary: truncated(JsonReindenter.normalize(value) as NSString, limit: limit), + isValid: true + ) } - static func literal(from rows: [ArrayEditorRow], delimiter: Character) -> String { - PostgresArrayLiteralCodec.serialize(rows.map(\.element), delimiter: delimiter) + private static func truncated(_ text: NSString, limit: Int) -> String { + guard text.length > limit else { return text as String } + return text.substring(to: limit) + "…" } static func pickerOptions(for element: PostgresArrayElement, allowedValues: [String]) -> [String] { diff --git a/TablePro/Views/Results/ArrayValueEditorView.swift b/TablePro/Views/Results/ArrayValueEditorView.swift index c720bcff49..5ad92ea87d 100644 --- a/TablePro/Views/Results/ArrayValueEditorView.swift +++ b/TablePro/Views/Results/ArrayValueEditorView.swift @@ -12,6 +12,8 @@ struct ArrayValueEditorView: View { let allowedValues: [String] let isNullable: Bool let delimiter: Character + let elementEditor: ArrayElementEditor + let isReadOnly: Bool let onCommit: (String?) -> Void let onDismiss: () -> Void @@ -19,50 +21,73 @@ struct ArrayValueEditorView: View { @State private var isNull: Bool @State private var isEditingRawText: Bool @State private var rawText: String + @State private var selection: UUID? + /// Takes the stored literal rather than parsed elements, so the editor is total over what a + /// column can hold. A literal the list cannot represent, a multi-dimensional one or one + /// carrying explicit bounds, opens in raw text mode over its own text instead of silently + /// becoming an empty array that the next OK would write over the user's value. init( - initialElements: [PostgresArrayElement]?, + literal: String?, allowedValues: [String], isNullable: Bool, delimiter: Character = PostgresArrayLiteralCodec.defaultDelimiter, + elementEditor: ArrayElementEditor = .scalar, + isReadOnly: Bool = false, onCommit: @escaping (String?) -> Void, onDismiss: @escaping () -> Void ) { self.allowedValues = allowedValues self.isNullable = isNullable self.delimiter = delimiter + self.elementEditor = elementEditor + self.isReadOnly = isReadOnly self.onCommit = onCommit self.onDismiss = onDismiss - let elements = initialElements ?? [] - _rows = State(initialValue: ArrayValueEditorModel.rows(from: elements)) - _isNull = State(initialValue: initialElements == nil) - _isEditingRawText = State(initialValue: false) - _rawText = State(initialValue: PostgresArrayLiteralCodec.serialize(elements, delimiter: delimiter)) + + let stored = literal ?? "" + let parsed = stored.isEmpty ? [] : PostgresArrayLiteralCodec.parse(stored, delimiter: delimiter) + _rows = State(initialValue: ArrayValueEditorModel.rows(from: parsed ?? [])) + _isNull = State(initialValue: literal == nil) + _isEditingRawText = State(initialValue: parsed == nil) + _rawText = State( + initialValue: parsed.map { PostgresArrayLiteralCodec.serialize($0, delimiter: delimiter) } ?? stored + ) + _selection = State(initialValue: nil) } + private var isJsonEditor: Bool { elementEditor == .json } + var body: some View { VStack(spacing: 0) { header Divider() if isEditingRawText { rawTextEditor + } else if isJsonEditor { + jsonElementEditor } else { elementList } Divider() footer } - .frame(width: 320) - .frame(maxHeight: 420) - .fixedSize(horizontal: false, vertical: true) + .modifier(ArrayEditorSizing(isJsonEditor: isJsonEditor)) .onExitCommand(perform: onDismiss) } + private var jsonElementEditor: some View { + ArrayJsonElementEditor(rows: $rows, selection: $selection, isReadOnly: isReadOnly) + .disabled(isNull) + .opacity(isNull ? 0.4 : 1) + } + private var header: some View { HStack(spacing: 8) { if isNullable { Toggle("NULL", isOn: $isNull) .toggleStyle(.checkbox) + .disabled(isReadOnly) } Text(elementCountLabel) .font(.caption) @@ -93,7 +118,7 @@ struct ArrayValueEditorView: View { } .padding(12) } - .disabled(isNull) + .disabled(isNull || isReadOnly) .opacity(isNull ? 0.4 : 1) } @@ -106,11 +131,10 @@ struct ArrayValueEditorView: View { labelPicker(for: row) } reorderButtons(for: row) - Button { + Button("Remove Element", systemImage: "minus.circle") { rows = ArrayValueEditorModel.removing(rows, id: row.id) - } label: { - Image(systemName: "minus.circle") } + .labelStyle(.iconOnly) .buttonStyle(.borderless) .help(Text("Remove Element")) } @@ -169,20 +193,18 @@ struct ArrayValueEditorView: View { private func reorderButtons(for row: ArrayEditorRow) -> some View { HStack(spacing: 2) { - Button { + Button("Move Up", systemImage: "chevron.up") { rows = ArrayValueEditorModel.moved(rows, id: row.id, by: -1) - } label: { - Image(systemName: "chevron.up") } + .labelStyle(.iconOnly) .buttonStyle(.borderless) .disabled(rows.first?.id == row.id) .help(Text("Move Up")) - Button { + Button("Move Down", systemImage: "chevron.down") { rows = ArrayValueEditorModel.moved(rows, id: row.id, by: 1) - } label: { - Image(systemName: "chevron.down") } + .labelStyle(.iconOnly) .buttonStyle(.borderless) .disabled(rows.last?.id == row.id) .help(Text("Move Down")) @@ -215,33 +237,83 @@ struct ArrayValueEditorView: View { } } .padding(12) - .disabled(isNull) + .disabled(isNull || isReadOnly) .opacity(isNull ? 0.4 : 1) } private var footer: some View { - HStack { + HStack(spacing: 2) { if !isEditingRawText { - Button { - rows.append(ArrayEditorRow(element: defaultNewElement)) - } label: { - Image(systemName: "plus") + Button("Add Element", systemImage: "plus", action: addElement) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(isNull || isReadOnly) + .help(Text("Add Element")) + if isJsonEditor { + selectionControls } - .buttonStyle(.borderless) - .disabled(isNull) - .help(Text("Add Element")) } Spacer() Button("Cancel") { onDismiss() } .keyboardShortcut(.cancelAction) Button("OK") { commitAndDismiss() } .keyboardShortcut(.defaultAction) + .disabled(isReadOnly) } .padding(.horizontal, 12) .padding(.vertical, 8) } + /// The JSON element list is a master list, so its add, remove and reorder act on the selection + /// from one place rather than repeating four controls on every row the way a compact list of + /// one-line fields can afford to. + private var selectionControls: some View { + Group { + Button("Remove Element", systemImage: "minus", action: removeSelected) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(isNull || isReadOnly || selection == nil) + .help(Text("Remove Element")) + + Button("Move Up", systemImage: "chevron.up") { moveSelected(by: -1) } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(isNull || isReadOnly || selectedIndex == nil || selectedIndex == 0) + .help(Text("Move Up")) + + Button("Move Down", systemImage: "chevron.down") { moveSelected(by: 1) } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .disabled(isNull || isReadOnly || selectedIndex == nil || selectedIndex == rows.count - 1) + .help(Text("Move Down")) + } + } + + private var selectedIndex: Int? { + guard let selection else { return nil } + return rows.firstIndex { $0.id == selection } + } + + private func addElement() { + let row = ArrayEditorRow(element: defaultNewElement) + rows.append(row) + if isJsonEditor { selection = row.id } + } + + private func removeSelected() { + guard let selection else { return } + let index = selectedIndex + rows = ArrayValueEditorModel.removing(rows, id: selection) + self.selection = index.map { min($0, rows.count - 1) }.flatMap { $0 >= 0 ? rows[$0].id : nil } + } + + private func moveSelected(by offset: Int) { + guard let selection else { return } + rows = ArrayValueEditorModel.moved(rows, id: selection, by: offset) + } + private var defaultNewElement: PostgresArrayElement { + if isJsonEditor { return .value("{}") } guard let first = allowedValues.first else { return .value("") } return .value(first) } @@ -259,10 +331,11 @@ struct ArrayValueEditorView: View { if isEditingRawText { guard let parsed = PostgresArrayLiteralCodec.parse(rawText, delimiter: delimiter) else { return } rows = ArrayValueEditorModel.rows(from: parsed) + selection = rows.first?.id isEditingRawText = false return } - rawText = ArrayValueEditorModel.literal(from: rows, delimiter: delimiter) + rawText = committedLiteral isEditingRawText = true } @@ -272,10 +345,32 @@ struct ArrayValueEditorView: View { onDismiss() return } - let value = isEditingRawText - ? rawText - : ArrayValueEditorModel.literal(from: rows, delimiter: delimiter) - onCommit(value) + onCommit(isEditingRawText ? rawText : committedLiteral) onDismiss() } + + private var committedLiteral: String { + ArrayValueEditorModel.literal(from: rows, delimiter: delimiter, elementEditor: elementEditor) + } +} + +/// A list of one-line fields can size itself to its rows; a list with a document editor under it +/// cannot, because the editor has no height of its own to offer. The JSON editor therefore takes a +/// definite range, the way the JSON cell popover already does, and only the scalar list sizes to +/// fit. +private struct ArrayEditorSizing: ViewModifier { + let isJsonEditor: Bool + + func body(content: Content) -> some View { + if isJsonEditor { + content + .frame(width: 620) + .frame(minHeight: 420, maxHeight: 560) + } else { + content + .frame(width: 320) + .frame(maxHeight: 420) + .fixedSize(horizontal: false, vertical: true) + } + } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 2b1a3bed21..8c3654f0ca 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -367,23 +367,28 @@ extension TableViewCoordinator { func showArrayEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard presentsCell(row: row, tableColumnIndex: column) else { return } let tableRows = tableRowsProvider() - guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + guard columnIndex >= 0, + columnIndex < tableRows.columns.count, + columnIndex < tableRows.columnTypes.count + else { return } let columnName = tableRows.columns[columnIndex] let typedValue = cellTypedValue(at: row, column: columnIndex) - let elements: [PostgresArrayElement]? + let literal: String? if typedValue.isNull { - elements = nil + literal = nil } else { - guard let parsed = PostgresArrayLiteralCodec.parse(typedValue.asText ?? "") else { + let stored = typedValue.asText ?? "" + guard PostgresArrayLiteralCodec.parse(stored) != nil else { beginCellEdit(row: row, tableColumnIndex: column) return } - elements = parsed + literal = stored } let allowedValues = tableRows.columnEnumValues[columnName] ?? [] let isNullable = tableRows.columnNullable[columnName] ?? true + let elementEditor = tableRows.columnTypes[columnIndex].arrayElementEditor ?? .scalar let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) dismissActiveCellEditorPopover() @@ -393,9 +398,10 @@ extension TableViewCoordinator { behavior: .applicationDefined ) { [weak self] dismiss in ArrayValueEditorView( - initialElements: elements, + literal: literal, allowedValues: allowedValues, isNullable: isNullable, + elementEditor: elementEditor, onCommit: { newValue in self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) }, diff --git a/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift b/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift new file mode 100644 index 0000000000..e13378b640 --- /dev/null +++ b/TablePro/Views/RowInspector/FieldEditors/ArrayFieldEditorView.swift @@ -0,0 +1,84 @@ +// +// ArrayFieldEditorView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +/// The row inspector's editor for an array column. +/// +/// It follows `SetPickerView`: a value built structurally cannot be typed into the field, so the +/// row shows the stored literal and opens the real editor in a popover that commits once. Writing +/// through on every keystroke would rebuild the literal from a half-finished element, and NULL and +/// DEFAULT would have nowhere to live, since the field's binding carries a `String` and neither is +/// one. +internal struct ArrayFieldEditorView: View { + internal let context: FieldEditorContext + internal let elementEditor: ArrayElementEditor + internal let allowedValues: [String] + internal var onSetNull: (() -> Void)? + internal var onSetDefault: (() -> Void)? + + @State private var isEditorPresented = false + + var body: some View { + Menu { + Button { + isEditorPresented = true + } label: { + Text(context.isReadOnly ? "View Elements…" : "Edit Elements…") + } + if context.canMutate, onSetNull != nil || onSetDefault != nil { + Divider() + if let onSetNull { + Button("Set NULL", action: onSetNull) + } + if let onSetDefault { + Button("Set DEFAULT", action: onSetDefault) + } + } + } label: { + Text(displayLabel) + .font(ThemeEngine.shared.valueFontSwiftUI) + .foregroundStyle(context.valueState.placeholder == nil ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .padding(.horizontal, 4) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + .background(.quinary, in: RoundedRectangle(cornerRadius: 5)) + .popover(isPresented: $isEditorPresented) { + ArrayValueEditorView( + literal: initialLiteral, + allowedValues: allowedValues, + isNullable: false, + elementEditor: elementEditor, + isReadOnly: context.isReadOnly, + onCommit: { context.value.wrappedValue = $0 ?? "" }, + onDismiss: { isEditorPresented = false } + ) + } + } + + /// Follows `valueState`, so a NULL column says NULL, a multi-row selection that disagrees says + /// so, and a literal the user has just committed shows what they built. + private var displayLabel: String { + if let placeholder = context.valueState.placeholder { return placeholder } + let text = context.valueState.editableText + return text.isEmpty ? String(localized: "Empty array") : text + } + + /// The field resolves to this editor only for a literal the list can read, but the resolved + /// kind is cached on the field, so an **Edit as Text** commit of a bounds-prefixed or + /// multi-dimensional literal reaches the popover again. The editor takes the literal and opens + /// its raw text mode over it, rather than showing an empty list that the next OK would write + /// over the user's value. + private var initialLiteral: String { + let text = context.valueState.editableText + return text.isEmpty ? PostgresArrayLiteralCodec.serialize([]) : text + } +} diff --git a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift index affe056c61..df5707a092 100644 --- a/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift +++ b/TablePro/Views/RowInspector/FieldEditors/FieldEditorContent.swift @@ -32,7 +32,7 @@ internal struct FieldEditorContent: View { /// take away the control that changes it back. private var isPicker: Bool { switch kind { - case .boolean, .enumPicker, .setPicker, .typePicker, .valuePicker: return true + case .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker: return true case .json, .phpSerialized, .image, .blobHex, .schemaText, .multiLine, .singleLine: return false } } @@ -44,8 +44,8 @@ internal struct FieldEditorContent: View { guard !state.isPending else { return false } switch kind { case .json, .phpSerialized, .multiLine: return true - case .image, .blobHex, .boolean, .enumPicker, .setPicker, .typePicker, .valuePicker, - .schemaText, .singleLine: + case .image, .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, + .valuePicker, .schemaText, .singleLine: return false } } @@ -58,7 +58,8 @@ internal struct FieldEditorContent: View { case .image: return 200 case .blobHex: return 60 case .multiLine: return ResizableFieldMetrics.defaultTextHeight - case .boolean, .enumPicker, .setPicker, .typePicker, .valuePicker, .schemaText, .singleLine: + case .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker, + .schemaText, .singleLine: return nil } } @@ -80,6 +81,14 @@ internal struct FieldEditorContent: View { EnumPickerView(context: context, values: values, onSetNull: onSetNull, onSetDefault: onSetDefault) case .setPicker(let values): SetPickerView(context: context, values: values, onSetNull: onSetNull, onSetDefault: onSetDefault) + case .arrayElements(let element, let values): + ArrayFieldEditorView( + context: context, + elementEditor: element, + allowedValues: values, + onSetNull: onSetNull, + onSetDefault: onSetDefault + ) case .typePicker: TypePickerFieldView(context: context, databaseType: databaseType) case .valuePicker(let options): diff --git a/TablePro/Views/RowInspector/InspectorFieldRow.swift b/TablePro/Views/RowInspector/InspectorFieldRow.swift index 3829b1b2c3..0d59388096 100644 --- a/TablePro/Views/RowInspector/InspectorFieldRow.swift +++ b/TablePro/Views/RowInspector/InspectorFieldRow.swift @@ -218,8 +218,8 @@ internal struct InspectorFieldRow: View { switch kind { case .json, .phpSerialized, .image: return nil - case .blobHex, .boolean, .enumPicker, .setPicker, .typePicker, .valuePicker, .schemaText, - .multiLine, .singleLine: + case .blobHex, .boolean, .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker, + .schemaText, .multiLine, .singleLine: return ThemeEngine.shared.valueFontSwiftUI } } diff --git a/TablePro/Views/RowInspector/RowInspectorView.swift b/TablePro/Views/RowInspector/RowInspectorView.swift index f0b24f0d64..9c4bb1ae45 100644 --- a/TablePro/Views/RowInspector/RowInspectorView.swift +++ b/TablePro/Views/RowInspector/RowInspectorView.swift @@ -125,7 +125,7 @@ internal struct RowInspectorView: View { case .phpSerialized: PhpViewerWindowController.open(text: text, columnName: field.columnName) case .multiLine, .singleLine, .schemaText, .blobHex, .image, .boolean, - .enumPicker, .setPicker, .typePicker, .valuePicker: + .enumPicker, .setPicker, .arrayElements, .typePicker, .valuePicker: TextViewerWindowController.open( text: text, columnName: field.columnName, diff --git a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift index 397b755732..47ea0707bb 100644 --- a/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift +++ b/TablePro/Views/Shared/FieldEditors/FieldEditorResolver.swift @@ -3,6 +3,7 @@ // TablePro import Foundation +import TableProPluginKit @MainActor internal enum FieldEditorResolver { @@ -50,6 +51,9 @@ internal enum FieldEditorResolver { } if structuredAllowed { + if let elementEditor = arrayElementEditor(for: type, originalValue: originalValue) { + return .arrayElements(element: elementEditor, values: type.enumValues ?? []) + } if type.isJsonType || (originalValue ?? "").looksLikeJson { return .json } @@ -80,6 +84,27 @@ internal enum FieldEditorResolver { return .singleLine } + /// The element editor an array column's value opens on, or nil where the list cannot represent + /// it and the plain text editor stays. + /// + /// The value is parsed, not the type alone: `jsonb[]` and `jsonb[][]` are one type in + /// PostgreSQL's catalog, and a dimension-prefixed literal such as `[0:2]={a,b,c}` is legal in + /// any array column, so the declared type cannot rule either out on a given row. This is the + /// same gate the grid applies before opening the popover. + /// + /// It is also what scopes the editor to the engines whose arrays are written this way. The + /// classifier's `[]` rule takes no engine, and a document store's `object[]` classifies the + /// same, so a field with no literal to read keeps the plain editor rather than being offered a + /// list that commits PostgreSQL `{…}` syntax. That leaves a stored NULL and a multi-row + /// selection on the plain editor, which is where they already were. + private static func arrayElementEditor(for type: ColumnType, originalValue: String?) -> ArrayElementEditor? { + guard let elementEditor = type.arrayElementEditor, + let originalValue, + PostgresArrayLiteralCodec.parse(originalValue) != nil + else { return nil } + return elementEditor + } + /// `isLongText` only matches six exact type names, so a large value in `VARCHAR(MAX)`, /// `NCLOB` or ClickHouse's `Nullable(String)` never reached the multi-line editor. Whether a /// value belongs on one line is a property of the value, so ask the value as well. diff --git a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift index 4249b539f5..091db4f238 100644 --- a/TableProTests/Core/Services/ColumnTypeClassifierTests.swift +++ b/TableProTests/Core/Services/ColumnTypeClassifierTests.swift @@ -996,16 +996,38 @@ struct ColumnTypeClassifierTests { #expect(classifier.classify(rawTypeName: "text[]").rawType == "text[]") } - @Test("Element editing is offered for scalar elements only") + @Test("Element editing is offered for scalar and JSON elements") func gatesElementEditing() { #expect(classifier.classify(rawTypeName: "ENUM[]").supportsElementEditing) #expect(classifier.classify(rawTypeName: "text[]").supportsElementEditing) #expect(classifier.classify(rawTypeName: "integer[]").supportsElementEditing) - #expect(!classifier.classify(rawTypeName: "jsonb[]").supportsElementEditing) + #expect(classifier.classify(rawTypeName: "jsonb[]").supportsElementEditing) #expect(!classifier.classify(rawTypeName: "bytea[]").supportsElementEditing) #expect(!classifier.classify(rawTypeName: "text").supportsElementEditing) } + @Test("The element type picks which editor its elements get") + func namesElementEditor() { + #expect(classifier.classify(rawTypeName: "jsonb[]").arrayElementEditor == .json) + #expect(classifier.classify(rawTypeName: "json[]").arrayElementEditor == .json) + #expect(classifier.classify(rawTypeName: "text[]").arrayElementEditor == .scalar) + #expect(classifier.classify(rawTypeName: "integer[]").arrayElementEditor == .scalar) + #expect(classifier.classify(rawTypeName: "ENUM[]").arrayElementEditor == .scalar) + #expect(classifier.classify(rawTypeName: "bytea[]").arrayElementEditor == nil) + #expect(classifier.classify(rawTypeName: "geometry[]").arrayElementEditor == nil) + #expect(classifier.classify(rawTypeName: "jsonb").arrayElementEditor == nil) + } + + /// The badge vocabulary is semantic rather than the SQL spelling, so `jsonb[]` badges as + /// `json[]` exactly as scalar `jsonb` badges as `json`. #2897 read that as a lost `b`. + @Test("A jsonb array keeps its raw type name behind the semantic badge") + func keepsRawTypeBehindBadge() { + let type = classifier.classify(rawTypeName: "jsonb[]") + #expect(type.rawType == "jsonb[]") + #expect(type.badgeLabel == "json[]") + #expect(classifier.classify(rawTypeName: "jsonb").badgeLabel == "json") + } + @Test("Array badges and display names name the element") func describesElement() { #expect(classifier.classify(rawTypeName: "text[]").badgeLabel == "string[]") diff --git a/TableProTests/Models/InspectorFieldLayoutTests.swift b/TableProTests/Models/InspectorFieldLayoutTests.swift index bba2fcbb7b..cea9ff9b2d 100644 --- a/TableProTests/Models/InspectorFieldLayoutTests.swift +++ b/TableProTests/Models/InspectorFieldLayoutTests.swift @@ -17,6 +17,9 @@ struct InspectorFieldLayoutTests { .typePicker, .enumPicker(values: ["a", "b"]), .setPicker(values: ["a", "b"]), + .arrayElements(element: .scalar, values: []), + .arrayElements(element: .json, values: []), + .valuePicker(options: []), .multiLine, .json, .phpSerialized, @@ -47,6 +50,10 @@ struct InspectorFieldLayoutTests { #expect(InspectorFieldLayout.resolve(for: .typePicker, isSchemaField: true) == .inline) #expect(InspectorFieldLayout.resolve(for: .enumPicker(values: ["a"]), isSchemaField: true) == .inline) #expect(InspectorFieldLayout.resolve(for: .setPicker(values: ["a"]), isSchemaField: true) == .inline) + #expect( + InspectorFieldLayout.resolve(for: .arrayElements(element: .json, values: []), isSchemaField: true) + == .inline + ) } /// The editors that need the pane's width still take it, on a schema row as on a data row. diff --git a/TableProTests/Plugins/PostgresArrayLiteralCodecTests.swift b/TableProTests/Plugins/PostgresArrayLiteralCodecTests.swift index fe72357607..d20b3d9271 100644 --- a/TableProTests/Plugins/PostgresArrayLiteralCodecTests.swift +++ b/TableProTests/Plugins/PostgresArrayLiteralCodecTests.swift @@ -120,4 +120,81 @@ struct PostgresArrayLiteralCodecTests { #expect(PostgresArrayLiteralCodec.serialize([.value("a,b")], delimiter: ";") == "{a,b}") #expect(PostgresArrayLiteralCodec.serialize([.value("a,b")]) == #"{"a,b"}"#) } + + /// Every literal below came off a live PostgreSQL 17.11 for the `jsonb[]` shapes in #2897. A + /// JSON element carries a second layer of escaping, which is why the element editor excluded + /// these columns; the array quoting round-trips it exactly, which is why it no longer does. + @Test("Round-trips the jsonb[] literals PostgreSQL emits, byte for byte") + func roundTripsJsonbArrayLiterals() throws { + let literals = [ + #"{"{\"id\": 1, \"name\": \"example\", \"metadata\": {\"tags\": [\"one\", \"two\"], \"enabled\": true}}","{\"id\": 2, \"name\": \"another\"}"}"#, + #"{1,true,"\"hello\"","\"has \\\"quote\\\"\"","\"a,b\"","[1, 2]","{}",[]}"#, + #"{"{\"p\": \"back\\\\slash\"}","{\"p\": \"tab\\there\"}"}"#, + #"{"\"{a,b}\""}"#, + #"{"{\"unicode\": \"café ❤\"}"}"#, + #"{"{\"nested\": {\"deep\": [1, {\"x\": null}]}}",NULL,[]}"#, + #"{"\" lead and trail \""}"#, + #"{"\"\""}"#, + #"{"{\"newline\": \"a\\nb\"}"}"#, + "{}" + ] + for literal in literals { + let parsed = try #require(PostgresArrayLiteralCodec.parse(literal), Comment(rawValue: literal)) + #expect(PostgresArrayLiteralCodec.serialize(parsed) == literal, Comment(rawValue: literal)) + } + } + + /// PostgreSQL quotes an element whose text would read back as the NULL keyword, so a JSON null + /// arrives as `"null"` and only a bare `NULL` is a SQL NULL. Collapsing the two is what + /// `to_jsonb()` does, and why it cannot stand in for editing the column. + @Test("A SQL NULL element and a JSON null element stay distinct") + func separatesSqlNullFromJsonNull() throws { + let literal = #"{NULL,"null","\"null\"","\"NULL\""}"# + let parsed = try #require(PostgresArrayLiteralCodec.parse(literal)) + #expect(parsed == [.null, .value("null"), .value("\"null\""), .value("\"NULL\"")]) + #expect(PostgresArrayLiteralCodec.serialize(parsed) == literal) + } + + /// Measured on PostgreSQL 17.11: `array_in` trims these six around an unquoted element and + /// `array_out` quotes an element containing any of them. + @Test("The six characters PostgreSQL treats as whitespace are trimmed") + func trimsPostgresWhitespace() { + for separator in [" ", "\t", "\n", "\r", "\u{0B}", "\u{0C}"] { + let literal = "{\(separator)abc\(separator),def}" + #expect( + PostgresArrayLiteralCodec.parse(literal) == [.value("abc"), .value("def")], + Comment(rawValue: literal.debugDescription) + ) + } + } + + /// The codec scans Unicode scalars because the server does. A Swift grapheme can carry a + /// structural scalar and a combining mark together, and every one of these cases is a + /// `Character` that compares equal to nothing the parser is looking for. + @Test("Scanning follows scalars, not graphemes") + func scansScalarsRatherThanGraphemes() { + let crlf = [PostgresArrayElement.value("\r\nabc")] + #expect(PostgresArrayLiteralCodec.serialize(crlf) == "{\"\r\nabc\"}") + #expect(PostgresArrayLiteralCodec.parse("{\r\nabc,def}") == [.value("abc"), .value("def")]) + + let combiningAfterSpace = [PostgresArrayElement.value(" \u{0301}abc")] + #expect(PostgresArrayLiteralCodec.serialize(combiningAfterSpace) == "{\" \u{0301}abc\"}") + + #expect(PostgresArrayLiteralCodec.parse("{a,\u{0301}b}") == [.value("a"), .value("\u{0301}b")]) + #expect(PostgresArrayLiteralCodec.serialize([.value("a,\u{0301}b")]) == #"{"a,\#u{0301}b"}"#) + } + + /// Measured on the same server: `array_out` writes U+00A0 and U+3000 unquoted and `array_in` + /// reads them back as part of the value. Trimming them here deleted a character from an element + /// the user had not touched, because committing the editor re-serializes every row. + @Test("Unicode whitespace PostgreSQL keeps is neither trimmed nor quoted") + func keepsUnicodeWhitespace() { + let nonBreaking = [PostgresArrayElement.value("\u{00A0}abc"), .value("def")] + #expect(PostgresArrayLiteralCodec.parse("{\u{00A0}abc,def}") == nonBreaking) + #expect(PostgresArrayLiteralCodec.serialize(nonBreaking) == "{\u{00A0}abc,def}") + + let ideographic = [PostgresArrayElement.value("abc\u{3000}"), .value("def")] + #expect(PostgresArrayLiteralCodec.parse("{abc\u{3000},def}") == ideographic) + #expect(PostgresArrayLiteralCodec.serialize(ideographic) == "{abc\u{3000},def}") + } } diff --git a/TableProTests/Views/ArrayValueEditorModelTests.swift b/TableProTests/Views/ArrayValueEditorModelTests.swift index 53458e9f30..5aedbd6df3 100644 --- a/TableProTests/Views/ArrayValueEditorModelTests.swift +++ b/TableProTests/Views/ArrayValueEditorModelTests.swift @@ -86,4 +86,78 @@ struct ArrayValueEditorModelTests { func buildsEmptyArrayLiteral() { #expect(ArrayValueEditorModel.literal(from: [], delimiter: ",") == "{}") } + + private let storedDocument = #"{"id": 1, "name": "example"}"# + + /// PostgreSQL stores `json` text verbatim, so a cell the user only looked at must write back + /// the bytes the server sent. The editor shows every element pretty-printed, and without this + /// pressing OK would rewrite all of them. + @Test("An element the user only read commits the bytes the server sent") + func keepsUntouchedJsonElementByteIdentical() { + var rows = ArrayValueEditorModel.rows(from: [.value(storedDocument), .value(#"{"id": 2}"#)]) + rows[0].element = .value("{\n \"id\": 1,\n \"name\": \"example\"\n}") + + let literal = ArrayValueEditorModel.literal(from: rows, delimiter: ",", elementEditor: .json) + #expect(PostgresArrayLiteralCodec.parse(literal) == [.value(storedDocument), .value(#"{"id": 2}"#)]) + } + + @Test("An element the user changed commits compact, the way the JSON cell editor does") + func commitsEditedJsonElementCompact() { + var rows = ArrayValueEditorModel.rows(from: [.value(storedDocument)]) + rows[0].element = .value("{\n \"id\": 2,\n \"name\": \"example\"\n}") + + let literal = ArrayValueEditorModel.literal(from: rows, delimiter: ",", elementEditor: .json) + #expect(PostgresArrayLiteralCodec.parse(literal) == [.value(#"{"id":2,"name":"example"}"#)]) + } + + @Test("A row the user added carries no original, so it commits what was typed") + func commitsAddedJsonElementAsTyped() { + let rows = [ArrayEditorRow(element: .value(#"{"id": 3}"#))] + let literal = ArrayValueEditorModel.literal(from: rows, delimiter: ",", elementEditor: .json) + #expect(PostgresArrayLiteralCodec.parse(literal) == [.value(#"{"id":3}"#)]) + } + + /// A scalar array can hold text that happens to parse as JSON, and there retyping the + /// whitespace inside it is a real edit rather than the same document. + @Test("The rule is confined to JSON elements") + func leavesScalarElementsAlone() { + var rows = ArrayValueEditorModel.rows(from: [.value(storedDocument)]) + rows[0].element = .value(#"{"id":1,"name":"example"}"#) + + let literal = ArrayValueEditorModel.literal(from: rows, delimiter: ",", elementEditor: .scalar) + #expect(PostgresArrayLiteralCodec.parse(literal) == [.value(#"{"id":1,"name":"example"}"#)]) + } + + @Test("A SQL NULL element stays SQL NULL and a JSON null element stays JSON null") + func keepsNullKindsDistinctThroughACommit() { + let rows = ArrayValueEditorModel.rows(from: [.null, .value("null")]) + let literal = ArrayValueEditorModel.literal(from: rows, delimiter: ",", elementEditor: .json) + #expect(literal == #"{NULL,"null"}"#) + #expect(PostgresArrayLiteralCodec.parse(literal) == [.null, .value("null")]) + } + + @Test("A JSON element's list summary is compact, and NULL has none of its own") + func summarisesJsonElements() { + #expect(ArrayValueEditorModel.jsonDisplay(of: .value(storedDocument)).summary == #"{"id":1,"name":"example"}"#) + #expect(ArrayValueEditorModel.jsonDisplay(of: .null).summary == nil) + #expect(ArrayValueEditorModel.jsonDisplay(of: .null).isValid) + #expect(ArrayValueEditorModel.jsonDisplay(of: .value("{oops")).isValid == false) + let long = ArrayValueEditorModel.jsonDisplay(of: .value(String(repeating: "a", count: 40)), limit: 8) + #expect(long.summary?.count == 9) + } + + /// The element list rebuilds on every keystroke in the detail editor, so parsing each visible + /// element per render is unbounded work on the main actor. Past the cap an element is previewed + /// from its prefix and reports no verdict rather than a wrong one. + @Test("An element too large to inspect is previewed without being parsed") + func doesNotParseAnOversizeElement() { + let oversize = "{\"k\": \"" + String(repeating: "x", count: 8_000) + "\"}" + let display = ArrayValueEditorModel.jsonDisplay(of: .value(oversize), limit: 40) + #expect(display.isValid) + #expect(display.summary?.count == 41) + #expect(display.summary?.hasPrefix("{\"k\": \"xxx") == true) + + let oversizeAndBroken = "{" + String(repeating: "x", count: 8_000) + #expect(ArrayValueEditorModel.jsonDisplay(of: .value(oversizeAndBroken)).isValid) + } } diff --git a/TableProTests/Views/Shared/FieldEditorResolverTests.swift b/TableProTests/Views/Shared/FieldEditorResolverTests.swift index 58947158ac..8bcf3ad3ef 100644 --- a/TableProTests/Views/Shared/FieldEditorResolverTests.swift +++ b/TableProTests/Views/Shared/FieldEditorResolverTests.swift @@ -274,4 +274,106 @@ struct FieldEditorResolverImageTests { ) #expect(kind == .json) } + + private var jsonArrayType: ColumnType { + .array(rawType: "jsonb[]", element: .json(rawType: "jsonb")) + } + + private var textArrayType: ColumnType { + .array(rawType: "text[]", element: .text(rawType: "text")) + } + + @Test("A jsonb[] value resolves to the element editor, not the JSON editor") + func jsonArrayResolvesToElements() { + let kind = FieldEditorResolver.resolve( + for: jsonArrayType, + isLongText: false, + originalValue: #"{"{\"id\": 1}","{\"id\": 2}"}"# + ) + #expect(kind == .arrayElements(element: .json, values: [])) + } + + @Test("A scalar array resolves to the element editor too") + func scalarArrayResolvesToElements() { + let kind = FieldEditorResolver.resolve( + for: textArrayType, + isLongText: false, + originalValue: "{a,b}" + ) + #expect(kind == .arrayElements(element: .scalar, values: [])) + } + + /// `jsonb[]` and `jsonb[][]` are one type in PostgreSQL's catalog and any array column may + /// carry a dimension prefix, so the declared type cannot rule either out on a given row. The + /// text editor over the raw literal is the lossless fallback, as it is in the grid. + @Test("A literal the element list cannot represent falls back to the text editor") + func unrepresentableArrayFallsBackToText() { + #expect( + FieldEditorResolver.resolve( + for: jsonArrayType, + isLongText: false, + originalValue: #"{{"{\"id\": 1}"},{"{\"id\": 2}"}}"# + ) == .multiLine + ) + #expect( + FieldEditorResolver.resolve( + for: textArrayType, + isLongText: false, + originalValue: "[0:2]={a,b,c}" + ) == .singleLine + ) + } + + /// An engine whose list literal is not PostgreSQL's reaches the same gate and fails it, so the + /// classifier's engine-blind `[]` rule cannot hand another driver's value to this editor. + @Test("A list literal from another engine does not reach the element editor") + func foreignListLiteralFallsBackToText() { + let kind = FieldEditorResolver.resolve( + for: textArrayType, + isLongText: false, + originalValue: "[a, b]" + ) + #expect(kind == .singleLine) + } + + /// The parse is also what scopes the editor to the engines whose arrays are written this way: + /// the classifier's `[]` rule takes no engine, and a document store's `object[]` classifies the + /// same, so a field with no literal to read must not be offered a list that commits `{…}`. + @Test("An array column with no value keeps the plain editor") + func absentArrayValueKeepsPlainEditor() { + #expect( + FieldEditorResolver.resolve(for: jsonArrayType, isLongText: false, originalValue: nil) + == .singleLine + ) + #expect( + FieldEditorResolver.resolve(for: textArrayType, isLongText: false, originalValue: "") + == .singleLine + ) + } + + /// The labels arrive separately in `TableRows.columnEnumValues` and are injected into the type + /// before the inspector resolves it, so they have to reach the element inside the array. + @Test("An enum array carries its declared labels into the element editor") + func enumArrayCarriesItsLabels() { + let labels = ["sad", "ok", "happy"] + let type = ColumnType + .array(rawType: "ENUM[]", element: .enumType(rawType: "ENUM", values: nil)) + .withAllowedValues(labels) + #expect( + FieldEditorResolver.resolve(for: type, isLongText: false, originalValue: "{happy}") + == .arrayElements(element: .scalar, values: labels) + ) + } + + /// An empty array literal is itself valid JSON, so resolving the array before the JSON branch + /// is what keeps `{}` out of the JSON editor. + @Test("An empty array opens the element editor rather than the JSON editor") + func emptyArrayResolvesToElements() { + let kind = FieldEditorResolver.resolve( + for: jsonArrayType, + isLongText: false, + originalValue: "{}" + ) + #expect(kind == .arrayElements(element: .json, values: [])) + } } diff --git a/docs/STYLE.md b/docs/STYLE.md index be61c1e801..9d3caeef94 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -203,9 +203,9 @@ Three slots, fixed order: **what you cannot do, what happens instead, what to do why. ``` -no Arrays of jsonb, bytea, or composite types keep the plain text editor, since - their quoting cannot round-trip through a per-element list. -yes The list editor covers arrays of simple types. jsonb[], bytea[], composite and +no Arrays of bytea or composite types keep the plain text editor, since their + quoting cannot round-trip through a per-element list. +yes The list editor covers arrays of simple types and of JSON. bytea[], composite and multi-dimensional arrays open the text editor instead: edit the {…} literal directly. ``` diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 1ec304caf9..4a3fb2f519 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -60,15 +60,20 @@ A partitioned table is listed once, under its own icon. Expand it for its partit `jsonb` renders as formatted JSON, and `uuid`, `inet`, `timestamp with time zone`, `interval` and `bytea` display natively. PostGIS `geometry` and `geography` render as EWKT with the SRID kept, `SRID=4326;POINT(-73 40.7237)`, rather than raw EWKB hex; a value that fails to convert stays hex. -An array column opens one of two editors, decided by its element type: +An array column opens one of three editors, decided by its element type: | Element type | Editor | |---|---| | `text[]`, `integer[]`, `numeric[]`, `uuid[]`, `boolean[]`, `timestamptz[]`, enum arrays such as `mood[]` | A list, one row per element | -| `jsonb[]`, `bytea[]`, composite arrays, and any multi-dimensional array | The plain text editor over the `{…}` literal | +| `jsonb[]` and `json[]` | The same list, with the selected element in the [JSON viewer](/features/json-viewer) | +| `bytea[]`, composite arrays, and any multi-dimensional array | The plain text editor over the `{…}` literal | In the list editor, reorder rows with the arrows, add and remove elements, and set a single element to NULL; an empty array and a NULL column stay distinct. Enum elements pick from the labels the type declares, and a label the type no longer lists stays selectable and is flagged. **Edit as Text** switches to the raw literal at any time. +A `jsonb[]` element gets the Text and Tree modes a `jsonb` cell gets, so the column reads the same browsing a table as it does through `to_jsonb(col)` in a query. Two things that query cannot show you: a SQL NULL element stays apart from a JSON `null` element, and reading an element does not rewrite it, so a `json[]` cell you open and save keeps the exact text it stored. + +A value the list cannot represent opens the text editor instead: a multi-dimensional value such as `{{1,2},{3,4}}`, or a literal carrying explicit bounds such as `[0:2]={a,b,c}`. Edit the `{…}` literal directly. + ## User-defined types Enums, composites, domains and ranges are listed under **Types** in each schema, the `CREATE` statement rebuilt from `pg_type`. An enum's labels are edited in place with `ALTER TYPE … ADD VALUE` and, from PostgreSQL 10, `RENAME VALUE`; PostgreSQL has no statement that drops or reorders a label. The structure editor's type picker offers the schema's types under **User-Defined**. See [User-Defined Types](/features/user-defined-types). diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index d5cc88a0fd..2515bf8f77 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -28,6 +28,7 @@ Double-click a cell, or press `Enter` on it. `Enter` commits the edit to the que | `ENUM` | Searchable value list | | `SET` (MySQL/MariaDB) | Multi-select checkboxes | | Array of a simple type (PostgreSQL) | Ordered list, one row per element | +| `jsonb[]`, `json[]` (PostgreSQL) | Ordered list, the selected element in the [JSON viewer](/features/json-viewer) | | `JSON`, `JSONB`, `BLOB`, binary | [Cell viewers](/features/json-viewer) | diff --git a/docs/features/json-viewer.mdx b/docs/features/json-viewer.mdx index ce3be6b138..ed7d2e1781 100644 --- a/docs/features/json-viewer.mdx +++ b/docs/features/json-viewer.mdx @@ -50,6 +50,14 @@ The search field above the tree filters keys and values as you type. The [PHP se - A row matches on its whole value, not the shortened form shown, so a match deep inside a long string counts. A match under a collapsed parent opens its parents, and a key that matches keeps its contents and stays expandable. - A filter that matches nothing says so, and says separately when the document was cut at 5,000 nodes, since the rows past the cut were never searched. +### Arrays of JSON + +A PostgreSQL `jsonb[]` or `json[]` cell opens on its elements: the list at the top, the selected element in the same Text and Tree viewer below. + +Add, remove and reorder elements with the controls under the list. **NULL** on an element writes a SQL NULL, which is a different value from the JSON `null` an element can hold: the list shows the first as `NULL` and the second as `null`. **Edit as Text** switches to the raw `{…}` literal. + +Reading an element does not change it. Only an element whose JSON you alter is rewritten, and it is written back compact, so a `json[]` column keeps the exact text it stored for every element you left alone. + ## PHP serialized viewer Set **Display As > PHP Serialized** on a text column, then double-click or press `Enter`. The viewer is read-only: PHP serialized values round-trip through PHP itself.