diff --git a/.spi.yml b/.spi.yml index 818e9152..a75857c7 100644 --- a/.spi.yml +++ b/.spi.yml @@ -3,4 +3,7 @@ builder: configs: - documentation_targets: - SharingGRDB - swift_version: 6.0 + - SharingGRDBCore + - StructuredQueriesGRDB + - StructuredQueriesGRDBCore + custom_documentation_parameters: [--enable-experimental-overloaded-symbol-presentation] diff --git a/Examples/CaseStudies/Animations.swift b/Examples/CaseStudies/Animations.swift index 00f5bbff..d5380c34 100644 --- a/Examples/CaseStudies/Animations.swift +++ b/Examples/CaseStudies/Animations.swift @@ -1,4 +1,3 @@ -import Dependencies import SharingGRDB import SwiftUI @@ -7,14 +6,14 @@ struct AnimationsCaseStudy: SwiftUICaseStudy { This demonstrates how to animate fetching data from the database, or when data changes in \ the database. Simply provide the `animation` argument to `fetchAll` (or the other querying \ tools, such as `fetch` and `fetchOne`). - + This is analogous to how animations work in SwiftData in which one provides an `animation` \ argument to the `@Query` macro. """ let caseStudyTitle = "Animations" - @SharedReader(.fetchAll(sql: #"SELECT * FROM "facts" ORDER BY "id" DESC"#, animation: .default)) - private var facts: [Fact] + @FetchAll(Fact.order { $0.id.desc() }, animation: .default) + private var facts @Dependency(\.defaultDatabase) var database @@ -36,7 +35,8 @@ struct AnimationsCaseStudy: SwiftUICaseStudy { as: UTF8.self ) try await database.write { db in - _ = try Fact(body: fact).inserted(db) + try Fact.insert(Fact.Draft(body: fact)) + .execute(db) } } } catch {} @@ -44,13 +44,10 @@ struct AnimationsCaseStudy: SwiftUICaseStudy { } } -private struct Fact: Codable, FetchableRecord, Identifiable, MutablePersistableRecord { - static let databaseTableName = "facts" - var id: Int64? +@Table +private struct Fact: Identifiable { + let id: Int var body: String - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } extension DatabaseWriter where Self == DatabaseQueue { @@ -58,10 +55,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create 'facts' table") { db in - try db.create(table: Fact.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("body", .text).notNull() - } + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudies/App.swift b/Examples/CaseStudies/App.swift index 31ffed46..6463b491 100644 --- a/Examples/CaseStudies/App.swift +++ b/Examples/CaseStudies/App.swift @@ -6,9 +6,11 @@ struct CaseStudiesApp: App { var body: some Scene { WindowGroup { Form { - Text(""" + Text( + """ Open the preview in each case study file to run a case study. - """) + """ + ) } } } diff --git a/Examples/CaseStudies/DynamicQuery.swift b/Examples/CaseStudies/DynamicQuery.swift index f90c5b5d..dd2d5f40 100644 --- a/Examples/CaseStudies/DynamicQuery.swift +++ b/Examples/CaseStudies/DynamicQuery.swift @@ -1,4 +1,3 @@ -import Dependencies import SharingGRDB import SwiftUI @@ -8,13 +7,13 @@ struct DynamicQueryDemo: SwiftUICaseStudy { a fact about a number is loaded from the network and saved to a database. You can search the \ facts for text, and the list will stay in sync so that if a new fact is added to the database \ that satisfies the search term, it will immediately appear. - - To accomplish this one can invoke the `load` method defined on the `@SharedReader` projected \ - value in order to set a new query with dynamic parameters. + + To accomplish this one can invoke the `load` method defined on the `@Fetch` projected value in \ + order to set a new query with dynamic parameters. """ let caseStudyTitle = "Dynamic Query" - @State.SharedReader(.fetch(Facts(), animation: .default)) private var facts = Facts.Value() + @Fetch(Facts(), animation: .default) private var facts = Facts.Value() @State var query = "" @Dependency(\.defaultDatabase) var database @@ -38,10 +37,14 @@ struct DynamicQueryDemo: SwiftUICaseStudy { ForEach(facts.facts) { fact in Text(fact.body) } - .onDelete { indexSet in + .onDelete { indices in withErrorReporting { try database.write { db in - _ = try Fact.deleteAll(db, ids: indexSet.compactMap { facts.facts[$0].id }) + let ids = indices.map { facts.facts[$0].id } + try Fact + .where { $0.id.in(ids) } + .delete() + .execute(db) } } } @@ -50,7 +53,7 @@ struct DynamicQueryDemo: SwiftUICaseStudy { .searchable(text: $query) .task(id: query) { await withErrorReporting { - try await $facts.load(.fetch(Facts(query: query), animation: .default)) + try await $facts.load(Facts(query: query), animation: .default) } } .task { @@ -65,7 +68,8 @@ struct DynamicQueryDemo: SwiftUICaseStudy { as: UTF8.self ) try await database.write { db in - _ = try Fact(body: fact).inserted(db) + try Fact.insert(Fact.Draft(body: fact)) + .execute(db) } } } catch {} @@ -80,24 +84,23 @@ struct DynamicQueryDemo: SwiftUICaseStudy { var totalCount = 0 } func fetch(_ db: Database) throws -> Value { - let query = Fact.order(Column("id").desc).filter(Column("body").like("%\(query)%")) + let search = + Fact + .where { $0.body.contains(query) } + .order { $0.id.desc() } return try Value( - facts: query.fetchAll(db), - searchCount: query.fetchCount(db), - totalCount: Fact.fetchCount(db) + facts: search.fetchAll(db), + searchCount: search.fetchCount(db), + totalCount: Fact.all.fetchCount(db) ) } } - } -private struct Fact: Codable, FetchableRecord, Identifiable, MutablePersistableRecord { - static let databaseTableName = "facts" - var id: Int64? +@Table +private struct Fact: Identifiable { + let id: Int var body: String - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } extension DatabaseWriter where Self == DatabaseQueue { @@ -105,10 +108,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create 'facts' table") { db in - try db.create(table: Fact.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("body", .text).notNull() - } + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudies/ObservableModelDemo.swift b/Examples/CaseStudies/ObservableModelDemo.swift index b9d44e94..a777fb64 100644 --- a/Examples/CaseStudies/ObservableModelDemo.swift +++ b/Examples/CaseStudies/ObservableModelDemo.swift @@ -1,4 +1,3 @@ -import Dependencies import SharingGRDB import SwiftUI @@ -7,7 +6,7 @@ struct ObservableModelDemo: SwiftUICaseStudy { This demonstrates how to use the `fetchAll` and `fetchOne` tools in an @Observable model. \ In SwiftUI, the `@Query` macro only works when installed directly in a SwiftUI view, and \ cannot be used outside of views. - + The tools provided with this library work basically anywhere, including in `@Observable` \ models and UIKit view controllers. """ @@ -47,10 +46,10 @@ struct ObservableModelDemo: SwiftUICaseStudy { @MainActor private class Model { @ObservationIgnored - @SharedReader(.fetchAll(sql: #"SELECT * FROM "facts" ORDER BY "id" DESC"#, animation: .default)) - var facts: [Fact] + @FetchAll(Fact.order { $0.id.desc() }, animation: .default) + var facts @ObservationIgnored - @SharedReader(.fetchOne(sql: #"SELECT count(*) FROM "facts""#, animation: .default)) + @FetchOne(Fact.count(), animation: .default) var factsCount = 0 var number = 0 @@ -66,27 +65,29 @@ private class Model { as: UTF8.self ) try await database.write { db in - _ = try Fact(body: fact).inserted(db) + try Fact.insert(Fact.Draft(body: fact)) + .execute(db) } } } func deleteFact(indices: IndexSet) { - _ = withErrorReporting { + withErrorReporting { try database.write { db in - try Fact.deleteAll(db, ids: indices.compactMap { facts[$0].id }) + let ids = indices.map { facts[$0].id } + try Fact + .where { $0.id.in(ids) } + .delete() + .execute(db) } } } } -private struct Fact: Codable, FetchableRecord, Identifiable, MutablePersistableRecord { - static let databaseTableName = "facts" - var id: Int64? +@Table +private struct Fact: Identifiable { + let id: Int var body: String - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } extension DatabaseWriter where Self == DatabaseQueue { @@ -94,10 +95,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create 'facts' table") { db in - try db.create(table: Fact.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("body", .text).notNull() - } + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudies/SwiftDataTemplateDemo.swift b/Examples/CaseStudies/SwiftDataTemplateDemo.swift index 9f2bca21..a1e86802 100644 --- a/Examples/CaseStudies/SwiftDataTemplateDemo.swift +++ b/Examples/CaseStudies/SwiftDataTemplateDemo.swift @@ -9,12 +9,12 @@ struct SwiftDataTemplateView: SwiftUICaseStudy { let caseStudyTitle = "SwiftData Template" @Dependency(\.defaultDatabase) private var database - @SharedReader(.fetch(Items(), animation: .default)) private var items + @FetchAll(Item.all, animation: .default) private var items var body: some View { NavigationStack { List { - ForEach(items, id: \.id) { item in + ForEach(items) { item in NavigationLink { Text( "Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))" @@ -41,7 +41,7 @@ struct SwiftDataTemplateView: SwiftUICaseStudy { private func addItem() { withErrorReporting { try database.write { db in - _ = try Item(timestamp: Date()).inserted(db) + try Item.insert().execute(db) } } } @@ -49,20 +49,16 @@ struct SwiftDataTemplateView: SwiftUICaseStudy { private func deleteItems(offsets: IndexSet) { withErrorReporting { try database.write { db in - _ = try Item.deleteAll(db, keys: offsets.map { items[$0].id }) + try Item.where { $0.id.in(offsets.map { items[$0].id }) }.delete().execute(db) } } } - - private struct Items: FetchKeyRequest { - func fetch(_ db: Database) throws -> [Item] { - try Item.order(Column("timestamp").desc).fetchAll(db) - } - } } -private struct Item: Codable, Hashable, FetchableRecord, MutablePersistableRecord { - var id: Int64? +@Table +private struct Item: Identifiable { + let id: Int + @Column(as: Date.ISO8601Representation.self) var timestamp: Date } @@ -71,10 +67,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create items table") { db in - try db.create(table: Item.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("timestamp", .datetime).notNull() - } + try #sql( + """ + CREATE TABLE "items" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "timestamp" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudies/SwiftUIDemo.swift b/Examples/CaseStudies/SwiftUIDemo.swift index 5e1df872..d4a12335 100644 --- a/Examples/CaseStudies/SwiftUIDemo.swift +++ b/Examples/CaseStudies/SwiftUIDemo.swift @@ -1,4 +1,3 @@ -import Dependencies import SharingGRDB import SwiftUI @@ -7,14 +6,14 @@ struct SwiftUIDemo: SwiftUICaseStudy { This demonstrates how to use the `fetchAll` and `fetchOne` queries directly in a SwiftUI view. \ The tools listen for changes in the database so that when the table changes it automatically \ updates state and re-renders the view. - + You can also delete rows by swiping on a row and tapping the "Delete" button. """ let caseStudyTitle = "SwiftUI Views" - @SharedReader(.fetchAll(sql: #"SELECT * FROM "facts" ORDER BY "id" DESC"#, animation: .default)) - private var facts: [Fact] - @SharedReader(.fetchOne(sql: #"SELECT count(*) FROM "facts""#, animation: .default)) + @FetchAll(Fact.order { $0.id.desc() }, animation: .default) + private var facts + @FetchOne(Fact.count(), animation: .default) var factsCount = 0 @Dependency(\.defaultDatabase) var database @@ -45,7 +44,8 @@ struct SwiftUIDemo: SwiftUICaseStudy { as: UTF8.self ) try await database.write { db in - _ = try Fact(body: fact).inserted(db) + try Fact.insert(Fact.Draft(body: fact)) + .execute(db) } } } catch {} @@ -53,13 +53,10 @@ struct SwiftUIDemo: SwiftUICaseStudy { } } -private struct Fact: Codable, FetchableRecord, Identifiable, MutablePersistableRecord { - static let databaseTableName = "facts" - var id: Int64? +@Table +private struct Fact: Identifiable { + let id: Int var body: String - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } extension DatabaseWriter where Self == DatabaseQueue { @@ -67,10 +64,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create 'facts' table") { db in - try db.create(table: Fact.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("body", .text).notNull() - } + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudies/TransactionDemo.swift b/Examples/CaseStudies/TransactionDemo.swift index 50b2da02..ddb629b7 100644 --- a/Examples/CaseStudies/TransactionDemo.swift +++ b/Examples/CaseStudies/TransactionDemo.swift @@ -1,4 +1,3 @@ -import Dependencies import SharingGRDB import SwiftUI @@ -8,7 +7,7 @@ struct TransactionDemo: SwiftUICaseStudy { database transaction. If you need to fetch multiple pieces of data from the database that \ all tend to change together, then performing those queries in a single transaction can be \ more performant. - + For example, if you need to fetch rows from a table as well as a count of the rows in the \ table, then those two pieces of data will tend to change at the same time (though not always). \ So, it can be better to perform the select and count as two different queries in the same \ @@ -16,7 +15,8 @@ struct TransactionDemo: SwiftUICaseStudy { """ let caseStudyTitle = "Database Transactions" - @SharedReader(.fetch(Facts(), animation: .default)) private var facts = Facts.Value() + @Fetch(Facts(), animation: .default) + private var facts = Facts.Value() @Dependency(\.defaultDatabase) var database @@ -46,7 +46,8 @@ struct TransactionDemo: SwiftUICaseStudy { as: UTF8.self ) try await database.write { db in - _ = try Fact(body: fact).inserted(db) + try Fact.insert(Fact.Draft(body: fact)) + .execute(db) } } } catch {} @@ -60,21 +61,18 @@ struct TransactionDemo: SwiftUICaseStudy { } func fetch(_ db: Database) throws -> Value { try Value( - facts: Fact.order(Column("id").desc).fetchAll(db), - count: Fact.fetchCount(db) + facts: Fact.order { $0.id.desc() }.fetchAll(db), + count: Fact.all.fetchCount(db) ) } } - } -private struct Fact: Codable, FetchableRecord, Identifiable, MutablePersistableRecord { +@Table +private struct Fact: Identifiable { static let databaseTableName = "facts" - var id: Int64? + let id: Int var body: String - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } extension DatabaseWriter where Self == DatabaseQueue { @@ -82,10 +80,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create 'facts' table") { db in - try db.create(table: Fact.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("body", .text).notNull() - } + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudies/UIKitDemo.swift b/Examples/CaseStudies/UIKitDemo.swift index 932d57e3..88951175 100644 --- a/Examples/CaseStudies/UIKitDemo.swift +++ b/Examples/CaseStudies/UIKitDemo.swift @@ -13,8 +13,8 @@ final class UIKitCaseStudyViewController: UICollectionViewController, UIKitCaseS """ private var dataSource: UICollectionViewDiffableDataSource! - @SharedReader(.fetchAll(sql: #"SELECT * FROM "facts" ORDER BY "id" DESC"#, animation: .default)) - private var facts: [Fact] + @FetchAll(Fact.order { $0.id.desc() }, animation: .default) + private var facts private var viewDidLoadTask: Task? @Dependency(\.defaultDatabase) var database @@ -29,9 +29,9 @@ final class UIKitCaseStudyViewController: UICollectionViewController, UIKitCaseS style: .destructive, title: "Delete" ) { action, view, completion in - _ = withErrorReporting { + withErrorReporting { try database.wrappedValue.write { db in - try facts.wrappedValue[indexPath.row].delete(db) + try Fact.delete(facts.wrappedValue[indexPath.row]).execute(db) } } } @@ -96,7 +96,8 @@ final class UIKitCaseStudyViewController: UICollectionViewController, UIKitCaseS if let fact { await withErrorReporting { try await database.write { db in - _ = try Fact(body: fact).inserted(db) + try Fact.insert(Fact.Draft(body: fact)) + .execute(db) } } } @@ -109,13 +110,10 @@ final class UIKitCaseStudyViewController: UICollectionViewController, UIKitCaseS } } -private struct Fact: Codable, FetchableRecord, Hashable, Identifiable, MutablePersistableRecord { - static let databaseTableName = "facts" - var id: Int64? +@Table +private struct Fact: Hashable, Identifiable { + let id: Int var body: String - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } extension DatabaseWriter where Self == DatabaseQueue { @@ -123,10 +121,15 @@ extension DatabaseWriter where Self == DatabaseQueue { let databaseQueue = try! DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create 'facts' table") { db in - try db.create(table: Fact.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("body", .text).notNull() - } + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) + """ + ) + .execute(db) } try! migrator.migrate(databaseQueue) return databaseQueue diff --git a/Examples/CaseStudiesTests/CaseStudiesTests.swift b/Examples/CaseStudiesTests/CaseStudiesTests.swift index 8c84261d..1d111ed1 100644 --- a/Examples/CaseStudiesTests/CaseStudiesTests.swift +++ b/Examples/CaseStudiesTests/CaseStudiesTests.swift @@ -1,4 +1,5 @@ import Testing + @testable import CaseStudies struct CaseStudiesTests { diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index ca5e7c62..d3c6a173 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; + CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CAFDD6492D5E823A00EE099E /* SharingGRDB */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; @@ -17,6 +19,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + CAD001812D874E6F00FA977A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAF836902D4735620047AEB5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DCBE89CB2D483FB90071F499; + remoteInfo = SyncUps; + }; CAF836A92D4735640047AEB5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CAF836902D4735620047AEB5 /* Project object */; @@ -28,6 +37,7 @@ /* Begin PBXFileReference section */ CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836982D4735620047AEB5 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836A82D4735640047AEB5 /* CaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836D82D4735AB0047AEB5 /* Reminders.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reminders.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -60,6 +70,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + CAD0017E2D874E6F00FA977A /* SyncUpTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SyncUpTests; + sourceTree = ""; + }; CAF8369A2D4735620047AEB5 /* CaseStudies */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -92,6 +107,14 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + CAD0017A2D874E6F00FA977A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAF836952D4735620047AEB5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -113,6 +136,7 @@ buildActionMask = 2147483647; files = ( CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */, + CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,6 +162,7 @@ CAF836AB2D4735640047AEB5 /* CaseStudiesTests */, CAF836D92D4735AB0047AEB5 /* Reminders */, DCBE89CD2D483FB90071F499 /* SyncUps */, + CAD0017E2D874E6F00FA977A /* SyncUpTests */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -150,6 +175,7 @@ CAF836A82D4735640047AEB5 /* CaseStudiesTests.xctest */, CAF836D82D4735AB0047AEB5 /* Reminders.app */, DCBE89CC2D483FB90071F499 /* SyncUps.app */, + CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, ); name = Products; sourceTree = ""; @@ -164,6 +190,30 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + CAD0017C2D874E6F00FA977A /* SyncUpTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */; + buildPhases = ( + CAD001792D874E6F00FA977A /* Sources */, + CAD0017A2D874E6F00FA977A /* Frameworks */, + CAD0017B2D874E6F00FA977A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CAD001822D874E6F00FA977A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + CAD0017E2D874E6F00FA977A /* SyncUpTests */, + ); + name = SyncUpTests; + packageProductDependencies = ( + CAD001862D874F1F00FA977A /* DependenciesTestSupport */, + ); + productName = SyncUpTests; + productReference = CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; CAF836972D4735620047AEB5 /* CaseStudies */ = { isa = PBXNativeTarget; buildConfigurationList = CAF836BC2D4735640047AEB5 /* Build configuration list for PBXNativeTarget "CaseStudies" */; @@ -229,6 +279,7 @@ name = Reminders; packageProductDependencies = ( CAFDD6492D5E823A00EE099E /* SharingGRDB */, + CA14DBC82DA884C400E36852 /* CasePaths */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -267,9 +318,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 1630; LastUpgradeCheck = 1620; TargetAttributes = { + CAD0017C2D874E6F00FA977A = { + CreatedOnToolsVersion = 16.3; + TestTargetID = DCBE89CB2D483FB90071F499; + }; CAF836972D4735620047AEB5 = { CreatedOnToolsVersion = 16.2; }; @@ -308,11 +363,19 @@ CAF836A72D4735640047AEB5 /* CaseStudiesTests */, CAF836D72D4735AB0047AEB5 /* Reminders */, DCBE89CB2D483FB90071F499 /* SyncUps */, + CAD0017C2D874E6F00FA977A /* SyncUpTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + CAD0017B2D874E6F00FA977A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAF836962D4735620047AEB5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -344,6 +407,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + CAD001792D874E6F00FA977A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAF836942D4735620047AEB5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -375,6 +445,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + CAD001822D874E6F00FA977A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DCBE89CB2D483FB90071F499 /* SyncUps */; + targetProxy = CAD001812D874E6F00FA977A /* PBXContainerItemProxy */; + }; CAF836AA2D4735640047AEB5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CAF836972D4735620047AEB5 /* CaseStudies */; @@ -383,6 +458,42 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + CAD001832D874E6F00FA977A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SyncUps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SyncUps"; + }; + name = Debug; + }; + CAD001842D874E6F00FA977A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SyncUps.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SyncUps"; + }; + name = Release; + }; CAF836BA2D4735640047AEB5 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -707,6 +818,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAD001832D874E6F00FA977A /* Debug */, + CAD001842D874E6F00FA977A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CAF836932D4735620047AEB5 /* Build configuration list for PBXProject "Examples" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -782,11 +902,21 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + CA14DBC82DA884C400E36852 /* CasePaths */ = { + isa = XCSwiftPackageProductDependency; + package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; + productName = CasePaths; + }; CA2908C82D4AF70E003F165F /* UIKitNavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; + CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { + isa = XCSwiftPackageProductDependency; + package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = DependenciesTestSupport; + }; CAFDD6492D5E823A00EE099E /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; productName = SharingGRDB; diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme index 0d56a702..97c6d079 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/SyncUps.xcscheme @@ -29,6 +29,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + Self { - Color( - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0 - ) + public struct HexRepresentation: QueryBindable, QueryRepresentable { + public var queryOutput: Color + public var queryBinding: QueryBinding { + guard let components = UIColor(queryOutput).cgColor.components + else { + struct InvalidColor: Error {} + return .invalid(InvalidColor()) + } + let r = Int64(components[0] * 0xFF) << 24 + let g = Int64(components[1] * 0xFF) << 16 + let b = Int64(components[2] * 0xFF) << 8 + let a = Int64((components.indices.contains(3) ? components[3] : 1) * 0xFF) + return .int(r | g | b | a) + } + public init(queryOutput: Color) { + self.queryOutput = queryOutput + } + public init(decoder: inout some QueryDecoder) throws { + let hex = try Int(decoder: &decoder) + self.init( + queryOutput: Color( + red: Double((hex >> 24) & 0xFF) / 0xFF, + green: Double((hex >> 16) & 0xFF) / 0xFF, + blue: Double((hex >> 8) & 0xFF) / 0xFF, + opacity: Double(hex & 0xFF) / 0xFF + ) + ) + } } } diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index ac09663e..6f94e7ad 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -1,31 +1,24 @@ -import Dependencies -import GRDB import IssueReporting -import Sharing import SharingGRDB import SwiftUI struct ReminderFormView: View { - @SharedReader(.fetchAll(sql: #"SELECT * FROM "remindersLists" ORDER BY "name" ASC"#)) - var remindersLists: [RemindersList] + @FetchAll(RemindersList.order(by: \.title)) var remindersLists @State var isPresentingTagsPopover = false @State var remindersList: RemindersList - @State var reminder: Reminder + @State var reminder: Reminder.Draft @State var selectedTags: [Tag] = [] @Dependency(\.defaultDatabase) private var database @Environment(\.dismiss) var dismiss - init?(existingReminder: Reminder? = nil, remindersList: RemindersList) { + init(existingReminder: Reminder? = nil, remindersList: RemindersList) { self.remindersList = remindersList if let existingReminder { - reminder = existingReminder - } else if let listID = remindersList.id { - reminder = Reminder(remindersListID: listID) + reminder = Reminder.Draft(existingReminder) } else { - reportIssue("'list.id' is required to be non-nil.") - return nil + reminder = Reminder.Draft(remindersListID: remindersList.id) } } @@ -54,13 +47,15 @@ struct ReminderFormView: View { .font(.title) .foregroundStyle(.gray) Text("Tags") - .foregroundStyle(.black) + .foregroundStyle(Color(.label)) Spacer() - tagsDetail - .lineLimit(1) - .truncationMode(.tail) - .font(.callout) - .foregroundStyle(.gray) + if let tagsDetail { + tagsDetail + .lineLimit(1) + .truncationMode(.tail) + .font(.callout) + .foregroundStyle(.gray) + } Image(systemName: "chevron.right") } } @@ -80,10 +75,10 @@ struct ReminderFormView: View { Text("Date") } } - if let date = reminder.date { + if let dueDate = reminder.dueDate { DatePicker( "", - selection: $reminder.date[coalesce: date], + selection: $reminder.dueDate[coalesce: dueDate], displayedComponents: [.date, .hourAndMinute] ) .padding([.top, .bottom], 2) @@ -100,11 +95,11 @@ struct ReminderFormView: View { } } Picker(selection: $reminder.priority) { - Text("None").tag(Int?.none) + Text("None").tag(Priority?.none) Divider() - Text("High").tag(3) - Text("Medium").tag(2) - Text("Low").tag(1) + Text("High").tag(Priority.high) + Text("Medium").tag(Priority.medium) + Text("Low").tag(Priority.low) } label: { HStack { Image(systemName: "exclamationmark.circle.fill") @@ -116,7 +111,7 @@ struct ReminderFormView: View { Picker(selection: $remindersList) { ForEach(remindersLists) { remindersList in - Text(remindersList.name) + Text(remindersList.title) .tag(remindersList) .buttonStyle(.plain) } @@ -124,22 +119,26 @@ struct ReminderFormView: View { HStack { Image(systemName: "list.bullet.circle.fill") .font(.title) - .foregroundStyle(Color.hex(remindersList.color)) + .foregroundStyle(remindersList.color) Text("List") } } .onChange(of: remindersList) { - reminder.remindersListID = remindersList.id! + reminder.remindersListID = remindersList.id } } } - .task { [reminderID = reminder.id] in + .padding(.top, -28) + .task { + guard let reminderID = reminder.id + else { return } do { selectedTags = try await database.read { db in - try Tag.all() - .joining(optional: Tag.hasMany(ReminderTag.self)) - .filter(Column("reminderID").detached == reminderID) - .order(Column("name")) + try Tag + .order(by: \.title) + .join(ReminderTag.all) { $0.id.eq($1.tagID) } + .where { $1.reminderID.eq(reminderID) } + .select { tag, _ in tag } .fetchAll(db) } } catch { @@ -147,7 +146,7 @@ struct ReminderFormView: View { reportIssue(error) } } - .navigationTitle(remindersList.name) + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem { Button(action: saveButtonTapped) { @@ -162,32 +161,40 @@ struct ReminderFormView: View { } } - private var tagsDetail: Text { - selectedTags.reduce(Text("")) { result, tag in - result + Text("#\(tag.name) ") + private var tagsDetail: Text? { + guard let tag = selectedTags.first else { return nil } + return selectedTags.dropFirst().reduce(Text("#\(tag.title)")) { result, tag in + result + Text(" #\(tag.title) ") } } private func saveButtonTapped() { withErrorReporting { try database.write { db in - try reminder.save(db) - try ReminderTag.filter(Column("reminderID") == reminder.id!).deleteAll(db) - for tag in selectedTags { - _ = try ReminderTag(reminderID: reminder.id!, tagID: tag.id!).saved(db) - } + let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! + try ReminderTag + .where { $0.reminderID.eq(reminderID) } + .delete() + .execute(db) + try ReminderTag.insert( + selectedTags.map { tag in + ReminderTag(reminderID: reminderID, tagID: tag.id) + } + ) + .execute(db) } } dismiss() } } -extension Reminder { +extension Reminder.Draft { fileprivate var isDateSet: Bool { - get { date != nil } - set { date = newValue ? Date() : nil } + get { dueDate != nil } + set { dueDate = newValue ? Date() : nil } } } + extension Optional { fileprivate subscript(coalesce coalesce: Wrapped) -> Wrapped { get { self ?? coalesce } @@ -195,18 +202,21 @@ extension Optional { } } -#Preview { - let (remindersList, reminder) = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - return try $0.defaultDatabase.write { db in - let remindersList = try RemindersList.fetchOne(db)! - return ( - remindersList, - try Reminder.filter(Column("remindersListID") == remindersList.id).fetchOne(db)! - ) +struct ReminderFormPreview: PreviewProvider { + static var previews: some View { + let (remindersList, reminder) = try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + return try $0.defaultDatabase.write { db in + let remindersList = try RemindersList.all.fetchOne(db)! + return ( + remindersList, + try Reminder.where { $0.remindersListID == remindersList.id }.fetchOne(db)! + ) + } + } + NavigationStack { + ReminderFormView(existingReminder: reminder, remindersList: remindersList) + .navigationTitle("Detail") } - } - NavigationStack { - ReminderFormView(existingReminder: reminder, remindersList: remindersList) } } diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index d72785cf..479a420f 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -1,21 +1,46 @@ -import Dependencies +import SharingGRDB import SwiftUI struct ReminderRow: View { + let color: Color let isPastDue: Bool + let notes: String let reminder: Reminder let remindersList: RemindersList + let showCompleted: Bool let tags: [String] @State var editReminder: Reminder? + @State var isCompleted: Bool @Dependency(\.defaultDatabase) private var database - + + init( + color: Color, + isPastDue: Bool, + notes: String, + reminder: Reminder, + remindersList: RemindersList, + showCompleted: Bool, + tags: [String], + editReminder: Reminder? = nil + ) { + self.color = color + self.isPastDue = isPastDue + self.notes = notes + self.reminder = reminder + self.remindersList = remindersList + self.showCompleted = showCompleted + self.tags = tags + self.editReminder = editReminder + self.isCompleted = reminder.isCompleted + } + var body: some View { HStack { - HStack(alignment: .top) { + HStack(alignment: .firstTextBaseline) { Button(action: completeButtonTapped) { - Image(systemName: reminder.isCompleted ? "circle.inset.filled": "circle") + Image(systemName: isCompleted ? "circle.inset.filled" : "circle") .foregroundStyle(.gray) .font(.title2) .padding([.trailing], 5) @@ -23,20 +48,17 @@ struct ReminderRow: View { VStack(alignment: .leading) { title(for: reminder) - let notes = reminder.notes - .split(separator: "\n", omittingEmptySubsequences: true) - .prefix(3) - .joined(separator: " ") if !notes.isEmpty { Text(notes) - .lineLimit(2) + .font(.subheadline) .foregroundStyle(.gray) + .lineLimit(2) } subtitleText } } Spacer() - if !reminder.isCompleted { + if !isCompleted { HStack { if reminder.isFlagged { Image(systemName: "flag.fill") @@ -47,27 +69,26 @@ struct ReminderRow: View { } label: { Image(systemName: "info.circle") } + .tint(color) } } } .buttonStyle(.borderless) .swipeActions { - Button("Delete") { + Button("Delete", role: .destructive) { withErrorReporting { - do { - _ = try database.write { db in - try reminder.delete(db) - } + try database.write { db in + try Reminder.delete(reminder).execute(db) } } } - .tint(.red) Button(reminder.isFlagged ? "Unflag" : "Flag") { withErrorReporting { try database.write { db in - var reminder = reminder - reminder.isFlagged.toggle() - _ = try reminder.saved(db) + try Reminder + .find(reminder.id) + .update { $0.isFlagged.toggle() } + .execute(db) } } } @@ -79,22 +100,45 @@ struct ReminderRow: View { .sheet(item: $editReminder) { reminder in NavigationStack { ReminderFormView(existingReminder: reminder, remindersList: remindersList) + .navigationTitle("Details") } } + .task(id: isCompleted) { + guard !showCompleted else { return } + guard + isCompleted, + isCompleted != reminder.isCompleted + else { return } + do { + try await Task.sleep(for: .seconds(2)) + toggleCompletion() + } catch {} + } } private func completeButtonTapped() { + if showCompleted { + toggleCompletion() + } else { + isCompleted.toggle() + } + } + + private func toggleCompletion() { withErrorReporting { try database.write { db in - var reminder = reminder - reminder.isCompleted.toggle() - _ = try reminder.saved(db) + isCompleted = + try Reminder + .find(reminder.id) + .update { $0.isCompleted.toggle() } + .returning(\.isCompleted) + .fetchOne(db) ?? isCompleted } } } private var dueText: Text { - if let date = reminder.date { + if let date = reminder.dueDate { Text(date.formatted(date: .numeric, time: .shortened)) .foregroundStyle(isPastDue ? .red : .gray) } else { @@ -103,46 +147,54 @@ struct ReminderRow: View { } private var subtitleText: Text { - let tagsText = tags.reduce(Text(reminder.date == nil ? "" : " ")) { result, tag in + let tagsText = tags.reduce(Text(reminder.dueDate == nil ? "" : " ")) { result, tag in result + Text("#\(tag) ") - .foregroundStyle(.gray) - .bold() } - return (dueText + tagsText).font(.callout) + return + (dueText + + tagsText + .foregroundStyle(.gray) + .bold()) + .font(.callout) } - + private func title(for reminder: Reminder) -> some View { - let exclamations = String(repeating: "!", count: reminder.priority ?? 0) - + (reminder.priority == nil ? "" : " ") - return ( - Text(exclamations) - .foregroundStyle(reminder.isCompleted ? .gray : Color.hex(remindersList.color)) - + Text(reminder.title) - .foregroundStyle(reminder.isCompleted ? .gray : .primary) - ) + return HStack(alignment: .firstTextBaseline) { + if let priority = reminder.priority { + Text(String(repeating: "!", count: priority.rawValue)) + .foregroundStyle(isCompleted ? .gray : remindersList.color) + } + Text(reminder.title) + .foregroundStyle(isCompleted ? .gray : .primary) + } .font(.title3) } } -#Preview { - var reminder: Reminder! - var reminderList: RemindersList! - let _ = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - try $0.defaultDatabase.read { db in - reminder = try Reminder.fetchOne(db) - reminderList = try RemindersList.fetchOne(db)! +struct ReminderRowPreview: PreviewProvider { + static var previews: some View { + var reminder: Reminder! + var remindersList: RemindersList! + let _ = try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + try $0.defaultDatabase.read { db in + reminder = try Reminder.all.fetchOne(db) + remindersList = try RemindersList.all.fetchOne(db)! + } } - } - - NavigationStack { - List { - ReminderRow( - isPastDue: false, - reminder: reminder, - remindersList: reminderList, - tags: ["point-free", "adulting"] - ) + + NavigationStack { + List { + ReminderRow( + color: remindersList.color, + isPastDue: false, + notes: reminder.notes.replacingOccurrences(of: "\n", with: " "), + reminder: reminder, + remindersList: remindersList, + showCompleted: true, + tags: ["point-free", "adulting"] + ) + } } } } diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 1ddd1e5d..57443a92 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,5 +1,4 @@ -import Dependencies -import GRDB +import SharingGRDB import SwiftUI @main @@ -9,7 +8,7 @@ struct RemindersApp: App { $0.defaultDatabase = try Reminders.appDatabase() } } - + var body: some Scene { WindowGroup { NavigationStack { diff --git a/Examples/Reminders/RemindersListDetail.swift b/Examples/Reminders/RemindersListDetail.swift index 867158f5..ab6986ba 100644 --- a/Examples/Reminders/RemindersListDetail.swift +++ b/Examples/Reminders/RemindersListDetail.swift @@ -1,117 +1,124 @@ -import Sharing +import CasePaths import SharingGRDB import SwiftUI struct RemindersListDetailView: View { - @SharedReader private var remindersState: [Reminders.Record] + @FetchAll private var reminderStates: [ReminderState] @AppStorage private var ordering: Ordering @AppStorage private var showCompleted: Bool - private let remindersList: RemindersList + let detailType: DetailType @State var isNewReminderSheetPresented = false + @State var isNavigationTitleVisible = false + @State var navigationTitleHeight: CGFloat = 36 - enum Ordering: String, CaseIterable { - case dueDate = "Due Date" - case priority = "Priority" - case title = "Title" - var icon: Image { - switch self { - case .dueDate: Image(systemName: "calendar") - case .priority: Image(systemName: "chart.bar.fill") - case .title: Image(systemName: "textformat.characters") - } - } - var queryString: String { - switch self { - case .dueDate: #""date""# - case .priority: #""priority" DESC, "isFlagged" DESC"# - case .title: #""title""# - } - } - } + @Dependency(\.defaultDatabase) private var database - init?(remindersList: RemindersList) { - self.remindersList = remindersList - if let listID = remindersList.id { - _ordering = AppStorage(wrappedValue: .dueDate, "ordering_list_\(listID)") - _showCompleted = AppStorage(wrappedValue: false, "show_completed_list_\(listID)") - _remindersState = SharedReader( - .fetch( - Reminders( - listID: listID, - ordering: _ordering.wrappedValue, - showCompleted: _showCompleted.wrappedValue - ), - animation: .default - ) - ) - } else { - reportIssue("'list.id' required to be non-nil.") - return nil - } + init(detailType: DetailType) { + self.detailType = detailType + _ordering = AppStorage(wrappedValue: .dueDate, "ordering_list_\(detailType.id)") + _showCompleted = AppStorage( + wrappedValue: detailType == .completed, + "show_completed_list_\(detailType.id)" + ) + _reminderStates = FetchAll(remindersQuery, animation: .default) } var body: some View { List { - ForEach(remindersState, id: \.reminder.id) { reminderState in + VStack(alignment: .leading) { + GeometryReader { proxy in + Text(detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(detailType.color) + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowSeparator(.hidden) + ForEach(reminderStates) { reminderState in ReminderRow( + color: detailType.color, isPastDue: reminderState.isPastDue, + notes: reminderState.notes, reminder: reminderState.reminder, - remindersList: remindersList, + remindersList: reminderState.remindersList, + showCompleted: showCompleted, tags: reminderState.tags ) } } + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight + } action: { + isNavigationTitleVisible = $1 + } + .listStyle(.plain) + .sheet(isPresented: $isNewReminderSheetPresented) { + if let remindersList = detailType.list { + NavigationStack { + ReminderFormView(remindersList: remindersList) + .navigationTitle("New Reminder") + } + } + } .task(id: [ordering, showCompleted] as [AnyHashable]) { await withErrorReporting { try await updateQuery() } } - .navigationTitle(remindersList.name) - .navigationBarTitleDisplayMode(.large) - .sheet(isPresented: $isNewReminderSheetPresented) { - NavigationStack { - ReminderFormView(remindersList: remindersList) + .toolbar { + ToolbarItem(placement: .principal) { + Text(detailType.navigationTitle) + .font(.headline) + .opacity(isNavigationTitleVisible ? 1 : 0) + .animation(.default.speed(2), value: isNavigationTitleVisible) } } + .toolbarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .bottomBar) { - HStack { - Button { - isNewReminderSheetPresented = true - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("New reminder") + if detailType.is(\.list) { + ToolbarItem(placement: .bottomBar) { + HStack { + Button { + isNewReminderSheetPresented = true + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("New Reminder") + } + .bold() + .font(.title3) } - .bold() - .font(.title3) + Spacer() } - Spacer() + .tint(detailType.color) } } ToolbarItem(placement: .primaryAction) { Menu { - Menu { - ForEach(Ordering.allCases, id: \.self) { ordering in - Button { - self.ordering = ordering - } label: { - Text(ordering.rawValue) - ordering.icon + Group { + Menu { + ForEach(Ordering.allCases, id: \.self) { ordering in + Button { + self.ordering = ordering + } label: { + Text(ordering.rawValue) + ordering.icon + } } + } label: { + Text("Sort By") + Text(ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + showCompleted.toggle() + } label: { + Text(showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: showCompleted ? "eye.slash.fill" : "eye") } - } label: { - Text("Sort By") - Text(ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } - Button { - showCompleted.toggle() - } label: { - Text(showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: showCompleted ? "eye.slash.fill" : "eye") } + .tint(detailType.color) } label: { Image(systemName: "ellipsis.circle") } @@ -119,64 +126,150 @@ struct RemindersListDetailView: View { } } + private enum Ordering: String, CaseIterable { + case dueDate = "Due Date" + case priority = "Priority" + case title = "Title" + var icon: Image { + switch self { + case .dueDate: Image(systemName: "calendar") + case .priority: Image(systemName: "chart.bar.fill") + case .title: Image(systemName: "textformat.characters") + } + } + } + + @CasePathable + @dynamicMemberLookup + enum DetailType: Hashable { + case all + case completed + case flagged + case list(RemindersList) + case scheduled + case tags([Tag]) + case today + } + private func updateQuery() async throws { - guard let listID = remindersList.id - else { return } + try await $reminderStates.load(remindersQuery) + } - try await $remindersState.load( - .fetch( - Reminders(listID: listID, ordering: ordering, showCompleted: showCompleted), - animation: .default - ) - ) + fileprivate var remindersQuery: some StructuredQueriesCore.Statement { + let query = + Reminder + .where { + if !showCompleted { + !$0.isCompleted + } + } + .order { $0.isCompleted } + .order { + switch ordering { + case .dueDate: $0.dueDate + case .priority: ($0.priority.desc(), $0.isFlagged.desc()) + case .title: $0.title + } + } + .withTags + .where { reminder, _, tag in + switch detailType { + case .all: !reminder.isCompleted + case .completed: reminder.isCompleted + case .flagged: reminder.isFlagged + case .list(let list): reminder.remindersListID.eq(list.id) + case .scheduled: reminder.isScheduled + case .tags(let tags): tag.id.ifnull(0).in(tags.map(\.id)) + case .today: reminder.isToday + } + } + .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .select { + ReminderState.Columns( + reminder: $0, + remindersList: $3, + isPastDue: $0.isPastDue, + notes: $0.inlineNotes.substr(0, 200), + tags: #sql("\($2.jsonNames)") + ) + } + return query + } + + @Selection + fileprivate struct ReminderState: Identifiable { + var id: Reminder.ID { reminder.id } + let reminder: Reminder + let remindersList: RemindersList + let isPastDue: Bool + let notes: String + @Column(as: JSONRepresentation<[String]>.self) + let tags: [String] } +} - private struct Reminders: FetchKeyRequest { - let listID: Int64 - let ordering: Ordering - let showCompleted: Bool - func fetch(_ db: Database) throws -> [Record] { - try Record - .fetchAll( - db, - sql: """ - SELECT - "reminders".*, - group_concat("tags"."name", ',') AS "commaSeparatedTags", - NOT "isCompleted" AND coalesce("reminders"."date", date('now')) < date('now') as "isPastDue" - FROM "reminders" - LEFT JOIN "remindersTags" ON "reminders"."id" = "remindersTags"."reminderID" - LEFT JOIN "tags" ON "remindersTags"."tagID" = "tags"."id" - WHERE - "reminders"."remindersListID" = ? - \(showCompleted ? "" : #"AND NOT "isCompleted""#) - GROUP BY "reminders"."id" - ORDER BY - "reminders"."isCompleted" ASC, - \(ordering.queryString) - """, - arguments: [listID] - ) +extension RemindersListDetailView.DetailType { + fileprivate var id: String { + switch self { + case .all: "all" + case .completed: "completed" + case .flagged: "flagged" + case .list(let list): "list_\(list.id)" + case .scheduled: "scheduled" + case .tags: "tags" + case .today: "today" } - struct Record: Decodable, FetchableRecord { - var reminder: Reminder - var isPastDue: Bool - var commaSeparatedTags: String? - var tags: [String] { - (commaSeparatedTags ?? "").split(separator: ",").map(String.init) + } + fileprivate var navigationTitle: String { + switch self { + case .all: "All" + case .completed: "Completed" + case .flagged: "Flagged" + case .list(let list): list.title + case .scheduled: "Scheduled" + case .tags(let tags): + switch tags.count { + case 0: "Tags" + case 1: "#\(tags[0].title)" + default: "\(tags.count) tags" } + case .today: "Today" + } + } + fileprivate var color: Color { + switch self { + case .all: .black + case .completed: .gray + case .flagged: .orange + case .list(let list): list.color + case .scheduled: .red + case .tags: .blue + case .today: .blue } } } -#Preview { - let remindersList = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - return try $0.defaultDatabase.read { db in - try RemindersList.fetchOne(db)! +struct RemindersListDetailPreview: PreviewProvider { + static var previews: some View { + let (remindersList, tag) = try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + return try $0.defaultDatabase.read { db in + ( + try RemindersList.all.fetchOne(db)!, + try Tag.all.fetchOne(db)! + ) + } + } + let detailTypes: [RemindersListDetailView.DetailType] = [ + .all, + .list(remindersList), + .tags([tag]), + ] + ForEach(detailTypes, id: \.self) { detailType in + NavigationStack { + RemindersListDetailView(detailType: detailType) + } + .previewDisplayName(detailType.navigationTitle) } - } - NavigationStack { - RemindersListDetailView(remindersList: remindersList) } } diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 9844f55c..37bb2acc 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -1,35 +1,41 @@ -import Dependencies -import GRDB import IssueReporting +import SharingGRDB import SwiftUI struct RemindersListForm: View { @Dependency(\.defaultDatabase) private var database - @State var remindersList: RemindersList + @State var remindersList: RemindersList.Draft @Environment(\.dismiss) var dismiss - init(existingList: RemindersList? = nil) { - if let existingList { - remindersList = existingList - } else { - remindersList = RemindersList() - } + init(existingList: RemindersList.Draft? = nil) { + remindersList = existingList ?? RemindersList.Draft() } var body: some View { Form { - TextField("Name", text: $remindersList.name) - ColorPicker("Color", selection: $remindersList.color.cgColor) + Section { + VStack { + TextField("List Name", text: $remindersList.title) + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(remindersList.color) + .multilineTextAlignment(.center) + .padding() + .textFieldStyle(.plain) + } + .background(Color(.secondarySystemBackground)) + .clipShape(.buttonBorder) + } + ColorPicker("Color", selection: $remindersList.color) } + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem { Button("Save") { withErrorReporting { - do { - try database.write { db in - _ = try remindersList.saved(db) - } + try database.write { db in + try RemindersList.upsert(remindersList) + .execute(db) } } dismiss() @@ -44,31 +50,12 @@ struct RemindersListForm: View { } } -extension Int { - fileprivate var cgColor: CGColor { - get { - CGColor( - red: Double((self >> 16) & 0xFF) / 255.0, - green: Double((self >> 8) & 0xFF) / 255.0, - blue: Double(self & 0xFF) / 255.0, - alpha: 1 - ) - } - set { - guard let components = newValue.components - else { return } - self = (Int(components[0] * 255) << 16) - | (Int(components[1] * 255) << 8) - | Int(components[2] * 255) - } - } -} - #Preview { let _ = try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() } NavigationStack { RemindersListForm() + .navigationTitle("New List") } } diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 6f5c0e35..553879a9 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -2,7 +2,7 @@ import SharingGRDB import SwiftUI struct RemindersListRow: View { - let reminderCount: Int + let remindersCount: Int let remindersList: RemindersList @State var editList: RemindersList? @@ -12,17 +12,22 @@ struct RemindersListRow: View { var body: some View { HStack { Image(systemName: "list.bullet.circle.fill") - .font(.title) - .foregroundStyle(Color.hex(remindersList.color)) - Text(remindersList.name) + .font(.largeTitle) + .foregroundStyle(remindersList.color) + .background( + Color.white.clipShape(Circle()).padding(4) + ) + Text(remindersList.title) Spacer() - Text("\(reminderCount)") + Text("\(remindersCount)") + .foregroundStyle(.gray) } .swipeActions { Button { withErrorReporting { - _ = try database.write { db in - try remindersList.delete(db) + try database.write { db in + try RemindersList.delete(remindersList) + .execute(db) } } } label: { @@ -37,7 +42,7 @@ struct RemindersListRow: View { } .sheet(item: $editList) { list in NavigationStack { - RemindersListForm(existingList: list) + RemindersListForm(existingList: RemindersList.Draft(list)) .navigationTitle("Edit list") } .presentationDetents([.medium]) @@ -49,9 +54,10 @@ struct RemindersListRow: View { NavigationStack { List { RemindersListRow( - reminderCount: 10, + remindersCount: 10, remindersList: RemindersList( - name: "Personal" + id: 1, + title: "Personal" ) ) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index c4b1e781..c7c2b57f 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,36 +1,90 @@ -import Dependencies -import GRDB -import Sharing import SharingGRDB import SwiftUI struct RemindersListsView: View { - @SharedReader(.fetch(RemindersLists(), animation: .default)) private var lists - @SharedReader(.fetch(Stats())) private var stats = Stats.Value() + @FetchAll( + RemindersList + .group(by: \.id) + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) && !$1.isCompleted } + .select { + ReminderListState.Columns(remindersCount: $1.id.count(), remindersList: $0) + }, + animation: .default + ) + private var remindersLists - @State private var isAddListPresented = false + @FetchAll( + Tag + .order(by: \.title) + .withReminders + .having { $2.count().gt(0) } + .select { tag, _, _ in tag }, + animation: .default + ) + private var tags + + @FetchOne( + Reminder.select { + Stats.Columns( + allCount: $0.count(filter: !$0.isCompleted), + flaggedCount: $0.count(filter: $0.isFlagged), + scheduledCount: $0.count(filter: $0.isScheduled), + todayCount: $0.count(filter: $0.isToday) + ) + } + ) + private var stats = Stats() + + @State private var destination: Destination? + @State private var remindersDetailType: RemindersListDetailView.DetailType? @State private var searchText = "" @Dependency(\.defaultDatabase) private var database + @Selection + fileprivate struct ReminderListState: Identifiable { + var id: RemindersList.ID { remindersList.id } + var remindersCount: Int + var remindersList: RemindersList + } + + @Selection + fileprivate struct Stats { + var allCount = 0 + var flaggedCount = 0 + var scheduledCount = 0 + var todayCount = 0 + } + + enum Destination: Int, Identifiable { + case addList + case newReminder + + var id: Int { rawValue } + } + var body: some View { List { if searchText.isEmpty { Section { - Grid(horizontalSpacing: 16, verticalSpacing: 16) { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { GridRow { ReminderGridCell( color: .blue, count: stats.todayCount, iconName: "calendar.circle.fill", title: "Today" - ) {} + ) { + remindersDetailType = .today + } ReminderGridCell( color: .red, count: stats.scheduledCount, iconName: "calendar.circle.fill", title: "Scheduled" - ) {} + ) { + remindersDetailType = .scheduled + } } GridRow { ReminderGridCell( @@ -38,148 +92,161 @@ struct RemindersListsView: View { count: stats.allCount, iconName: "tray.circle.fill", title: "All" - ) {} + ) { + remindersDetailType = .all + } ReminderGridCell( color: .orange, count: stats.flaggedCount, iconName: "flag.circle.fill", title: "Flagged" - ) {} + ) { + remindersDetailType = .flagged + } } GridRow { ReminderGridCell( color: .gray, - count: stats.completedCount, + count: nil, iconName: "checkmark.circle.fill", title: "Completed" - ) {} + ) { + remindersDetailType = .completed + } } } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .padding([.leading, .trailing], -20) } - .buttonStyle(.plain) - + Section { - ForEach(lists, id: \.remindersList.id) { state in + ForEach(remindersLists) { state in NavigationLink { - RemindersListDetailView(remindersList: state.remindersList) + RemindersListDetailView(detailType: .list(state.remindersList)) } label: { RemindersListRow( - reminderCount: state.reminderCount, + remindersCount: state.remindersCount, remindersList: state.remindersList ) } } } header: { - Text("My lists") - .font(.largeTitle) - .bold() - .foregroundStyle(.black) + Text("My Lists") + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(Color(.label)) + .textCase(nil) + .padding(.top, -16) + .padding([.leading, .trailing], 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + + Section { + ForEach(tags) { tag in + NavigationLink { + RemindersListDetailView(detailType: .tags([tag])) + } label: { + TagRow(tag: tag) + } + } + } header: { + Text("Tags") + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(Color(.label)) + .textCase(nil) + .padding(.top, -16) + .padding([.leading, .trailing], 4) } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) } else { SearchRemindersView(searchText: searchText) } } // NB: This explicit view identity works around a bug with 'List' view state not getting reset. .id(searchText) - .listStyle(.plain) + .listStyle(.insetGrouped) .toolbar { - Button("Add list") { - isAddListPresented = true - } - } - .sheet(isPresented: $isAddListPresented) { - NavigationStack { - RemindersListForm() - .navigationTitle("New list") + ToolbarItem(placement: .bottomBar) { + HStack { + Button { + destination = .newReminder + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("New Reminder") + } + .bold() + .font(.title3) + } + Spacer() + Button { + destination = .addList + } label: { + Text("Add List") + .font(.title3) + } + } } - .presentationDetents([.medium]) } - .sheet(isPresented: $isAddListPresented) { - NavigationStack { - RemindersListForm() - .navigationTitle("New list") + .sheet(item: $destination) { destination in + switch destination { + case .addList: + NavigationStack { + RemindersListForm() + .navigationTitle("New List") + } + .presentationDetents([.medium]) + case .newReminder: + if let remindersList = remindersLists.first?.remindersList { + NavigationStack { + ReminderFormView(remindersList: remindersList) + .navigationTitle("New Reminder") + } + } } - .presentationDetents([.medium]) } .searchable(text: $searchText) - } - - private struct RemindersLists: FetchKeyRequest { - func fetch(_ db: Database) throws -> [Record] { - try Record.fetchAll( - db, - RemindersList.annotated( - with: RemindersList - .hasMany(Reminder.self) - .filter(!Column("isCompleted")) - .count - ) - ) - } - struct Record: Decodable, FetchableRecord { - var reminderCount: Int - var remindersList: RemindersList - } - } - private struct Stats: FetchKeyRequest { - func fetch(_ db: Database) throws -> Value { - let todayCount = try Int.fetchOne(db, sql: """ - SELECT count(*) - FROM "reminders" - WHERE date("date") = date('now') - """) ?? 0 - let allCount = try Reminder.fetchCount(db) - let scheduledCount = try Int.fetchOne(db, sql: """ - SELECT count(*) - FROM "reminders" - WHERE date("date") > date('now') - """) ?? 0 - let flaggedCount = try Reminder.filter(Column("isFlagged")).fetchCount(db) - let completedCount = try Reminder.filter(Column("isCompleted")).fetchCount(db) - return Value( - allCount: allCount, - completedCount: completedCount, - flaggedCount: flaggedCount, - scheduledCount: scheduledCount, - todayCount: todayCount - ) - } - struct Value { - var allCount = 0 - var completedCount = 0 - var flaggedCount = 0 - var scheduledCount = 0 - var todayCount = 0 + .navigationDestination(item: $remindersDetailType) { detailType in + RemindersListDetailView(detailType: detailType) } } } private struct ReminderGridCell: View { let color: Color - let count: Int + let count: Int? let iconName: String let title: String let action: () -> Void var body: some View { Button(action: action) { - HStack(alignment: .top) { - VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 8) { Image(systemName: iconName) .font(.largeTitle) .bold() .foregroundStyle(color) + .background( + Color.white.clipShape(Circle()).padding(4) + ) Text(title) + .font(.headline) + .foregroundStyle(.gray) .bold() + .padding(.leading, 4) } Spacer() - Text("\(count)") - .font(.largeTitle) - .fontDesign(.rounded) - .bold() + if let count { + Text("\(count)") + .font(.largeTitle) + .fontDesign(.rounded) + .bold() + .foregroundStyle(Color(.label)) + } } - .padding() - .background(.black.opacity(0.05)) + .padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + .background(Color(.secondarySystemGroupedBackground)) .cornerRadius(10) } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 8360150c..f39a84bd 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -1,53 +1,86 @@ import Foundation -import GRDB import IssueReporting import SharingGRDB +import SwiftUI -struct RemindersList: Codable, FetchableRecord, Hashable, Identifiable, MutablePersistableRecord { - static let databaseTableName = "remindersLists" - - var id: Int64? - var color = 0x4a99ef - var name = "" - - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } +@Table +struct RemindersList: Hashable, Identifiable { + var id: Int + @Column(as: Color.HexRepresentation.self) + var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) + var title = "" } -struct Reminder: Codable, Equatable, FetchableRecord, Identifiable, MutablePersistableRecord { - static let databaseTableName = "reminders" - - var id: Int64? - var date: Date? +@Table +struct Reminder: Equatable, Identifiable { + var id: Int + @Column(as: Date.ISO8601Representation?.self) + var dueDate: Date? var isCompleted = false var isFlagged = false - var remindersListID: Int64 var notes = "" - var priority: Int? + var priority: Priority? + var remindersListID: Int var title = "" +} - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID +extension Reminder { + static let incomplete = Self.where { !$0.isCompleted } + static func searching(_ text: String) -> Where { + Self.where { + $0.title.collate(.nocase).contains(text) + || $0.notes.collate(.nocase).contains(text) + } } + static let withTags = group(by: \.id) + .leftJoin(ReminderTag.all) { $0.id.eq($1.reminderID) } + .leftJoin(Tag.all) { $1.tagID.eq($2.id) } } -struct Tag: Codable, FetchableRecord, MutablePersistableRecord { - static let databaseTableName = "tags" +extension Reminder.TableColumns { + var isPastDue: some QueryExpression { + !isCompleted && #sql("coalesce(date(\(dueDate)) < date('now'), 0)") + } + var isToday: some QueryExpression { + !isCompleted && #sql("coalesce(date(\(dueDate)) = date('now'), 0)") + } + var isScheduled: some QueryExpression { + !isCompleted && dueDate.isNot(nil) + } + var inlineNotes: some QueryExpression { + notes.replace("\n", " ") + } +} + +enum Priority: Int, QueryBindable { + case low = 1 + case medium + case high +} - var id: Int64? - var name = "" +@Table +struct Tag: Hashable, Identifiable { + var id: Int + var title = "" +} - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } +extension Tag { + static let withReminders = group(by: \.id) + .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } + .leftJoin(Reminder.all) { $1.reminderID.eq($2.id) } } -struct ReminderTag: Codable, FetchableRecord, MutablePersistableRecord { - static let databaseTableName = "remindersTags" +extension Tag.TableColumns { + var jsonNames: some QueryExpression> { + self.title.jsonGroupArray(filter: self.title.isNot(nil)) + } +} - var reminderID: Int64? - var tagID: Int64? +@Table("remindersTags") +struct ReminderTag: Hashable, Identifiable { + var reminderID: Reminder.ID + var tagID: Tag.ID + var id: Self { self } } func appDatabase() throws -> any DatabaseWriter { @@ -74,41 +107,64 @@ func appDatabase() throws -> any DatabaseWriter { migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Add reminders lists table") { db in - try db.create(table: RemindersList.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("color", .integer).defaults(to: 0x4a99ef).notNull() - table.column("name", .text).notNull() - } + try #sql( + """ + CREATE TABLE "remindersLists" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) } migrator.registerMigration("Add reminders table") { db in - try db.create(table: Reminder.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("date", .date) - table.column("isCompleted", .boolean).defaults(to: false).notNull() - table.column("isFlagged", .boolean).defaults(to: false).notNull() - table.column("notes", .text).notNull() - table.column("priority", .integer) - table.column("remindersListID", .integer) - .references(RemindersList.databaseTableName, column: "id", onDelete: .cascade) - .notNull() - table.column("title", .text).notNull() - } + try #sql( + """ + CREATE TABLE "reminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "dueDate" TEXT, + "isCompleted" INTEGER NOT NULL DEFAULT 0, + "isFlagged" INTEGER NOT NULL DEFAULT 0, + "notes" TEXT, + "priority" INTEGER, + "remindersListID" INTEGER NOT NULL, + "title" TEXT NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) } migrator.registerMigration("Add tags table") { db in - try db.create(table: Tag.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("name", .text).notNull().collate(.nocase).unique() - } - try db.create(table: ReminderTag.databaseTableName) { table in - table.column("reminderID", .integer).notNull() - .references(Reminder.databaseTableName, column: "id", onDelete: .cascade) - table.column("tagID", .integer).notNull() - .references(Tag.databaseTableName, column: "id", onDelete: .cascade) - } + try #sql( + """ + CREATE TABLE "tags" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL COLLATE NOCASE UNIQUE + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "remindersTags" ( + "reminderID" INTEGER NOT NULL, + "tagID" INTEGER NOT NULL, + + FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, + FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE + ) STRICT + """ + ) + .execute(db) } - #if DEBUG - migrator.registerMigration("Add mock data") { db in - try db.createMockData() + #if DEBUG && targetEnvironment(simulator) + if context != .test { + migrator.registerMigration("Seed sample data") { db in + try db.seedSampleData() + } } #endif try migrator.migrate(database) @@ -118,115 +174,116 @@ func appDatabase() throws -> any DatabaseWriter { #if DEBUG extension Database { - func createMockData() throws { - try createDebugRemindersLists() - try createDebugReminders() - try createDebugTags() - } - - func createDebugRemindersLists() throws { - _ = try RemindersList(color: 0x4a99ef, name: "Personal").inserted(self) - _ = try RemindersList(color: 0xed8935, name: "Family").inserted(self) - _ = try RemindersList(color: 0xb25dd3, name: "Business").inserted(self) - } - - func createDebugReminders() throws { - _ = try Reminder( - date: Date(), - remindersListID: 1, - notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - title: "Groceries" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isFlagged: true, - remindersListID: 1, - title: "Haircut" - ) - .inserted(self) - _ = try Reminder( - date: Date(), - remindersListID: 1, - notes: "Ask about diet", - priority: 3, - title: "Doctor appointment" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(-60 * 60 * 24 * 190), - isCompleted: true, - remindersListID: 1, - title: "Take a walk" - ) - .inserted(self) - _ = try Reminder( - date: Date(), - remindersListID: 1, - title: "Buy concert tickets" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(60 * 60 * 24 * 2), - isFlagged: true, - remindersListID: 2, - priority: 3, - title: "Pick up kids from school" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - remindersListID: 2, - priority: 1, - title: "Get laundry" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(60 * 60 * 24 * 4), - isCompleted: false, - remindersListID: 2, - priority: 3, - title: "Take out trash" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(60 * 60 * 24 * 2), - remindersListID: 3, - notes: """ - Status of tax return - Expenses for next year - Changing payroll company - """, - title: "Call accountant" - ) - .inserted(self) - _ = try Reminder( - date: Date().addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - remindersListID: 3, - priority: 2, - title: "Send weekly emails" - ) - .inserted(self) - } - - func createDebugTags() throws { - _ = try Tag(name: "car").inserted(self) - _ = try Tag(name: "kids").inserted(self) - _ = try Tag(name: "someday").inserted(self) - _ = try Tag(name: "optional").inserted(self) - _ = try Tag(name: "social").inserted(self) - _ = try Tag(name: "night").inserted(self) - _ = try Tag(name: "adulting").inserted(self) - _ = try ReminderTag(reminderID: 1, tagID: 3).inserted(self) - _ = try ReminderTag(reminderID: 1, tagID: 4).inserted(self) - _ = try ReminderTag(reminderID: 1, tagID: 7).inserted(self) - _ = try ReminderTag(reminderID: 2, tagID: 3).inserted(self) - _ = try ReminderTag(reminderID: 2, tagID: 4).inserted(self) - _ = try ReminderTag(reminderID: 3, tagID: 7).inserted(self) - _ = try ReminderTag(reminderID: 4, tagID: 1).inserted(self) - _ = try ReminderTag(reminderID: 4, tagID: 2).inserted(self) + func seedSampleData() throws { + try seed { + RemindersList( + id: 1, + color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), + title: "Personal" + ) + RemindersList( + id: 2, + color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), + title: "Family" + ) + RemindersList( + id: 3, + color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), + title: "Business" + ) + Reminder( + id: 1, + notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", + remindersListID: 1, + title: "Groceries" + ) + Reminder( + id: 2, + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isFlagged: true, + remindersListID: 1, + title: "Haircut" + ) + Reminder( + id: 3, + dueDate: Date(), + notes: "Ask about diet", + priority: .high, + remindersListID: 1, + title: "Doctor appointment" + ) + Reminder( + id: 4, + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), + isCompleted: true, + remindersListID: 1, + title: "Take a walk" + ) + Reminder( + id: 5, + dueDate: Date(), + remindersListID: 1, + title: "Buy concert tickets" + ) + Reminder( + id: 6, + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + isFlagged: true, + priority: .high, + remindersListID: 2, + title: "Pick up kids from school" + ) + Reminder( + id: 7, + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .low, + remindersListID: 2, + title: "Get laundry" + ) + Reminder( + id: 8, + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), + isCompleted: false, + priority: .high, + remindersListID: 2, + title: "Take out trash" + ) + Reminder( + id: 9, + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + notes: """ + Status of tax return + Expenses for next year + Changing payroll company + """, + remindersListID: 3, + title: "Call accountant" + ) + Reminder( + id: 10, + dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .medium, + remindersListID: 3, + title: "Send weekly emails" + ) + Tag(id: 1, title: "car") + Tag(id: 2, title: "kids") + Tag(id: 3, title: "someday") + Tag(id: 4, title: "optional") + Tag(id: 5, title: "social") + Tag(id: 6, title: "night") + Tag(id: 7, title: "adulting") + ReminderTag(reminderID: 1, tagID: 3) + ReminderTag(reminderID: 1, tagID: 4) + ReminderTag(reminderID: 1, tagID: 7) + ReminderTag(reminderID: 2, tagID: 3) + ReminderTag(reminderID: 2, tagID: 4) + ReminderTag(reminderID: 3, tagID: 7) + ReminderTag(reminderID: 4, tagID: 1) + ReminderTag(reminderID: 4, tagID: 2) + } } } #endif diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index e2f2d9de..0073c556 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -3,7 +3,8 @@ import SharingGRDB import SwiftUI struct SearchRemindersView: View { - @SharedReader var searchReminders: SearchReminders.Value + @FetchOne var completedCount: Int = 0 + @State @FetchAll var reminders: [ReminderState] let searchText: String @State var showCompletedInSearchResults = false @@ -12,24 +13,15 @@ struct SearchRemindersView: View { init(searchText: String) { self.searchText = searchText - _searchReminders = SharedReader( - wrappedValue: SearchReminders.Value(), - .fetch( - SearchReminders( - showCompletedInSearchResults: _showCompletedInSearchResults.wrappedValue, - searchText: searchText - ), - animation: .default - ) - ) + _reminders = State(wrappedValue: FetchAll()) } var body: some View { HStack { - Text("\(searchReminders.completedCount) Completed") + Text("\(completedCount) Completed") .monospacedDigit() .contentTransition(.numericText()) - if searchReminders.completedCount > 0 { + if completedCount > 0 { Text("•") Menu { Text("Clear Completed Reminders") @@ -41,14 +33,8 @@ struct SearchRemindersView: View { Text("Clear") } Spacer() - if showCompletedInSearchResults { - Button("Hide") { - showCompletedInSearchResults = false - } - } else { - Button("Show") { - showCompletedInSearchResults = true - } + Button(showCompletedInSearchResults ? "Hide" : "Show") { + showCompletedInSearchResults.toggle() } } } @@ -59,12 +45,15 @@ struct SearchRemindersView: View { } } - ForEach(searchReminders.reminders, id: \.reminder.id) { reminder in + ForEach(reminders) { reminder in ReminderRow( + color: reminder.remindersList.color, isPastDue: reminder.isPastDue, + notes: reminder.notes, reminder: reminder.reminder, remindersList: reminder.remindersList, - tags: (reminder.commaSeparatedTags ?? "").split(separator: ",").map(String.init) + showCompleted: showCompletedInSearchResults, + tags: reminder.tags ) } } @@ -73,115 +62,61 @@ struct SearchRemindersView: View { if searchText.isEmpty { showCompletedInSearchResults = false } - try await $searchReminders.load( - .fetch( - SearchReminders( - showCompletedInSearchResults: showCompletedInSearchResults, - searchText: searchText - ), - animation: .default - ) + try await $completedCount.load( + Reminder.searching(searchText) + .where(\.isCompleted) + .count(), + animation: .default + ) + try await $reminders.wrappedValue.load( + Reminder + .searching(searchText) + .where { showCompletedInSearchResults || !$0.isCompleted } + .order { ($0.isCompleted, $0.dueDate) } + .withTags + .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .select { + ReminderState.Columns( + isPastDue: $0.isPastDue, + notes: $0.inlineNotes, + reminder: $0, + remindersList: $3, + tags: #sql("\($2.jsonNames)") + ) + }, + animation: .default ) } private func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { try database.write { db in - let baseQuery = searchQueryBase(searchText: searchText) - .filter(Column("isCompleted")) - if let monthsAgo { - _ = try baseQuery - .filter(Column("date") < "date('now', '-\(monthsAgo) months')") - .deleteAll(db) - } else { - _ = try baseQuery.deleteAll(db) - } + try Reminder + .searching(searchText) + .where(\.isCompleted) + .where { + if let monthsAgo { + #sql("\($0.dueDate) < date('now', '-\(raw: monthsAgo) months')") + } + } + .delete() + .execute(db) } } } - struct SearchReminders: FetchKeyRequest { - let showCompletedInSearchResults: Bool - let searchText: String - - func fetch(_ db: Database) throws -> Value { - struct LocalRequest: Decodable, FetchableRecord { - var isPastDue: Bool - let reminder: Reminder - let remindersListID: Int64 - let commaSeparatedTags: String? - } - let reminders = try LocalRequest.fetchAll( - db, - SQLRequest(literal: """ - SELECT - "reminders".*, - "remindersLists"."id" AS "remindersListID", - group_concat("tags"."name", ',') AS "commaSeparatedTags", - NOT "isCompleted" AND coalesce("reminders"."date", date('now')) < date('now') AS "isPastDue" - FROM "reminders" - LEFT JOIN "remindersLists" ON "reminders"."remindersListID" = "remindersLists"."id" - LEFT JOIN "remindersTags" ON "reminders"."id" = "remindersTags"."reminderID" - LEFT JOIN "tags" ON "remindersTags"."tagID" = "tags"."id" - WHERE - ( - "reminders"."title" COLLATE NOCASE LIKE \("%\(searchText)%") - OR "reminders"."notes" COLLATE NOCASE LIKE \("%\(searchText)%") - ) - \(sql: showCompletedInSearchResults ? "" : #"AND NOT "reminders"."isCompleted""#) - GROUP BY "reminders"."id" - ORDER BY - "reminders"."isCompleted", "reminders"."date" - """) - ) - - // NB: We are loading lists as a separate query because we are not sure how to join - // "remindersLists" into the above query and decode it into 'State'. Ideally this - // could all be done with a single query. - let remindersLists = try RemindersList.fetchAll( - db, - keys: Set(reminders.map(\.remindersListID)) - ) - - let completedCount = try searchQueryBase(searchText: searchText) - .filter(Column("isCompleted")) - .fetchCount(db) - - return Value( - completedCount: completedCount, - reminders: reminders.map { reminder in - Value.Reminder( - isPastDue: reminder.isPastDue, - reminder: reminder.reminder, - remindersList: remindersLists.first( - where: { $0.id == reminder.reminder.remindersListID } - )!, - commaSeparatedTags: reminder.commaSeparatedTags - ) - } - ) - } - struct Value { - var completedCount = 0 - var reminders: [Reminder] = [] - struct Reminder: Decodable, FetchableRecord { - var isPastDue: Bool - let reminder: Reminders.Reminder - let remindersList: RemindersList - let commaSeparatedTags: String? - } - } + @Selection + struct ReminderState: Identifiable { + var id: Reminder.ID { reminder.id } + let isPastDue: Bool + let notes: String + let reminder: Reminders.Reminder + let remindersList: RemindersList + @Column(as: JSONRepresentation<[String]>.self) + let tags: [String] } } -private func searchQueryBase(searchText: String) -> QueryInterfaceRequest { - Reminder - .filter( - Column("title").collating(.nocase).like("%\(searchText.lowercased())%") - || Column("notes").collating(.nocase).like("%\(searchText.lowercased())%") - ) -} - #Preview { @Previewable @State var searchText = "take" let _ = try! prepareDependencies { diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift new file mode 100644 index 00000000..ae9c7e27 --- /dev/null +++ b/Examples/Reminders/TagRow.swift @@ -0,0 +1,40 @@ +import SharingGRDB +import SwiftUI + +struct TagRow: View { + let tag: Tag + @Dependency(\.defaultDatabase) var database + var body: some View { + HStack { + Image(systemName: "number.circle.fill") + .font(.largeTitle) + .foregroundStyle(.gray) + .background( + Color.white.clipShape(Circle()).padding(4) + ) + Text(tag.title) + Spacer() + } + .swipeActions { + Button { + withErrorReporting { + try database.write { db in + try Tag.delete(tag) + .execute(db) + } + } + } label: { + Image(systemName: "trash") + } + .tint(.red) + } + } +} + +#Preview { + NavigationStack { + List { + TagRow(tag: Tag(id: 1, title: "optional")) + } + } +} diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 212ddafa..8ba5b1a1 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -2,7 +2,7 @@ import SharingGRDB import SwiftUI struct TagsView: View { - @SharedReader(.fetch(Tags())) var tags = Tags.Value() + @Fetch(Tags()) var tags = Tags.Value() @Binding var selectedTags: [Tag] @Environment(\.dismiss) var dismiss @@ -45,29 +45,21 @@ struct TagsView: View { struct Tags: FetchKeyRequest { func fetch(_ db: Database) throws -> Value { - let top = try Tag.fetchAll( - db, - sql: """ - SELECT "tags".*, count("reminders"."id") - FROM "tags" - LEFT JOIN "remindersTags" - ON "tags"."id" = "remindersTags"."tagID" - LEFT JOIN "reminders" - ON "remindersTags"."reminderID" = "reminders"."id" - GROUP BY "tags"."id" - HAVING count("reminders"."id") > 0 - ORDER BY count("reminders"."id") DESC, "name" - LIMIT 3 - """) - let rest = try Tag.fetchAll( - db, - SQLRequest(literal: """ - SELECT "tags".* - FROM "tags" - WHERE "id" NOT IN \(top.compactMap(\.id)) - ORDER BY "name" - """) - ) + let top = + try Tag + .withReminders + .having { $2.count().gt(0) } + .order { ($2.count().desc(), $0.title) } + .select { tag, _, _ in tag } + .limit(3) + .fetchAll(db) + + let rest = + try Tag + .where { !$0.id.in(top.map(\.id)) } + .order(by: \.title) + .fetchAll(db) + return Value(rest: rest, top: top) } struct Value { @@ -94,10 +86,10 @@ private struct TagView: View { if isSelected { Image.init(systemName: "checkmark") } - Text(tag.name) + Text(tag.title) } } - .tint(isSelected ? .blue : .black) + .tint(isSelected ? .accentColor : .primary) } } diff --git a/Examples/SyncUpTests/SyncUpFormTests.swift b/Examples/SyncUpTests/SyncUpFormTests.swift new file mode 100644 index 00000000..0bba708a --- /dev/null +++ b/Examples/SyncUpTests/SyncUpFormTests.swift @@ -0,0 +1,93 @@ +import Dependencies +import DependenciesTestSupport +import Foundation +import GRDB +import StructuredQueries +import Testing + +@testable import SyncUps + +@Suite( + .dependencies { + $0.defaultDatabase = try! SyncUps.appDatabase() + try! $0.defaultDatabase.write { try $0.seedSyncUpFormTests() } + $0.uuid = .incrementing + } +) +struct SyncUpFormTests { + @Dependency(\.defaultDatabase) var database + + @Test func saveNew() async throws { + let draft = SyncUp.Draft(title: "Morning Sync") + let model = SyncUpFormModel(syncUp: draft) + model.addAttendeeButtonTapped() + model.addAttendeeButtonTapped() + model.attendees[0].name = "Blob" + model.attendees[1].name = "Blob Jr." + model.saveButtonTapped() + + let syncUp = try await database.read { db in + try #require(try SyncUp.order { $0.id.desc() }.fetchOne(db)) + } + #expect(syncUp.title == "Morning Sync") + let attendees = try await database.read { db in + try Attendee.where { $0.syncUpID.eq(syncUp.id) }.fetchAll(db) + } + #expect(attendees.map(\.name) == ["Blob", "Blob Jr."]) + } + + @Test func updateExisting() async throws { + let existingSyncUp = try await database.read { db in + try #require(try SyncUp.all.fetchOne(db)) + } + let draft = SyncUp.Draft(existingSyncUp) + let model = SyncUpFormModel(syncUp: draft) + model.syncUp.title = "Evening Sync" + model.deleteAttendees(atOffsets: [1, 2, 3, 4, 5]) + model.addAttendeeButtonTapped() + model.attendees[model.attendees.count - 1].name = "Blobby McBlob" + model.saveButtonTapped() + + let syncUp = try await database.read { db in + try #require(try SyncUp.where { $0.id.eq(existingSyncUp.id) }.fetchOne(db)) + } + #expect(syncUp.title == "Evening Sync") + let attendees = try await database.read { db in + try Attendee.where { $0.syncUpID.eq(existingSyncUp.id) }.fetchAll(db) + } + #expect(attendees.map(\.name) == ["Blob", "Blobby McBlob"]) + } +} + +extension Database { + fileprivate func seedSyncUpFormTests() throws { + try seed { + SyncUp(id: 1, seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: 2, seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: 3, seconds: 60 * 30, theme: .poppy, title: "Product") + + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + Attendee.Draft(name: name, syncUpID: 1) + } + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: 2) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: 3) + } + + Meeting.Draft( + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + syncUpID: 1, + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + """ + ) + } + } +} diff --git a/Examples/SyncUps/App.swift b/Examples/SyncUps/App.swift index f3553f9d..a3b6a0fb 100644 --- a/Examples/SyncUps/App.swift +++ b/Examples/SyncUps/App.swift @@ -12,8 +12,6 @@ class AppModel { didSet { bind() } } - @ObservationIgnored - @Dependency(\.continuousClock) var clock @ObservationIgnored @Dependency(\.date.now) var now @ObservationIgnored diff --git a/Examples/SyncUps/README.md b/Examples/SyncUps/README.md index c1416c56..192a72cb 100644 --- a/Examples/SyncUps/README.md +++ b/Examples/SyncUps/README.md @@ -1,9 +1,9 @@ # SyncUpsGRDB -A version of [SyncUps][syncups-gh] that persists its model data using SharingGRDB. +A version of [SyncUps][] that persists its model data using SharingGRDB. -SyncUps is a rebuild of Apple's [Scrumdinger][scrumdinger] demo application, but with a focus on +SyncUps is a rebuild of Apple's [Scrumdinger][] demo application, but with a focus on modern, best practices for SwiftUI development. -[syncups-gh]: https://github.com/pointfreeco/syncups -[scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger +[SyncUps]: https://github.com/pointfreeco/syncups +[Scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger diff --git a/Examples/SyncUps/RecordMeeting.swift b/Examples/SyncUps/RecordMeeting.swift index c1d05ab4..67b3bd9b 100644 --- a/Examples/SyncUps/RecordMeeting.swift +++ b/Examples/SyncUps/RecordMeeting.swift @@ -32,11 +32,11 @@ final class RecordMeetingModel: HashableObject { } var durationPerAttendee: Duration { - syncUp.duration / attendees.count + syncUp.seconds.duration / attendees.count } var durationRemaining: Duration { - syncUp.duration - .seconds(secondsElapsed) + syncUp.seconds.duration - .seconds(secondsElapsed) } func nextButtonTapped() { @@ -124,8 +124,10 @@ final class RecordMeetingModel: HashableObject { try? await clock.sleep(for: .seconds(0.4)) await withErrorReporting { try await database.write { [now, syncUp, transcript] db in - _ = try Meeting(date: now, syncUpID: syncUp.id!, transcript: transcript) - .inserted(db) + try Meeting.insert( + Meeting.Draft(date: now, syncUpID: syncUp.id, transcript: transcript) + ) + .execute(db) } } } @@ -376,9 +378,9 @@ struct MeetingFooterView: View { model: RecordMeetingModel( syncUp: SyncUp(id: 1, seconds: 60, theme: .bubblegum, title: "Engineering"), attendees: [ - Attendee(name: "Blob", syncUpID: 1), - Attendee(name: "Blob Jr", syncUpID: 1), - Attendee(name: "Blob Sr", syncUpID: 1), + Attendee(id: 1, name: "Blob", syncUpID: 1), + Attendee(id: 2, name: "Blob Jr", syncUpID: 1), + Attendee(id: 3, name: "Blob Sr", syncUpID: 1), ] ) ) diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 65c5dafb..cd5db217 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -1,50 +1,31 @@ import SharingGRDB import SwiftUI -struct SyncUp: Codable, Hashable, FetchableRecord, MutablePersistableRecord { - static let tableName = "syncUps" - - var id: Int64? - var seconds = 60 * 5 +@Table +struct SyncUp: Hashable, Identifiable { + let id: Int + var seconds: Int = 60 * 5 var theme: Theme = .bubblegum var title = "" - - var duration: Duration { - get { .seconds(seconds) } - set { seconds = Int(newValue.components.seconds) } - } - - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } -struct Attendee: Codable, Hashable, FetchableRecord, MutablePersistableRecord { - static let tableName = "attendees" - - var id: Int64? +@Table +struct Attendee: Hashable, Identifiable { + let id: Int var name = "" - var syncUpID: Int64 - - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } + var syncUpID: SyncUp.ID } -struct Meeting: Codable, Hashable, FetchableRecord, MutablePersistableRecord { - static let tableName = "meetings" - - var id: Int64? +@Table +struct Meeting: Hashable, Identifiable { + let id: Int + @Column(as: Date.ISO8601Representation.self) var date: Date - var syncUpID: Int64 + var syncUpID: SyncUp.ID var transcript: String - - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } -enum Theme: String, CaseIterable, Codable, Hashable, Identifiable, DatabaseValueConvertible { +enum Theme: String, CaseIterable, Hashable, Identifiable, QueryBindable { case appIndigo case appMagenta case appOrange @@ -87,6 +68,13 @@ enum Theme: String, CaseIterable, Codable, Hashable, Identifiable, DatabaseValue } } +extension Int { + var duration: Duration { + get { .seconds(self) } + set { self = Int(newValue.components.seconds) } + } +} + func appDatabase() throws -> any DatabaseWriter { let database: any DatabaseWriter var configuration = Configuration() @@ -111,35 +99,53 @@ func appDatabase() throws -> any DatabaseWriter { migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create sync-ups table") { db in - try db.create(table: SyncUp.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("seconds", .integer).defaults(to: 5 * 60).notNull() - table.column("theme", .text).notNull().defaults(to: Theme.bubblegum) - table.column("title", .text).notNull() - } + try #sql( + """ + CREATE TABLE "syncUps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "seconds" INTEGER NOT NULL DEFAULT 300, + "theme" TEXT NOT NULL DEFAULT \(raw: Theme.bubblegum.rawValue), + "title" TEXT NOT NULL + ) + """ + ) + .execute(db) } migrator.registerMigration("Create attendees table") { db in - try db.create(table: Attendee.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("name", .text).notNull() - table.column("syncUpID", .integer) - .references(SyncUp.databaseTableName, column: "id", onDelete: .cascade) - .notNull() - } + try #sql( + """ + CREATE TABLE "attendees" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "syncUpID" INTEGER NOT NULL, + + FOREIGN KEY("syncUpID") REFERENCES "syncUps"("id") ON DELETE CASCADE + ) + """ + ) + .execute(db) } migrator.registerMigration("Create meetings table") { db in - try db.create(table: Meeting.databaseTableName) { table in - table.autoIncrementedPrimaryKey("id") - table.column("date", .datetime).notNull().unique().defaults(sql: "CURRENT_TIMESTAMP") - table.column("syncUpID", .integer) - .references(SyncUp.databaseTableName, column: "id", onDelete: .cascade) - .notNull() - table.column("transcript", .text).notNull() - } + try #sql( + """ + CREATE TABLE "meetings" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "date" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP UNIQUE, + "syncUpID" INTEGER NOT NULL, + "transcript" TEXT NOT NULL, + + FOREIGN KEY("syncUpID") REFERENCES "syncUps"("id") ON DELETE CASCADE + ) + """ + ) + .execute(db) } - #if DEBUG - migrator.registerMigration("Insert sample data") { db in - try db.insertSampleData() + + #if DEBUG && targetEnvironment(simulator) + if context != .test { + migrator.registerMigration("Seed sample data") { db in + try db.seedSampleData() + } } #endif @@ -148,39 +154,35 @@ func appDatabase() throws -> any DatabaseWriter { return database } -#if DEBUG - extension Database { - func insertSampleData() throws { - let design = try SyncUp(seconds: 60, theme: .appOrange, title: "Design") - .inserted(self) +extension Database { + fileprivate func seedSampleData() throws { + try seed { + SyncUp(id: 1, seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: 2, seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: 3, seconds: 60 * 30, theme: .poppy, title: "Product") + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - _ = try Attendee(name: name, syncUpID: design.id!).inserted(self) + Attendee.Draft(name: name, syncUpID: 1) } - _ = try Meeting( + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: 2) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: 3) + } + + Meeting.Draft( date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: design.id!, + syncUpID: 1, transcript: """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \ - dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \ - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \ - mollit anim id est laborum. + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. """ ) - .inserted(self) - - let engineering = try SyncUp(seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - .inserted(self) - for name in ["Blob", "Blob Jr"] { - _ = try Attendee(name: name, syncUpID: engineering.id!).inserted(self) - } - - let product = try SyncUp(seconds: 60 * 30, theme: .poppy, title: "Product") - .inserted(self) - for name in ["Blob Sr", "Blob Jr"] { - _ = try Attendee(name: name, syncUpID: product.id!).inserted(self) - } } } -#endif +} diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index d81105c7..4d477e7c 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -7,7 +7,7 @@ import SwiftUINavigation final class SyncUpDetailModel: HashableObject { var destination: Destination? var isDismissed = false - @ObservationIgnored @SharedReader var details: Details.Value + @ObservationIgnored @Fetch var details: Details.Value var onMeetingStarted: (SyncUp, [Attendee]) -> Void = unimplemented("onMeetingStarted") @@ -33,16 +33,17 @@ final class SyncUpDetailModel: HashableObject { syncUp: SyncUp ) { self.destination = destination - _details = SharedReader( + _details = Fetch( wrappedValue: Details.Value(syncUp: syncUp), - .fetch(Details(syncUp: syncUp), animation: .default) + Details(syncUp: syncUp), animation: .default ) } func deleteMeetings(atOffsets indices: IndexSet) { withErrorReporting { try database.write { db in - _ = try Meeting.deleteAll(db, keys: indices.map { details.meetings[$0].id }) + let ids = indices.map { details.meetings[$0].id } + try Meeting.where { ids.contains($0.id) }.delete().execute(db) } } } @@ -58,7 +59,7 @@ final class SyncUpDetailModel: HashableObject { try? await clock.sleep(for: .seconds(0.4)) await withErrorReporting { try await database.write { [syncUp = details.syncUp] db in - _ = try syncUp.delete(db) + try SyncUp.delete(syncUp).execute(db) } } @@ -76,7 +77,7 @@ final class SyncUpDetailModel: HashableObject { func editButtonTapped() { destination = .edit( withDependencies(from: self) { - SyncUpFormModel(syncUp: details.syncUp, attendees: details.attendees) + SyncUpFormModel(syncUp: SyncUp.Draft(details.syncUp)) } ) } @@ -107,13 +108,16 @@ final class SyncUpDetailModel: HashableObject { let syncUp: SyncUp func fetch(_ db: Database) throws -> Value { - try Value( - attendees: Attendee.filter(Column("syncUpID") == syncUp.id).fetchAll(db), - meetings: Meeting - .filter(Column("syncUpID") == syncUp.id) - .order(Column("date").desc) + guard let syncUp = try SyncUp.where({ $0.id == syncUp.id }).fetchOne(db) + else { throw NotFound() } + return try Value( + attendees: Attendee.where { $0.syncUpID == syncUp.id }.fetchAll(db), + meetings: + Meeting + .where { $0.syncUpID.eq(syncUp.id) } + .order { $0.date.desc() } .fetchAll(db), - syncUp: SyncUp.fetchOne(db, key: syncUp.id) ?? SyncUp() + syncUp: syncUp ) } } @@ -136,7 +140,7 @@ struct SyncUpDetailView: View { HStack { Label("Length", systemImage: "clock") Spacer() - Text(model.details.syncUp.duration.formatted(.units())) + Text(model.details.syncUp.seconds.duration.formatted(.units())) } HStack { @@ -155,7 +159,9 @@ struct SyncUpDetailView: View { if !model.details.meetings.isEmpty { Section { ForEach(model.details.meetings, id: \.id) { meeting in - NavigationLink(value: AppModel.Path.meeting(meeting, attendees: model.details.attendees)) { + NavigationLink( + value: AppModel.Path.meeting(meeting, attendees: model.details.attendees) + ) { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) @@ -293,7 +299,7 @@ struct MeetingView: View { } @Dependency(\.defaultDatabase) var database let syncUp = try! database.read { db in - try SyncUp.fetchOne(db)! + try SyncUp.limit(1).fetchOne(db)! } NavigationStack { SyncUpDetailView( diff --git a/Examples/SyncUps/SyncUpForm.swift b/Examples/SyncUps/SyncUpForm.swift index e6336e55..369b61b7 100644 --- a/Examples/SyncUps/SyncUpForm.swift +++ b/Examples/SyncUps/SyncUpForm.swift @@ -1,4 +1,3 @@ -import Dependencies import SharingGRDB import SwiftUI import SwiftUINavigation @@ -8,7 +7,7 @@ final class SyncUpFormModel: Identifiable { var attendees: [AttendeeDraft] = [] var focus: Field? var isDismissed = false - var syncUp: SyncUp + var syncUp: SyncUp.Draft @ObservationIgnored @Dependency(\.defaultDatabase) var database @ObservationIgnored @Dependency(\.uuid) var uuid @@ -24,16 +23,27 @@ final class SyncUpFormModel: Identifiable { } init( - syncUp: SyncUp, - attendees: [Attendee] = [], + syncUp: SyncUp.Draft, focus: Field? = .title ) { self.syncUp = syncUp - self.attendees = attendees.map { AttendeeDraft(id: uuid(), name: $0.name) } - if attendees.isEmpty { - self.attendees.append(AttendeeDraft(id: uuid())) - } self.focus = focus + defer { + if attendees.isEmpty { + self.attendees.append(AttendeeDraft(id: uuid())) + } + } + guard let syncUpID = syncUp.id + else { return } + + withErrorReporting { + self.attendees = try database.read { db in + try Attendee.all + .where { $0.syncUpID.eq(syncUpID) } + .fetchAll(db) + .map { (attendee: Attendee) in AttendeeDraft(id: uuid(), name: attendee.name) } + } + } } func deleteAttendees(atOffsets indices: IndexSet) { @@ -66,11 +76,11 @@ final class SyncUpFormModel: Identifiable { } withErrorReporting { try database.write { db in - try syncUp.save(db) - try Attendee.filter(Column("syncUpID") == syncUp.id!).deleteAll(db) - for attendee in attendees { - _ = try Attendee(name: attendee.name, syncUpID: syncUp.id!).inserted(db) - } + let syncUpID = try SyncUp.upsert(syncUp).returning(\.id).fetchOne(db)! + try Attendee.where { $0.syncUpID == syncUpID }.delete().execute(db) + try Attendee + .insert(attendees.map { Attendee.Draft(name: $0.name, syncUpID: syncUpID) }) + .execute(db) } } isDismissed = true @@ -88,11 +98,11 @@ struct SyncUpFormView: View { TextField("Title", text: $model.syncUp.title) .focused($focus, equals: .title) HStack { - Slider(value: $model.syncUp.duration.seconds, in: 5...30, step: 1) { + Slider(value: $model.syncUp.seconds.toDouble, in: 5...30, step: 1) { Text("Length") } Spacer() - Text(model.syncUp.duration.formatted(.units())) + Text(model.syncUp.seconds.duration.formatted(.units())) } ThemePicker(selection: $model.syncUp.theme) } header: { @@ -133,6 +143,13 @@ struct SyncUpFormView: View { } } +extension Int { + fileprivate var toDouble: Double { + get { Double(self) } + set { self = Int(newValue) } + } +} + struct ThemePicker: View { @Binding var selection: Theme @@ -153,19 +170,14 @@ struct ThemePicker: View { } } -extension Duration { - fileprivate var seconds: Double { - get { Double(components.seconds / 60) } - set { self = .seconds(newValue * 60) } - } -} - -#Preview { - NavigationStack { - SyncUpFormView( - model: SyncUpFormModel( - syncUp: SyncUp() +struct SyncUpFormPreviews: PreviewProvider { + static var previews: some View { + NavigationStack { + SyncUpFormView( + model: SyncUpFormModel( + syncUp: SyncUp.Draft() + ) ) - ) + } } } diff --git a/Examples/SyncUps/SyncUpsApp.swift b/Examples/SyncUps/SyncUpsApp.swift index cabe7cde..4f8135a4 100644 --- a/Examples/SyncUps/SyncUpsApp.swift +++ b/Examples/SyncUps/SyncUpsApp.swift @@ -6,14 +6,18 @@ struct SyncUpsApp: App { static let model = AppModel() init() { - try! prepareDependencies { - $0.defaultDatabase = try SyncUps.appDatabase() + if !isTesting { + try! prepareDependencies { + $0.defaultDatabase = try SyncUps.appDatabase() + } } } var body: some Scene { WindowGroup { - AppView(model: Self.model) + if !isTesting { + AppView(model: Self.model) + } } } } diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index ba883bb2..4d9ff033 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -6,7 +6,15 @@ import SwiftUINavigation @Observable final class SyncUpsListModel { var addSyncUp: SyncUpFormModel? - @ObservationIgnored @SharedReader var syncUps: [SyncUps.Record] + @ObservationIgnored + @FetchAll( + SyncUp + .group(by: \.id) + .leftJoin(Attendee.all) { $0.id.eq($1.syncUpID) } + .select { Record.Columns(attendeeCount: $1.count(), syncUp: $0) }, + animation: .default + ) + var syncUps: [Record] @ObservationIgnored @Dependency(\.uuid) var uuid @ObservationIgnored @Dependency(\.defaultDatabase) var database @@ -14,26 +22,18 @@ final class SyncUpsListModel { addSyncUp: SyncUpFormModel? = nil ) { self.addSyncUp = addSyncUp - _syncUps = SharedReader(.fetch(SyncUps(), animation: .default)) } func addSyncUpButtonTapped() { addSyncUp = withDependencies(from: self) { - SyncUpFormModel(syncUp: SyncUp()) + SyncUpFormModel(syncUp: SyncUp.Draft()) } } - struct SyncUps: FetchKeyRequest { - struct Record: Decodable, FetchableRecord { - let syncUp: SyncUp - let attendeeCount: Int - } - func fetch(_ db: Database) throws -> [Record] { - try SyncUp.all() - .annotated(with: [SyncUp.hasMany(Attendee.self).count]) - .asRequest(of: Record.self) - .fetchAll(db) - } + @Selection + struct Record { + let attendeeCount: Int + let syncUp: SyncUp } } @@ -78,7 +78,7 @@ struct CardView: View { HStack { Label("\(attendeeCount)", systemImage: "person.3") Spacer() - Label(syncUp.duration.formatted(.units()), systemImage: "clock") + Label(syncUp.seconds.duration.formatted(.units()), systemImage: "clock") .labelStyle(.trailingIcon) } .font(.caption) diff --git a/Package.resolved b/Package.resolved index 52e8db32..530ddfcc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "907a66ed0063fc57bae4e70dda4caf1f101f4987c53698c98d5ac7ecd99b0a69", + "originHash" : "a8477db1fe79838ddca336ba53399bdd7e6a459f15e01079fc2acdc8b44a6077", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "6eba24d16952452a8a54f6a639491f3c8215527f", - "version" : "7.3.0" + "revision" : "04e73c26c4ce8218ab85aaf791942bb0b204f330", + "version" : "7.4.1" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "121a428c505c01c4ce02d5ada1c8fc3da93afce9", - "version" : "1.8.0" + "revision" : "fee6aa29908a75437506ddcbe7434c460605b7e6", + "version" : "1.9.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "21811d6230a625fa0f2e6ffa85be857075cc02c4", - "version" : "1.5.0" + "revision" : "671fa54b279fd73933b4a8b34782ebf6c8869145", + "version" : "1.5.1" } }, { @@ -105,8 +105,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "2c840cf2ae0526ad6090e7796c4e13d9a2339f4a", - "version" : "2.3.3" + "revision" : "732871fabfc6b38fcdff5ad2f7336327dbf78e81", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", + "version" : "1.18.3" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "7375bc75c4acaedffee9923e496b93fab18a7bd7", + "version" : "0.1.0" } }, { diff --git a/Package.swift b/Package.swift index 601e8857..b00ec79f 100644 --- a/Package.swift +++ b/Package.swift @@ -15,16 +15,38 @@ let package = Package( name: "SharingGRDB", targets: ["SharingGRDB"] ), + .library( + name: "SharingGRDBCore", + targets: ["SharingGRDBCore"] + ), + .library( + name: "StructuredQueriesGRDB", + targets: ["StructuredQueriesGRDB"] + ), + .library( + name: "StructuredQueriesGRDBCore", + targets: ["StructuredQueriesGRDBCore"] + ), ], dependencies: [ - .package(url: "https://github.com/groue/GRDB.swift", from: "7.1.0"), + .package(url: "https://github.com/groue/GRDB.swift", from: "7.4.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.1.0"), ], targets: [ .target( name: "SharingGRDB", dependencies: [ + "SharingGRDBCore", + "StructuredQueriesGRDB", + ] + ), + .target( + name: "SharingGRDBCore", + dependencies: [ + "StructuredQueriesGRDBCore", .product(name: "GRDB", package: "GRDB.swift"), .product(name: "Sharing", package: "swift-sharing"), ] @@ -34,12 +56,51 @@ let package = Package( dependencies: [ "SharingGRDB", .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "StructuredQueries", package: "swift-structured-queries"), + ] + ), + .target( + name: "StructuredQueriesGRDBCore", + dependencies: [ + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product(name: "StructuredQueriesCore", package: "swift-structured-queries"), + ] + ), + .target( + name: "StructuredQueriesGRDB", + dependencies: [ + "StructuredQueriesGRDBCore", + .product(name: "StructuredQueries", package: "swift-structured-queries"), + ] + ), + .testTarget( + name: "StructuredQueriesGRDBTests", + dependencies: [ + "StructuredQueriesGRDB", + .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "StructuredQueries", package: "swift-structured-queries"), ] ), ], swiftLanguageModes: [.v6] ) +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("MemberImportVisibility"), + // .unsafeFlags([ + // "-Xfrontend", + // "-warn-long-function-bodies=50", + // "-Xfrontend", + // "-warn-long-expression-type-checking=50", + // ]) +] + +for index in package.targets.indices { + package.targets[index].swiftSettings = swiftSettings +} + #if !os(Windows) // Add the documentation compiler plugin if possible package.dependencies.append( diff --git a/README.md b/README.md index 41ebb647..b99ca327 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ # SharingGRDB -A lightweight replacement for SwiftData and `@Query`. +A [fast](#Performance), lightweight replacement for SwiftData, powered by SQL. [![CI](https://github.com/pointfreeco/sharing-grdb/workflows/CI/badge.svg)](https://github.com/pointfreeco/sharing-grdb/actions?query=workflow%3ACI) +[![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](https://www.pointfree.co/slack-invite) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsharing-grdb%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/sharing-grdb) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fsharing-grdb%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/sharing-grdb) -* [Learn more](#Learn-more) -* [Overview](#Overview) -* [Demos](#Demos) -* [Documentation](#Documentation) -* [Installation](#Installation) -* [Community](#Community) -* [License](#License) + * [Learn more](#Learn-more) + * [Overview](#Overview) + * [Quick start](#Quick-start) + * [Performance](#Performance) + * [SQLite knowledge required](#SQLite-knowledge-required) + * [Overview](#Overview) + * [Demos](#Demos) + * [Documentation](#Documentation) + * [Installation](#Installation) + * [Community](#Community) + * [License](#License) ## Learn more @@ -28,8 +33,9 @@ library, [subscribe today](https://www.pointfree.co/pricing). ## Overview -SharingGRDB is lightweight replacement for SwiftData and the `@Query` macro that deploys all the way -back to the iOS 13 generation of targets. +SharingGRDB is a [fast](#performance), lightweight replacement for SwiftData that deploys all the +way back to the iOS 13 generation of targets. To populate data from the database you can use +the `@FetchAll` property wrapper, which is similar to SwiftData's `@Query` macro: @@ -40,12 +46,16 @@ back to the iOS 13 generation of targets. @@ -54,19 +64,34 @@ var items: [Item] ```swift @Query var items: [Item] + +@Model +class Item { + var title: String + var isInStock: Bool + var notes: String + init( + title: String = "", + isInStock: Bool = true, + notes: String = "" + ) { + self.title = title + self.isInStock = isInStock + self.notes = notes + } +} ```
```swift -@SharedReader( - .fetchAll( - sql: "SELECT * FROM items" - ) -) +@FetchAll var items: [Item] + +@Table +struct Item { + let id: Int + var title = "" + var isInStock = true + var notes = "" +} ```
-Both of the above examples fetch items from an external data store, and both are automatically -observed by SwiftUI so that views are recomputed when the external data changes, but SharingGRDB is -powered directly by SQLite using [Sharing][sharing-gh] and [GRDB][grdb], and is -usable from UIKit, `@Observable` models, and more. +Both of the above examples fetch items from an external data store using Swift data types, and both +are automatically observed by SwiftUI so that views are recomputed when the external data changes, +but SharingGRDB is powered directly by SQLite using [Sharing][], [StructuredQueries][], and +[GRDB][], and is usable from UIKit, `@Observable` models, and more. -> Note: It is not required to write queries as a raw SQL string, and a query builder can be used -> instead. For more information on SharingGRDB's querying capabilities, see +For more information on SharingGRDB's querying capabilities, see [Fetching model data][fetching-article]. ## Quick start @@ -124,15 +149,19 @@ struct MyApp: App { -> Note: For more information on preparing a SQLite database, see -[Preparing a SQLite database][preparing-db-article]. +> [!NOTE] +> For more information on preparing a SQLite database, see +> [Preparing a SQLite database][preparing-db-article]. This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like - [`fetchAll`][fetchall-docs]: +[`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs]: ```swift -@SharedReader(.fetchAll(sql: "SELECT * FROM items")) +@FetchAll var items: [Item] + +@FetchOne(Item.where(\.isInStock).count()) +var inStockItemsCount = 0 ``` And you can access this database throughout your application in a way similar to how one accesses @@ -150,9 +179,10 @@ a model context, via a property wrapper: @Dependency(\.defaultDatabase) var database -var newItem = Item(/* ... */) +let newItem = Item(/* ... */) try database.write { db in - try newItem.insert(db) + try Item.insert(newItem) + .execute(db)) } ``` @@ -172,71 +202,95 @@ try modelContext.save() -> Note: For more information on how SharingGRDB compares to SwiftData, see +> [!NOTE] +> For more information on how SharingGRDB compares to SwiftData, see > [Comparison with SwiftData][comparison-swiftdata-article]. This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read the [articles][articles] below to learn how to best utilize this library: -* [Fetching model data][fetching-article] -* [Observing changes to model data][observing-article] -* [Preparing a SQLite database][preparing-db-article] -* [Dynamic queries][dynamic-queries-article] -* [Comparison with SwiftData][comparison-swiftdata-article] + * [Fetching model data][fetching-article] + * [Observing changes to model data][observing-article] + * [Preparing a SQLite database][preparing-db-article] + * [Dynamic queries][dynamic-queries-article] + * [Comparison with SwiftData][comparison-swiftdata-article] [observing-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/observing [dynamic-queries-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/dynamicqueries [articles]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb#Essentials [comparison-swiftdata-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/comparisonwithswiftdata [fetching-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/fetching -[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/preparingdatabase - [fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/sharing/sharedreaderkey/fetchall(sql:arguments:database:animation:) +[preparing-db-article]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/preparingdatabase + + +[fetchall-docs]: https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/sharing/sharedreaderkey/fetchall(sql:arguments:database:animation:) +[fetchone-docs]: todo: find link + +## Performance + +SharingGRDB leverages high-performance decoding from [StructuredQueries][] to turn fetched data into +your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs directly. + +See the following benchmarks against +[Lighter's performance test suite](https://github.com/Lighter-swift/PerformanceTestSuite) for a +taste of how it compares: + +``` +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 + SharingGRDB (0.2.0) 0 0.172 8.511 + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 +``` ## SQLite knowledge required SQLite is one of the - [most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of +[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of software in the history of software. Knowledge of SQLite is a great skill for any app developer to have, and this library does not want to conceal it from you. So, we feel that to best wield this library you should be familiar with the basics of SQLite, including schema design and normalization, SQL queries, including joins and aggregates, and performance, including indices. With some basic knowledge you can apply this library to your database schema in order to query -for data and keep your views up-to-date when data in the database changes. You can use GRDB's -[query builder][query-interface] APIs to query your database, or you can use raw SQL queries, -along with all of the power that SQL has to offer. +for data and keep your views up-to-date when data in the database changes, and you can use +[StructuredQueries][] to build queries, either using its type-safe, discoverable +[query building APIs][], or using its `#sql` macro for writing [safe SQL strings][]. -[query-interface]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/queryinterface -[sharing-gh]: http://github.com/pointfreeco/swift-sharing -[grdb]: http://github.com/groue/grdb.swift -[swift-nav-gh]: https://github.com/pointfreeco/swift-navigation -[observe-docs]: https://swiftpackageindex.com/pointfreeco/swift-navigation/main/documentation/swiftnavigation/objectivec/nsobject/observe(_:)-94oxy +[Sharing]: https://github.com/pointfreeco/swift-sharing +[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries +[GRDB]: https://github.com/groue/GRDB.swift +[query building APIs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore +[safe SQL strings]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/safesqlstrings ## Demos This repo comes with _lots_ of examples to demonstrate how to solve common and complex problems with Sharing. Check out [this](./Examples) directory to see them all, including: - * [Case Studies](./Examples/CaseStudies): - A number of case studies demonstrating the built-in features of the library. + * [Case Studies](./Examples/CaseStudies): A number of case studies demonstrating the built-in + features of the library. - * [SyncUps](./Examples/SyncUps): We also rebuilt Apple's [Scrumdinger][scrumdinger] demo - application using modern, best practices for SwiftUI development, including using this library - to query and persist state using SQLite. + * [SyncUps](./Examples/SyncUps): We also rebuilt Apple's [Scrumdinger][] demo application using + modern, best practices for SwiftUI development, including using this library to query and + persist state using SQLite. * [Reminders](./Examples/Reminders): A rebuild of Apple's [Reminders][reminders-app-store] app that uses a SQLite database to model the reminders, lists and tags. It features many advanced queries, such as searching, and stats aggregation. -[scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger +[Scrumdinger]: https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger [reminders-app-store]: https://apps.apple.com/us/app/reminders/id1108187841 ## Documentation The documentation for releases and `main` are available here: -* [`main`](https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb) -* [0.1.x](https://swiftpackageindex.com/pointfreeco/sharing-grdb/~/documentation/sharinggrdb) + * [`main`](https://swiftpackageindex.com/pointfreeco/sharing-grdb/main/documentation/sharinggrdb/) + * [0.x.x](https://swiftpackageindex.com/pointfreeco/sharing-grdb/~/documentation/sharinggrdb/) ## Installation @@ -249,11 +303,11 @@ simple as adding it to your `Package.swift`: ``` swift dependencies: [ - .package(url: "https://github.com/pointfreeco/sharing-grdb", from: "0.1.0") + .package(url: "https://github.com/pointfreeco/sharing-grdb", from: "0.2.0") ] ``` -And then adding the product to any target that needs access to the library: +And then adding the following product to any target that needs access to the library: ```swift .product(name: "SharingGRDB", package: "sharing-grdb"), diff --git a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5a387aca..002edd92 100644 --- a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "0b0b2ba858f8b04ac444c901bdfa34146f3c9733447c716a345e024788ff20fb", + "originHash" : "38edb3e6d2da325e556817b8d786c591fb5f311680b010e7bceea6379fc6cc4d", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", - "version" : "1.0.2" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "6eba24d16952452a8a54f6a639491f3c8215527f", - "version" : "7.3.0" + "revision" : "04e73c26c4ce8218ab85aaf791942bb0b204f330", + "version" : "7.4.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "e7039aaa4d9cf386fa8324a89f258c3f2c54d751", - "version" : "1.6.0" + "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", + "version" : "1.6.1" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", - "version" : "1.0.5" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", - "version" : "1.3.0" + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "e28911721538fa0c2439e92320bad13e3200866f", - "version" : "2.2.3" + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "8d52279b9809ef27eabe7d5420f03734528f19da", - "version" : "1.4.1" + "revision" : "671fa54b279fd73933b4a8b34782ebf6c8869145", + "version" : "1.5.1" } }, { @@ -123,8 +123,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "6060619646deff09f0a785e17448441fdf2c146c", - "version" : "2.3.2" + "revision" : "732871fabfc6b38fcdff5ad2f7336327dbf78e81", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", + "version" : "1.18.3" + } + }, + { + "identity" : "swift-structured-queries", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-structured-queries", + "state" : { + "revision" : "7375bc75c4acaedffee9923e496b93fab18a7bd7", + "version" : "0.1.0" } }, { @@ -141,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "b444594f79844b0d6d76d70fbfb3f7f71728f938", - "version" : "1.5.1" + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" } } ], diff --git a/Sources/SharingGRDB/Documentation.docc/Articles/Fetching.md b/Sources/SharingGRDB/Documentation.docc/Articles/Fetching.md deleted file mode 100644 index c6856cac..00000000 --- a/Sources/SharingGRDB/Documentation.docc/Articles/Fetching.md +++ /dev/null @@ -1,215 +0,0 @@ -# Fetching model data - -Learn about the various tools for fetching data from a SQLite database. - -## Overview - -All data fetching happens by providing -[`fetchAll`](), -[`fetchOne`](), or - [`fetch`](), to the `@SharedReader` -property wrapper. The primary differences between these choices is whether you want to specify your -query as a raw SQL string, or as a query built with GRDB's query building tools. - - * [Querying with SQL](#Querying-with-SQL) - * [Querying with a SQL builder](#Querying-with-a-SQL-builder) - * [Multiple queries in a single transaction](#Multiple-queries-in-a-single-transaction) - -### Querying with SQL - -For simple queries it can often be very convenient to specify how you want to fetch data from SQLite -as a raw SQL query string. For example, if you simply want to fetch all records from a table, you -can do so using the -[`fetchAll`]() key: - -```swift -@SharedReader(.fetchAll(sql: "SELECT * FROM items")) var items: [Item] -``` - -And if you want to sort the results, you can do so with an ordering clause: - -```swift -@SharedReader(.fetchAll(sql: "SELECT * FROM items ORDER BY createdAt DESC")) -var items: [Item] -``` - -Or, if you want to only compute an aggregate of the data in a table, such as the count of the rows, -you can do so using the -[`fetchOne`]() key: - -```swift -@SharedReader(.fetchOne(sql: "SELECT count(*) FROM items")) -var itemsCount = 0 -``` - -In each of these cases, the query is so simple that you may prefer writing the SQL yourself. -After all, the data is ultimately stored in SQLite, and so being very familiar with how SQL works is -only beneficial to you. - -However, there are some downsides to this. First, complex queries can get very noisy at the call -site, such as if you have filtering logic: - -```swift -@SharedReader( - .fetchAll( - sql: """ - SELECT * FROM items - WHERE NOT isInStock - ORDER BY createdAt DESC - """ - ) -) -var outOfStockItems: [Item] -``` - -And second, and most egregious, is that a raw string is susceptible to typos and the compiler cannot -help us write valid SQL code. For example, if we accidentally specified "`ORDER`" instead of -"`ORDER BY`": - -```swift -@SharedReader( - .fetchAll( - sql: """ - SELECT * FROM items - WHERE NOT isInStock - ORDER createdAt DESC - """ - ) -) -var outOfStockItems: [Item] -``` - -…then this will compile just fine but will be a runtime error. And further, you will often need to -build up a complex query from various settings in your feature, and doing so will rely on messy -string interpolation. - -For these reasons, and more, people turn to query builders. - -### Querying with a SQL builder - -The GRDB library comes with a set of [query building tools][query-interface] that allow one to build -SQL statements in a safer manner. For example, something as simple as: - -```swift -let query = Item.all() -``` - -…represents the SQL statement: - -```sql -SELECT * FROM items -``` - -And the following: - -```swift -let query = Item.all() - .filter(!Column("isInStock")) - .order(Column("createdAt").desc) -``` - -…represents the SQL statement: - -```sql -SELECT * FROM items -WHERE NOT isInStock -ORDER BY createdAt DESC -``` - -Some may prefer writing their SQL statements in this style rather than a raw SQL string, but we -always recommend being fully familiar with the underlying SQL being generated by the builder. - -Unfortunately, one cannot use this query builder directly with the `@SharedReader` like this: - -```swift -@SharedReader(.fetch(Item.all())) var items 🛑 -``` - -This is because the query builder does not provide a unique, `Hashable` identity, which is necessary -to be used with `@SharedReader`. To work around this, one simply defines a conformance to our -``FetchKeyRequest`` protocol, which requires hashability, and in that conformance one can use -the builder tools to query the database: - -```swift -struct Items: FetchKeyRequest { - func fetch(_ db: Database) throws -> [Item] { - try Item.all() - .filter(!Column("isInStock")) - .order(Column("createdAt").desc) - .fetch(db) - } -} -``` - -With this conformance defined one can use -[`fetch`]() key to execute the -query specified by the `Items` type: - -```swift -@SharedReader(.fetch(Items()) var items -``` - -> Note: Because of the type information available to `Items`, the type and default value can be -> omitted from the declaration of `items`. - -Typically the conformances to ``FetchKeyRequest`` can even be made private and nested inside -whatever type they are used in, such as SwiftUI view, `@Observable` model, or UIKit view controller. -The only time it needs to be made public is if it's shared amongst many features. - -### Multiple queries in a single transaction - -Querying with ``FetchKeyRequest`` has the added benefit of being able to execute multiple queries in -a single database transaction. Right now each instance of `@SharedReader` in a feature executes -each of their queries in a separate transaction. So, if we wanted to query for all in stock -items, as well as the count of all items (in stock plus out of stock) like so: - -```swift -@SharedReader(.fetchOne(sql: "SELECT count(*) FROM items")) -var itemsCount = 0 - -@SharedReader(.fetchAll(sql: "SELECT * FROM items WHERE isInStock")) -var inStockItems: [Item] -``` - -…this is technically 2 queries run in 2 separate database transactions. - -Often this can be just fine, but if you have multiple queries that tend to change at the same -time (_e.g._, when items are created or deleted, `itemsCount` and `inStockItems` will change -at the same time), then you can use ``FetchKeyRequest`` to bundle these two queries into a single -transaction. - -To do this, define a ``FetchKeyRequest/Value`` type inside the conformance that represents all the -data you want to query for, and then construct it inside the ``FetchKeyRequest/fetch(_:)`` -method: - -```swift -struct Items: FetchKeyRequest { - struct Value { - var inStockItems: [Item] = [] - var itemsCount = 0 - } - func fetch(_ db: Database) throws -> Value { - try Value( - inStockItems: Item.all().filter(Column("isInStock")).fetchAll(db), - itemsCount: Item.fetchCount(db) - ) - } -} -``` - -This selects the in-stock items and the total count of items as two queries inside a single database -transaction. - -Then you can use this key just as you did before, but now you can access the `inStockItems` and -`itemsCount` properties to access the queried data: - -```swift -@SharedReader(.fetch(Items())) var items = Items.Value() -items.inStockItems // [Item(/* ... */), /* ... */] -items.itemsCount // 100 -``` - -> Note: A default must be provided to `@SharedReader` since it is querying for a custom data type -> instead of a collection of data. - -[query-interface]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/queryinterface diff --git a/Sources/SharingGRDB/Documentation.docc/Extensions/Fetch.md b/Sources/SharingGRDB/Documentation.docc/Extensions/Fetch.md deleted file mode 100644 index 75631699..00000000 --- a/Sources/SharingGRDB/Documentation.docc/Extensions/Fetch.md +++ /dev/null @@ -1,28 +0,0 @@ -# ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` - -## Overview - -## Topics - -### Custom queries - -- ``FetchKeyRequest`` - -### Collections - -- ``Sharing/SharedReaderKey/fetch(_:database:)-1ee8v`` - -### SwiftUI integration - -- ``Sharing/SharedReaderKey/fetch(_:database:animation:)-rgj4`` -- ``Sharing/SharedReaderKey/fetch(_:database:animation:)-j9jb`` - -### Custom scheduling - -- ``Sharing/SharedReaderKey/fetch(_:database:scheduler:)-9arcp`` -- ``Sharing/SharedReaderKey/fetch(_:database:scheduler:)-53u9o`` - -### Sharing infrastructure - -- ``FetchKey`` -- ``FetchKeyID`` diff --git a/Sources/SharingGRDB/Documentation.docc/Extensions/FetchAll.md b/Sources/SharingGRDB/Documentation.docc/Extensions/FetchAll.md deleted file mode 100644 index 93aa47da..00000000 --- a/Sources/SharingGRDB/Documentation.docc/Extensions/FetchAll.md +++ /dev/null @@ -1,13 +0,0 @@ -# ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` - -## Overview - -## Topics - -### SwiftUI integration - -- ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:animation:)`` - -### Custom scheduling - -- ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:scheduler:)`` diff --git a/Sources/SharingGRDB/Documentation.docc/Extensions/FetchOne.md b/Sources/SharingGRDB/Documentation.docc/Extensions/FetchOne.md deleted file mode 100644 index e20a0b6d..00000000 --- a/Sources/SharingGRDB/Documentation.docc/Extensions/FetchOne.md +++ /dev/null @@ -1,13 +0,0 @@ -# ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` - -## Overview - -## Topics - -### SwiftUI integration - -- ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:animation:)`` - -### Custom scheduling - -- ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:scheduler:)`` diff --git a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md b/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md index aaa717dc..b95003f3 100644 --- a/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md +++ b/Sources/SharingGRDB/Documentation.docc/SharingGRDB.md @@ -1,184 +1,22 @@ # ``SharingGRDB`` -## Overview - -SharingGRDB is lightweight replacement for SwiftData and the `@Query` macro that deploys all the way -back to the iOS 13 generation of targets. - -@Row { - @Column { - ```swift - // SharingGRDB - @SharedReader( - .fetchAll(sql: "SELECT * FROM items") - ) - var items: [Item] - ``` - } - @Column { - ```swift - // SwiftData - @Query - var items: [Item] - ``` - } -} - -Both of the above examples fetch items from an external data store, and both are automatically -observed by SwiftUI so that views are recomputed when the external data changes, but SharingGRDB is -powered directly by SQLite using [Sharing](#What-is-Sharing) and [GRDB](#What-is-GRDB), and is -usable from UIKit, `@Observable` models, and more. - -> Note: It is not required to write queries as a raw SQL string, and a query builder can be used -> instead. For more information on SharingGRDB's querying capabilities, see . - -## Quick start - -Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide---at -runtime---the default database it should use. This is typically done as early as possible in your -app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage -in SwiftData: - -@Row { - @Column { - ```swift - // SharingGRDB - @main - struct MyApp: App { - init() { - prepareDependencies { - // Create/migrate a database connection - let db = try! DatabaseQueue(/* ... */) - $0.defaultDatabase = db - } - } - // ... - } - ``` - } - @Column { - ```swift - // SwiftData - @main - struct MyApp: App { - let container = { - // Create/configure a container - try! ModelContainer(/* ... */) - }() - - var body: some Scene { - WindowGroup { - ContentView() - .modelContainer(container) - } - } - } - ``` - } -} - -> Note: For more information on preparing a SQLite database, see . - -This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like - [`fetchAll`](): - -```swift -@SharedReader(.fetchAll(sql: "SELECT * FROM items")) -var items: [Item] -``` - -And you can access this database throughout your application in a way similar to how one accesses -a model context, via a property wrapper: - -@Row { - @Column { - ```swift - // SharingGRDB - @Dependency(\.defaultDatabase) var database - - var newItem = Item(/* ... */) - try database.write { db in - try newItem.insert(db) - } - ``` - } - @Column { - ```swift - // SwiftData - @Environment(\.modelContext) var modelContext - - let newItem = Item(/* ... */) - modelContext.insert(newItem) - try modelContext.save() - ``` - } -} - -> Note: For more information on how SharingGRDB compares to SwiftData, see -> . +A fast, lightweight replacement for SwiftData, powered by SQL. -This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read -the [articles](#Essentials) below to learn how to best utilize this library. - -## SQLite knowledge required - -SQLite is one of the - [most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of -software in the history of software. Knowledge of SQLite is a great skill for any app developer to -have, and this library does not want to conceal it from you. So, we feel that to best wield this -library you should be familiar with the basics of SQLite, including schema design and normalization, -SQL queries, including joins and aggregates, and performance, including indices. - -With some basic knowledge you can apply this library to your database schema in order to query -for data and keep your views up-to-date when data in the database changes. You can use GRDB's -[query builder][query-interface] APIs to query your database, or you can use raw SQL queries, -along with all of the power that SQL has to offer. - -[query-interface]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/queryinterface - -## What is Sharing? - -[Sharing](https://github.com/pointfreeco/swift-sharing) is a universal and extensible solution for -sharing your app's model data across features and with external systems, such as user defaults, -the file system, and more. This library builds upon the tools from Sharing in order to allow for -the [fetching]() and [observing]() of data in a SQLite database. - -This is all you need to know about Sharing to hit the ground running with SharingGRDB, but it only -scratches the surface of what the library makes possible. It can also act as a replacement to -SwiftUI's `@AppStorage` that works with UIKit and `@Observable` models, and can be integrated -with custom persistence strategies. To learn more, check out -[the documentation](https://swiftpackageindex.com/pointfreeco/swift-sharing/main/documentation/sharing/). - -## What is GRDB? - -[GRDB](https://github.com/groue/GRDB.swift) is a popular Swift interface to SQLite with a rich -feature set and -[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). -This library leverages GRDB's' observation APIs to keep the `@SharedReader` property wrapper in -sync with the database and update SwiftUI views. - -If you're already familiar with SQLite, GRDB provides thin APIs that can be leveraged with raw SQL -in short order. If you're new to SQLite, GRDB offers a great introduction to a highly portable -database engine. We recommend -([as does GRDB](https://github.com/groue/GRDB.swift?tab=readme-ov-file#documentation)) a familiarity -with SQLite to take full advantage of GRDB and SharingGRDB. - -## Topics - -### Essentials +## Overview -- -- -- -- -- +The core functionality of this library is defined in +[`SharingGRDBCore`](sharinggrdbcore) and [`StructuredQueriesGRDBCore`](structuredquereisgrdbcore), +which this module automatically exports. -### Database configuration and access +> Note: This module also exports `StructuredQueries`, which provides the `@Table` macro for building +> and decoding queries. If you are using [GRDB][]'s built-in tools instead of +> [StructuredQueries][], consider depending on `SharingGRDBCore`, instead. -- ``Dependencies/DependencyValues/defaultDatabase`` +See [`SharingGRDBCore`](sharinggrdbcore) for documentation on the integration with the +`@FetchAll` property wrapper, which is equivalent to SwiftData's `@Query`. -### Fetch strategies +See [`StructuredQueriesGRDBCore`](sharinggrdbcore) for documentation on the integration between +[StructuredQueries][] and [GRDB][]. -- ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` -- ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` -- ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` +[GRDB]: https://github.com/groue/GRDB.swift +[StructuredQueries]: https://github.com/pointfreeco/swift-structured-queries diff --git a/Sources/SharingGRDB/Exports.swift b/Sources/SharingGRDB/Exports.swift new file mode 100644 index 00000000..07a5c659 --- /dev/null +++ b/Sources/SharingGRDB/Exports.swift @@ -0,0 +1,2 @@ +@_exported import SharingGRDBCore +@_exported import StructuredQueriesGRDB diff --git a/Sources/SharingGRDB/FetchKeyRequest.swift b/Sources/SharingGRDB/FetchKeyRequest.swift deleted file mode 100644 index 14d9d002..00000000 --- a/Sources/SharingGRDB/FetchKeyRequest.swift +++ /dev/null @@ -1,37 +0,0 @@ -import GRDB - -/// A type that can request a value from a database. -/// -/// This type can be used to describe a query to read data from SQLite: -/// -/// ```swift -/// struct Players: FetchKeyRequest { -/// func fetch(_ db: Database) throws -> [Player] { -/// try Player.all() -/// .filter(Column("isInjured") == false) -/// .order(Column("name")) -/// .limit(10) -/// .fetchAll(db) -/// } -/// } -/// ``` -/// -/// And then can be used with `@SharedReader` and -/// ``Sharing/SharedReaderKey/fetch(_:database:animation:)-rgj4`` to popular state with the query -/// in a SwiftUI view, `@Observable` model, UIKit controller, and more: -/// -/// ```swift -/// struct PlayersView: View { -/// @SharedReader(.fetch(Players())) var players -/// -/// var body: some View { -/// ForEach(players) { player in -/// // ... -/// } -/// } -/// } -/// ``` -public protocol FetchKeyRequest: Hashable, Sendable { - associatedtype Value - func fetch(_ db: Database) throws -> Value -} diff --git a/Sources/SharingGRDB/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md similarity index 75% rename from Sources/SharingGRDB/Documentation.docc/Articles/ComparisonWithSwiftData.md rename to Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md index ca19f816..1d4b53f2 100644 --- a/Sources/SharingGRDB/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -10,6 +10,7 @@ SwiftUI views (including UIKit, `@Observable` models, _etc._). This article desc approaches compare in a variety of situations, such as setting up the data store, fetching data, associations, and more. + * [Defining your schema](#Defining-your-schema) * [Setting up external storage](#Setting-up-external-storage) * [Fetching data for a view](#Fetching-data-for-a-view) * [Fetching data for an @Observable model](#Fetching-data-for-an-Observable-model) @@ -21,12 +22,68 @@ associations, and more. * [Manual migrations](#Manual-migrations) * [Supported Apple platforms](#Supported-Apple-platforms) +### Defining your schema + +Both SharingGRDB and SwiftData come with tools to expose your data types' fields to the compiler +so that type-safe and schema-safe queries can be written. SharingGRDB uses another library of ours +to provide these tools, called [StructuredQueries][sq-gh], and its `@Table` macro works similarly +to SwiftData's `@Model` macro: + +[sq-gh]: http://github.com/pointfreeco/swift-structured-queries + +@Row { + @Column { + ```swift + // SharingGRDB + @Table + struct Item { + let id: Int + var title = "" + var isInStock = true + var notes = "" + } + ``` + } + @Column { + ```swift + // SwiftData + @Model + class Item { + var title: String + var isInStock: Bool + var notes: String + init( + title: String = "", + isInStock: Bool = true, + notes: String = "" + ) { + self.title = title + self.isInStock = isInStock + self.notes = notes + } + } + ``` + } +} + +Some key differences: + + * The `@Table` macro works with struct data types, whereas `@Model` only works with classes. + * Because the `@Model` version of `Item` is a class it is necessary to provide an initializer. + * The `@Model` version of `Item` does not need an `id` field because SwiftData provides a + `persistentIdentifier` to each model. + +See the [documentation][sq-defining-schema] from StructuredQueries for more information on how +to define your schema. + +[sq-defining-schema]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/definingyourschema + ### Setting up external storage Both SharingGRDB and SwiftData require some work to be done at the entry point of the app in order to set up the external storage system that will be used throughout the app. In SharingGRDB we use -the `prepareDependencies` function to set up the ``Dependencies/DependencyValues/defaultDatabase`` -used, and in SwiftUI you construct a `ModelContainer` and propagate it through the environment: +the `prepareDependencies` function to set up the default database used, and in SwiftUI you construct +a `ModelContainer` and propagate it through the environment: @Row { @Column { @@ -71,7 +128,7 @@ configure your SQLite database for use with SharingGRDB. ### Fetching data for a view -To fetch data from a SQLite database you use the `@SharedReader` property wrapper in SharingGRDB, +To fetch data from a SQLite database you use the `@FetchAll` property wrapper in SharingGRDB, whereas you use the `@Query` macro with SwiftData: @Row { @@ -79,8 +136,8 @@ whereas you use the `@Query` macro with SwiftData: ```swift // SharingGRDB struct ItemsView: View { - @SharedReader(.fetchAll(sql: "SELECT * FROM items")) - var items: [Item] + @FetchAll(Item.order(by: \.title)) + var items var body: some View { ForEach(items) { item in @@ -94,7 +151,7 @@ whereas you use the `@Query` macro with SwiftData: ```swift // SwiftData struct ItemsView: View { - @Query + @Query(sort: \Item.title) var items: [Item] var body: some View { @@ -107,17 +164,30 @@ whereas you use the `@Query` macro with SwiftData: } } -The `@SharedReader` property wrapper takes a variety of options, detailed more in , -and allows you to write raw SQL queries for fetching and aggregating data from your database. It -is also possibly to construct SQL queries using GRDB's query builder syntax. See -[`fetch`]() for more information. +The `@FetchAll` property wrapper takes a variety of options and allows you to write queries using a +type-safe and schema-safe builder syntax, or you can write safe SQL strings that are schema-safe and +protect you from SQL injection. + +The library also ships a few other property wrappres that have no equivalent in SwiftData. For +example, the [`@FetchOne`]() property wrapper allows you to query for just a single +value, which can be useful for computing aggegrate data: + +```swift +@FetchOne(Item.where(\.isInStock).count()) +var inStockItemsCount = 0 +``` + +And the [`@Fetch`]() property wrapper allows you to execute multiple queries in a single +database transaction to gather you data into a single data type. SwiftData has no equivalent for +either of these operations. See for more detailed information on how to fetch +data from your database using the tools of this library. ### Fetching data for an @Observable model There are many reasons one may want to move logic out of the view and into an `@Observable` model, such as allowing to unit test your feature's logic, and making it possible to deep link in your -app. The `@SharedReader` and [data fetching tools]() work just as well in an -`@Observable` model as they do in a SwiftUI view. The state held in the property wrapper +app. The `@FetchAll` property warpper, and other [data fetching tools]() work just as +well in an `@Observable` model as they do in a SwiftUI view. The state held in the property wrapper automatically updates when changes are made to the database. The `@Query` macro, on the other hand, only works in SwiftUI views. This means if you want to move @@ -131,7 +201,7 @@ its functionality from scratch: @Observable class FeatureModel { @ObservationIgnored - @SharedReader(.fetchAll(sql: "SELECT * FROM items")) + @FetchAll(Item.order(by: \.title)) var items // ... } ``` @@ -176,8 +246,8 @@ its functionality from scratch: } } -> Note: It is necessary to annotate `@SharedReader` with `@ObservationIgnored` when using the -> `@Observable` macro due to how macros interact with property wrappers. However, `@SharedReader` +> Note: It is necessary to annotate `@FetchAll` with `@ObservationIgnored` when using the +> `@Observable` macro due to how macros interact with property wrappers. However, `@FetchAll` > handles its own observation, and so state will still be observed when accessed in a view. ### Dynamic queries @@ -192,24 +262,24 @@ search for rows in a table: // SharingGRDB struct ItemsView: View { @State var searchText = "" - @SharedReader var items: [Item] + @FetchAll var items: [Item] var body: some View { ForEach(items) { item in Text(item.name) } .searchable(text: $searchText) - .onChange(of: searchText) { - updateSearchQuery() + .task(id: searchText) { + await updateSearchQuery() } } func updateSearchQuery() { - $items = SharedReader( - wrappedValue: items, + await $items.load( .fetchAll( - sql: "SELECT * FROM items WHERE title LIKE ?", - arguments: ["%\(searchText)%"] + Item.where { + $0.title.contains(searchText) + } ) ) } @@ -259,21 +329,19 @@ because `@Query` state is not mutable after it is initialized. The only way to c state is if the view holding it is reinitialized, which requires a parent view to recreate the child view. -On the other hand, the same UI made with `@SharedReader` can all happen in a single view. We can +On the other hand, the same UI made with `@FetchAll` can all happen in a single view. We can hold onto the `searchText` state that the user edits, use the `searchable` view modifier for the -UI, and update the `@SharedReader` query when the `searchText` state changes. +UI, and update the `@FetchAll` query when the `searchText` state changes. See for more information on how to execute dynamic queries in the library. ### Creating, update and delete data -To create, update and delete data from the database you must use the -``Dependencies/DependencyValues/defaultDatabase`` dependency. This is similar to what one does -with SwiftData too, where all changes to the database go through the `ModelContext` and is not -done through the `@Query` macro at all. +To create, update and delete data from the database you must use the `defaultDatabase` dependency. +This is similar to what one does with SwiftData too, where all changes to the database go through +the `ModelContext` and is not done through the `@Query` macro at all. -For example, to get access to the ``Dependencies/DependencyValues/defaultDatabase``, you use the -`@Dependency` property wrapper: +For example, to get access to `defaultDatabase`, you use the `@Dependency` property wrapper: @Row { @Column { @@ -290,7 +358,7 @@ For example, to get access to the ``Dependencies/DependencyValues/defaultDatabas } } -Then, to create a new row in a table you use the `write` and `insert` methods from GRDB: +Then, to create a new row in a table you use the `write` and `insert` methods from SharingGRDB: @Row { @Column { @@ -298,9 +366,9 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr // SharingGRDB @Dependency(\.defaultDatabase) var database - var newItem = Item(/* ... */) try database.write { db in - try newItem.insert(db) + try Item.insert(Item(/* ... */)) + .execute(db) } ``` } @@ -316,7 +384,7 @@ Then, to create a new row in a table you use the `write` and `insert` methods fr } } -To update an existing row you can use the `write` and `update` methods from GRDB: +To update an existing row you can use the `write` and `update` methods from SharingGRDB: @Row { @Column { @@ -326,7 +394,7 @@ To update an existing row you can use the `write` and `update` methods from GRDB existingItem.title = "Computer" try database.write { db in - try existingItem.update(db) + try Item.update(existingItem).execute(db) } ``` } @@ -341,7 +409,7 @@ To update an existing row you can use the `write` and `update` methods from GRDB } } -And to delete an existing row, you can use the `write` and `delete` methods from GRDB: +And to delete an existing row, you can use the `write` and `delete` methods from SharingGRDB: @Row { @Column { @@ -350,7 +418,7 @@ And to delete an existing row, you can use the `write` and `delete` methods from @Dependency(\.defaultDatabase) var database try database.write { db in - try existingItem.delete(db) + try Item.delete(existingItem).execute(db) } ``` } @@ -367,8 +435,8 @@ And to delete an existing row, you can use the `write` and `delete` methods from ### Associations -The biggest difference between SwiftData and GRDB is that SwiftData provides tools for an -Object Relational Mapping (ORM), whereas GRDB is largely just a nice API for interacting with SQLite +The biggest difference between SwiftData and SharingGRDB is that SwiftData provides tools for an +Object Relational Mapping (ORM), whereas SharingGRDB is largely just a nice API for interacting with SQLite directly. For example, SwiftData allows you to model a `Sport` type that belongs to many `Team`s like @@ -398,60 +466,40 @@ for sport in sports { This is powerful, but it can also lead to a number of problems in apps. First, the only way for this mechanism to work is for `Team` and `Sport` to be classes, and the `@Model` macro enforces that. Second, because the SQLite execution is so abstracted from us, it makes it easy to execute many, -_many_ queries, leading to inefficient code. In this case, we are executing a whole new SQL query -for each sport in order to get their teams. And on top of that, we are loading every team into -memory just to get the number of teams. We don't actually need any data from the team. +_many_ queries, leading to inefficient code. In this case, we are first executing a query to +get all sports, and then executing a query for each sport to get the number of teams in each +sport. And on top of that, we are loading every team into memory just to compute the number of +teams. We don't actually need any data from the team, only their aggregate count. -GRDB does not provide these kinds of tools, and for good reason. Instead, if you know you want to -fetch all of the teams with their corresponding sport, you can simply perform a single query that -joins the two tables together: +SharingGRDB does not provide these kinds of tools, and for good reason. Instead, if you know you +want to fetch all of the teams with their corresponding sport, you can simply perform a single +query that joins the two tables together: ```swift -struct SportWithTeamCount: Decodable, FetchableRecord { +@Selection +struct SportWithTeamCount { let sport: Sport let teamCount: Int } -let sportsWithTeamCounts = try sportsWithTeamCounts.fetchAll( - db, - Sport.annotated( - with: Sport.hasMany(Team.self).count - ) +@FetchAll( + Sport + .group(by: \.id) + .leftJoin(Team.all) { $0.id.eq($1.sportID) } + .select { + SportWithTeamCount.Columns(sport: $0, teamCount: $1.count()) + } ) -``` - -This fetches all of the sports with the number of teams in each sport, all in a single query. And -most importantly, it does not load all of the team data into memory just to compute their count. - -One can package this query up into a dedicated ``FetchKeyRequest`` like so: - -```swift -struct SportsWithTeamCounts: FetchKeyRequest { - struct Record { - let sport: Sport - let teamCount: Int - } - func fetch(_ db: Database) throws -> [Record] { - try sportsWithTeamCounts.fetchAll( - db, - Sport.annotated( - with: Sport.hasMany(Team.self).count - ) - ) - } -} -``` - -And then use this with `@SharedReader` in order to model state that is a collection of all sports -along with their corresponding team count: - -```swift -@SharedReader(.fetch(SportsWithTeamCounts())) var sportsWithTeamCounts +var sportsWithTeamCounts ``` If either of the "sports" or "teams" tables change, this query will be executed again and the state will update to the freshest values. +This style of handling associations does require you to be knowledgable in SQL to wield it +correctly, but that is a benefit! SQL (and SQLite) are some of the most proven pieces of +technologies in the history of computers, and knowing how to wield their powers is a huge benefit. + ### Migrations [grdb-migration-docs]: https://swiftpackageindex.com/groue/grdb.swift/master/documentation/grdb/migrations @@ -473,18 +521,24 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a @Column { ```swift // SharingGRDB + @Table struct Item { - var id: Int64 + let id: Int var title = "" var isInStock = true } migrator.registerMigration("Create 'items' table") { db in - db.createTable("items") { table in - table.autoIncrementedPrimaryKey("id") - table.column("title", .text).notNull() - table.column("isInStock", .boolean).notNull().defaults(to: true) - } + try #sql( + """ + CREATE TABLE "items" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "isInStock" INTEGER NOT NULL DEFAULT 1 + ) + """ + ) + .execute(db) } ``` } @@ -509,18 +563,22 @@ adding a `description` field to the `Item` type: @Row { @Column { ```swift - // SharingGRDB + @Table struct Item { - var id: Int64 + let id: Int var title = "" var description = "" var isInStock = true } migrator.registerMigration("Add 'description' column to 'items'") { db in - db.alterTable("items") { table in - table.add(column: "description", .text) - } + try #sql( + """ + ALTER TABLE "items" + ADD COLUMN "description" TEXT + """ + ) + .execute(db) } ``` } @@ -585,16 +643,23 @@ structure of your data types. The overall steps to follow are as such: // SharingGRDB migrator.registerMigration("Make 'title' unique") { db in // 1️⃣ Delete all items that have duplicate title, keeping the first created one: - try db.execute(""" - DELETE FROM "items" - WHERE rowid NOT IN ( - SELECT min(rowid) - FROM "items" - GROUP BY "items"."title" - ) - """) + try Item + .where { + !$0.id.in( + Item + .select { $0.id.min() } + .group(by: \.title) + ) + } + .execute() // 2️⃣ Create unique index - try db.create(indexOn: "items", columns: ["title"], options: .unique) + try #sql( + """ + CREATE UNIQUE INDEX + "items_title" ON "items" ("title") + """ + ) + .execute(db) } ``` } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md new file mode 100644 index 00000000..b2eb37c8 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/Deprecations.md @@ -0,0 +1,14 @@ +# Deprecations + +Review unsupported APIs and their replacements. + +## Overview + +Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use +instead. + +## Topics + +### Sharing extensions + +- ``Sharing`` diff --git a/Sources/SharingGRDB/Documentation.docc/Articles/DynamicQueries.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md similarity index 74% rename from Sources/SharingGRDB/Documentation.docc/Articles/DynamicQueries.md rename to Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md index 57b97ddf..11bf5760 100644 --- a/Sources/SharingGRDB/Documentation.docc/Articles/DynamicQueries.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/DynamicQueries.md @@ -17,7 +17,7 @@ Take the following example: ```swift struct ContentView: View { - @SharedReader(.fetchAll("SELECT * FROM items")) var items: [Item] + @FetchAll var items: [Item] @State var filterDate: Date? @State var order: SortOrder = .reverse @@ -64,35 +64,33 @@ struct ContentView: View { private func updateQuery() async { do { - try await $items.load(.fetch(Items(filterDate: filterDate, order: order))) + try await $items.load( + .fetchAll( + Items + .where { $0.timestamp > #bind(filterDate ?? .distantPast) } + .order { + if order == .forward { + $0.timestamp + } else { + $0.timestamp.desc() + } + } + .limit(10) + ) + ) } catch { // Handle error... } } - private struct Items: FetchKeyRequest { - var filterDate: Date? - var order: SortOrder = .reverse - func fetch(_ db: Database) throws -> [Item] { - try Item.all() - .filter(Column("timestamp") > filterDate ?? .distantPast) - .sort( - order == .forward - ? Column("timestamp") - : Column("timestamp").desc - ) - .limit(10) - } - } - // ... } ``` > Important: If a parent view refreshes, a dynamically-updated query can be overwritten with the -> initial `@SharedReader`'s value, taken from the parent. To manage the state of this dynamic query -> locally to this view, we use `@State.SharedReader`, instead, which wraps a `@SharedReader` in -> SwiftUI `@State`. +> initial `@FetchAll`'s value, taken from the parent. To manage the state of this dynamic query +> locally to this view, we use `@State @FetchAll`, instead, and to access the underlying +> `FetchAll` value you can use `wrappedValue`. -> Note: We are using the ``Sharing/SharedReaderKey/fetch(_:database:animation:)-rgj4`` style of +> Note: We are using the ``Sharing/SharedReaderKey/fetchAll(_:database:)`` style of > querying the database. See for more APIs that can be used. diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md new file mode 100644 index 00000000..04e6a932 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/Fetching.md @@ -0,0 +1,254 @@ +# Fetching model data + +Learn about the various tools for fetching data from a SQLite database. + +## Overview + +All data fetching happens by using the `@FetchAll`, `@FetchOne`, or `@Fetch` property wrappers. +The primary difference between these choices is whether if you want to fetch a collection of +rows, or fetch a single row (_e.g._, an aggregate computation), or if you want to execute multiple +queries in a single transaction. + + * [`@FetchAll`](#FetchAll) + * [`@FetchOne`](#FetchOne) + * [`@Fetch`](#Fetch) + +### @FetchAll + +The [`@FetchAll`]() property wrapper allows you to fetch a collection of results from +your database using a SQL query. The query is created using our +[StructuredQueries][structured-queries-gh] library, which can build type-safe queries that safely +and performantly decode into Swift data types. + +To get access to these tools you must apply the `@Table` macro to your data type that represents +your table: + +```swift +@Table +struct Reminder { + let id: Int + var title = "" + @Column(as: Date.ISO8601Representation?.self) + var dueAt: Date? + var isCompleted = false +} +``` + +> Note: The `@Column` macro determines how to store the date in SQLite, which does not have a native +> date data type. The `Date.ISO8601Representation` strategy stores dates as text formatted with the +> ISO-8601 standard. See [Defining your schema] for more info. + +[Defining your schema]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/definingyourschema + +With that done you can already fetch all records from the `Reminder` table in their default order by +simply doing: + +```swift +@FetchAll var reminders: [Reminder] +``` + +If you want to execute a more complex query, such as one that sorts the results by the reminder's +title, then you can use the various query building APIs on `Reminder`: + +```swift +@FetchAll(Reminder.order(by: \.title)) +var reminders +``` + +Or if you want to only select the completed reminders, sorted by their titles in a descending +fashion: + +```swift +@FetchAll( + Reminder.where(\.isCompleted).order { $0.title.desc() } +) +var completedReminders +``` + +This is only the basics of what you can do with the query building tools of this library. To +learn more, be sure to check out the [documentation][sq-getting-started] of StructuredQueries. + +[sq-docs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore + +You can even execute a SQL string to populate the data in your features: + +```swift +@FetchAll( + #sql( + "SELECT * FROM reminders where isCompleted ORDER BY title DESC", + as: Reminder.self + ) +) +var completedReminders +``` + +This uses the `#sql` macro for constructing [safe SQL strings][sq-safe-sql-strings]. You are +automatically protected from SQL injection attacks, and it is even possible to use the static +description of your schema to prevent accidental typos: + +```swift +@FetchAll( + #sql( + """ + SELECT \(Reminder.columns) + FROM \(Reminder.self) + WHERE \(Reminder.isCompleted) + ORDER BY \(Reminder.title) DESC + """, + as: Reminder.self + ) +) +var completedReminders +``` + +These interpolations are completely safe to do because they are statically known at compile time, +and it will minimize your risk for typos. Be sure to read the [documentation][sq-safe-sql-strings] +of StructuredQueries to see more of what `#sql` is capable of. + +It is also possible to join tables together and query for multiple pieces of data at once. For +example, suppose we have another table for lists of reminders, and each reminder belongs to +exactly one list: + +```swift +@Table +struct Reminder { + let id: Int + var title = "" + @Column(as: Date.ISO8601Representation?.self) + var dueAt: Date? + var isCompleted = false + var remindersListID: RemindersList.ID +} +@Table +struct RemindersList: Identifiable { + let id: Int + var title = "" +} +``` + +And further suppose we have a feature that wants to load the title of every reminder, along with +the title of its associated list. Rather than loading all columns of all rows of both tables, which +is inefficient, we can select just the data we need. First we define a data type to hold just that +data, and decorate it with the `@Selection` macro: + +```swift +@Selection +struct Record { + let reminderTitle: String + let remindersListTitle: String +} +``` + +And then we construct a query that joins the `Reminder` table to the `RemindersList` table and +selects the titles from each table: + +```swift +@FetchAll( + Reminder + .join(RemindersList.all) { $0.remindersListID.eq($1.id) } + .select { + Record.Columns( + reminderTitle: $0.title, + remindersListTitle: $1.title + ) + } +) +var records +``` + +This is a very efficient query that selects only the bare essentials of data that the feature +needs to do its job. This kind of query is a lot more cumbersome to perform in SwiftData because +you must construct a dedicated `FetchDescriptor` value and set its `propertiesToFetch`. + +[sq-safe-sql-strings]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/safesqlstrings +[structured-queries-gh]: https://github.com/pointfreeco/swift-structured-queries +[structured-queries-docs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/ + +### @FetchOne + +The [`@FetchOne`]() property wrapper works similarly to `@FetchAll`, but fetches +only a single record from the database and you must provide a default for when no record is found. +This tool can be handy for computing aggregate data, such as the number of reminders in the +database: + +```swift +@FetchOne(Reminder.count()) +var remindersCount = 0 +``` + +You can perform any query you want in `@FetchOne`, including "where" clauses: + +```swift +@FetchOne(Reminder.where(\.isCompleted).count()) +var completedRemindersCount = 0 +``` + +You can use the `#sql` macro with `@FetchOne` to execute a safe SQL string: + +```swift +@FetchOne(#sql("SELECT count(*) FROM reminders WHERE isCompleted", as: Int.self)) +var completedRemindersCount = 0 +``` + +### @Fetch + +It is also possible to execute multiple database queries to fetch data for your features. This can +be useful for performing several queries in a single database transaction: + +Each instance of `@FetchAll` in a feature executes their queries in a separate transaction. So, if +we wanted to query for all completed reminders, along with a total count of reminders (completed and +uncompleted), we could do so like this: + +```swift +@FetchOne(Reminder.count()) +var remindersCount = 0 + +@FetchAll(Reminder.where(\.isCompleted))) +var completedReminders +``` + +…this is technically 2 queries run in 2 separate database transactions. + +Often this can be just fine, but if you have multiple queries that tend to change at the same time +(_e.g._, when reminders are created or deleted, `remindersCount` and `completedReminders` will +change at the same time), then you can bundle these two queries into a single transaction. + +To do this, one simply defines a conformance to our ``FetchKeyRequest`` protocol, and in that +conformance one can use the builder tools to query the database: + +```swift +struct Reminders: FetchKeyRequest { + struct Value { + var completedReminders: [Reminder] = [] + var remindersCount = 0 + } + func fetch(_ db: Database) throws -> Value { + try Value( + completedReminders: Reminder.where(\.isCompleted).fetchAll(db), + remindersCount: Reminder.fetchCount(db) + ) + } +} +``` + +Here we have defined a ``FetchKeyRequest/Value`` type inside the conformance that represents all the +data we want to query for in a single transaction, and then we can construct it and return it from +the ``FetchKeyRequest/fetch(_:)`` method. + +With this conformance defined we can use the +[`@Fetch`]() property wrapper to execute the query specified by +the `Reminders` type, and we can access the `completedReminders` and `remindersCount` properties +to get to the queried data: + +```swift +@Fetch(Reminders()) var reminders = Reminders.Value() +reminders.completedReminders // [Reminder(/* ... */), /* ... */] +reminders.remindersCount // 100 +``` + +> Note: A default must be provided to `@Fetch` since it is querying for a custom data type +> instead of a collection of data. + +Typically the conformances to ``FetchKeyRequest`` can even be made private and nested inside +whatever type they are used in, such as SwiftUI view, `@Observable` model, or UIKit view controller. +The only time it needs to be made public is if it's shared amongst many features. diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md new file mode 100644 index 00000000..38e793af --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides.md @@ -0,0 +1,17 @@ +# Migration guides + +Learn how to upgrade your application to the newest version of SharingGRDB. + +## Overview + +SharingGRDB is under constant development, and we are always looking for ways to +simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs +in favor of newer ones. We recommend people update their code as quickly as possible to the newest +APIs, and these guides contain tips to do so. + +> Important: Before following any particular migration guide be sure you have followed all the +> preceding migration guides. + +## Topics + +- diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md new file mode 100644 index 00000000..e300ffcd --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/MigrationGuides/MigratingTo0.2.md @@ -0,0 +1,70 @@ +# Migrating to 0.2 + +Update your code to make use of powerful new querying capabilities. + +## Overview + +SharingGRDB is under constant development, and we are always looking for ways to +simplify the library, and make it more powerful. As such, we often need to deprecate certain APIs +in favor of newer ones. We recommend people update their code as quickly as possible to the newest +APIs, and these guides contain tips to do so. + +* [@FetchAll, @FetchOne, @Fetch](#) +* [fetchAll, fetchOne, fetch: soft-deprecated](#) +* [Avoiding the cost of macros](#) + +## @FetchAll, @FetchOne, @Fetch + +SharingGRDB 0.2.0 comes with 3 brand new property wrappers that largely replace the need for +SwiftData and its `@Query` macro. In 0.1.0, one would perform queries as either a hard coded SQL +string: + +```swift +@SharedReader(.fetchAll("SELECT * FROM reminders WHERE isCompleted ORDER BY title")) +var completedReminders: [Reminder] +``` + +Or by defining a ``FetchKeyRequest`` conformance to perform a query using GRDB's query builder: + +```swift +struct CompletedReminders: FetchKeyRequest { + func fetch(_ db: Database) throws -> [Reminder] { + Reminder.all() + .where(Column("isCompleted")) + .order(Column("title")) + } +} + +@SharedReader(.fetch(CompletedReminders())) +var completedReminders +``` + +Each of these are cumbersome, and version 0.2.0 of SharingGRDB fixes it thanks to our newly +released [StructuredQueries][] library. You can now describe the query for your data in a type-safe +manner, and directly inline: + +```swift +@FetchAll(Reminde.where(\.isCompleted).order(by: \.title)) +var completedReminders: [Reminder] +``` + +Read for more information on how to use these new property wrappers. + +[StructuredQueries]: http://github.com/pointfreeco/swift-structured-queries + +## fetchAll, fetchOne, fetch: soft-deprecated + +The [`.fetchAll`](), +[`.fetchOne`](), +and [`.fetch`]() APIs have been soft-deprecated +in favor of the more modern tools described above and in . They will be hard +deprecated in a future release of SharingGRDB, and removed in 1.0. + +## Avoiding the cost of macros + +SharingGRDB introduces a macro in version 0.2.0 (in particular, the `@Table` macro), and +unfortunately macros currently come with an unfortunate cost in that you have to compile SwiftSyntax +from scratch, which can take time. If the cost of macros is too high for you, then you can depend +on the SharingGRDBCore module instead of the full SharingGRDB module. This will give you access to +only a subset of tools provided by SharingGRDB, but you will have access to all tools that were +available in version 0.1.0 of the library. diff --git a/Sources/SharingGRDB/Documentation.docc/Articles/Observing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md similarity index 68% rename from Sources/SharingGRDB/Documentation.docc/Articles/Observing.md rename to Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md index 31e572d6..9f13a208 100644 --- a/Sources/SharingGRDB/Documentation.docc/Articles/Observing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/Observing.md @@ -10,13 +10,14 @@ macro from SwiftData. ### SwiftUI -The `@SharedReader` property wrapper works in SwiftUI views similarly to how the `@Query` macro does -from SwiftData. You simply add a property to the view that is annotated with `@SharedReader` and -choose one of the various ways for [querying your database](): +The `@FetchAll`, `@FetchOne`, and `@Fetch` property wrappers work in SwiftUI views similarly to how +the `@Query` macro does from SwiftData. You simply add a property to the view that is annotated with +one of the various ways of [querying your database](): ```swift struct ItemsView: View { - @SharedReader(.fetchAll(sql: "SELECT * FROM items")) var items: [Item] + @FetchAll var items: [Item] + var body: some View { ForEach(items) { item in Text(item.name) @@ -30,27 +31,29 @@ queried data to update. ### @Observable models -The `@SharedReader` property also works in `@Observable` models (and `ObservableObject`s for pre-iOS -17 apps). You can add a property to an `@Observable` class, and its data will automatically update -when the database changes and cause any SwiftUI view using it to re-render: +SharedGRDB's property wrappers also works in `@Observable` models (and `ObservableObject`s for +pre-iOS 17 apps). You can add a property to an `@Observable` class, and its data will automatically +update when the database changes and cause any SwiftUI view using it to re-render: ```swift @Observable class ItemsModel { @ObservationIgnored - @SharedReader(.fetchAll(sql: "SELECT * FROM items")) var items: [Item] + @FetchAll var items: [Item] } struct ItemsView: View { + let model: ItemsModel + var body: some View { - ForEach(items) { item in + ForEach(model.items) { item in Text(item.name) } } } ``` -> Note: Due to how macros work in Swift, `@SharedReader` must be annotated with -> `@ObservationIgnored`, but that does not affect observation as `@SharedReader` handles its own +> Note: Due to how macros work in Swift, property wrappers must be annotated with +> `@ObservationIgnored`, but this does not affect observation as SharingGRDB handles its own > observation. ### UIKit @@ -61,7 +64,7 @@ then you can do roughly the following: ```swift class ItemsViewController: UICollectionViewController { - @SharedReader(.fetchAll("SELECT * FROM items")) var items: [Item] + @FetchAll var items: [Item] override func viewDidLoad() { // Set up data source and cell registration... @@ -79,8 +82,8 @@ class ItemsViewController: UICollectionViewController { } ``` -This uses the `publisher` property that is available on every `SharedReader` value to update the -collection view's data source whenever the `items` change. +This uses the `publisher` property that is available on every fetched value to update the collection +view's data source whenever the `items` change. > Tip: There is an alternative way to observe changes to `items`. If you are already depending on > our [Swift Navigation][swift-nav-gh] library to make use of powerful navigation APIs for SwiftUI diff --git a/Sources/SharingGRDB/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md similarity index 95% rename from Sources/SharingGRDB/Documentation.docc/Articles/PreparingDatabase.md rename to Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md index bf463e8d..44a0994a 100644 --- a/Sources/SharingGRDB/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md @@ -225,8 +225,8 @@ func appDatabase() throws -> any DatabaseWriter { ### Step 5: Set database connection in entry point Once you have defined your `appDatabase` helper for creating a database connection, you must set -it as the ``Dependencies/DependencyValues/defaultDatabase`` for your app in its entry point. This -can be in done in SwiftUI by using `prepareDependencies` in the `init` of your `App` conformance: +it as the `defaultDatabase` for your app in its entry point. This can be in done in SwiftUI by using +`prepareDependencies` in the `init` of your `App` conformance: ```swift import SharingGRDB @@ -246,8 +246,8 @@ struct MyApp: App { > Important: You can only prepare the default database a single time in the lifetime of your > application. It is best to do this as early as possible after the app launches. -If using app or scene delegates, then you can prepare the -``Dependencies/DependencyValues/defaultDatabase`` in one of those conformances: +If using app or scene delegates, then you can prepare the `defaultDatabase` in one of those +conformances: ```swift import SharingGRDB @@ -263,9 +263,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { } ``` -And if using something besides UIKit or SwiftUI, then simply set the -``Dependencies/DependencyValues/defaultDatabase`` as early as possible in the application's -lifecycle. +And if using something besides UIKit or SwiftUI, then simply set the `defaultDatabase` as early as +possible in the application's lifecycle. It is also important to prepare the database in Xcode previews. This can be done like so: diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md new file mode 100644 index 00000000..d6a51b81 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Extensions/Fetch.md @@ -0,0 +1,42 @@ +# ``SharingGRDBCore/Fetch`` + +## Overview + +## Topics + +### Fetching data + +- ``FetchKeyRequest`` +- ``init(wrappedValue:_:database:)`` +- ``init(_:database:)`` +- ``init(database:)`` +- ``init(wrappedValue:)`` +- ``load(_:database:)`` + +### Accessing state + +- ``wrappedValue`` +- ``projectedValue`` +- ``isLoading`` +- ``loadError`` + +### SwiftUI integration + +- ``init(wrappedValue:_:database:animation:)`` +- ``load(_:database:animation:)`` + +### Combine integration + +- ``publisher`` + +### Custom scheduling + +- ``init(wrappedValue:_:database:scheduler:)`` +- ``load(_:database:scheduler:)`` + +### Sharing infrastructure + +- ``sharedReader`` +- ``subscript(dynamicMember:)`` +- ``FetchKey`` +- ``FetchKeyID`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md new file mode 100644 index 00000000..457b6e99 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchAll.md @@ -0,0 +1,39 @@ +# ``SharingGRDBCore/FetchAll`` + +## Overview + +## Topics + +### Fetching data + +- ``init(wrappedValue:database:)`` +- ``init(wrappedValue:_:database:)`` +- ``load(_:database:)`` + +### Accessing state + +- ``wrappedValue`` +- ``projectedValue`` +- ``isLoading`` +- ``loadError`` + +### SwiftUI integration + +- ``init(wrappedValue:database:animation:)`` +- ``init(wrappedValue:_:database:animation:)`` +- ``load(_:database:animation:)`` + +### Combine integration + +- ``publisher`` + +### Custom scheduling + +- ``init(wrappedValue:database:scheduler:)`` +- ``init(wrappedValue:_:database:scheduler:)`` +- ``load(_:database:scheduler:)`` + +### Sharing infrastructure + +- ``sharedReader`` +- ``subscript(dynamicMember:)`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md new file mode 100644 index 00000000..49ed8509 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKey.md @@ -0,0 +1,8 @@ +# ``SharingGRDBCore/FetchKey`` + +## Topics + +### Key identity + +- ``FetchKeyID`` +- ``ID-swift.typealias`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md new file mode 100644 index 00000000..501238a2 --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchKeyRequest.md @@ -0,0 +1,11 @@ +# ``SharingGRDBCore/FetchKeyRequest`` + +## Topics + +### Fetch keys + +- ``FetchKey`` + +### Error handling + +- ``NotFound`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md new file mode 100644 index 00000000..d001456e --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/Extensions/FetchOne.md @@ -0,0 +1,40 @@ +# ``SharingGRDBCore/FetchOne`` + +## Overview + +## Topics + +### Fetching data + +- ``init(wrappedValue:database:)`` +- ``init(database:)`` +- ``init(wrappedValue:_:database:)`` +- ``load(_:database:)`` + +### Accessing state + +- ``wrappedValue`` +- ``projectedValue`` +- ``isLoading`` +- ``loadError`` + +### SwiftUI integration + +- ``init(wrappedValue:database:animation:)`` +- ``init(wrappedValue:_:database:animation:)`` +- ``load(_:database:animation:)`` + +### Combine integration + +- ``publisher`` + +### Custom scheduling + +- ``init(wrappedValue:database:scheduler:)`` +- ``init(wrappedValue:_:database:scheduler:)`` +- ``load(_:database:scheduler:)`` + +### Sharing infrastructure + +- ``sharedReader`` +- ``subscript(dynamicMember:)`` diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md new file mode 100644 index 00000000..289909be --- /dev/null +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -0,0 +1,245 @@ +# ``SharingGRDBCore`` + +A fast, lightweight replacement for SwiftData, powered by SQL. This module is automatically imported +when you `import SharingGRDB`. + +## Overview + +SharingGRDB is a [fast](#Performance), lightweight replacement for SwiftData that deploys all the +way back to the iOS 13 generation of targets. + +@Row { + @Column { + ```swift + // SharingGRDB + @FetchAll + var items: [Item] + + @Table + struct Item { + let id: Int + var title = "" + var isInStock = true + var notes = "" + } + ``` + } + @Column { + ```swift + // SwiftData + @Query + var items: [Item] + + @Model + class Item { + var title: String + var isInStock: Bool + var notes: String + init( + title: String = "", + isInStock: Bool = true, + notes: String = "" + ) { + self.title = title + self.isInStock = isInStock + self.notes = notes + } + } + ``` + } +} + +Both of the above examples fetch items from an external data store using Swift data types, and both +are automatically observed by SwiftUI so that views are recomputed when the external data changes, +but SharingGRDB is powered directly by SQLite using [Sharing](#What-is-Sharing), +[StructuredQueries](#What-is-StructuredQueries), and [GRDB](#What-is-GRDB), and is usable from +anywhere, including UIKit, `@Observable` models, and more. + +> Note: For more information on SharingGRDB's querying capabilities, see . + +## Quick start + +Before SharingGRDB's property wrappers can fetch data from SQLite, you need to provide---at +runtime---the default database it should use. This is typically done as early as possible in your +app's lifetime, like the app entry point in SwiftUI, and is analogous to configuring model storage +in SwiftData: + +@Row { + @Column { + ```swift + // SharingGRDB + @main + struct MyApp: App { + init() { + prepareDependencies { + // Create/migrate a database connection + let db = try! DatabaseQueue(/* ... */) + $0.defaultDatabase = db + } + } + // ... + } + ``` + } + @Column { + ```swift + // SwiftData + @main + struct MyApp: App { + let container = { + // Create/configure a container + try! ModelContainer(/* ... */) + }() + + var body: some Scene { + WindowGroup { + ContentView() + .modelContainer(container) + } + } + } + ``` + } +} + +> Note: For more information on preparing a SQLite database, see . + +This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like +[`@FetchAll`](): + +```swift +@FetchAll var items: [Item] +``` + +And you can access this database throughout your application in a way similar to how one accesses +a model context, via a property wrapper: + +@Row { + @Column { + ```swift + // SharingGRDB + @Dependency(\.defaultDatabase) var database + + try database.write { db in + try Item.insert(Item(/* ... */)) + .execute(db) + } + ``` + } + @Column { + ```swift + // SwiftData + @Environment(\.modelContext) var modelContext + + let newItem = Item(/* ... */) + modelContext.insert(newItem) + try modelContext.save() + ``` + } +} + +> Note: For more information on how SharingGRDB compares to SwiftData, see +> . + +This is all you need to know to get started with SharingGRDB, but there's much more to learn. Read +the [articles](#Essentials) below to learn how to best utilize this library. + +## Performance + +SharingGRDB leverages high-performance decoding from +[StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) to turn fetched data +into your Swift domain types, and has a performance profile similar to invoking SQLite's C APIs +directly. + +See the following benchmarks against +[Lighter's performance test suite](https://github.com/Lighter-swift/PerformanceTestSuite) for a +taste of how it compares: + +``` +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 + SharingGRDB (0.2.0) 0 0.172 8.511 + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 +``` + +## SQLite knowledge required + +SQLite is one of the +[most established and widely distributed](https://www.sqlite.org/mostdeployed.html) pieces of +software in the history of software. Knowledge of SQLite is a great skill for any app developer to +have, and this library does not want to conceal it from you. So, we feel that to best wield this +library you should be familiar with the basics of SQLite, including schema design and normalization, +SQL queries, including joins and aggregates, and performance, including indices. + +With some basic knowledge you can apply this library to your database schema in order to query +for data and keep your views up-to-date when data in the database changes, and you can use +[StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) to build queries, +either using its type-safe, discoverable query building APIs, or using its `#sql` macro for writing +safe SQL strings. + +## What is Sharing? + +[Sharing](https://github.com/pointfreeco/swift-sharing) is a universal and extensible solution for +sharing your app's model data across features and with external systems, such as user defaults, +the file system, and more. This library builds upon the tools from Sharing in order to allow for +the [fetching]() and [observing]() of data in a SQLite database. + +This is all you need to know about Sharing to hit the ground running with SharingGRDB, but it only +scratches the surface of what the library makes possible. It can also act as a replacement to +SwiftUI's `@AppStorage` that works with UIKit and `@Observable` models, and can be integrated +with custom persistence strategies. To learn more, check out +[the documentation](https://swiftpackageindex.com/pointfreeco/swift-sharing/~/documentation/sharing/). + +## What is StructuredQueries? + +[StructuredQueries](https://github.com/pointfreeco/swift-structured-queries) is a library for +building SQL in a safe, expressive, and composable manner, and decoding results with high +performance. Learn more about designing schemas and building queries with the library by seeing its +[documentation](https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/). + +SharingGRDB contains an official StructuredQueries driver that connects it to SQLite _via_ GRDB, +though its query builder and decoder are general purpose tools that can interface with other +databases (MySQL, Postgres, _etc._) and database libraries. + +## What is GRDB? + +[GRDB](https://github.com/groue/GRDB.swift) is a popular Swift interface to SQLite with a rich +feature set and +[extensive documentation](https://swiftpackageindex.com/groue/GRDB.swift/documentation/grdb). +This library leverages GRDB's' observation APIs to keep the `@FetchAll`, `@FetchOne`, and `@Fetch` +property wrappers in sync with the database and update SwiftUI views. + +If you're already familiar with SQLite, GRDB provides thin APIs that can be leveraged with raw SQL +in short order. If you're new to SQLite, GRDB offers a great introduction to a highly portable +database engine. We recommend +([as does GRDB](https://github.com/groue/GRDB.swift?tab=readme-ov-file#documentation)) a familiarity +with SQLite to take full advantage of GRDB and SharingGRDB. + +## Topics + +### Essentials + +- +- +- +- +- +- + +### Database configuration and access + +- ``Dependencies/DependencyValues/defaultDatabase`` + +### Fetch and observing queries + +- ``FetchAll`` +- ``FetchOne`` +- ``Fetch`` + +### Deprecated interfaces + +- diff --git a/Sources/SharingGRDBCore/Fetch.swift b/Sources/SharingGRDBCore/Fetch.swift new file mode 100644 index 00000000..b4335c49 --- /dev/null +++ b/Sources/SharingGRDBCore/Fetch.swift @@ -0,0 +1,188 @@ +#if canImport(Combine) + import Combine +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +/// A property that can query for data in a SQLite database. +/// +/// It takes a ``FetchKeyRequest`` that describes how to fetch data from a database: +/// +/// ```swift +/// @Fetch(Items()) var items = Items.Value() +/// ``` +/// +/// See for more information. +@dynamicMemberLookup +@propertyWrapper +public struct Fetch: Sendable { + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, + /// a general solution to observing and persisting changes to external data sources. + public var sharedReader: SharedReader + + /// Data associated with the underlying query. + public var wrappedValue: Value { + sharedReader.wrappedValue + } + + /// Returns this property wrapper. + /// + /// Useful if you want to access various property wrapper state, like ``loadError``, + /// ``isLoading``, and ``publisher``. + public var projectedValue: Self { + self + } + + /// Returns a ``sharedReader`` for the given key path. + /// + /// You do not invoke this subscript directly. Instead, Swift calls it for you when chaining into + /// a member of the underlying data type. + public subscript(dynamicMember keyPath: KeyPath) -> SharedReader { + sharedReader[dynamicMember: keyPath] + } + + /// An error encountered during the most recent attempt to load data. + public var loadError: (any Error)? { + sharedReader.loadError + } + + /// Whether or not data is loading from the database. + public var isLoading: Bool { + sharedReader.isLoading + } + + #if canImport(Combine) + /// A publisher that emits events when the database observes changes to the query. + public var publisher: some Publisher { + sharedReader.publisher + } + #endif + + /// Initializes this property with an initial value. + /// + /// - Parameter wrappedValue: A default value to associate with this property. + public init(wrappedValue: sending Value) { + sharedReader = SharedReader(value: wrappedValue) + } + + /// Initializes this property with a request associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - request: A request describing the data to fetch. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: Value, + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil + ) { + sharedReader = SharedReader(wrappedValue: wrappedValue, .fetch(request, database: database)) + } + + /// Replaces the wrapped value with data from the given request. + /// + /// - Parameters: + /// - request: A request describing the data to fetch. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public func load( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil + ) async throws { + try await sharedReader.load(.fetch(request, database: database)) + } +} + +extension Fetch { + /// Initializes this property with a request associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - request: A request describing the data to fetch. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: Value, + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch(request, database: database, scheduler: scheduler) + ) + } + + /// Replaces the wrapped value with data from the given request. + /// + /// - Parameters: + /// - request: A request describing the data to fetch. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public func load( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws { + try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) + } +} + +extension Fetch: Equatable where Value: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.sharedReader == rhs.sharedReader + } +} + +#if canImport(SwiftUI) + extension Fetch: DynamicProperty { + public func update() { + sharedReader.update() + } + + /// Initializes this property with a request associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - request: A request describing the data to fetch. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public init( + wrappedValue: Value, + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + animation: Animation + ) { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch(request, database: database, animation: animation) + ) + } + + /// Replaces the wrapped value with data from the given request. + /// + /// - Parameters: + /// - request: A request describing the data to fetch. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public func load( + _ request: some FetchKeyRequest, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws { + try await sharedReader.load(.fetch(request, database: database, animation: animation)) + } + } +#endif diff --git a/Sources/SharingGRDBCore/FetchAll.swift b/Sources/SharingGRDBCore/FetchAll.swift new file mode 100644 index 00000000..1b25d8ae --- /dev/null +++ b/Sources/SharingGRDBCore/FetchAll.swift @@ -0,0 +1,824 @@ +#if canImport(Combine) + import Combine +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +/// A property that can query for a collection of data in a SQLite database. +/// +/// It takes a query built using the StructuredQueries library: +/// +/// ```swift +/// @FetchAll(Item.order(by: \.name)) var items +/// ``` +/// +/// See for more information. +@dynamicMemberLookup +@propertyWrapper +public struct FetchAll: Sendable { + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, + /// a general solution to observing and persisting changes to external data sources. + public var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + + /// A collection of data associated with the underlying query. + public var wrappedValue: [Element] { + sharedReader.wrappedValue + } + + /// Returns this property wrapper. + /// + /// Useful if you want to access various property wrapper state, like ``loadError``, + /// ``isLoading``, and ``publisher``. + public var projectedValue: Self { + self + } + + /// Returns a ``sharedReader`` for the given key path. + /// + /// You do not invoke this subscript directly. Instead, Swift calls it for you when chaining into + /// a member of the underlying data type. + public subscript( + dynamicMember keyPath: KeyPath<[Element], Member> + ) -> SharedReader { + sharedReader[dynamicMember: keyPath] + } + + /// An error encountered during the most recent attempt to load data. + public var loadError: (any Error)? { + sharedReader.loadError + } + + /// Whether or not data is loading from the database. + public var isLoading: Bool { + sharedReader.isLoading + } + + #if canImport(Combine) + /// A publisher that emits events when the database observes changes to the query. + public var publisher: some Publisher<[Element], Never> { + sharedReader.publisher + } + #endif + + /// Initializes this property with a query that fetches every row from a table. + public init( + wrappedValue: [Element] = [], + database: (any DatabaseReader)? = nil + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement = Element.all.selectStar().asSelect() + self.init(wrappedValue: wrappedValue, statement, database: database) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: S, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + database: (any DatabaseReader)? = nil + ) + where + Element == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch(FetchAllStatementPackRequest(statement: statement), database: database) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil + ) + where + Element == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil + ) async throws + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil + ) async throws + where + Element == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public func load( + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil + ) async throws + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil + ) async throws + where + Element == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database + ) + ) + } +} + +extension FetchAll { + /// Initializes this property with a query that fetches every row from a table. + /// + /// - Parameters: + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement = Element.all.selectStar().asSelect() + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Element == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public func load( + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Element == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database + ) + ) + } +} + +extension FetchAll: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.sharedReader == rhs.sharedReader + } +} + +#if canImport(SwiftUI) + extension FetchAll: DynamicProperty { + public func update() { + sharedReader.update() + } + + /// Initializes this property with a query that fetches every row from a table. + /// + /// - Parameters: + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public init( + wrappedValue: [Element] = [], + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement = Element.all.selectStar().asSelect() + self.init(wrappedValue: wrappedValue, statement, database: database, animation: animation) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public init( + wrappedValue: [Element] = [], + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Element == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public func load( + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Element == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchAllStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + } +#endif + +private struct FetchAllStatementValueRequest: StatementKeyRequest { + let statement: any StructuredQueriesCore.Statement + func fetch(_ db: Database) throws -> [Value.QueryOutput] { + try statement.fetchAll(db) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private struct FetchAllStatementPackRequest: StatementKeyRequest { + let statement: any StructuredQueriesCore.Statement<(repeat each Value)> + func fetch(_ db: Database) throws -> [(repeat (each Value).QueryOutput)] { + try statement.fetchAll(db) + } +} diff --git a/Sources/SharingGRDB/FetchKey+SwiftUI.swift b/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift similarity index 70% rename from Sources/SharingGRDB/FetchKey+SwiftUI.swift rename to Sources/SharingGRDBCore/FetchKey+SwiftUI.swift index 1911cd74..4e161d30 100644 --- a/Sources/SharingGRDB/FetchKey+SwiftUI.swift +++ b/Sources/SharingGRDBCore/FetchKey+SwiftUI.swift @@ -6,9 +6,7 @@ extension SharedReaderKey { /// A key that can query for data in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` that can be configured - /// with a SwiftUI animation. See ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` for more - /// info on how to use this API. + /// A version of `fetch` that can be configured with a SwiftUI animation. /// /// - Parameters: /// - request: A request describing the data to fetch. @@ -16,6 +14,10 @@ /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") public static func fetch( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, @@ -27,9 +29,7 @@ /// A key that can query for a collection of data in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` that can be configured - /// with a SwiftUI animation. See ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` for more - /// info on how to use this API. + /// A version of `fetch` that can be configured with a SwiftUI animation. /// /// - Parameters: /// - request: A request describing the data to fetch. @@ -37,6 +37,10 @@ /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") public static func fetch( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, @@ -48,10 +52,7 @@ /// A key that can query for a collection of data in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` that can be - /// configured with a SwiftUI animation. See - /// ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` for more information on how to - /// use this API. + /// A version of `fetchAll` that can be configured with a SwiftUI animation. /// /// - Parameters: /// - sql: A raw SQL string describing the data to fetch. @@ -60,6 +61,10 @@ /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") public static func fetchAll( sql: String, arguments: StatementArguments = StatementArguments(), @@ -77,10 +82,7 @@ /// A key that can query for a value in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)`` that can be - /// configured with a SwiftUI animation. See - /// ``Sharing/SharedReaderKey/fetchAll(sql:arguments:database:)`` for more information on how to - /// use this API. + /// A version of `fetchOne` that can be configured with a SwiftUI animation. /// /// - Parameters: /// - sql: A raw SQL string describing the data to fetch. @@ -89,6 +91,10 @@ /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") public static func fetchOne( sql: String, arguments: StatementArguments = StatementArguments(), diff --git a/Sources/SharingGRDB/FetchKey.swift b/Sources/SharingGRDBCore/FetchKey.swift similarity index 73% rename from Sources/SharingGRDB/FetchKey.swift rename to Sources/SharingGRDBCore/FetchKey.swift index e9ff58b4..5d4d1619 100644 --- a/Sources/SharingGRDB/FetchKey.swift +++ b/Sources/SharingGRDBCore/FetchKey.swift @@ -2,6 +2,7 @@ import Dependencies import Dispatch import GRDB import Sharing +import StructuredQueriesGRDBCore #if canImport(Combine) @preconcurrency import Combine @@ -17,8 +18,8 @@ extension SharedReaderKey { /// ```swift /// struct Items: FetchKeyRequest { /// func fetch(_ db: Database) throws -> [Item] { - /// try Item.all() - /// .order(Column("timestamp").desc) + /// try Item.all + /// .order { $0.timestamp.desc() } /// .fetchAll(db) /// } /// } @@ -36,14 +37,18 @@ extension SharedReaderKey { /// ``Sharing/SharedReaderKey/fetchOne(sql:arguments:database:)``, instead. /// /// To animate or observe changes with a custom scheduler, see - /// ``Sharing/SharedReaderKey/fetch(_:database:animation:)-rgj4`` or - /// ``Sharing/SharedReaderKey/fetch(_:database:scheduler:)-9arcp``. + /// ``Sharing/SharedReaderKey/fetch(_:database:animation:)`` or + /// ``Sharing/SharedReaderKey/fetch(_:database:scheduler:)``. /// /// - Parameters: /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") public static func fetch( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil @@ -54,21 +59,23 @@ extension SharedReaderKey { /// A key that can query for a collection of data in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` that allows you to omit the - /// type and default from the `@SharedReader` property wrapper: + /// A version of `fetch` that allows you to omit the type and default from the `@SharedReader` + /// property wrapper: /// /// ```diff /// -@SharedReader(.fetch(Items()) var items: [Item] = [] /// +@SharedReader(.fetch(Items()) var items /// ``` /// - /// See ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` for more info on how to use this API. - /// /// - Parameters: /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") public static func fetch( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil @@ -86,14 +93,18 @@ extension SharedReaderKey { /// @SharedReader(.fetchAll(sql: "SELECT * FROM items")) var items: [Item] /// ``` /// - /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd``. + /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)``. /// /// - Parameters: /// - sql: A raw SQL string describing the data to fetch. /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") public static func fetchAll( sql: String, arguments: StatementArguments = StatementArguments(), @@ -101,7 +112,7 @@ extension SharedReaderKey { ) -> Self where Self == FetchKey<[Record]>.Default { Self[ - .fetch(FetchAll(sql: sql, arguments: arguments), database: database), + .fetch(FetchAllRequest(sql: sql, arguments: arguments), database: database), default: [] ] } @@ -115,38 +126,46 @@ extension SharedReaderKey { /// @SharedReader(.fetchOne(sql: "SELECT count(*) FROM items")) var itemsCount = 0 /// ``` /// - /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd``. + /// For more complex querying needs, see ``Sharing/SharedReaderKey/fetch(_:database:)``. /// /// - Parameters: /// - sql: A raw SQL string describing the data to fetch. /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") public static func fetchOne( sql: String, arguments: StatementArguments = StatementArguments(), database: (any DatabaseReader)? = nil ) -> Self where Self == FetchKey { - .fetch(FetchOne(sql: sql, arguments: arguments), database: database) + .fetch(FetchOneRequest(sql: sql, arguments: arguments), database: database) } } extension SharedReaderKey { /// A key that can query for data in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` that can be configured - /// with a scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` for more info on - /// how to use this API. + /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)`` that can be configured with a + /// scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)`` for more info on how to use this + /// API. /// /// - Parameters: /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") public static func fetch( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, @@ -158,17 +177,21 @@ extension SharedReaderKey { /// A key that can query for a collection of data in a SQLite database. /// - /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` that can be configured - /// with a scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)-3qcpd`` for more info on - /// how to use this API. + /// A version of ``Sharing/SharedReaderKey/fetch(_:database:)`` that can be configured with a + /// scheduler. See ``Sharing/SharedReaderKey/fetch(_:database:)`` for more info on how to use this + /// API. /// /// - Parameters: /// - request: A request describing the data to fetch. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(macOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(tvOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") + @available(watchOS, deprecated: 9999, message: "Use the '@Fetch' property wrapper, instead") public static func fetch( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, @@ -187,11 +210,15 @@ extension SharedReaderKey { /// - Parameters: /// - sql: A raw SQL string describing the data to fetch. /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(macOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(tvOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") + @available(watchOS, deprecated: 9999, message: "Use '@FetchAll' and '#sql', instead") public static func fetchAll( sql: String, arguments: StatementArguments = StatementArguments(), @@ -200,7 +227,9 @@ extension SharedReaderKey { ) -> Self where Self == FetchKey<[Record]>.Default { Self[ - .fetch(FetchAll(sql: sql, arguments: arguments), database: database, scheduler: scheduler), + .fetch( + FetchAllRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler + ), default: [] ] } @@ -214,11 +243,15 @@ extension SharedReaderKey { /// - Parameters: /// - sql: A raw SQL string describing the data to fetch. /// - arguments: Arguments to bind to the SQL statement. - /// - database: The database to read from. A value of `nil` will use the - /// ``Dependencies/DependencyValues/defaultDatabase``. + /// - database: The database to read from. A value of `nil` will use + /// `@Dependency(\.defaultDatabase)`. /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. /// - Returns: A key that can be passed to the `@SharedReader` property wrapper. + @available(iOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(macOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(tvOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") + @available(watchOS, deprecated: 9999, message: "Use '@FetchOne' and '#sql', instead") public static func fetchOne( sql: String, arguments: StatementArguments = StatementArguments(), @@ -226,7 +259,9 @@ extension SharedReaderKey { scheduler: some ValueObservationScheduler & Hashable ) -> Self where Self == FetchKey { - .fetch(FetchOne(sql: sql, arguments: arguments), database: database, scheduler: scheduler) + .fetch( + FetchOneRequest(sql: sql, arguments: arguments), database: database, scheduler: scheduler + ) } } @@ -235,8 +270,7 @@ extension SharedReaderKey { /// You typically do not refer to this type directly, and will use /// [`fetchAll`](), /// [`fetchOne`](), and -/// [`fetch`]() to create instances, -/// instead. +/// [`fetch`]() to create instances, instead. public struct FetchKey: SharedReaderKey { let database: any DatabaseReader let request: any FetchKeyRequest @@ -379,7 +413,7 @@ public struct FetchKeyID: Hashable { } } -private struct FetchAll: FetchKeyRequest { +private struct FetchAllRequest: FetchKeyRequest { var sql: String var arguments: StatementArguments = StatementArguments() func fetch(_ db: Database) throws -> [Element] { @@ -387,7 +421,7 @@ private struct FetchAll: FetchKeyRequest { } } -private struct FetchOne: FetchKeyRequest { +private struct FetchOneRequest: FetchKeyRequest { var sql: String var arguments: StatementArguments = StatementArguments() func fetch(_ db: Database) throws -> Value { @@ -397,4 +431,6 @@ private struct FetchOne: FetchKeyRequest { } } -private struct NotFound: Error {} +public struct NotFound: Error { + public init() {} +} diff --git a/Sources/SharingGRDBCore/FetchKeyRequest.swift b/Sources/SharingGRDBCore/FetchKeyRequest.swift new file mode 100644 index 00000000..cd89d074 --- /dev/null +++ b/Sources/SharingGRDBCore/FetchKeyRequest.swift @@ -0,0 +1,52 @@ +import GRDB + +/// A type that can request a value from a database. +/// +/// This type can be used to describe a transaction to read data from SQLite: +/// +/// ```swift +/// struct PlayersRequest: FetchKeyRequest { +/// struct Value { +/// let injuredPlayerCount: Int +/// let players: [Player] +/// } +/// +/// func fetch(_ db: Database) throws -> Value { +/// try Value( +/// injuredPlayerCount: Player +/// .where(\.isInjured) +/// .fetchCount(db), +/// players: Player +/// .where { !$0.isInjured } +/// .order(by: \.name) +/// .limit(10) +/// .fetchAll(db) +/// ) +/// } +/// } +/// ``` +/// +/// And then can be used with the ``Fetch`` property wrapper to popular state in a SwiftUI view, +/// `@Observable` model, UIKit view controller, and more: +/// +/// ```swift +/// struct PlayersView: View { +/// @Fetch(PlayersRequest()) var response +/// +/// var body: some View { +/// ForEach(response.players) { player in +/// // ... +/// } +/// Button("View injured players (\(response.injuredPlayerCount))") { +/// // ... +/// } +/// } +/// } +/// ``` +public protocol FetchKeyRequest: Hashable, Sendable { + /// The type associated with the request. + associatedtype Value + + /// Fetches a value from a database. + func fetch(_ db: Database) throws -> Value +} diff --git a/Sources/SharingGRDBCore/FetchOne.swift b/Sources/SharingGRDBCore/FetchOne.swift new file mode 100644 index 00000000..d18286ea --- /dev/null +++ b/Sources/SharingGRDBCore/FetchOne.swift @@ -0,0 +1,821 @@ +#if canImport(Combine) + import Combine +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +/// A property that can query for a value in a SQLite database. +/// +/// It takes a query built using the StructuredQueries library: +/// +/// ```swift +/// @FetchOne(Item.count) var itemsCount = 0 +/// ``` +/// +/// See for more information. +@dynamicMemberLookup +@propertyWrapper +public struct FetchOne: Sendable { + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, + /// a general solution to observing and persisting changes to external data sources. + public var sharedReader: SharedReader + + /// A value associated with the underlying query. + public var wrappedValue: Value { + sharedReader.wrappedValue + } + + /// Returns this property wrapper. + /// + /// Useful if you want to access various property wrapper state, like ``loadError``, + /// ``isLoading``, and ``publisher``. + public var projectedValue: Self { + self + } + + /// Returns a ``sharedReader`` for the given key path. + /// + /// You do not invoke this subscript directly. Instead, Swift calls it for you when chaining into + /// a member of the underlying data type. + public subscript(dynamicMember keyPath: KeyPath) -> SharedReader { + sharedReader[dynamicMember: keyPath] + } + + /// An error encountered during the most recent attempt to load data. + public var loadError: (any Error)? { + sharedReader.loadError + } + + /// Whether or not data is loading from the database. + public var isLoading: Bool { + sharedReader.isLoading + } + + #if canImport(Combine) + /// A publisher that emits events when the database observes changes to the query. + public var publisher: some Publisher { + sharedReader.publisher + } + #endif + + /// Initializes this property with a wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil + ) { + sharedReader = SharedReader(value: wrappedValue) + } + + /// Initializes this property with a wrapped value. + /// + /// - Parameters database: The database to read from. A value of `nil` will use the default + /// database (`@Dependency(\.defaultDatabase)`). + public init( + database: (any DatabaseReader)? = nil + ) where Value == Wrapped? { + self.init(wrappedValue: nil, database: database) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: S.From.QueryOutput, + _ statement: S, + database: (any DatabaseReader)? = nil + ) + where + Value == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), + _ statement: S, + database: (any DatabaseReader)? = nil + ) + where + Value == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: V.QueryOutput, + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil + ) + where + Value == V.QueryOutput, + V.QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil + ) + where + Value == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil + ) async throws + where + Value == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil + ) async throws + where + Value == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public func load( + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil + ) async throws + where + Value == V.QueryOutput, + V.QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil + ) async throws + where + Value == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database + ) + ) + } +} + +/// Initializes this property with a query associated with the wrapped value. +/// +/// - Parameters: +/// - wrappedValue: A default value to associate with this property. +/// - statement: A query associated with the wrapped value. +/// - database: The database to read from. A value of `nil` will use the default database +/// (`@Dependency(\.defaultDatabase)`). +/// - scheduler: The scheduler to observe from. By default, database observation is performed +/// asynchronously on the main queue. +extension FetchOne { + public init( + wrappedValue: S.From.QueryOutput, + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: V.QueryOutput, + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value == V.QueryOutput, + V.QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Value == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Value == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public func load( + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Value == V.QueryOutput, + V.QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws + where + Value == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } +} + +extension FetchOne: Equatable where Value: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.sharedReader == rhs.sharedReader + } +} + +#if canImport(SwiftUI) + extension FetchOne: DynamicProperty { + public func update() { + sharedReader.update() + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public init( + wrappedValue: S.From.QueryOutput, + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: (S.From.QueryOutput, repeat (each J).QueryOutput), + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public init( + wrappedValue: V.QueryOutput, + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value == V.QueryOutput, + V.QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Initializes this property with a query associated with the wrapped value. + /// + /// - Parameters: + /// - wrappedValue: A default value to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: (V1.QueryOutput, repeat (each V2).QueryOutput), + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Value == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: S, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Value == (S.From.QueryOutput, repeat (each J).QueryOutput), + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == (repeat each J), + repeat (each J).QueryOutput: Sendable + { + let statement = statement.selectStar().asSelect() + try await sharedReader.load( + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + public func load( + _ statement: some StructuredQueriesCore.Statement, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Value == V.QueryOutput, + V.QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + + /// Replaces the wrapped value with data from the given query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @_disfavoredOverload + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public func load( + _ statement: some StructuredQueriesCore.Statement<(V1, repeat each V2)>, + database: (any DatabaseReader)? = nil, + animation: Animation + ) async throws + where + Value == (V1.QueryOutput, repeat (each V2).QueryOutput), + V1.QueryOutput: Sendable, + repeat (each V2).QueryOutput: Sendable + { + try await sharedReader.load( + .fetch( + FetchOneStatementPackRequest(statement: statement), + database: database, + animation: animation + ) + ) + } + } +#endif + +private struct FetchOneStatementValueRequest: StatementKeyRequest { + let statement: any StructuredQueriesCore.Statement + func fetch(_ db: Database) throws -> Value.QueryOutput { + guard let result = try statement.fetchOne(db) + else { throw NotFound() } + return result + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private struct FetchOneStatementPackRequest: StatementKeyRequest { + let statement: any StructuredQueriesCore.Statement<(repeat each Value)> + func fetch(_ db: Database) throws -> (repeat (each Value).QueryOutput) { + guard let result = try statement.fetchOne(db) + else { throw NotFound() } + return result + } +} diff --git a/Sources/SharingGRDB/Internal/Exports.swift b/Sources/SharingGRDBCore/Internal/Exports.swift similarity index 64% rename from Sources/SharingGRDB/Internal/Exports.swift rename to Sources/SharingGRDBCore/Internal/Exports.swift index 96421c6b..8768304b 100644 --- a/Sources/SharingGRDB/Internal/Exports.swift +++ b/Sources/SharingGRDBCore/Internal/Exports.swift @@ -1,3 +1,4 @@ @_exported import Dependencies @_exported import GRDB @_exported import Sharing +@_exported import StructuredQueriesGRDBCore diff --git a/Sources/SharingGRDBCore/Internal/StatementKey.swift b/Sources/SharingGRDBCore/Internal/StatementKey.swift new file mode 100644 index 00000000..2942ac2f --- /dev/null +++ b/Sources/SharingGRDBCore/Internal/StatementKey.swift @@ -0,0 +1,23 @@ +protocol StatementKeyRequest: FetchKeyRequest { + associatedtype QueryValue + var statement: any StructuredQueriesCore.Statement { get } +} + +extension StatementKeyRequest { + static func == (lhs: Self, rhs: Self) -> Bool { + // NB: A Swift 6.1 regression prevents this from compiling: + // https://github.com/swiftlang/swift/issues/79623 + // return AnyHashable(lhs.statement) == AnyHashable(rhs.statement) + let lhs = lhs.statement + let rhs = rhs.statement + return AnyHashable(lhs) == AnyHashable(rhs) + } + + func hash(into hasher: inout Hasher) { + // NB: A Swift 6.1 regression prevents this from compiling: + // https://github.com/swiftlang/swift/issues/79623 + // hasher.combine(statement) + let statement = statement + hasher.combine(statement) + } +} diff --git a/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md b/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md new file mode 100644 index 00000000..c62671c0 --- /dev/null +++ b/Sources/StructuredQueriesGRDB/Documentation.docc/StructuredQueriesGRDB.md @@ -0,0 +1,10 @@ +# ``StructuredQueriesGRDB`` + +A library interfacing StructuredQueries with GRDB. This module is automatically imported when you +`import SharingGRDB`. + +## Overview + +The core functionality of this module is defined in +[`StructuredQueriesGRDBCore`](structuredqueriesgrdbcore) and then re-exported alongside +`StructuredQueries` and its macros. diff --git a/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift b/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift new file mode 100644 index 00000000..99a11a9d --- /dev/null +++ b/Sources/StructuredQueriesGRDB/StructuredQueriesGRDB.swift @@ -0,0 +1,2 @@ +@_exported import StructuredQueries +@_exported import StructuredQueriesGRDBCore diff --git a/Sources/SharingGRDB/DefaultDatabase.swift b/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift similarity index 97% rename from Sources/SharingGRDB/DefaultDatabase.swift rename to Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift index 8420408f..1e1d06af 100644 --- a/Sources/SharingGRDB/DefaultDatabase.swift +++ b/Sources/StructuredQueriesGRDBCore/DefaultDatabase.swift @@ -1,4 +1,5 @@ import Dependencies +import GRDB extension DependencyValues { /// The default database used by `fetchAll`, `fetchOne`, and `fetch`. @@ -106,6 +107,6 @@ extension DependencyValues { #if DEBUG extension String { - static let defaultDatabaseLabel = "co.pointfree.SharingGRDB.testValue" + package static let defaultDatabaseLabel = "co.pointfree.SharingGRDB.testValue" } #endif diff --git a/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md new file mode 100644 index 00000000..86895a82 --- /dev/null +++ b/Sources/StructuredQueriesGRDBCore/Documentation.docc/StructuredQueriesGRDBCore.md @@ -0,0 +1,70 @@ +# ``StructuredQueriesGRDBCore`` + +The core functionality of interfacing StructuredQueries with GRDB. This module is automatically +imported when you `import SharingGRDB` or `StructuredQueriesGRDB`. + +## Overview + +This library can be used to directly execute queries built using the [StructuredQueries][sq-gh] +library and a [GRDB][grdb-gh] database. + +While the `SharingGRDB` module provides tools to observe queries using the `@FetchAll`, `@FetchOne`, +and `@Fetch` property wrappers, you will also want to execute one-off queries directly, especially +when it comes to `INSERT`, `UPDATE`, and `DELETE` statements. This module extends +StructuredQueries' `Statement` type with `execute`, `fetchAll`, `fetchOne`, and `fetchCount` methods +that execute the query on a given GRDB database. + +```swift +@Table +struct Player { + let id: Int + var name = "" + var score = 0 +} + +try #sql( + """ + CREATE TABLE players ( + id INTEGER PRIMARY KEY, + name TEXT, + score INTEGER + ) + """ +) +.execute(db) + +let players = Player + .where { $0.score > 10 } + .fetchAll(db) +// SELECT … FROM "players" +// WHERE "players"."score" > 10 + +let averageScore = try Player + .select { $0.score.avg() } + .fetchOne(db) +// SELECT avg("players"."score") FROM "players" +``` + +For more information on how to build queries, see the [StructuredQueries documentation][sq-spi]. + +[sq-gh]: https://github.com/pointfreeco/swift-structured-queries +[sq-spi]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueries +[grdb-gh]: https://github.com/groue/GRDB.swift + +## Topics + +### Executing statements + +- ``StructuredQueriesCore/Statement/execute(_:)`` +- ``StructuredQueriesCore/Statement/fetchAll(_:)`` +- ``StructuredQueriesCore/Statement/fetchOne(_:)`` +- ``StructuredQueriesCore/Statement/fetchCursor(_:)`` +- ``StructuredQueriesCore/SelectStatement/fetchCount(_:)`` + +### Iterating over rows + +- ``QueryCursor`` + +### Seeding data + +- ``GRDB/Database/seed(_:)`` diff --git a/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift b/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift new file mode 100644 index 00000000..320bdbac --- /dev/null +++ b/Sources/StructuredQueriesGRDBCore/Internal/Exports.swift @@ -0,0 +1 @@ +@_exported import StructuredQueriesCore diff --git a/Sources/StructuredQueriesGRDBCore/QueryCursor.swift b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift new file mode 100644 index 00000000..c2782d73 --- /dev/null +++ b/Sources/StructuredQueriesGRDBCore/QueryCursor.swift @@ -0,0 +1,104 @@ +import Foundation +import GRDB +import SQLite3 +import StructuredQueriesCore + +/// A cursor of a structured query. +/// +/// Iterates over and decodes all of the rows of a structured query. +public class QueryCursor: DatabaseCursor { + public var _isDone = false + public let _statement: GRDB.Statement + + @usableFromInline + var decoder: SQLiteQueryDecoder + + @usableFromInline + init(db: Database, query: QueryFragment) throws { + (_statement, decoder) = try db.prepare(query: query) + } + + deinit { + sqlite3_reset(_statement.sqliteStatement) + } + + public func _element(sqliteStatement _: SQLiteStatement) throws -> Element { + fatalError("Abstract method should be overridden in subclass") + } +} + +@usableFromInline +final class QueryValueCursor: QueryCursor { + public typealias Element = QueryValue.QueryOutput + + @inlinable + public override func _element(sqliteStatement _: SQLiteStatement) throws -> Element { + let element = try QueryValue(decoder: &decoder).queryOutput + decoder.next() + return element + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +@usableFromInline +final class QueryPackCursor< + each QueryValue: QueryRepresentable +>: QueryCursor<(repeat (each QueryValue).QueryOutput)> { + public typealias Element = (repeat (each QueryValue).QueryOutput) + + @inlinable + public override func _element(sqliteStatement _: SQLiteStatement) throws -> Element { + let element = try decoder.decodeColumns((repeat each QueryValue).self) + decoder.next() + return element + } +} + +@usableFromInline +final class QueryVoidCursor: QueryCursor { + typealias Element = () + + @inlinable + override func _element(sqliteStatement _: SQLiteStatement) throws { + try decoder.decodeColumns(Void.self) + decoder.next() + } +} + +extension Database { + @inlinable + func prepare(query: QueryFragment) throws -> (GRDB.Statement, SQLiteQueryDecoder) { + let queryString = + query.isEmpty + ? "SELECT 1 WHERE 0 -- Empty query generated by StructuredQueries" + : query.string + let statement = try makeStatement(sql: queryString) + statement.arguments = try StatementArguments(query.bindings.map { try $0.databaseValue }) + return ( + statement, + SQLiteQueryDecoder(statement: statement.sqliteStatement) + ) + } +} + +extension QueryBinding { + @inlinable + var databaseValue: DatabaseValue { + get throws { + switch self { + case let .blob(blob): + return Data(blob).databaseValue + case let .double(double): + return double.databaseValue + case let .int(int): + return int.databaseValue + case .null: + return .null + case let .text(text): + return text.databaseValue + case let .invalid(error): + throw error + } + } + } +} diff --git a/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift new file mode 100644 index 00000000..481a2f15 --- /dev/null +++ b/Sources/StructuredQueriesGRDBCore/SQLiteQueryDecoder.swift @@ -0,0 +1,64 @@ +import SQLite3 +import StructuredQueriesCore + +@usableFromInline +struct SQLiteQueryDecoder: QueryDecoder { + @usableFromInline + let statement: OpaquePointer + + @usableFromInline + var currentIndex: Int32 = 0 + + @usableFromInline + init(statement: OpaquePointer) { + self.statement = statement + } + + @inlinable + mutating func next() { + currentIndex = 0 + } + + @inlinable + mutating func decode(_ columnType: [UInt8].Type) throws -> [UInt8]? { + defer { currentIndex += 1 } + guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } + return [UInt8]( + UnsafeRawBufferPointer( + start: sqlite3_column_blob(statement, currentIndex), + count: Int(sqlite3_column_bytes(statement, currentIndex)) + ) + ) + } + + @inlinable + mutating func decode(_ columnType: Double.Type) throws -> Double? { + defer { currentIndex += 1 } + guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } + return sqlite3_column_double(statement, currentIndex) + } + + @inlinable + mutating func decode(_ columnType: Int64.Type) throws -> Int64? { + defer { currentIndex += 1 } + guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } + return sqlite3_column_int64(statement, currentIndex) + } + + @inlinable + mutating func decode(_ columnType: String.Type) throws -> String? { + defer { currentIndex += 1 } + guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } + return String(cString: sqlite3_column_text(statement, currentIndex)) + } + + @inlinable + mutating func decode(_ columnType: Bool.Type) throws -> Bool? { + try decode(Int64.self).map { $0 != 0 } + } + + @inlinable + mutating func decode(_ columnType: Int.Type) throws -> Int? { + try decode(Int64.self).map(Int.init) + } +} diff --git a/Sources/StructuredQueriesGRDBCore/Seed.swift b/Sources/StructuredQueriesGRDBCore/Seed.swift new file mode 100644 index 00000000..271495a7 --- /dev/null +++ b/Sources/StructuredQueriesGRDBCore/Seed.swift @@ -0,0 +1,80 @@ +import Dependencies +import GRDB +import StructuredQueriesCore + +extension Database { + /// Seeds a database with the given values. + /// + /// This function is useful for seeding a database's initial state, especially for previews and + /// tests. You can list out a bunch of table records and drafts and they will be inserted into the + /// database: + /// + /// ```swift + /// try db.seed { + /// SyncUp(id: 1, seconds: 60, theme: .appOrange, title: "Design") + /// SyncUp(id: 2, seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + /// SyncUp(id: 3, seconds: 60 * 30, theme: .poppy, title: "Product") + /// + /// for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + /// Attendee.Draft(name: name, syncUpID: 1) + /// } + /// for name in ["Blob", "Blob Jr"] { + /// Attendee.Draft(name: name, syncUpID: 2) + /// } + /// for name in ["Blob Sr", "Blob Jr"] { + /// Attendee.Draft(name: name, syncUpID: 3) + /// } + /// } + /// // INSERT INTO "syncUps" + /// // ("id", "seconds", "theme", "title") + /// // VALUES + /// // (1, 60, 'appOrange', 'Design'), + /// // (2, 600, 'periwinkle', 'Engineering'), + /// // (3, 1800, 'poppy', 'Product'); + /// // INSERT INTO "attendees" + /// // ("id", "name", "syncUpID") + /// // VALUES + /// // (NULL, 'Blob', 1), + /// // (NULL, 'Blob Jr', 1), + /// // (NULL, 'Blob Sr', 1), + /// // (NULL, 'Blob Esq', 1), + /// // (NULL, 'Blob III', 1), + /// // (NULL, 'Blob I', 1), + /// // (NULL, 'Blob', 2), + /// // (NULL, 'Blob Jr', 2), + /// // (NULL, 'Blob Sr', 3), + /// // (NULL, 'Blob Jr', 3); + /// ``` + /// + /// Insertions are performed in order and in batches of consecutive records of the same table. + /// + /// - Parameter build: A result builder closure that inserts every built row. + public func seed( + @InsertValuesBuilder + _ build: () -> [any StructuredQueriesCore.Table] + ) throws { + var seeds = build() + while !seeds.isEmpty { + guard let first = seeds.first else { break } + let firstType = type(of: first) + + if let firstType = firstType as? any TableDraft.Type { + func insertBatch(_: T.Type) throws { + let batch = Array(seeds.lazy.prefix { $0 is T }.compactMap { $0 as? T }) + defer { seeds.removeFirst(batch.count) } + try T.PrimaryTable.insert(batch).execute(self) + } + + try insertBatch(firstType) + } else { + func insertBatch(_: T.Type) throws { + let batch = Array(seeds.lazy.prefix { $0 is T }.compactMap { $0 as? T }) + defer { seeds.removeFirst(batch.count) } + try T.insert(batch).execute(self) + } + + try insertBatch(firstType) + } + } + } +} diff --git a/Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift b/Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift new file mode 100644 index 00000000..4775a093 --- /dev/null +++ b/Sources/StructuredQueriesGRDBCore/Statement+GRDB.swift @@ -0,0 +1,231 @@ +import GRDB +import SQLite3 +import StructuredQueriesCore + +extension StructuredQueriesCore.Statement { + /// Executes a structured query on the given database connection. + /// + /// For example: + /// + /// ```swift + /// try database.write { db in + /// try Player.insert { $0.name } values: { "Arthur" } + /// .execute(db) + /// // INSERT INTO "players" ("name") + /// // VALUES ('Arthur'); + /// } + /// ``` + /// + /// - Parameter db: A database connection. + @inlinable + public func execute(_ db: Database) throws where QueryValue == () { + try QueryVoidCursor(db: db, query: query).next() + } + + /// Returns an array of all values fetched from the database. + /// + /// For example: + /// + /// ```swift + /// let players = try database.read { db in + /// let lastName = "O'Reilly" + /// try Player + /// .where { $0.lastName == lastName } + /// .fetchAll(db) + /// // SELECT … FROM "players" + /// // WHERE "players"."lastName" = 'O''Reilly' + /// } + /// ``` + /// + /// - Parameter db: A database connection. + /// - Returns: An array of all values decoded from the database. + @inlinable + public func fetchAll(_ db: Database) throws -> [QueryValue.QueryOutput] + where QueryValue: QueryRepresentable { + let cursor = try QueryValueCursor(db: db, query: query) + var output: [QueryValue.QueryOutput] = [] + try cursor.forEach { output.append($0) } + return output + } + + /// Returns a single value fetched from the database. + /// + /// For example: + /// + /// ```swift + /// let player = try database.read { db in + /// let lastName = "O'Reilly" + /// try Player + /// .where { $0.lastName == lastName } + /// .limit(1) + /// .fetchOne(db) + /// // SELECT … FROM "players" + /// // WHERE "players"."lastName" = 'O''Reilly' + /// // LIMIT 1 + /// } + /// ``` + /// + /// - Parameter db: A database connection. + /// - Returns: A single value decoded from the database. + @inlinable + public func fetchOne(_ db: Database) throws -> QueryValue.QueryOutput? + where QueryValue: QueryRepresentable { + try fetchCursor(db).next() + } + + /// Returns a cursor to all values fetched from the database. + /// + /// For example: + /// + /// ```swift + /// try database.read { db in + /// let lastName = "O'Reilly" + /// let query = Player.where { $0.lastName == lastName } + /// let players = try query.fetchCursor(db) + /// while let player = try players.next() { + /// print(player.name) + /// } + /// } + /// ``` + /// + /// - Parameter db: A database connection. + /// - Returns: A cursor to all values decoded from the database. + @inlinable + public func fetchCursor(_ db: Database) throws -> QueryCursor + where QueryValue: QueryRepresentable { + try QueryValueCursor(db: db, query: query) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension StructuredQueriesCore.Statement { + /// Returns an array of all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: An array of all values decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchAll( + _ db: Database + ) throws -> [(repeat (each Value).QueryOutput)] + where QueryValue == (repeat each Value) { + let cursor = try fetchCursor(db) + return try Array(cursor) + } + + /// Returns a single value fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A single value decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchOne( + _ db: Database + ) throws -> (repeat (each Value).QueryOutput)? + where QueryValue == (repeat each Value) { + let cursor = try fetchCursor(db) + return try cursor.next() + } + + /// Returns a cursor to all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A cursor to all values decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchCursor( + _ db: Database + ) throws -> QueryCursor<(repeat (each Value).QueryOutput)> + where QueryValue == (repeat each Value) { + try QueryPackCursor(db: db, query: query) + } +} + +extension SelectStatement where QueryValue == (), Joins == () { + /// Returns the number of rows fetched by the query. + /// + /// - Parameter db: A database connection. + /// - Returns: The number of rows fetched by the query. + @inlinable + public func fetchCount(_ db: Database) throws -> Int { + let query = asSelect().count() + return try query.fetchOne(db) ?? 0 + } +} + +extension SelectStatement where QueryValue == (), Joins == () { + /// Returns an array of all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: An array of all values decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchAll(_ db: Database) throws -> [From.QueryOutput] { + let cursor = try QueryValueCursor(db: db, query: query) + var output: [From.QueryOutput] = [] + try cursor.forEach { output.append($0) } + return output + } + + /// Returns a single value fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A single value decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchOne(_ db: Database) throws -> From.QueryOutput? { + try fetchCursor(db).next() + } + + /// Returns a cursor to all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A cursor to all values decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchCursor(_ db: Database) throws -> QueryCursor { + try QueryValueCursor(db: db, query: query) + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SelectStatement where QueryValue == () { + /// Returns an array of all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: An array of all values decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchAll( + _ db: Database + ) throws -> [(From.QueryOutput, repeat (each J).QueryOutput)] + where Joins == (repeat each J) { + try Array(fetchCursor(db)) + } + + /// Returns a single value fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A single value decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchOne( + _ db: Database + ) throws -> (From.QueryOutput, repeat (each J).QueryOutput)? + where Joins == (repeat each J) { + try fetchCursor(db).next() + } + + /// Returns a cursor to all values fetched from the database. + /// + /// - Parameter db: A database connection. + /// - Returns: A cursor to all values decoded from the database. + @_documentation(visibility: private) + @inlinable + public func fetchCursor( + _ db: Database + ) throws -> QueryCursor<(From.QueryOutput, repeat (each J).QueryOutput)> + where Joins == (repeat each J) { + try QueryPackCursor(db: db, query: query) + } +} diff --git a/Tests/SharingGRDBTests/IntegrationTests.swift b/Tests/SharingGRDBTests/IntegrationTests.swift index aa6fcf36..e80214a8 100644 --- a/Tests/SharingGRDBTests/IntegrationTests.swift +++ b/Tests/SharingGRDBTests/IntegrationTests.swift @@ -1,34 +1,34 @@ import Dependencies import DependenciesTestSupport -import GRDB import Sharing import SharingGRDB +import StructuredQueries import Testing @Suite(.dependency(\.defaultDatabase, try .syncUps())) struct IntegrationTests { + @Dependency(\.defaultDatabase) var database + @Test func fetchAll_SQLString() async throws { - @SharedReader(.fetchAll(sql: #"SELECT * FROM "syncUps" WHERE "isActive""#)) - var syncUps: [SyncUp] = [] + @FetchAll(SyncUp.where(\.isActive)) var syncUps: [SyncUp] #expect(syncUps == []) - @Dependency(\.defaultDatabase) var database try await database.write { db in - _ = try SyncUp(isActive: true, title: "Engineering") - .inserted(db) + _ = try SyncUp.insert(SyncUp.Draft(isActive: true, title: "Engineering")) + .execute(db) } try await Task.sleep(nanoseconds: 100_000_000) #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) try await database.write { db in - _ = try SyncUp(id: 1, isActive: false, title: "Engineering") - .saved(db) + _ = try SyncUp.upsert(SyncUp.Draft(id: 1, isActive: false, title: "Engineering")) + .execute(db) } try await Task.sleep(nanoseconds: 100_000_000) #expect(syncUps == []) try await database.write { db in - _ = try SyncUp(id: 1, isActive: true, title: "Engineering") - .saved(db) + _ = try SyncUp.upsert(SyncUp.Draft(id: 1, isActive: true, title: "Engineering")) + .execute(db) } try await Task.sleep(nanoseconds: 100_000_000) #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) @@ -36,67 +36,71 @@ struct IntegrationTests { @Test func fetch_FetchKeyRequest() async throws { - @SharedReader(.fetch(ActiveSyncUps())) - var syncUps: [SyncUp] = [] + @Fetch(ActiveSyncUps()) var syncUps: [SyncUp] = [] #expect(syncUps == []) - @Dependency(\.defaultDatabase) var database try await database.write { db in - _ = try SyncUp(isActive: true, title: "Engineering") - .inserted(db) + _ = try SyncUp.insert(SyncUp.Draft(isActive: true, title: "Engineering")) + .execute(db) } try await Task.sleep(nanoseconds: 100_000_000) #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) try await database.write { db in - _ = try SyncUp(id: 1, isActive: false, title: "Engineering") - .saved(db) + _ = try SyncUp.upsert(SyncUp.Draft(id: 1, isActive: false, title: "Engineering")) + .execute(db) } try await Task.sleep(nanoseconds: 100_000_000) #expect(syncUps == []) try await database.write { db in - _ = try SyncUp(id: 1, isActive: true, title: "Engineering") - .saved(db) + _ = try SyncUp.upsert(SyncUp.Draft(id: 1, isActive: true, title: "Engineering")) + .execute(db) } try await Task.sleep(nanoseconds: 100_000_000) #expect(syncUps == [SyncUp(id: 1, isActive: true, title: "Engineering")]) } } -private struct SyncUp: Codable, Equatable, FetchableRecord, MutablePersistableRecord { - var id: Int64? +@Table +private struct SyncUp: Equatable, Identifiable { + let id: Int var isActive: Bool var title: String - static let databaseTableName = "syncUps" - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } } -private struct Attendee: Codable, Equatable, FetchableRecord, MutablePersistableRecord { - var id: Int64? +@Table +private struct Attendee: Equatable { + let id: Int var name: String - var syncUpID: Int - static let databaseTableName = "attendees" - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } + var syncUpID: SyncUp.ID } -private extension DatabaseWriter where Self == DatabaseQueue { - static func syncUps() throws -> Self { +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func syncUps() throws -> Self { let database = try DatabaseQueue() var migrator = DatabaseMigrator() migrator.registerMigration("Create schema") { db in - try db.create(table: SyncUp.databaseTableName) { t in - t.autoIncrementedPrimaryKey("id") - t.column("isActive", .boolean).notNull() - t.column("title", .text).notNull() - } - try db.create(table: Attendee.databaseTableName) { t in - t.autoIncrementedPrimaryKey("id") - t.column("syncUpID", .integer).notNull() - t.column("name", .text).notNull() - } + try #sql( + """ + CREATE TABLE "syncUps" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "isActive" INTEGER NOT NULL, + "title" TEXT NOT NULL + ) + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "attendees" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "syncUpID" INTEGER NOT NULL, + "name" TEXT NOT NULL, + + FOREIGN KEY("syncUpID") REFERENCES "syncUps"("id") + ) + """ + ) + .execute(db) } try migrator.migrate(database) return database @@ -106,8 +110,7 @@ private extension DatabaseWriter where Self == DatabaseQueue { private struct ActiveSyncUps: FetchKeyRequest { func fetch(_ db: Database) throws -> [SyncUp] { try SyncUp - .all() - .filter(Column("isActive")) + .where(\.isActive) .fetchAll(db) } } diff --git a/Tests/SharingGRDBTests/SharingGRDBTests.swift b/Tests/SharingGRDBTests/SharingGRDBTests.swift index 7ffa9633..bf81c3e1 100644 --- a/Tests/SharingGRDBTests/SharingGRDBTests.swift +++ b/Tests/SharingGRDBTests/SharingGRDBTests.swift @@ -1,7 +1,10 @@ import Dependencies +import DependenciesTestSupport import GRDB import Sharing import SharingGRDB +import StructuredQueries +import SwiftUI import Testing @Suite struct GRDBSharingTests { @@ -10,7 +13,7 @@ import Testing try await withDependencies { $0.defaultDatabase = try DatabaseQueue() } operation: { - @SharedReader(.fetchOne(sql: "SELECT 1")) var bool = false + @FetchOne(#sql("SELECT 1", as: Bool.self)) var bool = false try await Task.sleep(nanoseconds: 100_000_000) #expect(bool) #expect($bool.loadError == nil) @@ -31,7 +34,7 @@ import Testing try await withDependencies { $0.defaultDatabase = try DatabaseQueue() } operation: { - @SharedReader(.fetchOne(sql: "SELEC 1")) var bool = false + @FetchOne(#sql("SELEC 1", as: Bool.self)) var bool = false #expect(bool == false) try await Task.sleep(nanoseconds: 100_000_000) #expect($bool.loadError is DatabaseError?) @@ -84,16 +87,16 @@ import Testing } } -fileprivate struct Fetch1: FetchKeyRequest { +private struct Fetch1: FetchKeyRequest { func fetch(_ db: Database) throws { } } -fileprivate struct Fetch2: FetchKeyRequest { +private struct Fetch2: FetchKeyRequest { func fetch(_ db: Database) throws { } } -fileprivate struct Record: Codable, Equatable, FetchableRecord, MutablePersistableRecord { +private struct Record: Codable, Equatable, FetchableRecord, MutablePersistableRecord { static let databaseTableName = "records" let id: Int } @@ -107,9 +110,12 @@ extension DatabaseWriter where Self == DatabaseQueue { } var migrator = DatabaseMigrator() migrator.registerMigration("Up") { db in - try db.create(table: "records") { table in - table.column("id", .integer).primaryKey(autoincrement: true) - } + try #sql( + """ + CREATE TABLE "records" ("id" INTEGER PRIMARY KEY AUTOINCREMENT) + """ + ) + .execute(db) for index in 1...3 { _ = try Record(id: index).inserted(db) } diff --git a/Tests/StructuredQueriesGRDBTests/MigrationTests.swift b/Tests/StructuredQueriesGRDBTests/MigrationTests.swift new file mode 100644 index 00000000..c79a081d --- /dev/null +++ b/Tests/StructuredQueriesGRDBTests/MigrationTests.swift @@ -0,0 +1,41 @@ +import Foundation +import GRDB +import StructuredQueriesGRDB +import Testing + +@Suite struct MigrationTests { + @available(iOS 15, *) + @Test func dates() throws { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "models" ( + "date" TEXT NOT NULL + ) + """ + ) + .execute(db) + } + + let timestamp = 123.456 + try database.write { db in + try db.execute( + literal: "INSERT INTO models (date) VALUES (\(Date(timeIntervalSince1970: timestamp)))" + ) + } + try database.read { db in + let grdbDate = try Date.fetchOne(db, sql: "SELECT * FROM models") + try #expect(abs(#require(grdbDate).timeIntervalSince1970 - timestamp) < 0.001) + + let date = try #require(try Model.all.fetchOne(db)).date + #expect(abs(date.timeIntervalSince1970 - timestamp) < 0.001) + } + } +} + +@available(iOS 15, *) +@Table private struct Model { + @Column(as: Date.ISO8601Representation.self) + var date: Date +} diff --git a/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift b/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift new file mode 100644 index 00000000..dd31d774 --- /dev/null +++ b/Tests/StructuredQueriesGRDBTests/QueryCursorTests.swift @@ -0,0 +1,34 @@ +import GRDB +import StructuredQueriesGRDB +import Testing + +@Suite struct QueryCursorTests { + let database: DatabaseQueue + init() throws { + var configuration = Configuration() + configuration.prepareDatabase { + $0.trace { print($0) } + } + database = try DatabaseQueue(configuration: configuration) + try database.write { db in + try #sql(#"CREATE TABLE "numbers" ("value" INTEGER NOT NULL)"#) + .execute(db) + } + } + + @Test func emptyInsert() throws { + try database.write { db in + try Number.insert([]).execute(db) + } + } + + @Test func emptyUpdate() throws { + try database.write { db in + try Number.update { _ in }.execute(db) + } + } +} + +@Table private struct Number { + var value = 0 +}