diff --git a/Package.resolved b/Package.resolved index 90cd82c2e..0627bd344 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "FMDB", - "repositoryURL": "https://github.com/ccgus/fmdb", - "state": { - "branch": null, - "revision": "61e51fde7f7aab6554f30ab061cc588b28a97d04", - "version": "2.7.7" - } - }, { "package": "Mocker", "repositoryURL": "https://github.com/WeTransfer/Mocker.git", diff --git a/Package.swift b/Package.swift index e6d82bb48..666795f11 100644 --- a/Package.swift +++ b/Package.swift @@ -16,13 +16,11 @@ let package = Package( targets: ["SnowplowTracker"]), ], dependencies: [ - .package(name: "FMDB", url: "https://github.com/ccgus/fmdb", from: "2.7.6"), .package(name: "Mocker", url: "https://github.com/WeTransfer/Mocker.git", from: "2.5.4"), ], targets: [ .target( name: "SnowplowTracker", - dependencies: ["FMDB"], path: "./Sources"), .testTarget( name: "Tests", diff --git a/SnowplowTracker.podspec b/SnowplowTracker.podspec index e332c59c9..8f40318dd 100644 --- a/SnowplowTracker.podspec +++ b/SnowplowTracker.podspec @@ -26,6 +26,4 @@ Pod::Spec.new do |s| s.tvos.frameworks = 'UIKit', 'Foundation' s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } - - s.dependency 'FMDB', '~> 2.7' end diff --git a/Sources/Core/Storage/Database.swift b/Sources/Core/Storage/Database.swift new file mode 100644 index 000000000..3897f148e --- /dev/null +++ b/Sources/Core/Storage/Database.swift @@ -0,0 +1,183 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +#if os(iOS) || os(macOS) + +import Foundation +import SQLite3 + +class Database { + private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + private let dbPath: String + + static func dbPath(namespace: String) -> String { + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] + + // Create snowplow subdirectory if it doesn't exist + let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path + try? FileManager.default.createDirectory(atPath: snowplowDirPath, withIntermediateDirectories: true, attributes: nil) + + // Create path for the database + let regex: NSRegularExpression? = try? NSRegularExpression(pattern: "[^a-zA-Z0-9_]+", options: []) + + let sqliteSuffix = regex?.stringByReplacingMatches(in: namespace, options: [], range: NSRange(location: 0, length: namespace.count), withTemplate: "-") + let sqliteFilename = "snowplowEvents-\(sqliteSuffix ?? "").sqlite" + return URL(fileURLWithPath: snowplowDirPath).appendingPathComponent(sqliteFilename).path + } + + init(namespace: String) { + dbPath = Database.dbPath(namespace: namespace) + + createTable() + } + + private func createTable() { + let sql = """ + CREATE TABLE IF NOT EXISTS 'events' + (id INTEGER PRIMARY KEY, eventData BLOB, dateCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP) + """ + + _ = execute(sql: sql, name: "Create table") + } + + func insertRow(_ dict: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: dict) else { + logError(message: "Failed to serialize event to save in database") + return + } + + let insertString = "INSERT INTO 'events' (eventData) VALUES (?)" + data.withUnsafeBytes { rawBuffer in + if let pointer = rawBuffer.baseAddress { + prepare(sql: insertString, name: "Insert row") { insertStatement, db in + sqlite3_bind_blob(insertStatement, 1, pointer, Int32(rawBuffer.count), SQLITE_TRANSIENT) + + if sqlite3_step(insertStatement) == SQLITE_DONE { + logDebug(message: "Event stored in database") + } else { + logSqlError(message: "Failed to insert event to database", connection: db) + } + } + } + } + } + + func deleteRows(ids: [Int64]? = nil) -> Bool { + var sql = "DELETE FROM 'events'" + if let ids = ids { + sql += " WHERE id IN \(idsSqlString(ids))" + } + return execute(sql: sql, name: "Delete rows") + } + + func countRows() -> Int64? { + var count: Int64? = nil + let sql = "SELECT COUNT(*) AS count FROM 'events'" + + prepare(sql: sql, name: "Count rows") { selectStatement, _ in + if sqlite3_step(selectStatement) == SQLITE_ROW { + count = sqlite3_column_int64(selectStatement, 0) + } + } + return count + } + + private func idsSqlString(_ ids: [Int64] = []) -> String { + return "(" + ids.map { "\($0)" }.joined(separator: ",") + ")" + } + + func readRows(numRows: Int) -> [(id: Int64, data: [String: Any])] { + var rows: [(id: Int64, data: [String: Any])] = [] + let sql = "SELECT id, eventData FROM 'events' LIMIT \(numRows)" + + var rowsRead: Int = 0 + prepare(sql: sql, name: "Select rows") { selectStatement, db in + while sqlite3_step(selectStatement) == SQLITE_ROW { + if let blob = sqlite3_column_blob(selectStatement, 1) { + let blobLength = sqlite3_column_bytes(selectStatement, 1) + let data = Data(bytes: blob, count: Int(blobLength)) + let id = sqlite3_column_int64(selectStatement, 0) + + if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + rows.append((id: id, data: dict)) + + rowsRead += 1 + } + } else { + logSqlError(message: "No data found for row in events", connection: db) + } + } + if rowsRead > 0 { + logDebug(message: "Read \(rowsRead) events from database") + } + } + return rows + } + + private func prepare(sql: String, name: String, closure: (OpaquePointer?, OpaquePointer?) -> ()) { + withConnection { db in + var statement: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { + closure(statement, db) + } else { + logSqlError(message: "\(name) failed to prepare", connection: db) + } + sqlite3_finalize(statement) + } + } + + private func execute(sql: String, name: String) -> Bool { + var success = false + prepare(sql: sql, name: name) { statement, db in + if sqlite3_step(statement) == SQLITE_DONE { + logDebug(message: "\(name) successful") + success = true + } else { + logSqlError(message: "\(name) failed", connection: db) + } + } + return success + } + + private func logSqlError(message: String? = nil, connection: OpaquePointer? = nil) { + if let msg = message { + logError(message: msg) + } + if let db = connection { + let sqlError = String(cString: sqlite3_errmsg(db)!) + logError(message: sqlError) + } + } + + private func withConnection(closure: (OpaquePointer) -> T) -> T? { + if let connection = open() { + defer { close(connection) } + return closure(connection) + } + return nil + } + + private func open() -> OpaquePointer? { + var connection: OpaquePointer? + if sqlite3_open_v2(dbPath, &connection, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) != SQLITE_OK { + logSqlError(message: "Failed to open database: \(dbPath)") + } + return connection + } + + private func close(_ connection: OpaquePointer) { + sqlite3_close(connection) + } +} + +#endif diff --git a/Sources/Core/Storage/SQLiteEventStore.swift b/Sources/Core/Storage/SQLiteEventStore.swift index 101badb09..69a6046a1 100644 --- a/Sources/Core/Storage/SQLiteEventStore.swift +++ b/Sources/Core/Storage/SQLiteEventStore.swift @@ -13,271 +13,72 @@ #if os(iOS) || os(macOS) -import FMDB import Foundation -let _queryCreateTable = "CREATE TABLE IF NOT EXISTS 'events' (id INTEGER PRIMARY KEY, eventData BLOB, dateCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" -let _querySelectAll = "SELECT * FROM 'events'" -let _querySelectCount = "SELECT Count(*) FROM 'events'" -let _queryInsertEvent = "INSERT INTO 'events' (eventData) VALUES (?)" -let _querySelectId = "SELECT * FROM 'events' WHERE id=?" -let _queryDeleteId = "DELETE FROM 'events' WHERE id=?" -let _queryDeleteIds = "DELETE FROM 'events' WHERE id IN (%@)" -let _queryDeleteAll = "DELETE FROM 'events'" - class SQLiteEventStore: NSObject, EventStore { - var namespace: String - var sqliteFilename: String - var dbPath: String - var queue: FMDatabaseQueue? - var sendLimit: Int - - /// IMPORTANT: This method is for internal use only. It's signature and behaviour might change in any - /// future tracker release. - /// - /// Clears all the EventStores not associated at any of the namespaces passed as parameter. - /// - /// - Parameter allowedNamespaces: The namespace allowed. All the EventStores not associated at any of - /// the allowedNamespaces will be cleared. - /// - Returns: The list of namespaces that have been found with EventStores and have been cleared out. - class func removeUnsentEventsExcept(forNamespaces allowedNamespaces: [String]?) -> [String]? { - #if os(tvOS) - let libraryPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).map(\.path)[0] - #else - let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] - #endif - let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path - var files: [String]? = nil - do { - files = try FileManager.default.contentsOfDirectory(atPath: snowplowDirPath) - } catch { - } - var allowedFiles: [String]? = [] - for namespace in allowedNamespaces ?? [] { - var regex: NSRegularExpression? = nil - do { - regex = try NSRegularExpression(pattern: "[^a-zA-Z0-9_]+", options: []) - } catch { - } - let sqliteSuffix = regex?.stringByReplacingMatches(in: namespace, options: [], range: NSRange(location: 0, length: namespace.count), withTemplate: "-") - let sqliteFilename = "snowplowEvents-\(sqliteSuffix ?? "").sqlite" - allowedFiles?.append(sqliteFilename) - } - var removedFiles: [String]? = [] - for file in files ?? [] { - if !(allowedFiles?.contains(file) ?? false) { - let pathToRemove = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent(file).path - try? FileManager.default.removeItem(atPath: pathToRemove) - removedFiles?.append(file) - } - } - return removedFiles - } - - /// Basic initializer that creates a database event table (if one does not exist) and then closes the connection. - convenience init(namespace: String?) { - self.init(namespace: namespace, limit: 250) - } - - init(namespace: String?, limit: Int) { - self.namespace = namespace ?? "" - sendLimit = limit - - #if os(tvOS) - let libraryPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).map(\.path)[0] - #else - let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] - #endif - // Create snowplow subdirectory if it doesn't exist - let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path - try? FileManager.default.createDirectory(atPath: snowplowDirPath, withIntermediateDirectories: true, attributes: nil) - - // Create path for the database - var regex: NSRegularExpression? = nil - do { - regex = try NSRegularExpression(pattern: "[^a-zA-Z0-9_]+", options: []) - } catch { - } - let sqliteSuffix = regex?.stringByReplacingMatches(in: self.namespace, options: [], range: NSRange(location: 0, length: namespace?.count ?? 0), withTemplate: "-") - sqliteFilename = "snowplowEvents-\(sqliteSuffix ?? "").sqlite" - dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent(sqliteFilename).path + private var database: Database + private var sendLimit: Int + private var dispatchQueue = DispatchQueue(label: "snowplow.event_store") + init(namespace: String?, limit: Int = 250) { + let namespace = namespace ?? "" + // Migrate old database if it exists + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] let oldDbPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplowEvents.sqlite").path if FileManager.default.fileExists(atPath: oldDbPath) { - try? FileManager.default.moveItem(atPath: oldDbPath, toPath: dbPath) + let newDbPath = Database.dbPath(namespace: namespace) + try? FileManager.default.moveItem(atPath: oldDbPath, toPath: newDbPath) } - - // Create database - queue = FMDatabaseQueue(path: dbPath) - super.init() - _ = createTable() - } - - deinit { - queue?.close() + + database = Database(namespace: namespace) + sendLimit = limit } // MARK: SPEventStore implementation methods func addEvent(_ payload: Payload) { - _ = insertDictionaryData(payload.dictionary) + dispatchQueue.sync { + self.database.insertRow(payload.dictionary) + } } func removeEvent(withId storeId: Int64) -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() { - logDebug(message: String(format: "Removing %d from database now.", storeId)) - res = db.executeUpdate(_queryDeleteId, withArgumentsIn: [storeId]) - } - }) - return res + dispatchQueue.sync { + return database.deleteRows(ids: [storeId]) + } } func removeEvents(withIds storeIds: [Int64]) -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() && storeIds.count != 0 { - let ids = storeIds.map { String(describing: $0) }.joined(separator: ",") - logDebug(message: String(format: "Removing [%@] from database now.", ids)) - let query = String(format: _queryDeleteIds, ids) - res = db.executeUpdate(query, withArgumentsIn: []) - } - }) - return res + dispatchQueue.sync { + return database.deleteRows(ids: storeIds) + } } func removeAllEvents() -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() { - logDebug(message: "Removing all events from database now.") - res = db.executeUpdate(_queryDeleteAll, withArgumentsIn: []) - } - }) - return res + dispatchQueue.sync { + return database.deleteRows() + } } func count() -> UInt { - var num: UInt = 0 - queue?.inDatabase({ db in - if db.open() { - if let s = db.executeQuery(_querySelectCount, withArgumentsIn: []) { - while s.next() { - num = NSNumber(value: s.int(forColumnIndex: 0)).uintValue - } - s.close() - } + dispatchQueue.sync { + if let count = database.countRows() { + return UInt(count) } - }) - return num + return 0 + } } func emittableEvents(withQueryLimit queryLimit: UInt) -> [EmitterEvent] { - return getAllEventsLimited(min(queryLimit, UInt(sendLimit))) ?? [] - } - - // MARK: SPSQLiteEventStore methods - - func createTable() -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() { - res = db.executeStatements(_queryCreateTable) + dispatchQueue.sync { + let limit = min(Int(queryLimit), sendLimit) + let rows = database.readRows(numRows: limit) + return rows.map { row in + let payload = Payload(dictionary: row.data) + return EmitterEvent(payload: payload, storeId: row.id) } - }) - return res - } - - /// Inserts events into the sqlite table for the app identified with it's bundleId (appId). - /// - Parameter payload: A SnowplowPayload instance to be inserted into the database. - /// - Returns: If the insert was successful, we return the rowId of the inserted entry, otherwise -1. We explicitly do this in the case of an error, sqlite would return the previous successful insert leading to incorrect data removals. - func insertEvent(_ payload: Payload?) -> Int64 { - return insertDictionaryData(payload?.dictionary) - } - - func insertDictionaryData(_ dict: [AnyHashable : Any]?) -> Int64 { - var res: Int64 = -1 - if dict == nil { - return res } - queue?.inDatabase({ db in - if db.open() { - if let dict = dict, - let data = try? JSONSerialization.data(withJSONObject: dict) { - try? db.executeUpdate(_queryInsertEvent, values: [data]) - res = db.lastInsertRowId - } - } - }) - return res - } - - /// Finds the row in the event table with the supplied ID. - /// - Parameter id_: Unique ID of the row in the events table to be returned. - /// - Returns: A dictionary containing data with keys: 'ID', 'eventData', and 'dateCreated'. - func getEventWithId(_ id_: Int64) -> EmitterEvent? { - var event: EmitterEvent? = nil - queue?.inDatabase({ db in - if db.open() { - if let s = try? db.executeQuery(_querySelectId, values: [id_]) { - while s.next() { - if let data = s.data(forColumn: "eventData"), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let payload = Payload(dictionary: dict) - event = EmitterEvent(payload: payload, storeId: id_) - } - } - s.close() - } - } - }) - return event - } - - /// Returns all the events in an array of dictionaries. - /// - Returns: An array with each dictionary element containing key-value pairs of 'date', 'data', 'ID'. - func getAllEvents() -> [EmitterEvent]? { - return self.getAllEvents(withQuery: _querySelectAll) - } - - /// Returns limited number the events that are NOT pending in an array of dictionaries. - /// - Returns: An array with each dictionary element containing key-value pairs of 'date', 'data', 'ID'. - func getAllEventsLimited(_ limit: UInt) -> [EmitterEvent]? { - let query = "\(_querySelectAll) LIMIT \((NSNumber(value: limit)).stringValue)" - return getAllEvents(withQuery: query) - } - - func getAllEvents(withQuery query: String) -> [EmitterEvent]? { - var res: [EmitterEvent] = [] - queue?.inDatabase({ db in - if db.open() { - if let s = try? db.executeQuery(query, values: []) { - while s.next() { - let index = s.longLongInt(forColumn: "ID") - if let data = s.data(forColumn: "eventData"), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let payload = Payload(dictionary: dict) - let event = EmitterEvent(payload: payload, storeId: index) - res.append(event) - } - } - s.close() - } - } - }) - return res - } - - /// The row ID of the last insert made. - /// - Returns: The row ID of the last insert made. - func getLastInsertedRowId() -> Int64 { - var res: Int64 = -1 - queue?.inDatabase({ db in - res = db.lastInsertRowId - }) - return res } } diff --git a/Tests/Storage/TestDatabase.swift b/Tests/Storage/TestDatabase.swift new file mode 100644 index 000000000..645c588a7 --- /dev/null +++ b/Tests/Storage/TestDatabase.swift @@ -0,0 +1,120 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +#if os(iOS) || os(macOS) + +import XCTest +@testable import SnowplowTracker + +class TestDatabase: XCTestCase { + + func testDatabasePathConsistentForNamespace() { + XCTAssertEqual(Database.dbPath(namespace: "ns1"), Database.dbPath(namespace: "ns1")) + } + + func testDatabasePathDiffersByNamespace() { + XCTAssertNotEqual(Database.dbPath(namespace: "ns1"), Database.dbPath(namespace: "ns2")) + } + + func testDatabasePathDoesntContainSpecialChars() { + XCTAssertFalse(Database.dbPath(namespace: "%*$@db").contains("%*$@")) + } + + func testInsertsAndReadsRow() { + let database = createDatabase("db1") + + database.insertRow(["test": true]) + let rows = database.readRows(numRows: 100) + + XCTAssertEqual(1, rows.count) + XCTAssertEqual( + try? JSONSerialization.data(withJSONObject: rows.first?.data ?? []), + try? JSONSerialization.data(withJSONObject: ["test": true]) + ) + } + + func testCanWorkWithTwoOpenDatabases() { + let db1 = createDatabase("db1") + let db2 = createDatabase("db2") + + db1.insertRow(["test": 1]) + db2.insertRow(["test": 2]) + let rows1 = db1.readRows(numRows: 100) + let rows2 = db2.readRows(numRows: 100) + + XCTAssertEqual(1, rows1.count) + XCTAssertEqual(1, rows2.count) + + XCTAssertNotEqual( + try? JSONSerialization.data(withJSONObject: rows1.first?.data ?? []), + try? JSONSerialization.data(withJSONObject: rows2.first?.data ?? []) + ) + } + + func testDeleteAllRows() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + db.insertRow(["test": 2]) + + XCTAssertEqual(db.readRows(numRows: 100).count, 2) + + XCTAssertTrue(db.deleteRows()) + + XCTAssertEqual(db.readRows(numRows: 100).count, 0) + } + + func testDeleteSpecificRows() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + db.insertRow(["test": 2]) + + let rows = db.readRows(numRows: 100) + XCTAssertEqual(rows.count, 2) + + XCTAssertTrue(db.deleteRows(ids: [rows.first?.id ?? 0])) + + let newRows = db.readRows(numRows: 100) + XCTAssertEqual(newRows.count, 1) + XCTAssertEqual(newRows.first?.id, rows.last?.id) + } + + func testSelectRowsWithLimit() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + db.insertRow(["test": 2]) + + let rows = db.readRows(numRows: 1) + XCTAssertEqual(rows.count, 1) + } + + func testCountRows() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + XCTAssertEqual(db.countRows(), 1) + db.insertRow(["test": 2]) + XCTAssertEqual(db.countRows(), 2) + XCTAssertTrue(db.deleteRows()) + XCTAssertEqual(db.countRows(), 0) + } + + private func createDatabase(_ namespace: String) -> Database { + DatabaseHelpers.clearPreviousDatabase(namespace) + return Database(namespace: namespace) + } +} + +#endif diff --git a/Tests/TestSQLiteEventStore.swift b/Tests/Storage/TestSQLiteEventStore.swift similarity index 66% rename from Tests/TestSQLiteEventStore.swift rename to Tests/Storage/TestSQLiteEventStore.swift index f152b7d3f..9b2e6d018 100644 --- a/Tests/TestSQLiteEventStore.swift +++ b/Tests/Storage/TestSQLiteEventStore.swift @@ -17,17 +17,9 @@ import XCTest @testable import SnowplowTracker class TestSQLiteEventStore: XCTestCase { - override func setUp() { - _ = SQLiteEventStore.removeUnsentEventsExcept(forNamespaces: []) - } - - func testInit() { - let eventStore = SQLiteEventStore(namespace: "aNamespace") - XCTAssertNotNil(eventStore) - } func testInsertPayload() { - let eventStore = SQLiteEventStore(namespace: "aNamespace") + let eventStore = createEventStore("aNamespace") _ = eventStore.removeAllEvents() // Build an event @@ -38,19 +30,19 @@ class TestSQLiteEventStore: XCTestCase { payload.addValueToPayload("MEEEE", forKey: "refr") // Insert an event - _ = eventStore.insertEvent(payload) + eventStore.addEvent(payload) XCTAssertEqual(eventStore.count(), 1) - XCTAssertEqual(eventStore.getEventWithId(1)?.payload.dictionary as! [String : String], + let emittableEvents = eventStore.emittableEvents(withQueryLimit: 10) + XCTAssertEqual(emittableEvents.first?.payload.dictionary as! [String : String], payload.dictionary as! [String : String]) - XCTAssertEqual(eventStore.getLastInsertedRowId(), 1) - _ = eventStore.removeEvent(withId: 1) + _ = eventStore.removeEvent(withId: emittableEvents.first?.storeId ?? 0) XCTAssertEqual(eventStore.count(), 0) } func testInsertManyPayloads() { - let eventStore = SQLiteEventStore(namespace: "aNamespace") + let eventStore = createEventStore("aNamespace") _ = eventStore.removeAllEvents() // Build an event @@ -60,44 +52,46 @@ class TestSQLiteEventStore: XCTestCase { payload.addValueToPayload("Welcome to foobar!", forKey: "page") payload.addValueToPayload("MEEEE", forKey: "refr") - for _ in 0..<250 { - _ = eventStore.insertEvent(payload) + let dispatchQueue = DispatchQueue(label: "Save events", attributes: .concurrent) + let expectations = [ + XCTestExpectation(), + XCTestExpectation(), + XCTestExpectation(), + XCTestExpectation(), + XCTestExpectation() + ] + for i in 0..<5 { + dispatchQueue.async { + for _ in 0..<500 { + eventStore.addEvent(payload) + } + expectations[i].fulfill() + } } - - XCTAssertEqual(eventStore.count(), 250) - XCTAssertEqual(eventStore.getAllEventsLimited(600)?.count, 250) - XCTAssertEqual(eventStore.getAllEventsLimited(150)?.count, 150) - XCTAssertEqual(eventStore.getAllEvents()?.count, 250) - + wait(for: expectations) + + XCTAssertEqual(eventStore.count(), 2500) + XCTAssertEqual(eventStore.emittableEvents(withQueryLimit: 600).count, 250) + XCTAssertEqual(eventStore.emittableEvents(withQueryLimit: 150).count, 150) + _ = eventStore.removeAllEvents() XCTAssertEqual(eventStore.count(), 0) } func testSQLiteEventStoreCreateSQLiteFile() { - _ = SQLiteEventStore(namespace: "aNamespace") + let eventStore = createEventStore("aNamespace") + _ = eventStore.count() + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path let dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace.sqlite").path XCTAssertTrue(FileManager.default.fileExists(atPath: dbPath)) } - func testSQLiteEventStoreRemoveFiles() { - _ = SQLiteEventStore(namespace: "aNamespace1") - _ = SQLiteEventStore(namespace: "aNamespace2") - _ = SQLiteEventStore(namespace: "aNamespace3") - let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] - let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path - _ = SQLiteEventStore.removeUnsentEventsExcept(forNamespaces: ["aNamespace2"]) - var dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace1.sqlite").path - XCTAssertFalse(FileManager.default.fileExists(atPath: dbPath)) - dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace2.sqlite").path - XCTAssertTrue(FileManager.default.fileExists(atPath: dbPath)) - dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace3.sqlite").path - XCTAssertFalse(FileManager.default.fileExists(atPath: dbPath)) - } - func testSQLiteEventStoreInvalidNamespaceConversion() { - _ = SQLiteEventStore(namespace: "namespace*.^?1ò2@") + let eventStore = createEventStore("namespace*.^?1ò2@") + _ = eventStore.count() + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path let dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-namespace-1-2-.sqlite").path @@ -105,7 +99,7 @@ class TestSQLiteEventStore: XCTestCase { } func testMigrationFromLegacyToNamespacedEventStore() { - var eventStore = SQLiteEventStore(namespace: "aNamespace") + var eventStore = self.createEventStore("aNamespace") eventStore.addEvent(Payload(dictionary: [ "key": "value" ])) @@ -123,18 +117,18 @@ class TestSQLiteEventStore: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: newDbPath)) // Migrate database when SQLiteEventStore is launched the first time - eventStore = SQLiteEventStore(namespace: "aNewNamespace") + eventStore = createEventStore("aNewNamespace") + XCTAssertEqual(1, eventStore.count()) newDbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNewNamespace.sqlite").path XCTAssertFalse(FileManager.default.fileExists(atPath: oldDbPath)) XCTAssertTrue(FileManager.default.fileExists(atPath: newDbPath)) - XCTAssertEqual(1, eventStore.count()) - for event in eventStore.getAllEvents() ?? [] { + for event in eventStore.emittableEvents(withQueryLimit: 100) { XCTAssertEqual("value", event.payload.dictionary["key"] as? String) } } func testMultipleAccessToSameSQLiteFile() { - let eventStore1 = SQLiteEventStore(namespace: "aNamespace") + let eventStore1 = createEventStore("aNamespace") eventStore1.addEvent(Payload(dictionary: [ "key1": "value1" ])) @@ -146,6 +140,11 @@ class TestSQLiteEventStore: XCTestCase { ])) XCTAssertEqual(2, eventStore2.count()) } + + private func createEventStore(_ namespace: String, limit: Int = 250) -> SQLiteEventStore { + DatabaseHelpers.clearPreviousDatabase(namespace) + return SQLiteEventStore(namespace: namespace, limit: limit) + } } #endif diff --git a/Tests/Utils/DatabaseHelpers.swift b/Tests/Utils/DatabaseHelpers.swift new file mode 100644 index 000000000..af1c4b7be --- /dev/null +++ b/Tests/Utils/DatabaseHelpers.swift @@ -0,0 +1,24 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation +@testable import SnowplowTracker + +class DatabaseHelpers { + static func clearPreviousDatabase(_ namespace: String) { + let path = Database.dbPath(namespace: namespace) + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + } + } +}