Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JSON functions to the query interface #1423

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GRDB.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@
D263F40A26C613090038B07F /* DatabaseColumnEncodingStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D263F40926C613090038B07F /* DatabaseColumnEncodingStrategyTests.swift */; };
DC2393C81ABE35F8003FF113 /* GRDB-Bridging.h in Headers */ = {isa = PBXBuildFile; fileRef = DC2393C61ABE35F8003FF113 /* GRDB-Bridging.h */; settings = {ATTRIBUTES = (Public, ); }; };
DC3773F919C8CBB3004FCF85 /* GRDB.h in Headers */ = {isa = PBXBuildFile; fileRef = DC3773F819C8CBB3004FCF85 /* GRDB.h */; settings = {ATTRIBUTES = (Public, ); }; };
E7370F532A9899D0006DBC6C /* JSONFunctionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7370F522A9899D0006DBC6C /* JSONFunctionTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -821,6 +822,7 @@
DC3773F719C8CBB3004FCF85 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DC3773F819C8CBB3004FCF85 /* GRDB.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GRDB.h; sourceTree = "<group>"; };
DC37740419C8CBB3004FCF85 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E7370F522A9899D0006DBC6C /* JSONFunctionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONFunctionTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -1042,6 +1044,7 @@
563B5335267E2F90009549B5 /* TableTests.swift */,
5698AC7F1DA380A20056AF8C /* VirtualTableModuleTests.swift */,
5653EABF20944B1300F46237 /* Association */,
E7370F522A9899D0006DBC6C /* JSONFunctionTests.swift */,
);
name = QueryInterface;
sourceTree = "<group>";
Expand Down Expand Up @@ -1979,6 +1982,7 @@
56677C0D241CD0D00050755D /* ValueObservationRecorder.swift in Sources */,
5653EADA20944B4F00F46237 /* AssociationRowScopeSearchTests.swift in Sources */,
563B5336267E2F90009549B5 /* TableTests.swift in Sources */,
E7370F532A9899D0006DBC6C /* JSONFunctionTests.swift in Sources */,
56D4965A1D81304E008276D7 /* FoundationNSDataTests.swift in Sources */,
56D496791D81309E008276D7 /* RecordWithColumnNameManglingTests.swift in Sources */,
56D4966C1D81309E008276D7 /* RecordMinimalPrimaryKeyRowIDTests.swift in Sources */,
Expand Down
100 changes: 100 additions & 0 deletions GRDB/QueryInterface/SQL/SQLFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,103 @@ public func julianDay(_ value: some SQLSpecificExpressible, _ modifiers: SQLDate
public func dateTime(_ value: some SQLSpecificExpressible, _ modifiers: SQLDateModifier...) -> SQLExpression {
.function("DATETIME", [value.sqlExpression] + modifiers.map(\.sqlExpression))
}

// MARK: - JSON functions

/// The `JSON` SQL function.
///
/// Verifies that the argument is valid JSON, and returns the minified version.
///
/// This function can be used to convert raw text into valid JSON
/// that can be further used in other JSON functions so that it's interpreted as JSON and not text.
///
/// - Attention: This function is not appropriate for checking the validity of JSON.
/// Use ``isJSONValid(_:)`` instead.
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jmini>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func json(_ value: some SQLSpecificExpressible) -> SQLExpression {
.function("JSON", [value.sqlExpression])
}

/// The `JSON_ARRAY` SQL function.
///
/// Returns a well formed JSON array composed of the input parameters.
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jarray>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func jsonArray(_ values: (any SQLSpecificExpressible)...) -> SQLExpression {
.function("JSON_ARRAY", values.map(\.sqlExpression))
}

/// The `JSON_ARRAY_LENGTH` SQL function.
///
/// Returns the length of a JSON array, or 0 if the input is not a JSON array.
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jarraylen>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func jsonArrayLength(_ value: some SQLSpecificExpressible) -> SQLExpression {
.function("JSON_ARRAY_LENGTH", [value.sqlExpression])
}

/// The `JSON_ARRAY_LENGTH` SQL function.
///
/// Returns the length of a JSON array located within the given path in the input,
/// or 0 if the input is not a JSON array.
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jarraylen>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func jsonArrayLength(_ value: some SQLSpecificExpressible, _ path: String) -> SQLExpression {
.function("JSON_ARRAY_LENGTH", [value.sqlExpression, path.sqlExpression])
}

/// The `JSON_EXTRACT` SQL function.
///
/// Extracts and returns one or more values from well-formed JSON.
/// If multiple paths are provided, the function returns a JSON array holding the extracted values.
///
/// For example:
///
/// ```swift
/// // JSON_EXTRACT(jsonData, '$')
/// jsonExtract(Column("jsonData"), "$")
///
/// // JSON_EXTRACT(jsonData, '$.values')
/// jsonExtract(Column("jsonData"), "$.values")
///
/// // JSON_EXTRACT(jsonData, '$.values[2]')
/// jsonExtract(Column("jsonData"), "$.values[2]")
///
/// // JSON_EXTRACT(jsonData, '$.first_key', '$.second_key')
/// jsonExtract(Column("jsonData"), "$.first_key", "$.second_key")
/// ```
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jex>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func jsonExtract(_ value: some SQLSpecificExpressible, _ paths: String...) -> SQLExpression {
.function("JSON_EXTRACT", [value.sqlExpression] + paths.map(\.sqlExpression))
}

/// The `JSON_VALID` SQL function.
///
/// Checks if the given expression is a well-formed,
/// canonical [RFC-7159](https://datatracker.ietf.org/doc/html/rfc7159) JSON string
/// without any JSON5 extensions.
///
/// For example:
///
/// ``` swift
/// // json_valid('{"x":35}')
/// isJSONValid(#"{"x":35}"#.databaseValue) // 1
///
/// // json_valid('{"x":35')
/// isJSONValid(#"{"x":35"#.databaseValue) // 0
/// ```
///
/// Related SQLite documentation: <https://www.sqlite.org/json1.html#jvalid>
/// - parameter value: JSON to be validated.
/// - returns: ``SQLExpression`` returning `1` if the input was valid, and `0` if it was invalid.
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func isJSONValid(_ value: some SQLSpecificExpressible) -> SQLExpression {
.function("JSON_VALID", [value.sqlExpression])
}
29 changes: 29 additions & 0 deletions GRDB/QueryInterface/SQL/SQLOperators.swift
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,32 @@ extension SQLSpecificExpressible {
.escapableBinary(.like, sqlExpression, pattern.sqlExpression, escape: escape?.sqlExpression)
}
}

