From 8b099385f5a4f3e6d352b77e54b02afca8db8025 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 10 Aug 2026 15:13:52 -0700 Subject: [PATCH] `@FetchOne`: Automatically observe primary-keyed records Right now a `@FetchOne` merely fetches the first row of a table, meaning if you forget to initialize it with a query, it could reload with the wrong data (simply the first row of the table): ```swift struct ProfileView: View { @FetchOne var profile: Profile init(profile: Profile) { _profile = FetchOne(wrappedValue: profile, Profile.find(profile.id)) } var body: some View { /* ... */ } } ``` To avoid this bug, let's bake this behavior into `@FetchOne`. --- Sources/SQLiteData/FetchOne.swift | 174 ++++++++++++++++++++++ Tests/SQLiteDataTests/FetchOneTests.swift | 30 +++- Tests/SQLiteDataTests/FetchTests.swift | 6 +- 3 files changed, 204 insertions(+), 6 deletions(-) diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 4e6b799da..2944682e7 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -134,6 +134,51 @@ public struct FetchOne: Sendable { ) } + /// Initializes this property with a query that fetches the first row from a table. + /// + /// - 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 + ) + where + Value: StructuredQueriesCore._OptionalProtocol, + Value: PrimaryKeyedTable, + Value.QueryOutput == Value + { + let statement = Value.all.selectStar().asSelect().limit(1) + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + ) + } + + /// Initializes this property with a query that observes the given row from a table. + /// + /// - 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 + ) + where + Value: PrimaryKeyedTable & QueryRepresentable, Value.QueryOutput == Value + { + let statement = Value.all + .selectStar() + .asSelect() + .find(Value.PrimaryKey(queryOutput: wrappedValue.primaryKey)) + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + ) + } + /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: @@ -444,6 +489,20 @@ extension FetchOne { sharedReader = SharedReader(value: wrappedValue) } + @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value: _Selection, + Value: PrimaryKeyedTable, + Value.QueryOutput == Value + { + sharedReader = SharedReader(value: wrappedValue) + } + @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") public init( wrappedValue: sending Value = Value._none, @@ -514,6 +573,65 @@ extension FetchOne { ) } + /// Initializes this property with a query that fetches the first row from a table. + /// + /// - 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value: StructuredQueriesCore._OptionalProtocol, + Value: PrimaryKeyedTable, + Value.QueryOutput == Value + { + let statement = Value.all.selectStar().asSelect().limit(1) + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementOptionalProtocolRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + + /// Initializes this property with a query that observes the given row from a table. + /// + /// - 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Value: PrimaryKeyedTable & QueryRepresentable, Value.QueryOutput == Value + { + let statement = Value.all + .selectStar() + .asSelect() + .find(Value.PrimaryKey(queryOutput: wrappedValue.primaryKey)) + sharedReader = SharedReader( + wrappedValue: wrappedValue, + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + } + /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: @@ -921,6 +1039,20 @@ extension FetchOne: Equatable where Value: Equatable { sharedReader = SharedReader(value: wrappedValue) } + @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value: _Selection, + Value: PrimaryKeyedTable, + Value.QueryOutput == Value + { + sharedReader = SharedReader(value: wrappedValue) + } + @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") public init( wrappedValue: sending Value = Value._none, @@ -977,6 +1109,48 @@ extension FetchOne: Equatable where Value: Equatable { self.init(wrappedValue: wrappedValue, database: database, scheduler: .animation(animation)) } + /// Initializes this property with a query that fetches the first row from a table. + /// + /// - 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)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value: StructuredQueriesCore._OptionalProtocol, + Value: PrimaryKeyedTable, + Value.QueryOutput == Value + { + self.init(wrappedValue: wrappedValue, database: database, scheduler: .animation(animation)) + } + + /// Initializes this property with a query that observes the given row from a table. + /// + /// - 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)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: sending Value, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Value: PrimaryKeyedTable & QueryRepresentable, Value.QueryOutput == Value + { + self.init(wrappedValue: wrappedValue, database: database, scheduler: .animation(animation)) + } + /// Initializes this property with a query associated with the wrapped value. /// /// - Parameters: diff --git a/Tests/SQLiteDataTests/FetchOneTests.swift b/Tests/SQLiteDataTests/FetchOneTests.swift index 1bfffe48d..5ff18cb47 100644 --- a/Tests/SQLiteDataTests/FetchOneTests.swift +++ b/Tests/SQLiteDataTests/FetchOneTests.swift @@ -17,18 +17,28 @@ import Testing } @Test func tableInit() async throws { - @FetchOne var record = Record(id: 0) + @FetchOne var record = Record(id: 2) try await $record.load() - #expect(record == Record(id: 1)) + #expect(record == Record(id: 2)) #expect($record.loadError == nil) + try await database.write { try Record.find(2).update { $0.parentID = #bind(1) }.execute($0) } + try await $record.load() + #expect(record == Record(id: 2, parentID: 1)) try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } - #expect(record == Record(id: 1)) + #expect(record == Record(id: 2, parentID: 1)) #expect($record.loadError is NotFound) } + @Test func nonPrimaryKeyedTableInit() async throws { + @FetchOne var log = Log(message: "") + try await $log.load() + #expect(log == Log(message: "first")) + #expect($log.loadError == nil) + } + @Test func optionalTableInit() async throws { @FetchOne var record: Record? try await $record.load() @@ -222,6 +232,11 @@ private struct Row { let id: Int } +@Table +private struct Log: Equatable { + var message = "" +} + extension DatabaseWriter where Self == DatabaseQueue { fileprivate static func database() throws -> DatabaseQueue { let database = try DatabaseQueue() @@ -240,6 +255,15 @@ extension DatabaseWriter where Self == DatabaseQueue { for _ in 1...3 { _ = try Record.insert { Record.Draft() }.execute(db) } + try #sql( + """ + CREATE TABLE "logs" ( + "message" TEXT NOT NULL + ) + """ + ) + .execute(db) + try Log.insert { [Log(message: "first"), Log(message: "second")] }.execute(db) } return database } diff --git a/Tests/SQLiteDataTests/FetchTests.swift b/Tests/SQLiteDataTests/FetchTests.swift index 7461e0352..fc3795d90 100644 --- a/Tests/SQLiteDataTests/FetchTests.swift +++ b/Tests/SQLiteDataTests/FetchTests.swift @@ -45,16 +45,16 @@ struct FetchTests { } @Test func fetchOneWithDefault() async throws { - @FetchOne var record = Record(id: 0) + @FetchOne var record = Record(id: 2) try await $record.load() - #expect(record == Record(id: 1)) + #expect(record == Record(id: 2)) try await database.write { try Record.delete().execute($0) } await #expect(throws: NotFound.self) { try await $record.load() } #expect($record.loadError is NotFound) - #expect(record == Record(id: 1)) + #expect(record == Record(id: 2)) } @Test func fetchOneOptional_SQL() async throws {