Skip to content
Open
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
26 changes: 26 additions & 0 deletions Sources/Helpers/PostgRESTFilterValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,29 @@ package func escapePostgRESTFilterValue(_ raw: String) -> String {
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}

/// Characters that carry structural meaning inside a PostgREST array literal
/// (e.g. `cs.{a,b}`) and therefore require the element to be double-quoted.
private let postgrestArrayLiteralReservedCharacters: Set<Character> = [
",", "{", "}", "\"", "\\",
]

/// Whether `element` must be double-quoted when embedded in a PostgREST array
/// literal, i.e. it is empty, equals `NULL`, contains a reserved character, or
/// has surrounding whitespace.
package func postgrestArrayLiteralElementNeedsQuoting(_ element: String) -> Bool {
element.isEmpty
|| element.caseInsensitiveCompare("NULL") == .orderedSame
|| element.contains(where: postgrestArrayLiteralReservedCharacters.contains)
|| element != element.trimmingCharacters(in: .whitespaces)
}

/// Escapes a raw value for safe inclusion as an element of a PostgREST array
/// literal such as `cs.{...}`. Elements needing quoting are double-quoted, with
/// `\` and `"` backslash-escaped.
package func escapePostgRESTArrayLiteralElement(_ raw: String) -> String {
guard postgrestArrayLiteralElementNeedsQuoting(raw) else { return raw }
let escaped = raw.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
10 changes: 9 additions & 1 deletion Sources/PostgREST/PostgrestFilterValue.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
public import Foundation
import Helpers

/// A value that can be used as a filter operand in PostgREST queries.
///
Expand Down Expand Up @@ -67,7 +68,14 @@ extension Date: PostgrestFilterValue {
/// The raw value is a PostgreSQL array literal, e.g. `{a,b,c}`.
extension Array: PostgrestFilterValue where Element: PostgrestFilterValue {
public var rawValue: String {
"{\(map(\.rawValue).joined(separator: ","))}"
let elements = map { element -> String in
let raw = element.rawValue
if raw.hasPrefix("{"), raw.hasSuffix("}") {
return raw
}
return escapePostgRESTArrayLiteralElement(raw)
Comment on lines +71 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not infer array-member semantics from rawValue.

A scalar ["{a,b}"] becomes {{a,b}} (a nested array), while [Optional<Int>.none] and [AnyJSON.null] become {"NULL"} (a string rather than a NULL element). Add a typed array-member encoding contract/marker before stringification, and regressions for both cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/PostgREST/PostgrestFilterValue.swift` around lines 71 - 76, Update
the array-element encoding flow around the map closure to use a typed
array-member encoding contract or marker before converting values to raw
strings; do not infer nested-array or NULL semantics from rawValue alone. Ensure
scalar brace-delimited strings remain escaped as scalar elements, while
Optional<Int>.none and AnyJSON.null encode as actual NULL array members, and add
regressions covering both cases.

}
return "{\(elements.joined(separator: ","))}"
}
}

Expand Down
38 changes: 38 additions & 0 deletions Tests/PostgRESTTests/PostgrestFilterValueTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,44 @@ struct PostgrestFilterValueTests {
#expect(queryValue == "{is:online,faction:red}")
}

@Test
func arrayQuotesElementsContainingReservedCharacters() {
#expect(["a,b"].rawValue == "{\"a,b\"}")
#expect(["a", "b,c", "d"].rawValue == "{a,\"b,c\",d}")
#expect(["a{b"].rawValue == "{\"a{b\"}")
}

@Test
func arrayEscapesQuotesAndBackslashes() {
#expect([#"a"b"#].rawValue == #"{"a\"b"}"#)
#expect([#"a\b"#].rawValue == #"{"a\\b"}"#)
}

@Test
func arrayQuotesWhitespaceEmptyAndNullElements() {
#expect([" a"].rawValue == "{\" a\"}")
#expect([""].rawValue == "{\"\"}")
#expect(["NULL"].rawValue == "{\"NULL\"}")
#expect(["null"].rawValue == "{\"null\"}")
}

@Test
func arrayLeavesSafeAndNumericElementsUnquoted() {
#expect([1, 2, 3].rawValue == "{1,2,3}")
#expect(["admin", "user"].rawValue == "{admin,user}")
#expect(["9:00", "17:00"].rawValue == "{9:00,17:00}")
}

@Test
func arrayPreservesNestedArrayLiterals() {
#expect([[1, 2], [3, 4]].rawValue == "{{1,2},{3,4}}")
}

@Test
func anyJSONArrayEscapesReservedCharacters() {
#expect(AnyJSON.array(["a,b"]).rawValue == "{\"a,b\"}")
}

@Test
func anyJSON() {
#expect(
Expand Down