Skip to content

Commit

Permalink
Add support for nested subpath (JSON) expressions (#169)
Browse files Browse the repository at this point in the history
* Add a new SQLDialect method for generating nested subpath expressions (JSON paths) and a SQLNestedSubpathExpression expression for actually using it.
  • Loading branch information
gwynne authored Jul 11, 2023
1 parent 5026e7c commit b2f128c
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 0 deletions.
21 changes: 21 additions & 0 deletions Sources/SQLKit/Query/SQLNestedSubpathExpression.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// Represents a "nested subpath" expression. At this time, this always represents a key path leading to a
/// specific value in a JSON object.
public struct SQLNestedSubpathExpression: SQLExpression {
public var column: any SQLExpression
public var path: [String]

public init(column: any SQLExpression, path: [String]) {
assert(!path.isEmpty)

self.column = column
self.path = path
}

public init(column: String, path: [String]) {
self.init(column: SQLIdentifier(column), path: path)
}

public func serialize(to serializer: inout SQLSerializer) {
serializer.dialect.nestedSubpathExpression(in: self.column, for: self.path)?.serialize(to: &serializer)
}
}
8 changes: 8 additions & 0 deletions Sources/SQLKit/SQLDialect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ public protocol SQLDialect {
/// support exclusive locking requests, which causes the locking clause to be silently ignored.
var exclusiveSelectLockExpression: (any SQLExpression)? { get }

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

/// Controls `ALTER TABLE` syntax.
Expand Down Expand Up @@ -323,4 +330,5 @@ extension SQLDialect {
public var unionFeatures: SQLUnionFeatures { [.union, .unionAll] }
public var sharedSelectLockExpression: (any SQLExpression)? { nil }
public var exclusiveSelectLockExpression: (any SQLExpression)? { nil }
public func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? { nil }
}
47 changes: 47 additions & 0 deletions Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import SQLKit
import XCTest

extension SQLBenchmarker {
public func testJSONPaths() throws {
try self.runTest {
try $0.drop(table: "planet_metadata")
.ifExists()
.run().wait()
try $0.create(table: "planet_metadata")
.column("id", type: .bigint, .primaryKey(autoIncrement: $0.dialect.supportsAutoIncrement))
.column("metadata", type: .custom(SQLRaw($0.dialect.name == "postgresql" ? "jsonb" : "json")))
.run().wait()

// insert
try $0.insert(into: "planet_metadata")
.columns("id", "metadata")
.values(SQLLiteral.default, SQLLiteral.string(#"{"a":{"b":{"c":[1,2,3]}}}"#))
.run().wait()

// try to extract fields
let objectARows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a"]), as: "data").from("planet_metadata").all().wait()
let objectARow = try XCTUnwrap(objectARows.first)
let objectARaw = try objectARow.decode(column: "data", as: String.self)
let objectA = try JSONDecoder().decode([String: [String: [Int]]].self, from: objectARaw.data(using: .utf8)!)

XCTAssertEqual(objectARows.count, 1)
XCTAssertEqual(objectA, ["b": ["c": [1, 2 ,3]]])

let objectBRows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b"]), as: "data").from("planet_metadata").all().wait()
let objectBRow = try XCTUnwrap(objectBRows.first)
let objectBRaw = try objectBRow.decode(column: "data", as: String.self)
let objectB = try JSONDecoder().decode([String: [Int]].self, from: objectBRaw.data(using: .utf8)!)

XCTAssertEqual(objectBRows.count, 1)
XCTAssertEqual(objectB, ["c": [1, 2, 3]])

let objectCRows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b", "c"]), as: "data").from("planet_metadata").all().wait()
let objectCRow = try XCTUnwrap(objectCRows.first)
let objectCRaw = try objectCRow.decode(column: "data", as: String.self)
let objectC = try JSONDecoder().decode([Int].self, from: objectCRaw.data(using: .utf8)!)

XCTAssertEqual(objectCRows.count, 1)
XCTAssertEqual(objectC, [1, 2, 3])
}
}
}
1 change: 1 addition & 0 deletions Sources/SQLKitBenchmark/SQLBenchmarker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public final class SQLBenchmarker {
if self.database.dialect.name != "generic" {
try self.testUpserts()
try self.testUnions()
try self.testJSONPaths()
}
}

Expand Down
11 changes: 11 additions & 0 deletions Tests/SQLKitTests/SQLKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -966,4 +966,15 @@ CREATE TABLE `planets`(`id` BIGINT, `name` TEXT, `diameter` INTEGER, `galaxy_nam
.wait()
XCTAssertEqual(db.results[20], "(SELECT * FROM `t1`) UNION (SELECT * FROM `t2`) ORDER BY `id` ASC, `name` DESC")
}

func testJSONPaths() throws {
try db.select()
.column(SQLNestedSubpathExpression(column: "json", path: ["a"]))
.column(SQLNestedSubpathExpression(column: "json", path: ["a", "b"]))
.column(SQLNestedSubpathExpression(column: "json", path: ["a", "b", "c"]))
.column(SQLNestedSubpathExpression(column: SQLColumn("json", table: "table"), path: ["a", "b"]))
.run()
.wait()
XCTAssertEqual(db.results[0], "SELECT (`json`->>'a'), (`json`->'a'->>'b'), (`json`->'a'->'b'->>'c'), (`table`.`json`->'a'->>'b')")
}
}
5 changes: 5 additions & 0 deletions Tests/SQLKitTests/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ struct GenericDialect: SQLDialect {
var unionFeatures: SQLUnionFeatures = []
var sharedSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR SHARE") }
var exclusiveSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR UPDATE") }
func nestedSubpathExpression(in column: SQLExpression, for path: [String]) -> (SQLExpression)? {
precondition(!path.isEmpty)
let descender = SQLList([column] + path.dropLast().map(SQLLiteral.string(_:)), separator: SQLRaw("->"))
return SQLGroupExpression(SQLList([descender, SQLLiteral.string(path.last!)], separator: SQLRaw("->>")))
}

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

0 comments on commit b2f128c

Please sign in to comment.