From b2f128cb62a3abfbb1e3b2893ff3ee69e70f4f0f Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 10 Jul 2023 21:03:41 -0500 Subject: [PATCH] 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. --- .../Query/SQLNestedSubpathExpression.swift | 21 +++++++++ Sources/SQLKit/SQLDialect.swift | 8 ++++ .../SQLBenchmark+JSONPaths.swift | 47 +++++++++++++++++++ Sources/SQLKitBenchmark/SQLBenchmarker.swift | 1 + Tests/SQLKitTests/SQLKitTests.swift | 11 +++++ Tests/SQLKitTests/Utilities.swift | 5 ++ 6 files changed, 93 insertions(+) create mode 100644 Sources/SQLKit/Query/SQLNestedSubpathExpression.swift create mode 100644 Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift diff --git a/Sources/SQLKit/Query/SQLNestedSubpathExpression.swift b/Sources/SQLKit/Query/SQLNestedSubpathExpression.swift new file mode 100644 index 00000000..32b0f174 --- /dev/null +++ b/Sources/SQLKit/Query/SQLNestedSubpathExpression.swift @@ -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) + } +} diff --git a/Sources/SQLKit/SQLDialect.swift b/Sources/SQLKit/SQLDialect.swift index bac41e3d..581f1592 100644 --- a/Sources/SQLKit/SQLDialect.swift +++ b/Sources/SQLKit/SQLDialect.swift @@ -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. @@ -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 } } diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift b/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift new file mode 100644 index 00000000..f9ac46bf --- /dev/null +++ b/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift @@ -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]) + } + } +} diff --git a/Sources/SQLKitBenchmark/SQLBenchmarker.swift b/Sources/SQLKitBenchmark/SQLBenchmarker.swift index 1caa5820..f2301ff2 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmarker.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmarker.swift @@ -15,6 +15,7 @@ public final class SQLBenchmarker { if self.database.dialect.name != "generic" { try self.testUpserts() try self.testUnions() + try self.testJSONPaths() } } diff --git a/Tests/SQLKitTests/SQLKitTests.swift b/Tests/SQLKitTests/SQLKitTests.swift index f157ce9d..c6deb391 100644 --- a/Tests/SQLKitTests/SQLKitTests.swift +++ b/Tests/SQLKitTests/SQLKitTests.swift @@ -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')") + } } diff --git a/Tests/SQLKitTests/Utilities.swift b/Tests/SQLKitTests/Utilities.swift index bfdea0dd..b0270621 100644 --- a/Tests/SQLKitTests/Utilities.swift +++ b/Tests/SQLKitTests/Utilities.swift @@ -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