Skip to content

Commit 58d7e2d

Browse files
authored
Correctly parse SQL statements to find tables to observe
1 parent e1e803c commit 58d7e2d

File tree

7 files changed

+200
-18
lines changed

7 files changed

+200
-18
lines changed

SQLite.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
5716605E206842A1000F615F /* SQLite+Monitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5716605D206842A0000F615F /* SQLite+Monitor.swift */; };
1414
571C8C8D2036DA620096BCBC /* SQLite.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 571C8C832036DA620096BCBC /* SQLite.framework */; };
1515
571C8C922036DA620096BCBC /* SQLite+DatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571C8C912036DA620096BCBC /* SQLite+DatabaseTests.swift */; };
16+
5725C6AA22A40A9A00B73CDD /* SQLite+QueryPlanParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5725C6A922A40A9A00B73CDD /* SQLite+QueryPlanParserTests.swift */; };
17+
5725C6AC22A41BE700B73CDD /* SQLite+QueryPlanParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5725C6AB22A41BE700B73CDD /* SQLite+QueryPlanParser.swift */; };
1618
576E2BE32177754D00010AB3 /* SQLiteRow+ExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 576E2BE22177754D00010AB3 /* SQLiteRow+ExtensionsTests.swift */; };
1719
57747C772036E75D008D13D2 /* SQLite+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57747C762036E75D008D13D2 /* SQLite+Error.swift */; };
1820
57747C792039B373008D13D2 /* SQLite+Value.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57747C782039B373008D13D2 /* SQLite+Value.swift */; };
@@ -47,6 +49,8 @@
4749
571C8C8C2036DA620096BCBC /* SQLiteTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLiteTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
4850
571C8C912036DA620096BCBC /* SQLite+DatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLite+DatabaseTests.swift"; sourceTree = "<group>"; };
4951
571C8C932036DA620096BCBC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
52+
5725C6A922A40A9A00B73CDD /* SQLite+QueryPlanParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLite+QueryPlanParserTests.swift"; sourceTree = "<group>"; };
53+
5725C6AB22A41BE700B73CDD /* SQLite+QueryPlanParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLite+QueryPlanParser.swift"; sourceTree = "<group>"; };
5054
575DCD3520AA2C00007BE521 /* sqlitetests-debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "sqlitetests-debug.xcconfig"; sourceTree = "<group>"; };
5155
575DCD3620AA2C00007BE521 /* sqlite-debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "sqlite-debug.xcconfig"; sourceTree = "<group>"; };
5256
575DCD3720AA2C00007BE521 /* sqlitetests-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "sqlitetests-release.xcconfig"; sourceTree = "<group>"; };
@@ -122,6 +126,7 @@
122126
5790E1AA20728BBF00CE4E22 /* SQLite+Hook.swift */,
123127
5716605D206842A0000F615F /* SQLite+Monitor.swift */,
124128
5793EF4C21BD57FA00E0FB41 /* SQLite+Observer.swift */,
129+
5725C6AB22A41BE700B73CDD /* SQLite+QueryPlanParser.swift */,
125130
57747C782039B373008D13D2 /* SQLite+Value.swift */,
126131
57FEDE7821749A8C00298A03 /* SQLiteRow+Extensions.swift */,
127132
57FEDE742174792800298A03 /* SQLiteTransformable.swift */,
@@ -137,6 +142,7 @@
137142
571C8C912036DA620096BCBC /* SQLite+DatabaseTests.swift */,
138143
570935742132DD7B0020EBFE /* SQLite+DateFormatterTests.swift */,
139144
57FEDE7621748EB600298A03 /* SQLite+ObserveTests.swift */,
145+
5725C6A922A40A9A00B73CDD /* SQLite+QueryPlanParserTests.swift */,
140146
576E2BE22177754D00010AB3 /* SQLiteRow+ExtensionsTests.swift */,
141147
57F4CFA9204319C800FBC540 /* TestCodableType.swift */,
142148
57767D2A21986485006906DE /* TestTransformableType.swift */,
@@ -286,6 +292,7 @@
286292
57CCD9112036E562001D0E23 /* SQLite+Database.swift in Sources */,
287293
57CCD90A2036DF6D001D0E23 /* SQLite.swift in Sources */,
288294
5716605E206842A1000F615F /* SQLite+Monitor.swift in Sources */,
295+
5725C6AC22A41BE700B73CDD /* SQLite+QueryPlanParser.swift in Sources */,
289296
57747C792039B373008D13D2 /* SQLite+Value.swift in Sources */,
290297
57FEDE7921749A8C00298A03 /* SQLiteRow+Extensions.swift in Sources */,
291298
57FEDE752174792800298A03 /* SQLiteTransformable.swift in Sources */,
@@ -300,6 +307,7 @@
300307
57F4CFA82043184200FBC540 /* SQLite+CodableTests.swift in Sources */,
301308
570935752132DD7B0020EBFE /* SQLite+DateFormatterTests.swift in Sources */,
302309
576E2BE32177754D00010AB3 /* SQLiteRow+ExtensionsTests.swift in Sources */,
310+
5725C6AA22A40A9A00B73CDD /* SQLite+QueryPlanParserTests.swift in Sources */,
303311
57F4CFAA204319C800FBC540 /* TestCodableType.swift in Sources */,
304312
57FEDE7721748EB600298A03 /* SQLite+ObserveTests.swift in Sources */,
305313
571C8C922036DA620096BCBC /* SQLite+DatabaseTests.swift in Sources */,

