diff --git a/Sources/PostgREST/PostgrestFilterBuilder.swift b/Sources/PostgREST/PostgrestFilterBuilder.swift index f851a255..fbd264aa 100644 --- a/Sources/PostgREST/PostgrestFilterBuilder.swift +++ b/Sources/PostgREST/PostgrestFilterBuilder.swift @@ -160,6 +160,36 @@ public class PostgrestFilterBuilder: PostgrestTransformBuilder { like(column, pattern: value) } + /// Match only rows where `column` matches all of `patterns` case-sensitively. + /// - Parameters: + /// - column: The column to filter on + /// - patterns: The patterns to match with + public func likeAllOf( + _ column: String, + patterns: [some URLQueryRepresentable] + ) -> PostgrestFilterBuilder { + let queryValue = patterns.queryValue + mutableState.withValue { + $0.request.query.append(URLQueryItem(name: column, value: "like(all).\(queryValue)")) + } + return self + } + + /// Match only rows where `column` matches any of `patterns` case-sensitively. + /// - Parameters: + /// - column: The column to filter on + /// - patterns: The patterns to match with + public func likeAnyOf( + _ column: String, + patterns: [some URLQueryRepresentable] + ) -> PostgrestFilterBuilder { + let queryValue = patterns.queryValue + mutableState.withValue { + $0.request.query.append(URLQueryItem(name: column, value: "like(any).\(queryValue)")) + } + return self + } + /// Match only rows where `column` matches `pattern` case-insensitively. /// /// - Parameters: @@ -184,6 +214,36 @@ public class PostgrestFilterBuilder: PostgrestTransformBuilder { ilike(column, pattern: value) } + /// Match only rows where `column` matches all of `patterns` case-insensitively. + /// - Parameters: + /// - column: The column to filter on + /// - patterns: The patterns to match with + public func iLikeAllOf( + _ column: String, + patterns: [some URLQueryRepresentable] + ) -> PostgrestFilterBuilder { + let queryValue = patterns.queryValue + mutableState.withValue { + $0.request.query.append(URLQueryItem(name: column, value: "ilike(all).\(queryValue)")) + } + return self + } + + /// Match only rows where `column` matches any of `patterns` case-insensitively. + /// - Parameters: + /// - column: The column to filter on + /// - patterns: The patterns to match with + public func iLikeAnyOf( + _ column: String, + patterns: [some URLQueryRepresentable] + ) -> PostgrestFilterBuilder { + let queryValue = patterns.queryValue + mutableState.withValue { + $0.request.query.append(URLQueryItem(name: column, value: "ilike(any).\(queryValue)")) + } + return self + } + /// Match only rows where `column` IS `value`. /// /// For non-boolean columns, this is only relevant for checking if the value of `column` is NULL by setting `value` to `null`. @@ -250,6 +310,24 @@ public class PostgrestFilterBuilder: PostgrestTransformBuilder { return self } + /// Match only rows where every element appearing in `column` is contained by `value`. + /// + /// Only relevant for jsonb, array, and range columns. + /// + /// - Parameters: + /// - column: The jsonb, array, or range column to filter on + /// - value: The jsonb, array, or range value to filter with + public func containedBy( + _ column: String, + value: some URLQueryRepresentable + ) -> PostgrestFilterBuilder { + let queryValue = value.queryValue + mutableState.withValue { + $0.request.query.append(URLQueryItem(name: column, value: "cd.\(queryValue)")) + } + return self + } + /// Match only rows where every element in `column` is less than any element in `range`. /// /// Only relevant for range columns. diff --git a/Sources/PostgREST/PostgrestTransformBuilder.swift b/Sources/PostgREST/PostgrestTransformBuilder.swift index b10ce385..b61aa72d 100644 --- a/Sources/PostgREST/PostgrestTransformBuilder.swift +++ b/Sources/PostgREST/PostgrestTransformBuilder.swift @@ -145,4 +145,53 @@ public class PostgrestTransformBuilder: PostgrestBuilder { } return self } + + /// Return `value` as an object in [GeoJSON](https://geojson.org) format. + public func geojson() -> PostgrestTransformBuilder { + mutableState.withValue { + $0.request.headers["Accept"] = "application/geo+json" + } + return self + } + + /// Return `data` as the EXPLAIN plan for the query. + /// + /// You need to enable the [db_plan_enabled](https://supabase.com/docs/guides/database/debugging-performance#enabling-explain) + /// setting before using this method. + /// + /// - Parameters: + /// - analyze: If `true`, the query will be executed and the actual run time will be returned + /// - verbose: If `true`, the query identifier will be returned and `data` will include the + /// output columns of the query + /// - settings: If `true`, include information on configuration parameters that affect query + /// planning + /// - buffers: If `true`, include information on buffer usage + /// - wal: If `true`, include information on WAL record generation + /// - format: The format of the output, can be `"text"` (default) or `"json"` + public func explain( + analyze: Bool = false, + verbose: Bool = false, + settings: Bool = false, + buffers: Bool = false, + wal: Bool = false, + format: String = "text" + ) -> PostgrestTransformBuilder { + mutableState.withValue { + let options = [ + analyze ? "analyze" : nil, + verbose ? "verbose" : nil, + settings ? "settings" : nil, + buffers ? "buffers" : nil, + wal ? "wal" : nil, + ] + .compactMap { $0 } + .joined(separator: "|") + let forMediaType = $0.request.headers["Accept"] ?? "application/json" + $0.request + .headers["Accept"] = + "application/vnd.pgrst.plan+\"\(format)\"; for=\(forMediaType); options=\(options);" + } + + return self + } } diff --git a/Sources/PostgREST/URLQueryRepresentable.swift b/Sources/PostgREST/URLQueryRepresentable.swift index 1b25f2e2..039b52d0 100644 --- a/Sources/PostgREST/URLQueryRepresentable.swift +++ b/Sources/PostgREST/URLQueryRepresentable.swift @@ -1,3 +1,4 @@ +import _Helpers import Foundation /// A type that can fit into the query part of a URL. @@ -32,6 +33,20 @@ extension Array: URLQueryRepresentable where Element: URLQueryRepresentable { } } +extension AnyJSON: URLQueryRepresentable { + public var queryValue: String { + switch self { + case let .array(array): array.queryValue + case let .object(object): object.queryValue + case let .string(string): string.queryValue + case let .double(double): double.queryValue + case let .integer(integer): integer.queryValue + case let .bool(bool): bool.queryValue + case .null: "NULL" + } + } +} + extension Optional: URLQueryRepresentable where Wrapped: URLQueryRepresentable { public var queryValue: String { if let value = self { @@ -42,13 +57,10 @@ extension Optional: URLQueryRepresentable where Wrapped: URLQueryRepresentable { } } -extension Dictionary: URLQueryRepresentable - where - Key: URLQueryRepresentable, - Value: URLQueryRepresentable -{ +extension JSONObject: URLQueryRepresentable { public var queryValue: String { - JSONSerialization.stringfy(self) + let value = mapValues(\.value) + return JSONSerialization.stringfy(value) } } diff --git a/Tests/PostgRESTTests/BuildURLRequestTests.swift b/Tests/PostgRESTTests/BuildURLRequestTests.swift index 4a3d1432..faefca6c 100644 --- a/Tests/PostgRESTTests/BuildURLRequestTests.swift +++ b/Tests/PostgRESTTests/BuildURLRequestTests.swift @@ -15,7 +15,6 @@ struct User: Encodable { var username: String? } -@MainActor final class BuildURLRequestTests: XCTestCase { let url = URL(string: "https://example.supabase.co")! @@ -172,6 +171,41 @@ final class BuildURLRequestTests: XCTestCase { .select() .is("email", value: String?.none) }, + TestCase(name: "likeAllOf") { client in + client.from("users") + .select() + .likeAllOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) + }, + TestCase(name: "likeAnyOf") { client in + client.from("users") + .select() + .likeAnyOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) + }, + TestCase(name: "iLikeAllOf") { client in + client.from("users") + .select() + .iLikeAllOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) + }, + TestCase(name: "iLikeAnyOf") { client in + client.from("users") + .select() + .iLikeAnyOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) + }, + TestCase(name: "containedBy using array") { client in + client.from("users") + .select() + .containedBy("id", value: ["a", "b", "c"]) + }, + TestCase(name: "containedBy using range") { client in + client.from("users") + .select() + .containedBy("age", value: "[10,20]") + }, + TestCase(name: "containedBy using json") { client in + client.from("users") + .select() + .containedBy("userMetadata", value: ["age": 18]) + }, ] for testCase in testCases { diff --git a/Tests/PostgRESTTests/URLQueryRepresentableTests.swift b/Tests/PostgRESTTests/URLQueryRepresentableTests.swift index 0bdc4c4f..bc7b53b0 100644 --- a/Tests/PostgRESTTests/URLQueryRepresentableTests.swift +++ b/Tests/PostgRESTTests/URLQueryRepresentableTests.swift @@ -8,9 +8,19 @@ final class URLQueryRepresentableTests: XCTestCase { XCTAssertEqual(queryValue, "{is:online,faction:red}") } - func testDictionary() { - let dictionary = ["postalcode": 90210] - let queryValue = dictionary.queryValue - XCTAssertEqual(queryValue, "{\"postalcode\":90210}") + func testAnyJSON() { + XCTAssertEqual( + AnyJSON.array(["is:online", "faction:red"]).queryValue, + "{is:online,faction:red}" + ) + XCTAssertEqual( + AnyJSON.object(["postalcode": 90210]).queryValue, + "{\"postalcode\":90210}" + ) + XCTAssertEqual(AnyJSON.string("string").queryValue, "string") + XCTAssertEqual(AnyJSON.double(3.14).queryValue, "3.14") + XCTAssertEqual(AnyJSON.integer(3).queryValue, "3") + XCTAssertEqual(AnyJSON.bool(true).queryValue, "true") + XCTAssertEqual(AnyJSON.null.queryValue, "NULL") } } diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-array.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-array.txt new file mode 100644 index 00000000..536bbe05 --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-array.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?id=cd.%7Ba,b,c%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-json.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-json.txt new file mode 100644 index 00000000..e057183b --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-json.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?select=*&userMetadata=cd.%7B%22age%22:18%7D" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-range.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-range.txt new file mode 100644 index 00000000..61d3c710 --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-range.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?age=cd.%5B10,20%5D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAllOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAllOf.txt new file mode 100644 index 00000000..9342ef74 --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAllOf.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?email=ilike(all).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAnyOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAnyOf.txt new file mode 100644 index 00000000..768e28cf --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAnyOf.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?email=ilike(any).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAllOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAllOf.txt new file mode 100644 index 00000000..6fe5a420 --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAllOf.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?email=like(all).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAnyOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAnyOf.txt new file mode 100644 index 00000000..4e316daf --- /dev/null +++ b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAnyOf.txt @@ -0,0 +1,5 @@ +curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/x.y.z" \ + "https://example.supabase.co/users?email=like(any).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file