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 eff28b510..fcdb3b1c7 100644 --- a/Tests/PostgRESTTests/PostgrestFilterValueTests.swift +++ b/Tests/PostgRESTTests/PostgrestFilterValueTests.swift @@ -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(