SQLite/SQLite+Database.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ extension SQLite.Database: Equatable {
203203
}
204204
}
205205

206+
extension SQLite.Database {
207+
public var supportsJSON: Bool {
208+
return isCompileOptionEnabled("ENABLE_JSON1")
209+
}
210+
211+
public func isCompileOptionEnabled(_ name: String) -> Bool {
212+
return sqlite3_compileoption_used(name) == 1
213+
}
214+
}
215+
206216
extension SQLite.Database {
207217
func createUpdateHandler(_ block: @escaping (String) -> Void) {
208218
let updateBlock: UpdateHookCallback = { _, _, _, tableName, _ in

SQLite/SQLite+Error.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extension SQLite {
1616
case onRemoveFunction(String, Int32)
1717
case onGetColumnInTable(String)
1818
case onGetIndexInTable(String)
19+
case onGetSQL
1920
case onInvalidTableName(String)
2021
case onDecodingRow(String)
2122
case onInvalidDecodingType(String)
@@ -57,6 +58,8 @@ extension SQLite.Error: CustomStringConvertible {
5758
return "Could not get column in table: \(error)"
5859
case .onGetIndexInTable(let error):
5960
return "Could not get index in table: \(error)"
61+
case .onGetSQL:
62+
return "Could not get SQL for prepared statement"
6063
case .onInvalidTableName(let tableName):
6164
return "'\(tableName)' is not a valid table name"
6265
case .onDecodingRow(let valueName):

SQLite/SQLite+Monitor.swift

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ extension SQLite {
3232
throw SQLite.Error.onInternalError("SQLite.Database is missing")
3333
}
3434

35-
let tables = try statement.resultTables()
35+
let tables = try tablesToObserve(for: statement, in: database)
3636
assert(tables.isEmpty == false)
3737

3838
if _observers.isEmpty {
@@ -92,6 +92,16 @@ extension SQLite.Monitor {
9292
}
9393
}
9494

95+
extension SQLite.Monitor {
96+
private func tablesToObserve(for statement: OpaquePointer,
97+
in database: SQLite.Database) throws -> Set<String> {
98+
guard let sql = sqlite3_sql(statement) else { throw SQLite.Error.onGetSQL }
99+
let explain = "EXPLAIN QUERY PLAN \(String(cString: sql));"
100+
let queryPlan = try database.execute(raw: explain)
101+
return SQLite.QueryPlanParser.tables(in: queryPlan, matching: try database.tables())
102+
}
103+
}
104+
95105
private class Observers {
96106
private var _observers = Array<WeakObserver>()
97107

@@ -124,21 +134,6 @@ private class Observers {
124134
}
125135
}
126136

127-
private extension Statement {
128-
func resultTables() throws -> Set<String> {
129-
let count = sqlite3_column_count(self)
130-
guard count > 0 else { throw SQLite.Error.onInvalidSelectStatementColumnCount }
131-
132-
var tables = Set<String>()
133-
try (0..<count).forEach { (column: Int32) in
134-
let tableName = String(cString: sqlite3_column_table_name(self, column))
135-
guard tableName.isEmpty == false else { throw SQLite.Error.onInvalidTableName(tableName) }
136-
tables.insert(tableName)
137-
}
138-
return tables
139-
}
140-
}
141-
142137
private extension Set {
143138
func intersects(_ other: Set<Element>) -> Bool {
144139
return self.intersection(other).isEmpty == false
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
extension SQLite {
4+
struct QueryPlanParser {
5+
static func tables(in queryPlan: Array<SQLiteRow>,
6+
matching databaseTables: Array<String>) -> Set<String> {
7+
let databaseTables = databaseTables.sortedByLongestToShortest()
8+
var tables = Set<String>()
9+
for row in queryPlan {
10+
guard let detail = row["detail"]?.stringValue else { continue }
11+
guard let start = detail.tableNameStart else { continue }
12+
guard let end = detail.tableNameEnd(startingAt: start, matching: databaseTables) else { continue }
13+
let table = detail[start..<end]
14+
guard table.isEmpty == false else { continue }
15+
tables.insert(String(table))
16+
}
17+
return tables
18+
}
19+
}
20+
}
21+
22+
private extension String {
23+
var tableNameStart: String.Index? {
24+
guard hasPrefix("SCAN TABLE ") || hasPrefix("SEARCH TABLE ") else { return nil }
25+
return range(of: " TABLE ")?.upperBound
26+
}
27+
28+
func tableNameEnd(startingAt start: String.Index, matching databaseTables: Array<String>) -> String.Index? {
29+
for table in databaseTables {
30+
if let end = range(of: table, options: [.anchored], range: start..<endIndex) {
31+
return end.upperBound
32+
}
33+
}
34+
let substring = String(self[start..<endIndex])
35+
return databaseTables.contains(substring) ? endIndex : nil
36+
}
37+
}
38+
39+
private extension Array where Element == String {
40+
func sortedByLongestToShortest() -> Array<String> {
41+
return sorted(by: { $0.count > $1.count })
42+
}
43+
}

SQLiteTests/SQLite+DatabaseTests.swift

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class SQLiteDatabaseTests: XCTestCase {
3434
XCTAssertEqual(123, database.userVersion)
3535
}
3636

37+
func testSupportsJSON() {
38+
XCTAssertTrue(database.supportsJSON)
39+
}
40+
3741
func testCreateTable() {
3842
XCTAssertNoThrow(try database.execute(raw: _createTableWithBlob))
3943
let tableNames = try! database.tables()
@@ -173,6 +177,48 @@ class SQLiteDatabaseTests: XCTestCase {
173177
}
174178
}
175179

180+
func testInsertAndFetchValidJSON() {
181+
guard database.supportsJSON else { return XCTFail() }
182+
183+
let json = """
184+
{
185+
"text": "This is some text",
186+
"number": 1234.03,
187+
"array": [
188+
true,
189+
false
190+
],
191+
"object": {
192+
"inner": null
193+
}
194+
}
195+
"""
196+
197+
do {
198+
let write: SQL = "INSERT INTO test VALUES (:id, json(:string));"
199+
let read: SQL = "SELECT json_extract(string, '$.text') AS text FROM test WHERE id=:id;"
200+
201+
try database.execute(raw: _createTableWithIDAsStringAndNullableString)
202+
try database.write(write, arguments: ["id": .text("1"), "string": .text(json)])
203+
let result = try database.read(read, arguments: ["id": .text("1")])
204+
XCTAssertEqual(1, result.count)
205+
XCTAssertEqual(SQLite.Value.text("This is some text"), result[0]["text"])
206+
} catch {
207+
XCTFail(String(describing: error))
208+
}
209+
}
210+
211+
func testInsertInvalidJSON() {
212+
guard database.supportsJSON else { return XCTFail() }
213+
214+
try! database.execute(raw: _createTableWithIDAsStringAndNullableString)
215+
216+
let invalidJSON = "\"text\": What is this supposed to be?"
217+
let write: SQL = "INSERT INTO test VALUES (:id, json(:string));"
218+
let args: SQLiteArguments = ["id": .text("1"), "string": .text(invalidJSON)]
219+
XCTAssertThrowsError(try database.write(write, arguments: args))
220+
}
221+
176222
func testInsertFloatStringAndDataInTransaction() {
177223
let one: SQLiteArguments =
178224
["id": .integer(1), "float": .double(1.23), "string": .text("123"), "data": .data(_textData)]
@@ -328,11 +374,11 @@ extension SQLiteDatabaseTests {
328374
}
329375

330376
fileprivate var _insertIDAndString: String {
331-
return "INSERT INTO test VALUES (:id, :string)"
377+
return "INSERT INTO test VALUES (:id, :string);"
332378
}
333379

334380
fileprivate var _insertOrReplaceIDAndString: String {
335-
return "INSERT OR REPLACE INTO test VALUES (:id, :string)"
381+
return "INSERT OR REPLACE INTO test VALUES (:id, :string);"
336382
}
337383

338384
fileprivate var _selectWhereID: String {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import XCTest
2+
import SQLite3
3+
@testable import SQLite
4+
5+
class QueryPlanParserTests: XCTestCase {
6+
func testColumnsFromSingleTables() {
7+
let tables = ["conversations", "TABLE", "SCAN"]
8+
let queryPlan: Array<SQLiteRow> = self.queryPlan([(1, 0, 0, "SCAN TABLE conversations")])
9+
let expected: Set<String> = ["conversations"]
10+
let actual = SQLite.QueryPlanParser.tables(in: queryPlan, matching: tables)
11+
XCTAssertEqual(expected, actual)
12+
}
13+
14+
func testColumnsFromMultipleTables() {
15+
let tables = ["✌🏼 table", "first table", "sqlite_autoindex_✌🏼 table_1", "USING"]
16+
let queryPlan: Array<SQLiteRow> = self.queryPlan([
17+
(1, 0, 0, "SEARCH TABLE ✌🏼 table USING INDEX sqlite_autoindex_✌🏼 table_1 (id column=?)"),
18+
(4, 0, 0, "SEARCH TABLE first table USING INDEX sqlite_autoindex_first table_1 (id column=?)")
19+
])
20+
let expected: Set<String> = ["first table", "✌🏼 table"]
21+
let actual = SQLite.QueryPlanParser.tables(in: queryPlan, matching: tables)
22+
XCTAssertEqual(expected, actual)
23+
}
24+
25+
func testColumnsWithMergesJoinsAndJSON() {
26+
let tables = ["AS", "text_messages", "providers", "patients", "|||"]
27+
let queryPlan: Array<SQLiteRow> = self.queryPlan([
28+
(1, 0, 0, "MERGE (UNION ALL)"),
29+
(3, 1, 0, "LEFT"),
30+
(10, 3, 0, "SEARCH TABLE text_messages USING INDEX text_messages_index"),
31+
(18, 3, 0, "SCAN TABLE patients"),
32+
(27, 3, 0, "SCAN TABLE json_each AS USING VIRTUAL TABLE INDEX 1:"),
33+
(52, 1, 0, "RIGHT"),
34+
(62, 52, 0, "SEARCH TABLE text_messages USING CONVERING INDEX sqlite_autoindex_1"),
35+
(66, 52, 0, "SCAN TABLE json_each AS USING VIRTUAL TABLE INDEX 1:"),
36+
])
37+
let expected: Set<String> = ["text_messages", "patients"]
38+
let actual = SQLite.QueryPlanParser.tables(in: queryPlan, matching: tables)
39+
XCTAssertEqual(expected, actual)
40+
}
41+
42+
func testColumnsWithSimilarNames() {
43+
let tables = ["a", "ab", "abc", "abcd"]
44+
let queryPlan: Array<SQLiteRow> = self.queryPlan([
45+
(1, 0, 0, "SCAN TABLE a"),
46+
(3, 1, 0, "SCAN TABLE abcd"),
47+
(10, 3, 0, "SEARCH TABLE ab USING INDEX ab_index"),
48+
])
49+
let expected: Set<String> = ["a", "ab", "abcd"]
50+
let actual = SQLite.QueryPlanParser.tables(in: queryPlan, matching: tables)
51+
XCTAssertEqual(expected, actual)
52+
}
53+
54+
func testColumnsWithReservedWordsAndControlCharacters() {
55+
let tables = ["USING", "| |", "AS", "&&", "||", "USING AS"]
56+
let queryPlan: Array<SQLiteRow> = self.queryPlan([
57+
(1, 0, 0, "SEARCH TABLE USING AS USING USING_AS_index"),
58+
(3, 1, 0, "SCAN TABLE &&"),
59+
(10, 3, 0, "SEARCH TABLE | | USING INDEX ab_index"),
60+
])
61+
let expected: Set<String> = ["USING AS", "&&", "| |"]
62+
let actual = SQLite.QueryPlanParser.tables(in: queryPlan, matching: tables)
63+
XCTAssertEqual(expected, actual)
64+
}
65+
}
66+
67+
extension QueryPlanParserTests {
68+
private func queryPlan(_ rows: Array<(Int, Int, Int, String)>) -> Array<SQLiteRow> {
69+
return rows.map { queryPlan($0, $1, $2, $3) }
70+
}
71+
72+
private func queryPlan(_ id: Int, _ parent: Int, _ notused: Int,
73+
_ detail: String) -> SQLiteRow {
74+
return ["id": .integer(Int64(id)), "parent": .integer(Int64(parent)),
75+
"notused": .integer(Int64(notused)), "detail": .text(detail)]
76+
}
77+
}

0 commit comments

Comments
 (0)