Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 107 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
""")
Expand Down Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions Sources/SkipSQLCore/SQLCodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: SQLCodable>(_ 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<T: SQLCodable>(_ 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<T: SQLCodable>(_ 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<T: SQLCodable>(_ 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<T: SQLCodable>(_ 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<T: SQLCodable>(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<T: SQLCodable>(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<T: SQLCodable>(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<T: SQLCodable>(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<T: SQLCodable>(instances: [T]) throws {
try delete(T.self, where: primaryKeyQuery(instances))
Expand Down
37 changes: 37 additions & 0 deletions Sources/SkipSQLCore/SQLSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: SQLCodable>(_ 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..<migrations.count {
try transaction {
try migrations[version](self)
self.userVersion = Int64(version + 1)
}
}
}
}

/// `sqlite_master` (the new recommended `sqlite_schema` name was introduced in SQLite 3.33.0)
Expand Down
Loading
Loading