// MARK: - JSON operators

extension SQLSpecificExpressible {

/// The `->` SQL operator.
///
/// Returns the object from the selected JSON path, or `NULL` if the path doesn't exist.
///
/// For getting a JSON object, use ``subscript(valueAt:)`` instead.
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jptr>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public subscript(objectAt jsonPath: String) -> SQLExpression {
.literal("\(self) -> \(jsonPath)")
}

/// The `->>` SQL operator.
///
/// Returns the value from the selected JSON path, or `NULL` if the path doesn't exist.
///
/// For getting a JSON object, use ``subscript(objectAt:)`` instead.
///
/// Related SQLite documentation:<https://www.sqlite.org/json1.html#jptr>
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public subscript(valueAt jsonPath: String) -> SQLExpression {
.literal("\(self) ->> \(jsonPath)")
}
}
132 changes: 132 additions & 0 deletions Tests/GRDBTests/JSONFunctionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import XCTest
import GRDB

final class JSONFunctionTests: GRDBTestCase {

private func assert<Output: DatabaseValueConvertible & Equatable>(
_ db: Database,
_ expression: SQLExpression,
equal expectedOutput: Output,
file: StaticString = #file,
line: UInt = #line) throws
{
let request: SQLRequest<Output> = "SELECT \(expression)"
guard let json = try request.fetchOne(db) else {
XCTFail(file: file, line: line)
return
}
XCTAssertEqual(json, expectedOutput, file: file, line: line)
}

func testJSON() throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
let dbQueue = try makeDatabaseQueue()

let input = """
{ "this" : "is", "a": [ "test" ] }
""".databaseValue

let expected = """
{"this":"is","a":["test"]}
"""

try dbQueue.inDatabase { db in
try assert(db, json(input), equal: expected)
}
}
}

