diff --git a/README.md b/README.md index 40d50b5..dbc6866 100644 --- a/README.md +++ b/README.md @@ -84,27 +84,59 @@ try sqlite.transaction { ### Schema Migration -There is no built-in support for schema migrations. Following is a part of a sample of how you might perform migrations in your own app. +SkipSQL provides built-in support for version-based schema migrations using SQLite's `userVersion` pragma. The `migrate()` method accepts an array of migration closures, each representing a schema version. Only migrations that haven't been applied yet will run, each within its own transaction: + +```swift +try ctx.migrate(migrations: [ + // Version 1: initial schema + { ctx in + try ctx.exec(sql: "CREATE TABLE DATA_ITEM (ID INTEGER PRIMARY KEY AUTOINCREMENT)") + }, + // Version 2: add description column + { ctx in + try ctx.exec(sql: "ALTER TABLE DATA_ITEM ADD COLUMN DESCRIPTION TEXT") + }, + // Version 3: add a new table + { ctx in + try ctx.createTableIfNotExists(NewTable.self) + }, +]) +``` + +Each migration runs in a transaction and increments `userVersion` upon success. If a migration fails, it rolls back without affecting subsequent migrations. You can safely add new closures to the end of the array as your schema evolves — previously applied migrations are skipped. + +#### Helper Methods + +Additional schema utilities are available: + +```swift +// Check if a table exists in the database +let exists = try ctx.tableExists("DATA_ITEM") // true + +// Create a table from an SQLCodable type (safe if already exists) +try ctx.createTableIfNotExists(DemoTable.self) + +// Add a column to an existing table (safe if already exists) +try ctx.addColumnIfNotExists(DemoTable.newColumn, to: DemoTable.table) +``` + +#### Manual Migration + +For more control, you can implement migrations manually using the `userVersion` pragma directly: ```swift -// track the version of the schema with the `userVersion` pragma, which can be used for schema migration func migrateSchema(v version: Int64, ddl: String) throws { if ctx.userVersion < version { - let startTime = Date.now try ctx.transaction { - try ctx.exec(sql: ddl) // perform the DDL operation - // then update the schema version + try ctx.exec(sql: ddl) ctx.userVersion = version } - logger.log("updated database schema to \(version) in \(startTime.durationToNow)") } } -// the initial creation script for a new database try migrateSchema(v: 1, ddl: """ CREATE TABLE DATA_ITEM (ID INTEGER PRIMARY KEY AUTOINCREMENT) """) -// migrate records to have new description column try migrateSchema(v: 2, ddl: """ ALTER TABLE DATA_ITEM ADD COLUMN DESCRIPTION TEXT """) @@ -215,6 +247,73 @@ while let row = cursor.next() { } ``` +### Convenience Query Methods + +`SQLContext` provides several convenience methods for common operations on `SQLCodable` types: + +#### Checking Existence + +```swift +// Check if any rows exist +let hasRows = try ctx.exists(DemoTable.self) + +// Check if rows match a predicate +let hasMatch = try ctx.exists(DemoTable.self, where: DemoTable.txt.equals(SQLValue("ABC"))) +``` + +#### Fetching + +```swift +// Fetch all rows +let all: [DemoTable] = try ctx.fetchAll(DemoTable.self) + +// Fetch with filtering, ordering, and pagination +let page: [DemoTable] = try ctx.fetchAll(DemoTable.self, + where: DemoTable.num.isNotNull(), + orderBy: DemoTable.int, + order: .descending, + limit: 10, + offset: 20) +``` + +#### Batch Insert + +```swift +// Insert multiple instances in a single transaction +let items = [ + DemoTable(int: 1, txt: "A"), + DemoTable(int: 2, txt: "B"), + DemoTable(int: 3, txt: "C"), +] +let inserted = try ctx.insertAll(items) // returns instances with assigned primary keys +``` + +#### Deleting All Rows + +```swift +// Delete all rows from a table +try ctx.deleteAll(DemoTable.self) +``` + +#### Aggregate Functions + +```swift +// Sum, average, min, max of a column +let total = try ctx.sum(column: DemoTable.int, of: DemoTable.self) +let average = try ctx.avg(column: DemoTable.int, of: DemoTable.self) +let minimum = try ctx.min(column: DemoTable.int, of: DemoTable.self) +let maximum = try ctx.max(column: DemoTable.int, of: DemoTable.self) + +// With filtering +let filteredSum = try ctx.sum(column: DemoTable.int, of: DemoTable.self, + where: DemoTable.txt.isNotNull()) + +// Generic aggregate for any SQL aggregate function +let result = try ctx.aggregate("GROUP_CONCAT", column: DemoTable.txt, type: DemoTable.self) +``` + +All aggregate functions return an `SQLValue`, which can be accessed with `.longValue`, `.realValue`, `.textValue`, etc. + ### Primary keys and auto-increment columns SkipSQL supports primary keys that work with SQLite's ROWID mechanism, diff --git a/Sources/SkipSQLCore/SQLCodable.swift b/Sources/SkipSQLCore/SQLCodable.swift index 2c0b1b8..2190457 100644 --- a/Sources/SkipSQLCore/SQLCodable.swift +++ b/Sources/SkipSQLCore/SQLCodable.swift @@ -85,6 +85,78 @@ public extension SQLContext { return try cursor(countSQL).map({ try $0.get() }).first?.first?.longValue ?? 0 } + /// Returns true if any rows exist matching the optional predicate. + func exists(_ type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil) throws -> Bool { + var sql = SQLExpression("SELECT EXISTS(SELECT 1 FROM " + type.table.quotedName(inSchema: schemaName)) + if let predicate { + sql.append(" WHERE ") + predicate.apply(to: &sql) + } + sql.append(")") + return try cursor(sql).map({ try $0.get() }).first?.first?.longValue == Int64(1) + } + + /// Fetches all instances of the given type, with optional filtering, ordering, and limit. + func fetchAll(_ type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil, orderBy orderByColumn: SQLRepresentable? = nil, order: SQLOrder = .ascending, limit: Int? = nil, offset: Int? = nil) throws -> [T] { + var q = query(type, schema: schemaName).where(predicate) + if let orderByColumn { + q = q.orderBy(orderByColumn, order: order) + } + if let limit { + q = q.limit(limit, offset: offset) + } + let cursor = try q.eval() + defer { cursor.close() } + return try cursor.load() + } + + /// Deletes all rows from the given table. + func deleteAll(_ type: T.Type, inSchema schemaName: String? = nil) throws { + try exec(SQLExpression("DELETE FROM " + type.table.quotedName(inSchema: schemaName))) + } + + /// Inserts multiple instances in a single transaction for efficiency. Returns the inserted instances with assigned primary keys. + @inline(__always) @discardableResult func insertAll(_ instances: [T], inSchema schemaName: String? = nil) throws -> [T] { + if instances.isEmpty { return [] } + return try transaction { + var results: [T] = [] + for instance in instances { + results.append(try insert(instance, inSchema: schemaName)) + } + return results + } + } + + /// Returns the result of an aggregate function applied to a column, with optional filtering. + func aggregate(_ function: String, column: SQLColumn, type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil) throws -> SQLValue { + var sql = SQLExpression("SELECT " + function + "(" + column.quotedName() + ") FROM " + type.table.quotedName(inSchema: schemaName)) + if let predicate { + sql.append(" WHERE ") + predicate.apply(to: &sql) + } + return try cursor(sql).map({ try $0.get() }).first?.first ?? .null + } + + /// Returns the sum of the given column. + func sum(column: SQLColumn, of type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil) throws -> SQLValue { + try aggregate("SUM", column: column, type: type, inSchema: schemaName, where: predicate) + } + + /// Returns the average of the given column. + func avg(column: SQLColumn, of type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil) throws -> SQLValue { + try aggregate("AVG", column: column, type: type, inSchema: schemaName, where: predicate) + } + + /// Returns the minimum value of the given column. + func min(column: SQLColumn, of type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil) throws -> SQLValue { + try aggregate("MIN", column: column, type: type, inSchema: schemaName, where: predicate) + } + + /// Returns the maximum value of the given column. + func max(column: SQLColumn, of type: T.Type, inSchema schemaName: String? = nil, where predicate: SQLPredicate? = nil) throws -> SQLValue { + try aggregate("MAX", column: column, type: type, inSchema: schemaName, where: predicate) + } + /// Delete the given instances from the database. Instances must have at least one primary key defined. @inline(__always) func delete(instances: [T]) throws { try delete(T.self, where: primaryKeyQuery(instances)) diff --git a/Sources/SkipSQLCore/SQLSchema.swift b/Sources/SkipSQLCore/SQLSchema.swift index a15d9af..2e97dbe 100644 --- a/Sources/SkipSQLCore/SQLSchema.swift +++ b/Sources/SkipSQLCore/SQLSchema.swift @@ -333,6 +333,43 @@ public extension SQLContext { // the custom sql is a bit of a hack try issueQuery(ColumnInfo.self, where: .custom(sql: "name IS NOT NULL", bindings: [.text(tableName), .text(schemaName)])).load() } + + /// Returns true if the given table exists in the database. + func tableExists(_ tableName: String) throws -> Bool { + let result = try selectAll(sql: "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", parameters: [.text(tableName)]) + return result.first?.first?.longValue ?? 0 > 0 + } + + /// Creates the table for the given type if it does not already exist, along with any indices. + func createTableIfNotExists(_ type: T.Type, inSchema schemaName: String? = nil) throws { + for ddl in type.table.createTableSQL(inSchema: schemaName, ifNotExists: true) { + try exec(ddl) + } + } + + /// Adds a column to the table if it does not already exist. Useful for schema migrations. + func addColumnIfNotExists(_ column: SQLColumn, to table: SQLTable, inSchema schemaName: String? = nil) throws { + let existingColumns = try columns(for: table.name, in: schemaName ?? "main") + let columnExists = existingColumns.contains(where: { $0.name == column.name }) + if !columnExists { + for ddl in table.addColumnSQL(column: column, inSchema: schemaName) { + try exec(ddl) + } + } + } + + /// Performs a schema migration using the `userVersion` property. + /// The `migrations` array is 0-indexed: migrations[0] runs when upgrading from version 0 to 1, etc. + /// Each migration closure receives the `SQLContext` and should perform the necessary DDL changes. + func migrate(migrations: [(SQLContext) throws -> Void]) throws { + let currentVersion = Int(userVersion) + for version in currentVersion.. Void] = [ + // Migration 0 → 1: create initial table + { ctx in + try ctx.exec(sql: #"CREATE TABLE "ITEMS" ("ID" INTEGER PRIMARY KEY, "NAME" TEXT NOT NULL)"#) + }, + // Migration 1 → 2: add a column + { ctx in + try ctx.exec(sql: #"ALTER TABLE "ITEMS" ADD COLUMN "SCORE" REAL"#) + }, + // Migration 2 → 3: add another column with default + { ctx in + try ctx.exec(sql: #"ALTER TABLE "ITEMS" ADD COLUMN "ACTIVE" INTEGER DEFAULT 1"#) + }, + ] + + // run all migrations from scratch + try sqlite.migrate(migrations: migrations) + XCTAssertEqual(3, sqlite.userVersion) + + // verify schema + let cols = try sqlite.columns(for: "ITEMS") + XCTAssertEqual(4, cols.count) + XCTAssertEqual("ID", cols[0].name) + XCTAssertEqual("NAME", cols[1].name) + XCTAssertEqual("SCORE", cols[2].name) + XCTAssertEqual("ACTIVE", cols[3].name) + + // running again should be a no-op + try sqlite.migrate(migrations: migrations) + XCTAssertEqual(3, sqlite.userVersion) + + // insert data to verify the table works + try sqlite.exec(sql: "INSERT INTO ITEMS (NAME, SCORE) VALUES (?, ?)", parameters: [.text("Test"), .real(9.5)]) + let rows = try sqlite.selectAll(sql: "SELECT * FROM ITEMS") + XCTAssertEqual(1, rows.count) + + try sqlite.exec(sql: #"DROP TABLE "ITEMS""#) + } + + func testMigrateIncremental() throws { + let sqlite = try SQLContextTest() + + // start with 2 migrations + let initialMigrations: [(SQLContext) throws -> Void] = [ + { ctx in try ctx.exec(sql: #"CREATE TABLE "VERSIONED" ("ID" INTEGER PRIMARY KEY)"#) }, + { ctx in try ctx.exec(sql: #"ALTER TABLE "VERSIONED" ADD COLUMN "V2" TEXT"#) }, + ] + try sqlite.migrate(migrations: initialMigrations) + XCTAssertEqual(2, sqlite.userVersion) + + // now add a third migration — only the new one should run + let extendedMigrations: [(SQLContext) throws -> Void] = [ + { ctx in try ctx.exec(sql: #"CREATE TABLE "VERSIONED" ("ID" INTEGER PRIMARY KEY)"#) }, + { ctx in try ctx.exec(sql: #"ALTER TABLE "VERSIONED" ADD COLUMN "V2" TEXT"#) }, + { ctx in try ctx.exec(sql: #"ALTER TABLE "VERSIONED" ADD COLUMN "V3" INTEGER"#) }, + ] + try sqlite.migrate(migrations: extendedMigrations) + XCTAssertEqual(3, sqlite.userVersion) + + let cols = try sqlite.columns(for: "VERSIONED") + XCTAssertEqual(3, cols.count) + XCTAssertEqual("V3", cols[2].name) + + try sqlite.exec(sql: #"DROP TABLE "VERSIONED""#) + } } extension SQLContext {