Skip to content

Commit 56c8117

Browse files
authored
feat(postgrest): add geojson, explain, and new filters (#343)
* feat(postgrest): add geojson and explain methods * feat: add `likeAllOf`, `likeAnyOf`, `iLikeAllOf`, `iLikeAnyOf`, and `containedBy` methods * test: add tests for new methods
1 parent 06d85e1 commit 56c8117

12 files changed

+229
-11
lines changed

Sources/PostgREST/PostgrestFilterBuilder.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,36 @@ public class PostgrestFilterBuilder: PostgrestTransformBuilder {
160160
like(column, pattern: value)
161161
}
162162

163+
/// Match only rows where `column` matches all of `patterns` case-sensitively.
164+
/// - Parameters:
165+
/// - column: The column to filter on
166+
/// - patterns: The patterns to match with
167+
public func likeAllOf(
168+
_ column: String,
169+
patterns: [some URLQueryRepresentable]
170+
) -> PostgrestFilterBuilder {
171+
let queryValue = patterns.queryValue
172+
mutableState.withValue {
173+
$0.request.query.append(URLQueryItem(name: column, value: "like(all).\(queryValue)"))
174+
}
175+
return self
176+
}
177+
178+
/// Match only rows where `column` matches any of `patterns` case-sensitively.
179+
/// - Parameters:
180+
/// - column: The column to filter on
181+
/// - patterns: The patterns to match with
182+
public func likeAnyOf(
183+
_ column: String,
184+
patterns: [some URLQueryRepresentable]
185+
) -> PostgrestFilterBuilder {
186+
let queryValue = patterns.queryValue
187+
mutableState.withValue {
188+
$0.request.query.append(URLQueryItem(name: column, value: "like(any).\(queryValue)"))
189+
}
190+
return self
191+
}
192+
163193
/// Match only rows where `column` matches `pattern` case-insensitively.
164194
///
165195
/// - Parameters:
@@ -184,6 +214,36 @@ public class PostgrestFilterBuilder: PostgrestTransformBuilder {
184214
ilike(column, pattern: value)
185215
}
186216

217+
/// Match only rows where `column` matches all of `patterns` case-insensitively.
218+
/// - Parameters:
219+
/// - column: The column to filter on
220+
/// - patterns: The patterns to match with
221+
public func iLikeAllOf(
222+
_ column: String,
223+
patterns: [some URLQueryRepresentable]
224+
) -> PostgrestFilterBuilder {
225+
let queryValue = patterns.queryValue
226+
mutableState.withValue {
227+
$0.request.query.append(URLQueryItem(name: column, value: "ilike(all).\(queryValue)"))
228+
}
229+
return self
230+
}
231+
232+
/// Match only rows where `column` matches any of `patterns` case-insensitively.
233+
/// - Parameters:
234+
/// - column: The column to filter on
235+
/// - patterns: The patterns to match with
236+
public func iLikeAnyOf(
237+
_ column: String,
238+
patterns: [some URLQueryRepresentable]
239+
) -> PostgrestFilterBuilder {
240+
let queryValue = patterns.queryValue
241+
mutableState.withValue {
242+
$0.request.query.append(URLQueryItem(name: column, value: "ilike(any).\(queryValue)"))
243+
}
244+
return self
245+
}
246+
187247
/// Match only rows where `column` IS `value`.
188248
///
189249
/// 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 {
250310
return self
251311
}
252312

313+
/// Match only rows where every element appearing in `column` is contained by `value`.
314+
///
315+
/// Only relevant for jsonb, array, and range columns.
316+
///
317+
/// - Parameters:
318+
/// - column: The jsonb, array, or range column to filter on
319+
/// - value: The jsonb, array, or range value to filter with
320+
public func containedBy(
321+
_ column: String,
322+
value: some URLQueryRepresentable
323+
) -> PostgrestFilterBuilder {
324+
let queryValue = value.queryValue
325+
mutableState.withValue {
326+
$0.request.query.append(URLQueryItem(name: column, value: "cd.\(queryValue)"))
327+
}
328+
return self
329+
}
330+
253331
/// Match only rows where every element in `column` is less than any element in `range`.
254332
///
255333
/// Only relevant for range columns.

Sources/PostgREST/PostgrestTransformBuilder.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,53 @@ public class PostgrestTransformBuilder: PostgrestBuilder {
145145
}
146146
return self
147147
}
148+
149+
/// Return `value` as an object in [GeoJSON](https://geojson.org) format.
150+
public func geojson() -> PostgrestTransformBuilder {
151+
mutableState.withValue {
152+
$0.request.headers["Accept"] = "application/geo+json"
153+
}
154+
return self
155+
}
156+
157+
/// Return `data` as the EXPLAIN plan for the query.
158+
///
159+
/// You need to enable the [db_plan_enabled](https://supabase.com/docs/guides/database/debugging-performance#enabling-explain)
160+
/// setting before using this method.
161+
///
162+
/// - Parameters:
163+
/// - analyze: If `true`, the query will be executed and the actual run time will be returned
164+
/// - verbose: If `true`, the query identifier will be returned and `data` will include the
165+
/// output columns of the query
166+
/// - settings: If `true`, include information on configuration parameters that affect query
167+
/// planning
168+
/// - buffers: If `true`, include information on buffer usage
169+
/// - wal: If `true`, include information on WAL record generation
170+
/// - format: The format of the output, can be `"text"` (default) or `"json"`
171+
public func explain(
172+
analyze: Bool = false,
173+
verbose: Bool = false,
174+
settings: Bool = false,
175+
buffers: Bool = false,
176+
wal: Bool = false,
177+
format: String = "text"
178+
) -> PostgrestTransformBuilder {
179+
mutableState.withValue {
180+
let options = [
181+
analyze ? "analyze" : nil,
182+
verbose ? "verbose" : nil,
183+
settings ? "settings" : nil,
184+
buffers ? "buffers" : nil,
185+
wal ? "wal" : nil,
186+
]
187+
.compactMap { $0 }
188+
.joined(separator: "|")
189+
let forMediaType = $0.request.headers["Accept"] ?? "application/json"
190+
$0.request
191+
.headers["Accept"] =
192+
"application/vnd.pgrst.plan+\"\(format)\"; for=\(forMediaType); options=\(options);"
193+
}
194+
195+
return self
196+
}
148197
}

Sources/PostgREST/URLQueryRepresentable.swift

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import _Helpers
12
import Foundation
23

34
/// A type that can fit into the query part of a URL.
@@ -32,6 +33,20 @@ extension Array: URLQueryRepresentable where Element: URLQueryRepresentable {
3233
}
3334
}
3435

36+
extension AnyJSON: URLQueryRepresentable {
37+
public var queryValue: String {
38+
switch self {
39+
case let .array(array): array.queryValue
40+
case let .object(object): object.queryValue
41+
case let .string(string): string.queryValue
42+
case let .double(double): double.queryValue
43+
case let .integer(integer): integer.queryValue
44+
case let .bool(bool): bool.queryValue
45+
case .null: "NULL"
46+
}
47+
}
48+
}
49+
3550
extension Optional: URLQueryRepresentable where Wrapped: URLQueryRepresentable {
3651
public var queryValue: String {
3752
if let value = self {
@@ -42,13 +57,10 @@ extension Optional: URLQueryRepresentable where Wrapped: URLQueryRepresentable {
4257
}
4358
}
4459

45-
extension Dictionary: URLQueryRepresentable
46-
where
47-
Key: URLQueryRepresentable,
48-
Value: URLQueryRepresentable
49-
{
60+
extension JSONObject: URLQueryRepresentable {
5061
public var queryValue: String {
51-
JSONSerialization.stringfy(self)
62+
let value = mapValues(\.value)
63+
return JSONSerialization.stringfy(value)
5264
}
5365
}
5466

Tests/PostgRESTTests/BuildURLRequestTests.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ struct User: Encodable {
1515
var username: String?
1616
}
1717

18-
@MainActor
1918
final class BuildURLRequestTests: XCTestCase {
2019
let url = URL(string: "https://example.supabase.co")!
2120

@@ -172,6 +171,41 @@ final class BuildURLRequestTests: XCTestCase {
172171
.select()
173172
.is("email", value: String?.none)
174173
},
174+
TestCase(name: "likeAllOf") { client in
175+
client.from("users")
176+
.select()
177+
.likeAllOf("email", patterns: ["%@supabase.io", "%@supabase.com"])
178+
},
179+
TestCase(name: "likeAnyOf") { client in
180+
client.from("users")
181+
.select()
182+
.likeAnyOf("email", patterns: ["%@supabase.io", "%@supabase.com"])
183+
},
184+
TestCase(name: "iLikeAllOf") { client in
185+
client.from("users")
186+
.select()
187+
.iLikeAllOf("email", patterns: ["%@supabase.io", "%@supabase.com"])
188+
},
189+
TestCase(name: "iLikeAnyOf") { client in
190+
client.from("users")
191+
.select()
192+
.iLikeAnyOf("email", patterns: ["%@supabase.io", "%@supabase.com"])
193+
},
194+
TestCase(name: "containedBy using array") { client in
195+
client.from("users")
196+
.select()
197+
.containedBy("id", value: ["a", "b", "c"])
198+
},
199+
TestCase(name: "containedBy using range") { client in
200+
client.from("users")
201+
.select()
202+
.containedBy("age", value: "[10,20]")
203+
},
204+
TestCase(name: "containedBy using json") { client in
205+
client.from("users")
206+
.select()
207+
.containedBy("userMetadata", value: ["age": 18])
208+
},
175209
]
176210

177211
for testCase in testCases {

Tests/PostgRESTTests/URLQueryRepresentableTests.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,19 @@ final class URLQueryRepresentableTests: XCTestCase {
88
XCTAssertEqual(queryValue, "{is:online,faction:red}")
99
}
1010

11-
func testDictionary() {
12-
let dictionary = ["postalcode": 90210]
13-
let queryValue = dictionary.queryValue
14-
XCTAssertEqual(queryValue, "{\"postalcode\":90210}")
11+
func testAnyJSON() {
12+
XCTAssertEqual(
13+
AnyJSON.array(["is:online", "faction:red"]).queryValue,
14+
"{is:online,faction:red}"
15+
)
16+
XCTAssertEqual(
17+
AnyJSON.object(["postalcode": 90210]).queryValue,
18+
"{\"postalcode\":90210}"
19+
)
20+
XCTAssertEqual(AnyJSON.string("string").queryValue, "string")
21+
XCTAssertEqual(AnyJSON.double(3.14).queryValue, "3.14")
22+
XCTAssertEqual(AnyJSON.integer(3).queryValue, "3")
23+
XCTAssertEqual(AnyJSON.bool(true).queryValue, "true")
24+
XCTAssertEqual(AnyJSON.null.queryValue, "NULL")
1525
}
1626
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
curl \
2+
--header "Accept: application/json" \
3+
--header "Content-Type: application/json" \
4+
--header "X-Client-Info: postgrest-swift/x.y.z" \
5+
"https://example.supabase.co/users?id=cd.%7Ba,b,c%7D&select=*"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
curl \
2+
--header "Accept: application/json" \
3+
--header "Content-Type: application/json" \
4+
--header "X-Client-Info: postgrest-swift/x.y.z" \
5+
"https://example.supabase.co/users?select=*&userMetadata=cd.%7B%22age%22:18%7D"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
curl \
2+
--header "Accept: application/json" \
3+
--header "Content-Type: application/json" \
4+
--header "X-Client-Info: postgrest-swift/x.y.z" \
5+
"https://example.supabase.co/users?age=cd.%5B10,20%5D&select=*"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
curl \
2+
--header "Accept: application/json" \
3+
--header "Content-Type: application/json" \
4+
--header "X-Client-Info: postgrest-swift/x.y.z" \
5+
"https://example.supabase.co/users?email=ilike(all).%7B%[email protected],%[email protected]%7D&select=*"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
curl \
2+
--header "Accept: application/json" \
3+
--header "Content-Type: application/json" \
4+
--header "X-Client-Info: postgrest-swift/x.y.z" \
5+
"https://example.supabase.co/users?email=ilike(any).%7B%[email protected],%[email protected]%7D&select=*"

0 commit comments

Comments
 (0)