From 110dd17c90dd88881e9c94a0f8ff62a86576f966 Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Tue, 14 Jul 2026 01:11:24 +0530 Subject: [PATCH] fix(postgrest): escape reserved characters in array filter values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Array's PostgrestFilterValue.rawValue joined elements with commas inside {...} without escaping, so an element containing a comma, brace, quote, backslash, surrounding whitespace, or the literal NULL corrupted the array literal — e.g. ["a,b"] became {a,b}, which PostgREST reads as two separate values. Quote and backslash-escape elements that need it, mirroring the existing in() filter escaping. Nested array literals ({...}) pass through unchanged so arrays of arrays keep working. --- Sources/Helpers/PostgRESTFilterValue.swift | 26 +++++++++++++++ Sources/PostgREST/PostgrestFilterValue.swift | 10 +++++- .../PostgrestFilterValueTests.swift | 32 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Sources/Helpers/PostgRESTFilterValue.swift b/Sources/Helpers/PostgRESTFilterValue.swift index 517c48462..0b298d01b 100644 --- a/Sources/Helpers/PostgRESTFilterValue.swift +++ b/Sources/Helpers/PostgRESTFilterValue.swift @@ -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 = [ + ",", "{", "}", "\"", "\\", +] + +/// 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)\"" +} diff --git a/Sources/PostgREST/PostgrestFilterValue.swift b/Sources/PostgREST/PostgrestFilterValue.swift index 9a6d19a61..c4b3cd132 100644 --- a/Sources/PostgREST/PostgrestFilterValue.swift +++ b/Sources/PostgREST/PostgrestFilterValue.swift @@ -1,4 +1,5 @@ public import Foundation +import Helpers /// A value that can be used as a filter operand in PostgREST queries. /// @@ -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) + } + return "{\(elements.joined(separator: ","))}" } } diff --git a/Tests/PostgRESTTests/PostgrestFilterValueTests.swift b/Tests/PostgRESTTests/PostgrestFilterValueTests.swift index e48efe645..1600d997e 100644 --- a/Tests/PostgRESTTests/PostgrestFilterValueTests.swift +++ b/Tests/PostgRESTTests/PostgrestFilterValueTests.swift @@ -8,6 +8,38 @@ final class PostgrestFilterValue: XCTestCase { XCTAssertEqual(queryValue, "{is:online,faction:red}") } + func testArrayQuotesElementsContainingReservedCharacters() { + XCTAssertEqual(["a,b"].rawValue, "{\"a,b\"}") + XCTAssertEqual(["a", "b,c", "d"].rawValue, "{a,\"b,c\",d}") + XCTAssertEqual(["a{b"].rawValue, "{\"a{b\"}") + } + + func testArrayEscapesQuotesAndBackslashes() { + XCTAssertEqual([#"a"b"#].rawValue, #"{"a\"b"}"#) + XCTAssertEqual([#"a\b"#].rawValue, #"{"a\\b"}"#) + } + + func testArrayQuotesWhitespaceEmptyAndNullElements() { + XCTAssertEqual([" a"].rawValue, "{\" a\"}") + XCTAssertEqual([""].rawValue, "{\"\"}") + XCTAssertEqual(["NULL"].rawValue, "{\"NULL\"}") + XCTAssertEqual(["null"].rawValue, "{\"null\"}") + } + + func testArrayLeavesSafeAndNumericElementsUnquoted() { + XCTAssertEqual([1, 2, 3].rawValue, "{1,2,3}") + XCTAssertEqual(["admin", "user"].rawValue, "{admin,user}") + XCTAssertEqual(["9:00", "17:00"].rawValue, "{9:00,17:00}") + } + + func testArrayPreservesNestedArrayLiterals() { + XCTAssertEqual([[1, 2], [3, 4]].rawValue, "{{1,2},{3,4}}") + } + + func testAnyJSONArrayEscapesReservedCharacters() { + XCTAssertEqual(AnyJSON.array(["a,b"]).rawValue, "{\"a,b\"}") + } + func testAnyJSON() { XCTAssertEqual( AnyJSON.array(["is:online", "faction:red"]).rawValue,