Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **Keyword case** in Settings > Editor: completed keywords and functions follow the case you type. (#2833)

### Changed
Expand Down Expand Up @@ -70,6 +72,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`.
Expand Down
157 changes: 89 additions & 68 deletions Plugins/TableProPluginKit/PostgresArrayLiteralCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unicode.Scalar> = [" ", "\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<Unicode.Scalar>) -> 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)
}
}
}
50 changes: 44 additions & 6 deletions TablePro/Core/Services/ColumnType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Models/UI/FieldEditorKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions TablePro/Models/UI/InspectorFieldLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
Expand Down
Loading
Loading