func testJSONArray() throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
let dbQueue = try makeDatabaseQueue()

try dbQueue.inDatabase { db in
try assert(
db,
jsonArray(
1.databaseValue,
2.databaseValue,
"3".databaseValue,
4.databaseValue
),
equal: "[1,2,\"3\",4]"
)
try assert(
db,
jsonArray(
jsonArray(
1.databaseValue,
2.databaseValue,
"3".databaseValue,
4.databaseValue
)
),
equal: "[[1,2,\"3\",4]]"
)
try assert(
db,
jsonArray(
1.databaseValue,
DatabaseValue.null,
"3".databaseValue,
json("[4,5]".databaseValue),
json("{\"six\":7.7}".databaseValue)
),
equal: "[1,null,\"3\",[4,5],{\"six\":7.7}]"
)
}
}
}

func testJSONArrayLength() throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
let dbQueue = try makeDatabaseQueue()

try dbQueue.inDatabase { db in
try assert(db, jsonArrayLength("[1,2,3,4]".databaseValue), equal: 4)
try assert(db, jsonArrayLength("{\"one\":[1,2,3]}".databaseValue), equal: 0)
}
}
}

func testJSONArrayLengthWithPath() throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
let dbQueue = try makeDatabaseQueue()

try dbQueue.inDatabase { db in
try assert(db, jsonArrayLength("[1,2,3,4]".databaseValue, "$"), equal: 4)
try assert(db, jsonArrayLength("[1,2,3,4]".databaseValue, "$[2]"), equal: 0)
}
}
}

func testJSONExtract() throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
let dbQueue = try makeDatabaseQueue()

let input = """
{"a":2,"c":[4,5,{"f":7}]}
""".databaseValue

try dbQueue.inDatabase { db in
try assert(db, jsonExtract(input, "$"), equal: input)
try assert(db, jsonExtract(input, "$.c"), equal: "[4,5,{\"f\":7}]")
try assert(db, jsonExtract(input, "$.c[2]"), equal: "{\"f\":7}")
try assert(db, jsonExtract(input, "$.c[2].f"), equal: 7)
try assert(db, jsonExtract(input, "$.x"), equal: DatabaseValue.null)
try assert(db, jsonExtract(input, "$.x", "$.a"), equal: "[null,2]")
}
}
}

func testIsJSONValid() throws {
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
let dbQueue = try makeDatabaseQueue()

try dbQueue.inDatabase { db in
try assert(db, isJSONValid(#"{"x":35}"#.databaseValue), equal: 1)
try assert(db, isJSONValid(#"{"x":35"#.databaseValue), equal: 0)
}
}
}
}
20 changes: 20 additions & 0 deletions Tests/GRDBTests/QueryInterfaceExpressionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1678,4 +1678,24 @@ class QueryInterfaceExpressionsTests: GRDBTestCase {
""")
}
}

// MARK: - JSON Operators

func testJSONExtractOperator() throws {
let dbQueue = try makeDatabaseQueue()

if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
XCTAssertEqual(
sql(dbQueue, tableRequest.select(Col.name[objectAt: "$"])),
"""
SELECT "name" -> '$' FROM "readers"
""")

XCTAssertEqual(
sql(dbQueue, tableRequest.select(Col.name[valueAt: "$"])),
"""
SELECT "name" ->> '$' FROM "readers"
""")
}
}
}