From 258362d720a4624ac0f878665ce3a3f69fd754b8 Mon Sep 17 00:00:00 2001 From: Fernando Draghi <44760248+ferdraghi@users.noreply.github.com> Date: Thu, 16 May 2024 15:14:52 -0300 Subject: [PATCH] [EMBR-1911] DB Migrations (#211) * migration prototype MigrationService - runs list of Migration objects that are passed in Migration - protocol to define metadata about Migration and `perform` method * Updates tests to check for migration that duplicates table definition * Adds test to check migrations run multiple times * Adds migration to create SpanRecord table * Adds 'AddSpanRecordMigration' to list of defined migrations Removes `defineTable` method from SpanRecord. * Adds AddSessionRecordMigrationTests Renames test file to include date * Adds `AddMetadataRecordMigration`. Updates filenames to account for multiple migrations in the same day * Adds AddLogRecordMigration * Removes `defineTable` methods from LogRecord, MetadataRecord, SessionRecord These are now performed by migrations * Adds tests for Migration identifiers and Migrations+Current definition * Updates EmbraceStorage+Options to use `fileURL` computed property * Updates EmbraceStorage to reset database if migrations fail * Fixes typo in tests * Re-adds missing `databaseTableName` override to each record * Adds explict check for databaseTableName in each record test * Adds changelog entry --------- Co-authored-by: Fernando Draghi Co-authored-by: Austin Emmons --- CHANGELOG.md | 9 + .../EmbraceCore/Internal/Embrace+Setup.swift | 4 +- .../EmbraceStorage+Options.swift | 6 +- Sources/EmbraceStorage/EmbraceStorage.swift | 137 ++++++++------ .../EmbraceStorage/Migration/Migration.swift | 27 +++ .../Migration/MigrationService.swift | 39 ++++ .../20240509_00_AddSpanRecordMigration.swift | 36 ++++ ...0240510_00_AddSessionRecordMigration.swift | 39 ++++ ...240510_01_AddMetadataRecordMigration.swift | 29 +++ .../20240510_02_AddLogRecordMigration.swift | 21 +++ .../Migrations/Migrations+Current.swift | 19 ++ .../Records/Log/LogRecord.swift | 17 +- .../Records/MetadataRecord.swift | 25 +-- .../Records/SessionRecord.swift | 36 +--- .../EmbraceStorage/Records/SpanRecord.swift | 32 +--- .../EmbraceStorageOptionsTests.swift | 4 +- .../EmbraceStorageTests.swift | 123 ++++++++++--- .../MetadataRecordTests.swift | 6 +- .../Migration/MigrationServiceTests.swift | 172 ++++++++++++++++++ .../Migration/MigrationTests.swift | 43 +++++ ...40509_00_AddSpanRecordMigrationTests.swift | 123 +++++++++++++ ...10_00_AddSessionRecordMigrationTests.swift | 143 +++++++++++++++ ...0_01_AddMetadataRecordMigrationTests.swift | 99 ++++++++++ ...240510_02_AddLogRecordMigrationTests.swift | 94 ++++++++++ .../Migrations/Migrations+CurrentTests.swift | 25 +++ .../Records/LogRecord/LogRecordTests.swift | 6 +- .../SessionRecordTests.swift | 5 +- .../EmbraceStorageTests/SpanRecordTests.swift | 10 +- .../ThrowingMigrationService.swift | 35 ++++ .../Extensions/EmbraceStorage+Extension.swift | 17 +- 30 files changed, 1165 insertions(+), 216 deletions(-) create mode 100644 Sources/EmbraceStorage/Migration/Migration.swift create mode 100644 Sources/EmbraceStorage/Migration/MigrationService.swift create mode 100644 Sources/EmbraceStorage/Migration/Migrations/20240509_00_AddSpanRecordMigration.swift create mode 100644 Sources/EmbraceStorage/Migration/Migrations/20240510_00_AddSessionRecordMigration.swift create mode 100644 Sources/EmbraceStorage/Migration/Migrations/20240510_01_AddMetadataRecordMigration.swift create mode 100644 Sources/EmbraceStorage/Migration/Migrations/20240510_02_AddLogRecordMigration.swift create mode 100644 Sources/EmbraceStorage/Migration/Migrations/Migrations+Current.swift create mode 100644 Tests/EmbraceStorageTests/Migration/MigrationServiceTests.swift create mode 100644 Tests/EmbraceStorageTests/Migration/MigrationTests.swift create mode 100644 Tests/EmbraceStorageTests/Migration/Migrations/20240509_00_AddSpanRecordMigrationTests.swift create mode 100644 Tests/EmbraceStorageTests/Migration/Migrations/20240510_00_AddSessionRecordMigrationTests.swift create mode 100644 Tests/EmbraceStorageTests/Migration/Migrations/20240510_01_AddMetadataRecordMigrationTests.swift create mode 100644 Tests/EmbraceStorageTests/Migration/Migrations/20240510_02_AddLogRecordMigrationTests.swift create mode 100644 Tests/EmbraceStorageTests/Migration/Migrations/Migrations+CurrentTests.swift create mode 100644 Tests/EmbraceStorageTests/TestSupport/ThrowingMigrationService.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b8d375..cb999dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ +**Features** + +**Updates** + +* Adds `MigrationService` in `EmbraceStorage` target to structure DB migrations that occur. Will perform migrations +during SDK setup if any are outstanding. Converted existing DB schema to be initialized using migrations. + + + ## 6.0.0 April 22nd, 2024 diff --git a/Sources/EmbraceCore/Internal/Embrace+Setup.swift b/Sources/EmbraceCore/Internal/Embrace+Setup.swift index 3ac71c1b..a13aa7ec 100644 --- a/Sources/EmbraceCore/Internal/Embrace+Setup.swift +++ b/Sources/EmbraceCore/Internal/Embrace+Setup.swift @@ -17,7 +17,9 @@ extension Embrace { ) { do { let storageOptions = EmbraceStorage.Options(baseUrl: storageUrl, fileName: "db.sqlite") - return try EmbraceStorage(options: storageOptions) + let storage = try EmbraceStorage(options: storageOptions) + try storage.performMigration() + return storage } catch { throw EmbraceSetupError.failedStorageCreation("Failed to create EmbraceStorage") } diff --git a/Sources/EmbraceStorage/EmbraceStorage+Options.swift b/Sources/EmbraceStorage/EmbraceStorage+Options.swift index 4868947b..f6b7cd26 100644 --- a/Sources/EmbraceStorage/EmbraceStorage+Options.swift +++ b/Sources/EmbraceStorage/EmbraceStorage+Options.swift @@ -66,10 +66,10 @@ extension EmbraceStorage.Options { return nil } - /// Full path to the storage file - public var filePath: String? { + /// URL to the storage file + public var fileURL: URL? { if case let .onDisk(url, filename) = storageMechanism { - return url.appendingPathComponent(filename).path + return url.appendingPathComponent(filename) } return nil } diff --git a/Sources/EmbraceStorage/EmbraceStorage.swift b/Sources/EmbraceStorage/EmbraceStorage.swift index d2c3f18d..4bea9b2f 100644 --- a/Sources/EmbraceStorage/EmbraceStorage.swift +++ b/Sources/EmbraceStorage/EmbraceStorage.swift @@ -18,73 +18,39 @@ public class EmbraceStorage: Storage { /// - Parameters: /// - baseUrl: URL containing the path when the database will be stored. public init(options: Options) throws { - self.options = options - - if case let .inMemory(name) = options.storageMechanism { - dbQueue = try DatabaseQueue(named: name) - } else if case let .onDisk(baseURL, fileName) = options.storageMechanism { - // create base directory if necessary - try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) - - // create sqlite file - let filepath = baseURL.appendingPathComponent(fileName) - - dbQueue = try EmbraceStorage.getDBQueueIfPossible(at: filepath) - } else { - fatalError("Unsupported storage mechansim added") - } - - // define tables - try dbQueue.write { db in - try SessionRecord.defineTable(db: db) - try SpanRecord.defineTable(db: db) - try LogRecord.defineTable(db: db) - try MetadataRecord.defineTable(db: db) - } + dbQueue = try Self.createDBQueue(options: options) } - /// Will attempt to create or open the DB File. If first attempt fails due to GRDB error, it'll assume the existing DB is corruped and try again after deleting the existing DB file. - private static func getDBQueueIfPossible(at fileURL: URL) throws -> DatabaseQueue { + /// Performs any DB migrations + /// - Parameters: + /// - resetIfError: If true and the migrations fail the DB will be reset entirely. + public func performMigration( + resetIfError: Bool = true, + service: MigrationServiceProtocol = MigrationService() + ) throws { do { - return try DatabaseQueue(path: fileURL.path) - } catch { - if let dbError = error as? DatabaseError { - ConsoleLog.error(""" -GRDB Failed to initialize EmbraceStorage. -Will attempt to remove existing file and create a new DB. -Message: \(dbError.message ?? "[empty message]"), -Result Code: \(dbError.resultCode), -SQLite Extended Code: \(dbError.extendedResultCode) -""") + try service.perform(dbQueue, migrations: .current) + } catch let error { + if resetIfError { + ConsoleLog.error("Error performing migrations, resetting EmbraceStorage: \(error)") + try reset(migrationService: service) } else { - ConsoleLog.error(""" -Unknown error while trying to initialize EmbraceStorage: \(error) -Will attempt to recover by deleting existing DB. -""") + ConsoleLog.error("Error performing migrations. Reset not enabled: \(error)") + throw error // re-throw error if auto-recover is not enabled } } - - try EmbraceStorage.deleteDBFile(at: fileURL) - - return try DatabaseQueue(path: fileURL.path) } - /// Will attempt to delete the provided file. - private static func deleteDBFile(at fileURL: URL) throws { - do { - let fileURL = URL(fileURLWithPath: fileURL.path) + /// Deletes the database and recreates it from scratch + func reset(migrationService: MigrationServiceProtocol = MigrationService()) throws { + if let fileURL = options.fileURL { try FileManager.default.removeItem(at: fileURL) - } catch let error { - ConsoleLog.error(""" -EmbraceStorage failed to remove DB file. -Domain: \(error._domain) -Code: \(error._code) -Filepath: \(fileURL) -""") } - } + dbQueue = try Self.createDBQueue(options: options) + try performMigration(resetIfError: false, service: migrationService) // Do not perpetuate loop + } } // MARK: - Sync operations @@ -172,3 +138,64 @@ extension EmbraceStorage { }, completion: completion) } } + +extension EmbraceStorage { + + private static func createDBQueue(options: EmbraceStorage.Options) throws -> DatabaseQueue { + if case let .inMemory(name) = options.storageMechanism { + return try DatabaseQueue(named: name) + } else if case let .onDisk(baseURL, _) = options.storageMechanism, let fileURL = options.fileURL { + // create base directory if necessary + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + return try EmbraceStorage.getDBQueueIfPossible(at: fileURL) + } else { + fatalError("Unsupported storage mechansim added") + } + } + + /// Will attempt to create or open the DB File. If first attempt fails due to GRDB error, it'll assume the existing DB is corruped and try again after deleting the existing DB file. + private static func getDBQueueIfPossible(at fileURL: URL) throws -> DatabaseQueue { + do { + return try DatabaseQueue(path: fileURL.path) + } catch { + if let dbError = error as? DatabaseError { + ConsoleLog.error( + """ + GRDB Failed to initialize EmbraceStorage. + Will attempt to remove existing file and create a new DB. + Message: \(dbError.message ?? "[empty message]"), + Result Code: \(dbError.resultCode), + SQLite Extended Code: \(dbError.extendedResultCode) + """ + ) + } else { + ConsoleLog.error( + """ + Unknown error while trying to initialize EmbraceStorage: \(error) + Will attempt to recover by deleting existing DB. + """ + ) + } + } + + try EmbraceStorage.deleteDBFile(at: fileURL) + + return try DatabaseQueue(path: fileURL.path) + } + + /// Will attempt to delete the provided file. + private static func deleteDBFile(at fileURL: URL) throws { + do { + let fileURL = URL(fileURLWithPath: fileURL.path) + try FileManager.default.removeItem(at: fileURL) + } catch let error { + ConsoleLog.error( + """ + EmbraceStorage failed to remove DB file. + Error: \(error.localizedDescription) + Filepath: \(fileURL) + """ + ) + } + } +} diff --git a/Sources/EmbraceStorage/Migration/Migration.swift b/Sources/EmbraceStorage/Migration/Migration.swift new file mode 100644 index 00000000..70961c95 --- /dev/null +++ b/Sources/EmbraceStorage/Migration/Migration.swift @@ -0,0 +1,27 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation +import GRDB + +public protocol Migration { + /// The identifier to register this migration under. Must be unique + static var identifier: StringLiteralType { get } + + /// Controls how this migration handles foreign key constraints. Defaults to `immediate`. + static var foreignKeyChecks: DatabaseMigrator.ForeignKeyChecks { get } + + /// Operation that performs migration. + /// See [GRDB Reference](https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/migrations). + func perform(_ db: Database) throws +} + +extension Migration { + var identifier: StringLiteralType { Self.identifier } + var foreignKeyChecks: DatabaseMigrator.ForeignKeyChecks { Self.foreignKeyChecks } +} + +extension Migration { + static var foreignKeyChecks: DatabaseMigrator.ForeignKeyChecks { .immediate } +} diff --git a/Sources/EmbraceStorage/Migration/MigrationService.swift b/Sources/EmbraceStorage/Migration/MigrationService.swift new file mode 100644 index 00000000..601a9735 --- /dev/null +++ b/Sources/EmbraceStorage/Migration/MigrationService.swift @@ -0,0 +1,39 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import GRDB +import EmbraceCommon + +public protocol MigrationServiceProtocol { + func perform(_ dbQueue: DatabaseWriter, migrations: [Migration]) throws +} + +final public class MigrationService: MigrationServiceProtocol { + public init() { } + + public func perform(_ dbQueue: DatabaseWriter, migrations: [Migration]) throws { + guard migrations.count > 0 else { + ConsoleLog.debug("No migrations to perform") + return + } + + var migrator = DatabaseMigrator() + migrations.forEach { migration in + migrator.registerMigration(migration.identifier, + foreignKeyChecks: migration.foreignKeyChecks, + migrate: migration.perform(_:)) + } + + try dbQueue.read { db in + if try migrator.hasCompletedMigrations(db) { + ConsoleLog.debug("DB is up to date") + return + } else { + ConsoleLog.debug("Running up to \(migrations.count) migrations") + } + } + + try migrator.migrate(dbQueue) + } +} diff --git a/Sources/EmbraceStorage/Migration/Migrations/20240509_00_AddSpanRecordMigration.swift b/Sources/EmbraceStorage/Migration/Migrations/20240509_00_AddSpanRecordMigration.swift new file mode 100644 index 00000000..da1cd42b --- /dev/null +++ b/Sources/EmbraceStorage/Migration/Migrations/20240509_00_AddSpanRecordMigration.swift @@ -0,0 +1,36 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import GRDB + +struct AddSpanRecordMigration: Migration { + static let identifier = "CreateSpanRecordTable" // DEV: Must not change + + func perform(_ db: GRDB.Database) throws { + try db.create(table: SpanRecord.databaseTableName, options: .ifNotExists) { t in + t.column(SpanRecord.Schema.id.name, .text).notNull() + t.column(SpanRecord.Schema.name.name, .text).notNull() + t.column(SpanRecord.Schema.traceId.name, .text).notNull() + t.primaryKey([SpanRecord.Schema.traceId.name, SpanRecord.Schema.id.name]) + + t.column(SpanRecord.Schema.type.name, .text).notNull() + t.column(SpanRecord.Schema.startTime.name, .datetime).notNull() + t.column(SpanRecord.Schema.endTime.name, .datetime) + + t.column(SpanRecord.Schema.data.name, .blob).notNull() + } + + let preventClosedSpanModification = """ + CREATE TRIGGER IF NOT EXISTS prevent_closed_span_modification + BEFORE UPDATE ON \(SpanRecord.databaseTableName) + WHEN OLD.end_time IS NOT NULL + BEGIN + SELECT RAISE(ABORT,'Attempted to modify an already closed span.'); + END; + """ + + try db.execute(sql: preventClosedSpanModification) + } + +} diff --git a/Sources/EmbraceStorage/Migration/Migrations/20240510_00_AddSessionRecordMigration.swift b/Sources/EmbraceStorage/Migration/Migrations/20240510_00_AddSessionRecordMigration.swift new file mode 100644 index 00000000..98193c4c --- /dev/null +++ b/Sources/EmbraceStorage/Migration/Migrations/20240510_00_AddSessionRecordMigration.swift @@ -0,0 +1,39 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import GRDB + +struct AddSessionRecordMigration: Migration { + static var identifier = "CreateSessionRecordTable" // DEV: Must not change + + func perform(_ db: GRDB.Database) throws { + try db.create(table: SessionRecord.databaseTableName, options: .ifNotExists) { t in + + t.primaryKey(SessionRecord.Schema.id.name, .text).notNull() + + t.column(SessionRecord.Schema.state.name, .text).notNull() + t.column(SessionRecord.Schema.processId.name, .text).notNull() + t.column(SessionRecord.Schema.traceId.name, .text).notNull() + t.column(SessionRecord.Schema.spanId.name, .text).notNull() + + t.column(SessionRecord.Schema.startTime.name, .datetime).notNull() + t.column(SessionRecord.Schema.endTime.name, .datetime) + t.column(SessionRecord.Schema.lastHeartbeatTime.name, .datetime).notNull() + + t.column(SessionRecord.Schema.coldStart.name, .boolean) + .notNull() + .defaults(to: false) + + t.column(SessionRecord.Schema.cleanExit.name, .boolean) + .notNull() + .defaults(to: false) + + t.column(SessionRecord.Schema.appTerminated.name, .boolean) + .notNull() + .defaults(to: false) + + t.column(SessionRecord.Schema.crashReportId.name, .text) + } + } +} diff --git a/Sources/EmbraceStorage/Migration/Migrations/20240510_01_AddMetadataRecordMigration.swift b/Sources/EmbraceStorage/Migration/Migrations/20240510_01_AddMetadataRecordMigration.swift new file mode 100644 index 00000000..9db40a98 --- /dev/null +++ b/Sources/EmbraceStorage/Migration/Migrations/20240510_01_AddMetadataRecordMigration.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import GRDB + +struct AddMetadataRecordMigration: Migration { + + static var identifier = "CreateMetadataRecordTable" // DEV: Must not change + + func perform(_ db: Database) throws { + try db.create(table: MetadataRecord.databaseTableName, options: .ifNotExists) { t in + + t.column(MetadataRecord.Schema.key.name, .text).notNull() + t.column(MetadataRecord.Schema.value.name, .text).notNull() + t.column(MetadataRecord.Schema.type.name, .text).notNull() + t.column(MetadataRecord.Schema.lifespan.name, .text).notNull() + t.column(MetadataRecord.Schema.lifespanId.name, .text).notNull() + t.column(MetadataRecord.Schema.collectedAt.name, .datetime).notNull() + + t.primaryKey([ + MetadataRecord.Schema.key.name, + MetadataRecord.Schema.type.name, + MetadataRecord.Schema.lifespan.name, + MetadataRecord.Schema.lifespanId.name + ]) + } + } +} diff --git a/Sources/EmbraceStorage/Migration/Migrations/20240510_02_AddLogRecordMigration.swift b/Sources/EmbraceStorage/Migration/Migrations/20240510_02_AddLogRecordMigration.swift new file mode 100644 index 00000000..fe9bb6b9 --- /dev/null +++ b/Sources/EmbraceStorage/Migration/Migrations/20240510_02_AddLogRecordMigration.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import GRDB + +struct AddLogRecordMigration: Migration { + + static var identifier = "CreateLogRecordTable" // DEV: Must not change + + func perform(_ db: Database) throws { + try db.create(table: LogRecord.databaseTableName, options: .ifNotExists) { t in + t.primaryKey(LogRecord.Schema.identifier.name, .text).notNull() + t.column(LogRecord.Schema.processIdentifier.name, .integer).notNull() + t.column(LogRecord.Schema.severity.name, .integer).notNull() + t.column(LogRecord.Schema.body.name, .text).notNull() + t.column(LogRecord.Schema.timestamp.name, .datetime).notNull() + t.column(LogRecord.Schema.attributes.name, .text).notNull() + } + } +} diff --git a/Sources/EmbraceStorage/Migration/Migrations/Migrations+Current.swift b/Sources/EmbraceStorage/Migration/Migrations/Migrations+Current.swift new file mode 100644 index 00000000..df611ae4 --- /dev/null +++ b/Sources/EmbraceStorage/Migration/Migrations/Migrations+Current.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation + +extension Array where Element == Migration { + + static var current: [Migration] { + return [ + // register migrations here + // order matters + AddSpanRecordMigration(), + AddSessionRecordMigration(), + AddMetadataRecordMigration(), + AddLogRecordMigration() + ] + } +} diff --git a/Sources/EmbraceStorage/Records/Log/LogRecord.swift b/Sources/EmbraceStorage/Records/Log/LogRecord.swift index 12c8ac3d..76108d2a 100644 --- a/Sources/EmbraceStorage/Records/Log/LogRecord.swift +++ b/Sources/EmbraceStorage/Records/Log/LogRecord.swift @@ -30,6 +30,8 @@ public struct LogRecord { } extension LogRecord: FetchableRecord, PersistableRecord { + public static let databaseTableName: String = "logs" + public static let databaseColumnDecodingStrategy = DatabaseColumnDecodingStrategy.convertFromSnakeCase public static let databaseColumnEncodingStrategy = DatabaseColumnEncodingStrategy.convertToSnakeCase public static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .replace, update: .replace) @@ -80,18 +82,3 @@ extension LogRecord { static var attributes: Column { Column("attributes") } } } - -extension LogRecord: TableRecord { - public static let databaseTableName: String = "logs" - - internal static func defineTable(db: Database) throws { - try db.create(table: LogRecord.databaseTableName, options: .ifNotExists) { t in - t.primaryKey(Schema.identifier.name, .text).notNull() - t.column(Schema.processIdentifier.name, .integer).notNull() - t.column(Schema.severity.name, .integer).notNull() - t.column(Schema.body.name, .text).notNull() - t.column(Schema.timestamp.name, .datetime).notNull() - t.column(Schema.attributes.name, .text).notNull() - } - } -} diff --git a/Sources/EmbraceStorage/Records/MetadataRecord.swift b/Sources/EmbraceStorage/Records/MetadataRecord.swift index 490944c2..1110aa93 100644 --- a/Sources/EmbraceStorage/Records/MetadataRecord.swift +++ b/Sources/EmbraceStorage/Records/MetadataRecord.swift @@ -67,34 +67,13 @@ extension MetadataRecord { } extension MetadataRecord: FetchableRecord, PersistableRecord, MutablePersistableRecord { + public static let databaseTableName: String = "metadata" + public static let databaseColumnDecodingStrategy = DatabaseColumnDecodingStrategy.convertFromSnakeCase public static let databaseColumnEncodingStrategy = DatabaseColumnEncodingStrategy.convertToSnakeCase public static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .replace, update: .replace) } -extension MetadataRecord: TableRecord { - public static let databaseTableName: String = "metadata" - - internal static func defineTable(db: Database) throws { - try db.create(table: MetadataRecord.databaseTableName, options: .ifNotExists) { t in - - t.column(Schema.key.name, .text).notNull() - t.column(Schema.value.name, .text).notNull() - t.column(Schema.type.name, .text).notNull() - t.column(Schema.lifespan.name, .text).notNull() - t.column(Schema.lifespanId.name, .text).notNull() - t.column(Schema.collectedAt.name, .datetime).notNull() - - t.primaryKey([ - Schema.key.name, - Schema.type.name, - Schema.lifespan.name, - Schema.lifespanId.name - ]) - } - } -} - extension MetadataRecord: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { return lhs.key == rhs.key diff --git a/Sources/EmbraceStorage/Records/SessionRecord.swift b/Sources/EmbraceStorage/Records/SessionRecord.swift index a295af15..347aa804 100644 --- a/Sources/EmbraceStorage/Records/SessionRecord.swift +++ b/Sources/EmbraceStorage/Records/SessionRecord.swift @@ -74,45 +74,13 @@ extension SessionRecord { } extension SessionRecord: FetchableRecord, PersistableRecord, MutablePersistableRecord { + public static let databaseTableName: String = "sessions" + public static let databaseColumnDecodingStrategy = DatabaseColumnDecodingStrategy.convertFromSnakeCase public static let databaseColumnEncodingStrategy = DatabaseColumnEncodingStrategy.convertToSnakeCase public static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .replace, update: .replace) } -extension SessionRecord: TableRecord { - public static let databaseTableName: String = "sessions" - - internal static func defineTable(db: Database) throws { - try db.create(table: SessionRecord.databaseTableName, options: .ifNotExists) { t in - - t.primaryKey(Schema.id.name, .text).notNull() - - t.column(Schema.state.name, .text).notNull() - t.column(Schema.processId.name, .text).notNull() - t.column(Schema.traceId.name, .text).notNull() - t.column(Schema.spanId.name, .text).notNull() - - t.column(Schema.startTime.name, .datetime).notNull() - t.column(Schema.endTime.name, .datetime) - t.column(Schema.lastHeartbeatTime.name, .datetime).notNull() - - t.column(Schema.coldStart.name, .boolean) - .notNull() - .defaults(to: false) - - t.column(Schema.cleanExit.name, .boolean) - .notNull() - .defaults(to: false) - - t.column(Schema.appTerminated.name, .boolean) - .notNull() - .defaults(to: false) - - t.column(Schema.crashReportId.name, .text) - } - } -} - extension SessionRecord: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { return lhs.id == rhs.id diff --git a/Sources/EmbraceStorage/Records/SpanRecord.swift b/Sources/EmbraceStorage/Records/SpanRecord.swift index bfff4037..129353d8 100644 --- a/Sources/EmbraceStorage/Records/SpanRecord.swift +++ b/Sources/EmbraceStorage/Records/SpanRecord.swift @@ -48,41 +48,13 @@ extension SpanRecord { } extension SpanRecord: FetchableRecord, PersistableRecord, MutablePersistableRecord { + public static let databaseTableName: String = "spans" + public static let databaseColumnDecodingStrategy = DatabaseColumnDecodingStrategy.convertFromSnakeCase public static let databaseColumnEncodingStrategy = DatabaseColumnEncodingStrategy.convertToSnakeCase public static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .replace, update: .replace) } -extension SpanRecord: TableRecord { - public static let databaseTableName: String = "spans" - - internal static func defineTable(db: Database) throws { - try db.create(table: SpanRecord.databaseTableName, options: .ifNotExists) { t in - t.column(Schema.id.name, .text).notNull() - t.column(Schema.name.name, .text).notNull() - t.column(Schema.traceId.name, .text).notNull() - t.primaryKey([Schema.traceId.name, Schema.id.name]) - - t.column(Schema.type.name, .text).notNull() - t.column(Schema.startTime.name, .datetime).notNull() - t.column(Schema.endTime.name, .datetime) - - t.column(Schema.data.name, .blob).notNull() - } - - let preventClosedSpanModification = """ - CREATE TRIGGER IF NOT EXISTS prevent_closed_span_modification - BEFORE UPDATE ON \(SpanRecord.databaseTableName) - WHEN OLD.end_time IS NOT NULL - BEGIN - SELECT RAISE(ABORT,'Attempted to modify an already closed span.'); - END; - """ - - try db.execute(sql: preventClosedSpanModification) - } -} - extension SpanRecord: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { return diff --git a/Tests/EmbraceStorageTests/EmbraceStorageOptionsTests.swift b/Tests/EmbraceStorageTests/EmbraceStorageOptionsTests.swift index 1db8829d..640f168c 100644 --- a/Tests/EmbraceStorageTests/EmbraceStorageOptionsTests.swift +++ b/Tests/EmbraceStorageTests/EmbraceStorageOptionsTests.swift @@ -14,7 +14,7 @@ class EmbraceStorageOptionsTests: XCTestCase { XCTAssertNil(options.name) XCTAssertEqual(options.baseUrl, url) XCTAssertEqual(options.fileName, "test.sqlite") - XCTAssertEqual(options.filePath, url.appendingPathComponent("test.sqlite").path) + XCTAssertEqual(options.fileURL, url.appendingPathComponent("test.sqlite")) } func test_init_withName() { @@ -23,6 +23,6 @@ class EmbraceStorageOptionsTests: XCTestCase { XCTAssertEqual(options.name, "example") XCTAssertNil(options.baseUrl) XCTAssertNil(options.fileName) - XCTAssertNil(options.filePath) + XCTAssertNil(options.fileURL) } } diff --git a/Tests/EmbraceStorageTests/EmbraceStorageTests.swift b/Tests/EmbraceStorageTests/EmbraceStorageTests.swift index 4b198838..2c6130cb 100644 --- a/Tests/EmbraceStorageTests/EmbraceStorageTests.swift +++ b/Tests/EmbraceStorageTests/EmbraceStorageTests.swift @@ -19,21 +19,111 @@ class EmbraceStorageTests: XCTestCase { } func test_databaseSchema() throws { - let expectation = XCTestExpectation() - // then all required tables should be present try storage.dbQueue.read { db in - XCTAssert(try db.tableExists(SessionRecord.databaseTableName)) - XCTAssert(try db.tableExists(SpanRecord.databaseTableName)) - XCTAssert(try db.tableExists(MetadataRecord.databaseTableName)) - XCTAssert(try db.tableExists(LogRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(SessionRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(SpanRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(MetadataRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(LogRecord.databaseTableName)) + } + } - expectation.fulfill() + func test_performMigration_generatesTables() throws { + storage = try EmbraceStorage.createInMemoryDb(runMigrations: false) + + try storage.dbQueue.read { db in + XCTAssertFalse(try db.tableExists(SessionRecord.databaseTableName)) + XCTAssertFalse(try db.tableExists(SpanRecord.databaseTableName)) + XCTAssertFalse(try db.tableExists(MetadataRecord.databaseTableName)) + XCTAssertFalse(try db.tableExists(LogRecord.databaseTableName)) } - wait(for: [expectation], timeout: .defaultTimeout) + try storage.performMigration() + + try storage.dbQueue.read { db in + XCTAssertTrue(try db.tableExists(SessionRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(SpanRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(MetadataRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(LogRecord.databaseTableName)) + } + } + + func test_performMigration_ifResetsIfErrorTrue_resetsDB() throws { + storage = try EmbraceStorage.createInMemoryDb() + + let service = ThrowingMigrationService(performToThrow: 1) + try storage.performMigration( + resetIfError: true, + service: service + ) + + try storage.dbQueue.read { db in + XCTAssertTrue(try db.tableExists(SessionRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(SpanRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(MetadataRecord.databaseTableName)) + XCTAssertTrue(try db.tableExists(LogRecord.databaseTableName)) + } + + XCTAssertEqual(service.currentPerformCount, 2) + } + + func test_performMigration_ifResetsIfErrorTrue_andMigrationFailsTwice_rethrowsError() throws { + storage = try EmbraceStorage.createInMemoryDb() + + let service = ThrowingMigrationService(performsToThrow: [1, 2]) + XCTAssertThrowsError( + try storage.performMigration( + resetIfError: true, + service: service + ) + ) + + XCTAssertEqual(service.currentPerformCount, 2) + } + + func test_performMigration_ifResetsIfErrorFalse_rethrowsError() throws { + storage = try EmbraceStorage.createInMemoryDb() + let service = ThrowingMigrationService(performToThrow: 1) + + XCTAssertThrowsError( + try storage.performMigration( + resetIfError: false, + service: service + ) + ) + XCTAssertEqual(service.currentPerformCount, 1) + } + + func test_reset_remakesDB() throws { + storage = try .createInDiskDb() // need to use on disk DB, + // inMemory will keep same memory instance because dbQueue `name` is the same. + + // given inserted record + let span = SpanRecord( + id: "id", + name: "a name", + traceId: "traceId", + type: .performance, + data: Data(), + startTime: Date() + ) + + try storage.dbQueue.write { db in + try span.insert(db) + } + + try storage.reset() + + // then record should not exist in storage + try storage.dbQueue.read { db in + XCTAssertFalse(try span.exists(db)) + } + + try FileManager.default.removeItem(at: storage.options.fileURL!) } +// MARK: - DB actions + func test_update() throws { // given inserted record var span = SpanRecord( @@ -50,14 +140,10 @@ class EmbraceStorageTests: XCTestCase { } // then record should exist in storage - let expectation1 = XCTestExpectation() try storage.dbQueue.read { db in XCTAssert(try span.exists(db)) - expectation1.fulfill() } - wait(for: [expectation1], timeout: .defaultTimeout) - // when updating record let endTime = Date(timeInterval: 10, since: span.startTime) span.endTime = endTime @@ -65,16 +151,11 @@ class EmbraceStorageTests: XCTestCase { try storage.update(record: span) // the record should update successfuly - let expectation2 = XCTestExpectation() try storage.dbQueue.read { db in XCTAssert(try span.exists(db)) XCTAssertNotNil(span.endTime) XCTAssertEqual(span.endTime, endTime) - - expectation2.fulfill() } - - wait(for: [expectation2], timeout: .defaultTimeout) } func test_delete() throws { @@ -93,26 +174,18 @@ class EmbraceStorageTests: XCTestCase { } // then record should exist in storage - let expectation1 = XCTestExpectation() try storage.dbQueue.read { db in XCTAssert(try span.exists(db)) - expectation1.fulfill() } - wait(for: [expectation1], timeout: .defaultTimeout) - // when deleting record let success = try storage.delete(record: span) XCTAssert(success) // then record should not exist in storage - let expectation2 = XCTestExpectation() try storage.dbQueue.read { db in XCTAssertFalse(try span.exists(db)) - expectation2.fulfill() } - - wait(for: [expectation2], timeout: .defaultTimeout) } func test_fetchAll() throws { diff --git a/Tests/EmbraceStorageTests/MetadataRecordTests.swift b/Tests/EmbraceStorageTests/MetadataRecordTests.swift index a74e1aa3..c75fc54e 100644 --- a/Tests/EmbraceStorageTests/MetadataRecordTests.swift +++ b/Tests/EmbraceStorageTests/MetadataRecordTests.swift @@ -21,7 +21,7 @@ class MetadataRecordTests: XCTestCase { } func test_tableSchema() throws { - let expectation = XCTestExpectation() + XCTAssertEqual(MetadataRecord.databaseTableName, "metadata") // then the table and its colums should be correct try storage.dbQueue.read { db in @@ -95,11 +95,7 @@ class MetadataRecordTests: XCTestCase { } else { XCTAssert(false, "lifespan_id column not found!") } - - expectation.fulfill() } - - wait(for: [expectation], timeout: .defaultTimeout) } func test_addMetadata() throws { diff --git a/Tests/EmbraceStorageTests/Migration/MigrationServiceTests.swift b/Tests/EmbraceStorageTests/Migration/MigrationServiceTests.swift new file mode 100644 index 00000000..a0fb92a1 --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/MigrationServiceTests.swift @@ -0,0 +1,172 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest +import TestSupport +import GRDB +@testable import EmbraceStorage + +class MigrationServiceTests: XCTestCase { + var dbQueue: DatabaseQueue! + var migrationService = MigrationService() + + override func setUpWithError() throws { + dbQueue = try DatabaseQueue(named: name) + } + + func test_perform_runsMigrations_thatAreNotRun() throws { + // Given an existing table + try dbQueue.write { db in + try TestMigrationRecord.defineTable(db: db) + } + + // Checking it only contains the original schema + try dbQueue.read { db in + let columns = try db.columns(in: "test_migrations") + XCTAssertEqual(columns.count, 1) + XCTAssertEqual(columns[0].name, "id") + } + + // When performing a migration + let migrations: [Migration] = [ + AddColumnSomethingNew(), + AddColumnSomethingNewer() + ] + try migrationService.perform(dbQueue, migrations: migrations) + + // Then all migrations have been completed and all new keys have been added to the table. + try dbQueue.read { db in + /// Check database now has 3 columns. + let columns = try db.columns(in: "test_migrations") + XCTAssertEqual(columns.count, 3) + + /// Check all expected migrations have been completed + let identifiers = try DatabaseMigrator().appliedIdentifiers(db) + XCTAssertEqual( + Set(identifiers), + Set(["AddColumnSomethingNew_1", "AddColumnSomethingNewer_2"]) + ) + + /// Check new expected columns have been added. + let somethingNew = columns.first { column in + column.name == "something_new" + } + let somethingNewer = columns.first { column in + column.name == "something_newer" + } + XCTAssertNotNil(somethingNew) + XCTAssertNotNil(somethingNewer) + } + } + + func test_perform_whenTableIsDefined_andMigrationTriesToRedefineIt_doesNotFail() throws { + // Given an existing table + try dbQueue.write { db in + try TestMigrationRecord.defineTable(db: db) + } + + // When performing a migration + try migrationService.perform(dbQueue, migrations: [ + InitialSchema(), + AddColumnSomethingNew(), + AddColumnSomethingNewer() + ]) + + try dbQueue.read { db in + let columns = try db.columns(in: "test_migrations") + XCTAssertEqual(columns.count, 3) + + XCTAssertNotNil(columns.first { info in + info.name == "id" && + info.type == "TEXT" && + info.isNotNull + }) + + XCTAssertNotNil(columns.first { info in + info.name == "something_new" && + info.type == "TEXT" && + info.isNotNull == false + }) + + XCTAssertNotNil(columns.first { info in + info.name == "something_newer" && + info.type == "TEXT" && + info.isNotNull == false + }) + } + } + + func test_perform_whenRunMultipleTimes_doesNotFail() throws { + for _ in 0..<5 { + try migrationService.perform(dbQueue, migrations: [ + InitialSchema(), + AddColumnSomethingNew(), + AddColumnSomethingNewer() + ]) + } + + try dbQueue.read { db in + let columns = try db.columns(in: "test_migrations") + + XCTAssertEqual(columns.count, 3) + XCTAssertNotNil(columns.first { info in + info.name == "id" && + info.type == "TEXT" && + info.isNotNull + }) + + XCTAssertNotNil(columns.first { info in + info.name == "something_new" && + info.type == "TEXT" && + info.isNotNull == false + }) + + XCTAssertNotNil(columns.first { info in + info.name == "something_newer" && + info.type == "TEXT" && + info.isNotNull == false + }) + } + } +} + +extension MigrationServiceTests { + + struct TestMigrationRecord: TableRecord { + internal static func defineTable(db: Database) throws { + try db.create(table: "test_migrations", options: .ifNotExists) { t in + t.primaryKey("id", .text).notNull() + } + } + } + + class InitialSchema: Migration { + static var identifier = "Initial Schema" + + func perform(_ db: GRDB.Database) throws { + try TestMigrationRecord.defineTable(db: db) + } + } + + class AddColumnSomethingNew: Migration { + static let identifier = "AddColumnSomethingNew_1" + + func perform(_ db: Database) throws { + try db.alter(table: "test_migrations") { table in + table.add(column: "something_new", .text) + } + } + } + + class AddColumnSomethingNewer: Migration { + static let identifier: StringLiteralType = "AddColumnSomethingNewer_2" + + func perform(_ db: GRDB.Database) throws { + try db.alter(table: "test_migrations") { table in + table.add(column: "something_newer", .text) + } + } + } + +} diff --git a/Tests/EmbraceStorageTests/Migration/MigrationTests.swift b/Tests/EmbraceStorageTests/Migration/MigrationTests.swift new file mode 100644 index 00000000..3deb3ae3 --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/MigrationTests.swift @@ -0,0 +1,43 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest + +@testable import EmbraceStorage +import GRDB + +final class MigrationTests: XCTestCase { + + func test_migration_hasDefault_foreignKeyChecks() throws { + let migration = ExampleMigration() + + XCTAssertEqual(migration.foreignKeyChecks, .immediate) + XCTAssertEqual(type(of: migration).foreignKeyChecks, .immediate) + } + + func test_migration_allowsForCustom_foreignKeyChecks() throws { + + let migration = CustomForeignKeyMigration() + + XCTAssertEqual(migration.foreignKeyChecks, .deferred) + XCTAssertEqual(type(of: migration).foreignKeyChecks, .deferred) + + XCTAssertEqual(migration.identifier, "CustomForeignKeyMigration_001") + XCTAssertEqual(type(of: migration).identifier, "CustomForeignKeyMigration_001") + } +} + +extension MigrationTests { + struct ExampleMigration: Migration { + static let identifier = "ExampleMigration_001" + func perform(_ db: GRDB.Database) throws { } + } + + struct CustomForeignKeyMigration: Migration { + static let foreignKeyChecks: DatabaseMigrator.ForeignKeyChecks = .deferred + + static let identifier = "CustomForeignKeyMigration_001" + func perform(_ db: GRDB.Database) throws { } + } +} diff --git a/Tests/EmbraceStorageTests/Migration/Migrations/20240509_00_AddSpanRecordMigrationTests.swift b/Tests/EmbraceStorageTests/Migration/Migrations/20240509_00_AddSpanRecordMigrationTests.swift new file mode 100644 index 00000000..750694bd --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/Migrations/20240509_00_AddSpanRecordMigrationTests.swift @@ -0,0 +1,123 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest + +@testable import EmbraceStorage +import GRDB + +final class AddSpanRecordMigrationTests: XCTestCase { + + var storage: EmbraceStorage! + + override func setUpWithError() throws { + storage = try EmbraceStorage.createInMemoryDb(runMigrations: false) + } + + override func tearDownWithError() throws { + try storage.teardown() + } + + func test_identifier() { + let migration = AddSpanRecordMigration() + XCTAssertEqual(migration.identifier, "CreateSpanRecordTable") + } + + func test_perform_createsTableWithCorrectSchema() throws { + let migration = AddSpanRecordMigration() + + try storage.dbQueue.write { db in + try migration.perform(db) + } + + try storage.dbQueue.read { db in + XCTAssertTrue(try db.tableExists(SpanRecord.databaseTableName)) + + let columns = try db.columns(in: SpanRecord.databaseTableName) + XCTAssertEqual(columns.count, 7) + + // primary key + XCTAssert(try db.table( + SpanRecord.databaseTableName, + hasUniqueKey: [ + SpanRecord.Schema.traceId.name, + SpanRecord.Schema.id.name + ] + )) + + // id + let idColumn = columns.first { info in + info.name == SpanRecord.Schema.id.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(idColumn) + + // name + let nameColumn = columns.first { info in + info.name == SpanRecord.Schema.name.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(nameColumn) + + // trace_id + let traceIdColumn = columns.first { info in + info.name == SpanRecord.Schema.traceId.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(traceIdColumn) + + // type + let typeColumn = columns.first { info in + info.name == SpanRecord.Schema.type.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(typeColumn) + + // start_time + let startTimeColumn = columns.first { info in + info.name == SpanRecord.Schema.startTime.name && + info.type == "DATETIME" && + info.isNotNull == true + } + XCTAssertNotNil(startTimeColumn) + + // end_time + let endTimeColumn = columns.first { info in + info.name == SpanRecord.Schema.endTime.name && + info.type == "DATETIME" && + info.isNotNull == false + } + XCTAssertNotNil(endTimeColumn) + + // data + let dataColumn = columns.first { info in + info.name == SpanRecord.Schema.data.name && + info.type == "BLOB" && + info.isNotNull == true + } + XCTAssertNotNil(dataColumn) + } + } + + func test_perform_createsClosedSpanTrigger() throws { + let migration = AddSpanRecordMigration() + + try storage.dbQueue.write { db in + try migration.perform(db) + } + + try storage.dbQueue.read { db in + let rows = try Row.fetchAll(db, sql: "SELECT * FROM sqlite_master where type = 'trigger'") + XCTAssertEqual(rows.count, 1) + + let triggerRow = try XCTUnwrap(rows.first) + XCTAssertEqual(triggerRow["name"], "prevent_closed_span_modification") + XCTAssertEqual(triggerRow["tbl_name"], "spans") + } + } +} diff --git a/Tests/EmbraceStorageTests/Migration/Migrations/20240510_00_AddSessionRecordMigrationTests.swift b/Tests/EmbraceStorageTests/Migration/Migrations/20240510_00_AddSessionRecordMigrationTests.swift new file mode 100644 index 00000000..606f8972 --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/Migrations/20240510_00_AddSessionRecordMigrationTests.swift @@ -0,0 +1,143 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest + +@testable import EmbraceStorage +import GRDB + +final class _0240510_AddSessionRecordMigrationTests: XCTestCase { + + var storage: EmbraceStorage! + + override func setUpWithError() throws { + storage = try EmbraceStorage.createInMemoryDb(runMigrations: false) + } + + override func tearDownWithError() throws { + try storage.teardown() + } + + func test_identifier() { + let migration = AddSessionRecordMigration() + XCTAssertEqual(migration.identifier, "CreateSessionRecordTable") + } + + func test_perform_createsTableWithCorrectSchema() throws { + let migration = AddSessionRecordMigration() + + try storage.dbQueue.write { db in + try migration.perform(db) + } + + try storage.dbQueue.read { db in + XCTAssert(try db.tableExists(SessionRecord.databaseTableName)) + + XCTAssert(try db.table(SessionRecord.databaseTableName, hasUniqueKey: [SessionRecord.Schema.id.name])) + + let columns = try db.columns(in: SessionRecord.databaseTableName) + XCTAssertEqual(columns.count, 12) + + // id + let idColumn = columns.first { info in + info.name == SessionRecord.Schema.id.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(idColumn) + + // state + let stateTimeColumn = columns.first { info in + info.name == SessionRecord.Schema.state.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(stateTimeColumn) + + // process_id + let processIdColumn = columns.first { info in + info.name == SessionRecord.Schema.processId.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(processIdColumn) + + // trace_id + let traceIdColumn = columns.first { info in + info.name == SessionRecord.Schema.traceId.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(traceIdColumn) + + // span_id + let spanIdColumn = columns.first { info in + info.name == SessionRecord.Schema.spanId.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(spanIdColumn) + + // start_time + let startTimeColumn = columns.first { info in + info.name == SessionRecord.Schema.startTime.name && + info.type == "DATETIME" && + info.isNotNull == true + } + XCTAssertNotNil(startTimeColumn) + + // end_time + let endTimeColumn = columns.first { info in + info.name == SessionRecord.Schema.endTime.name && + info.type == "DATETIME" && + info.isNotNull == false + } + XCTAssertNotNil(endTimeColumn) + + // last_heartbeat_time + let lastHeartbeatTimeColumn = columns.first { info in + info.name == SessionRecord.Schema.lastHeartbeatTime.name && + info.type == "DATETIME" && + info.isNotNull == true + } + XCTAssertNotNil(lastHeartbeatTimeColumn) + + // cold_start + let coldStartColumn = columns.first { info in + info.name == SessionRecord.Schema.coldStart.name && + info.type == "BOOLEAN" && + info.isNotNull == true && + info.defaultValueSQL == "0" + } + XCTAssertNotNil(coldStartColumn) + + // clean_exit + let cleanExitColumn = columns.first { info in + info.name == SessionRecord.Schema.cleanExit.name && + info.type == "BOOLEAN" && + info.isNotNull == true && + info.defaultValueSQL == "0" + } + XCTAssertNotNil(cleanExitColumn) + + // app_terminated + let appTerminatedColumn = columns.first { info in + info.name == SessionRecord.Schema.appTerminated.name && + info.type == "BOOLEAN" && + info.isNotNull == true && + info.defaultValueSQL == "0" + } + XCTAssertNotNil(appTerminatedColumn) + + // crash_report_id + let crashReportIdColumn = columns.first { info in + info.name == SessionRecord.Schema.crashReportId.name && + info.type == "TEXT" && + info.isNotNull == false + } + XCTAssertNotNil(crashReportIdColumn) + } + } + +} diff --git a/Tests/EmbraceStorageTests/Migration/Migrations/20240510_01_AddMetadataRecordMigrationTests.swift b/Tests/EmbraceStorageTests/Migration/Migrations/20240510_01_AddMetadataRecordMigrationTests.swift new file mode 100644 index 00000000..e8cffdd4 --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/Migrations/20240510_01_AddMetadataRecordMigrationTests.swift @@ -0,0 +1,99 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest + +@testable import EmbraceStorage +import GRDB + +final class _0240510_01_AddMetadataRecordMigrationTests: XCTestCase { + + var storage: EmbraceStorage! + + override func setUpWithError() throws { + storage = try EmbraceStorage.createInMemoryDb(runMigrations: false) + } + + override func tearDownWithError() throws { + try storage.teardown() + } + + func test_identifier() { + let migration = AddMetadataRecordMigration() + XCTAssertEqual(migration.identifier, "CreateMetadataRecordTable") + } + + func test_perform_createsTableWithCorrectSchema() throws { + let migration = AddMetadataRecordMigration() + + try storage.dbQueue.write { db in + try migration.perform(db) + } + + try storage.dbQueue.read { db in + XCTAssertTrue(try db.tableExists(MetadataRecord.databaseTableName)) + + XCTAssertTrue( + try db.table(MetadataRecord.databaseTableName, + hasUniqueKey: [ + MetadataRecord.Schema.key.name, + MetadataRecord.Schema.type.name, + MetadataRecord.Schema.lifespan.name, + MetadataRecord.Schema.lifespanId.name + ] ) + ) + + let columns = try db.columns(in: MetadataRecord.databaseTableName) + XCTAssertEqual(columns.count, 6) + + // key + let keyColumn = columns.first { info in + info.name == MetadataRecord.Schema.key.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(keyColumn) + + // value + let valueColumn = columns.first { info in + info.name == MetadataRecord.Schema.value.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(valueColumn) + + // type + let typeColumn = columns.first { info in + info.name == MetadataRecord.Schema.type.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(typeColumn) + + // lifespan + let lifespanColumn = columns.first { info in + info.name == MetadataRecord.Schema.lifespan.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(lifespanColumn) + + // lifespan_id + let lifespanIdColumn = columns.first { info in + info.name == MetadataRecord.Schema.lifespanId.name && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(lifespanIdColumn) + + // collected_at + let collectedAtColumn = columns.first { info in + info.name == MetadataRecord.Schema.collectedAt.name && + info.type == "DATETIME" && + info.isNotNull == true + } + XCTAssertNotNil(collectedAtColumn) + } + } +} diff --git a/Tests/EmbraceStorageTests/Migration/Migrations/20240510_02_AddLogRecordMigrationTests.swift b/Tests/EmbraceStorageTests/Migration/Migrations/20240510_02_AddLogRecordMigrationTests.swift new file mode 100644 index 00000000..02c390b4 --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/Migrations/20240510_02_AddLogRecordMigrationTests.swift @@ -0,0 +1,94 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest + +@testable import EmbraceStorage +import GRDB + +final class _0240510_02_AddLogRecordMigrationTests: XCTestCase { + + var storage: EmbraceStorage! + + override func setUpWithError() throws { + storage = try EmbraceStorage.createInMemoryDb(runMigrations: false) + } + + override func tearDownWithError() throws { + try storage.teardown() + } + + func test_identifier() { + let migration = AddLogRecordMigration() + XCTAssertEqual(migration.identifier, "CreateLogRecordTable") + } + + func test_perform_createsTableWithCorrectSchema() throws { + let migration = AddLogRecordMigration() + + try storage.dbQueue.write { db in + try migration.perform(db) + } + + try storage.dbQueue.read { db in + XCTAssert(try db.tableExists(LogRecord.databaseTableName)) + + let columns = try db.columns(in: LogRecord.databaseTableName) + + XCTAssert(try db.table( + LogRecord.databaseTableName, + hasUniqueKey: ["identifier"] + )) + + // identifier + let idColumn = columns.first { info in + info.name == "identifier" && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(idColumn, "identifier column not found!") + + // process_identifier + let processIdColumn = columns.first { info in + info.name == "process_identifier" && + info.type == "INTEGER" && + info.isNotNull == true + } + XCTAssertNotNil(processIdColumn, "process_identifier column not found!") + + // severity + let severityColumn = columns.first { info in + info.name == "severity" && + info.type == "INTEGER" && + info.isNotNull == true + } + XCTAssertNotNil(severityColumn, "severity column not found!") + + // body + let bodyColumn = columns.first { info in + info.name == "body" && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(bodyColumn, "body column not found!") + + // timestamp + let timestampColumn = columns.first { info in + info.name == "timestamp" && + info.type == "DATETIME" && + info.isNotNull == true + } + XCTAssertNotNil(timestampColumn, "timestamp column not found!") + + // attributes + let attributesColumn = columns.first { info in + info.name == "attributes" && + info.type == "TEXT" && + info.isNotNull == true + } + XCTAssertNotNil(attributesColumn, "attributes column not found!") + } + } + +} diff --git a/Tests/EmbraceStorageTests/Migration/Migrations/Migrations+CurrentTests.swift b/Tests/EmbraceStorageTests/Migration/Migrations/Migrations+CurrentTests.swift new file mode 100644 index 00000000..6f1f05ca --- /dev/null +++ b/Tests/EmbraceStorageTests/Migration/Migrations/Migrations+CurrentTests.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import XCTest + +@testable import EmbraceStorage + +final class Migrations_CurrentTests: XCTestCase { + + func test_current_returnsMigrationsWithIdentifiersInCorrectOrder() throws { + let migrations: [Migration] = .current + let identifiers = migrations.map(\.identifier) + + XCTAssertEqual(migrations.count, 4) + XCTAssertEqual(identifiers, [ + // add identifiers here + "CreateSpanRecordTable", + "CreateSessionRecordTable", + "CreateMetadataRecordTable", + "CreateLogRecordTable" + ]) + } + +} diff --git a/Tests/EmbraceStorageTests/Records/LogRecord/LogRecordTests.swift b/Tests/EmbraceStorageTests/Records/LogRecord/LogRecordTests.swift index 8e0665b1..6a77654b 100644 --- a/Tests/EmbraceStorageTests/Records/LogRecord/LogRecordTests.swift +++ b/Tests/EmbraceStorageTests/Records/LogRecord/LogRecordTests.swift @@ -19,7 +19,7 @@ class LogRecordTests: XCTestCase { } func test_tableSchema() throws { - let expectation = XCTestExpectation() + XCTAssertEqual(LogRecord.databaseTableName, "logs") // then the table and its colums should be correct try storage.dbQueue.read { db in @@ -79,10 +79,6 @@ class LogRecordTests: XCTestCase { } else { XCTAssert(false, "attributes column not found!") } - - expectation.fulfill() } - - wait(for: [expectation], timeout: .defaultTimeout) } } diff --git a/Tests/EmbraceStorageTests/SessionRecordTests.swift b/Tests/EmbraceStorageTests/SessionRecordTests.swift index d362ac10..2000c810 100644 --- a/Tests/EmbraceStorageTests/SessionRecordTests.swift +++ b/Tests/EmbraceStorageTests/SessionRecordTests.swift @@ -19,7 +19,7 @@ class SessionRecordTests: XCTestCase { } func test_tableSchema() throws { - let expectation = XCTestExpectation() + XCTAssertEqual(SessionRecord.databaseTableName, "sessions") // then the table and its colums should be correct try storage.dbQueue.read { db in @@ -141,10 +141,7 @@ class SessionRecordTests: XCTestCase { XCTAssert(false, "app_terminated column not found!") } - expectation.fulfill() } - - wait(for: [expectation], timeout: .defaultTimeout) } func test_addSession() throws { diff --git a/Tests/EmbraceStorageTests/SpanRecordTests.swift b/Tests/EmbraceStorageTests/SpanRecordTests.swift index 75bea9c6..78211fe9 100644 --- a/Tests/EmbraceStorageTests/SpanRecordTests.swift +++ b/Tests/EmbraceStorageTests/SpanRecordTests.swift @@ -20,7 +20,7 @@ class SpanRecordTests: XCTestCase { } func test_tableSchema() throws { - let expectation = XCTestExpectation() + XCTAssertEqual(SpanRecord.databaseTableName, "spans") // then the table and its colums should be correct try storage.dbQueue.read { db in @@ -99,11 +99,7 @@ class SpanRecordTests: XCTestCase { } else { XCTAssert(false, "data column not found!") } - - expectation.fulfill() } - - wait(for: [expectation], timeout: .defaultTimeout) } func test_addSpan() throws { @@ -120,13 +116,9 @@ class SpanRecordTests: XCTestCase { XCTAssertNotNil(span) // then span should exist in storage - let expectation = XCTestExpectation() try storage.dbQueue.read { db in XCTAssert(try span.exists(db)) - expectation.fulfill() } - - wait(for: [expectation], timeout: .defaultTimeout) } func test_upsertSpan() throws { diff --git a/Tests/EmbraceStorageTests/TestSupport/ThrowingMigrationService.swift b/Tests/EmbraceStorageTests/TestSupport/ThrowingMigrationService.swift new file mode 100644 index 00000000..6587db60 --- /dev/null +++ b/Tests/EmbraceStorageTests/TestSupport/ThrowingMigrationService.swift @@ -0,0 +1,35 @@ +// +// Copyright © 2023 Embrace Mobile, Inc. All rights reserved. +// + +import Foundation +import EmbraceStorage +import GRDB + +class ThrowingMigrationService: MigrationServiceProtocol { + enum MigrationServiceError: Error { + case alwaysError + } + + let performsToThrow: [Int] + private(set) var currentPerformCount: Int = 0 + + /// - Parameters: + /// - performToThrow: The invocation of `perform` that should throw. Defaults to 1, the first call to `perform`. + init(performToThrow: Int = 1) { + self.performsToThrow = [performToThrow] + } + + /// - Parameters: + /// - performToThrow: The invocation of `perform` that should throw. Defaults to 1, the first call to `perform`. + init(performsToThrow: [Int]) { + self.performsToThrow = performsToThrow + } + + func perform(_ dbQueue: DatabaseWriter, migrations: [Migration]) throws { + currentPerformCount += 1 + if performsToThrow.contains(currentPerformCount) { + throw MigrationServiceError.alwaysError + } + } +} diff --git a/Tests/TestSupport/Extensions/EmbraceStorage+Extension.swift b/Tests/TestSupport/Extensions/EmbraceStorage+Extension.swift index 09b166b9..2f14b896 100644 --- a/Tests/TestSupport/Extensions/EmbraceStorage+Extension.swift +++ b/Tests/TestSupport/Extensions/EmbraceStorage+Extension.swift @@ -3,16 +3,23 @@ // import Foundation -import EmbraceStorage +@testable import EmbraceStorage public extension EmbraceStorage { - static func createInMemoryDb() throws -> EmbraceStorage { - try .init(options: .init(named: UUID().uuidString)) + static func createInMemoryDb(runMigrations: Bool = true) throws -> EmbraceStorage { + let storage = try EmbraceStorage(options: .init(named: UUID().uuidString)) + if runMigrations { try storage.performMigration() } + return storage } - static func createInDiskDb() throws -> EmbraceStorage { + static func createInDiskDb(runMigrations: Bool = true) throws -> EmbraceStorage { let url = URL(fileURLWithPath: NSTemporaryDirectory()) - return try .init(options: .init(baseUrl: url, fileName: "\(UUID().uuidString).sqlite")) + let storage = try EmbraceStorage( + options: .init(baseUrl: url, fileName: "\(UUID().uuidString).sqlite") + ) + + if runMigrations { try storage.performMigration() } + return storage } }