Skip to content

Commit b2f128c

Browse files
authored
Add support for nested subpath (JSON) expressions (#169)
* Add a new SQLDialect method for generating nested subpath expressions (JSON paths) and a SQLNestedSubpathExpression expression for actually using it.
1 parent 5026e7c commit b2f128c

File tree

6 files changed

+93
-0
lines changed

6 files changed

+93
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/// Represents a "nested subpath" expression. At this time, this always represents a key path leading to a
2+
/// specific value in a JSON object.
3+
public struct SQLNestedSubpathExpression: SQLExpression {
4+
public var column: any SQLExpression
5+
public var path: [String]
6+
7+
public init(column: any SQLExpression, path: [String]) {
8+
assert(!path.isEmpty)
9+
10+
self.column = column
11+
self.path = path
12+
}
13+
14+
public init(column: String, path: [String]) {
15+
self.init(column: SQLIdentifier(column), path: path)
16+
}
17+
18+
public func serialize(to serializer: inout SQLSerializer) {
19+
serializer.dialect.nestedSubpathExpression(in: self.column, for: self.path)?.serialize(to: &serializer)
20+
}
21+
}

Sources/SQLKit/SQLDialect.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ public protocol SQLDialect {
166166
/// support exclusive locking requests, which causes the locking clause to be silently ignored.
167167
var exclusiveSelectLockExpression: (any SQLExpression)? { get }
168168

169+
/// Given a column name and a path consisting of one or more elements, assume the column is of
170+
/// JSON type and return an appropriate expression for accessing the value at the given JSON
171+
/// path, according to the semantics of the dialect. Return `nil` if JSON subpath expressions
172+
/// are not supported or the given path is not valid in the dialect.
173+
///
174+
/// Defaults to returning `nil`.
175+
func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)?
169176
}
170177

171178
/// Controls `ALTER TABLE` syntax.
@@ -323,4 +330,5 @@ extension SQLDialect {
323330
public var unionFeatures: SQLUnionFeatures { [.union, .unionAll] }
324331
public var sharedSelectLockExpression: (any SQLExpression)? { nil }
325332
public var exclusiveSelectLockExpression: (any SQLExpression)? { nil }
333+
public func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? { nil }
326334
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import SQLKit
2+
import XCTest
3+
4+
extension SQLBenchmarker {
5+
public func testJSONPaths() throws {
6+
try self.runTest {
7+
try $0.drop(table: "planet_metadata")
8+
.ifExists()
9+
.run().wait()
10+
try $0.create(table: "planet_metadata")
11+
.column("id", type: .bigint, .primaryKey(autoIncrement: $0.dialect.supportsAutoIncrement))
12+
.column("metadata", type: .custom(SQLRaw($0.dialect.name == "postgresql" ? "jsonb" : "json")))
13+
.run().wait()
14+
15+
// insert
16+
try $0.insert(into: "planet_metadata")
17+
.columns("id", "metadata")
18+
.values(SQLLiteral.default, SQLLiteral.string(#"{"a":{"b":{"c":[1,2,3]}}}"#))
19+
.run().wait()
20+
21+
// try to extract fields
22+
let objectARows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a"]), as: "data").from("planet_metadata").all().wait()
23+
let objectARow = try XCTUnwrap(objectARows.first)
24+
let objectARaw = try objectARow.decode(column: "data", as: String.self)
25+
let objectA = try JSONDecoder().decode([String: [String: [Int]]].self, from: objectARaw.data(using: .utf8)!)
26+
27+
XCTAssertEqual(objectARows.count, 1)
28+
XCTAssertEqual(objectA, ["b": ["c": [1, 2 ,3]]])
29+
30+
let objectBRows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b"]), as: "data").from("planet_metadata").all().wait()
31+
let objectBRow = try XCTUnwrap(objectBRows.first)
32+
let objectBRaw = try objectBRow.decode(column: "data", as: String.self)
33+
let objectB = try JSONDecoder().decode([String: [Int]].self, from: objectBRaw.data(using: .utf8)!)
34+
35+
XCTAssertEqual(objectBRows.count, 1)
36+
XCTAssertEqual(objectB, ["c": [1, 2, 3]])
37+
38+
let objectCRows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b", "c"]), as: "data").from("planet_metadata").all().wait()
39+
let objectCRow = try XCTUnwrap(objectCRows.first)
40+
let objectCRaw = try objectCRow.decode(column: "data", as: String.self)
41+
let objectC = try JSONDecoder().decode([Int].self, from: objectCRaw.data(using: .utf8)!)
42+
43+
XCTAssertEqual(objectCRows.count, 1)
44+
XCTAssertEqual(objectC, [1, 2, 3])
45+
}
46+
}
47+
}

Sources/SQLKitBenchmark/SQLBenchmarker.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public final class SQLBenchmarker {
1515
if self.database.dialect.name != "generic" {
1616
try self.testUpserts()
1717
try self.testUnions()
18+
try self.testJSONPaths()
1819
}
1920
}
2021

Tests/SQLKitTests/SQLKitTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,4 +966,15 @@ CREATE TABLE `planets`(`id` BIGINT, `name` TEXT, `diameter` INTEGER, `galaxy_nam
966966
.wait()
967967
XCTAssertEqual(db.results[20], "(SELECT * FROM `t1`) UNION (SELECT * FROM `t2`) ORDER BY `id` ASC, `name` DESC")
968968
}
969+
970+
func testJSONPaths() throws {
971+
try db.select()
972+
.column(SQLNestedSubpathExpression(column: "json", path: ["a"]))
973+
.column(SQLNestedSubpathExpression(column: "json", path: ["a", "b"]))
974+
.column(SQLNestedSubpathExpression(column: "json", path: ["a", "b", "c"]))
975+
.column(SQLNestedSubpathExpression(column: SQLColumn("json", table: "table"), path: ["a", "b"]))
976+
.run()
977+
.wait()
978+
XCTAssertEqual(db.results[0], "SELECT (`json`->>'a'), (`json`->'a'->>'b'), (`json`->'a'->'b'->>'c'), (`table`.`json`->'a'->>'b')")
979+
}
969980
}

Tests/SQLKitTests/Utilities.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ struct GenericDialect: SQLDialect {
8787
var unionFeatures: SQLUnionFeatures = []
8888
var sharedSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR SHARE") }
8989
var exclusiveSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR UPDATE") }
90+
func nestedSubpathExpression(in column: SQLExpression, for path: [String]) -> (SQLExpression)? {
91+
precondition(!path.isEmpty)
92+
let descender = SQLList([column] + path.dropLast().map(SQLLiteral.string(_:)), separator: SQLRaw("->"))
93+
return SQLGroupExpression(SQLList([descender, SQLLiteral.string(path.last!)], separator: SQLRaw("->>")))
94+
}
9095

9196
mutating func setTriggerSyntax(create: SQLTriggerSyntax.Create = [], drop: SQLTriggerSyntax.Drop = []) {
9297
self.triggerSyntax.create = create

0 commit comments

Comments
 (0)