diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 4d22126d4..2c6f2d6c8 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -3,22 +3,24 @@ archiveVersion = 1; classes = { }; - objectVersion = 100; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; - CA2BDE2C2E71C472000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2B2E71C472000974D3 /* SQLiteData */; }; - CA2BDE2E2E71C479000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2D2E71C479000974D3 /* SQLiteData */; }; - CA2BDE302E71C480000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2F2E71C480000974D3 /* SQLiteData */; }; CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */; }; CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */; }; CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; + DCB8AD4A30363D3800ACF09E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = DCB8AD4930363D3800ACF09E /* SQLiteData */; }; + DCB8AD4C30363DB100ACF09E /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCB8AD4B30363DB100ACF09E /* UIKitNavigation */; }; + DCB8AD4E30363F2100ACF09E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = DCB8AD4D30363F2100ACF09E /* SQLiteData */; }; + DCB8AD5030363F2600ACF09E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = DCB8AD4F30363F2600ACF09E /* SQLiteData */; }; + DCB8AD5230363F2A00ACF09E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = DCB8AD5130363F2A00ACF09E /* SQLiteData */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ @@ -56,6 +58,8 @@ 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; }; + DC1584A630363595009DD95C /* Integration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Integration.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DC55FADE30363BB000F2D8D3 /* sqlite-data */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sqlite-data"; path = "/Users/stephen/Developer/pointfreeco/sqlite-data"; sourceTree = ""; }; DCBE89CC2D483FB90071F499 /* SyncUps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SyncUps.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -75,6 +79,14 @@ ); target = CAF836972D4735620047AEB5 /* CaseStudies */; }; + DC15854C303636A7009DD95C /* Exceptions for "CaseStudies" folder in "Integration" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Internal/CaseStudy.swift, + "Internal/Text+Template.swift", + ); + target = DC1584A530363595009DD95C /* Integration */; + }; DCA44CFA2D5D9D1E008D4E76 /* Exceptions for "Reminders" folder in "Reminders" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -116,6 +128,7 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( CAD4819A2D584B510004799A /* Exceptions for "CaseStudies" folder in "CaseStudies" target */, + DC15854C303636A7009DD95C /* Exceptions for "CaseStudies" folder in "Integration" target */, ); path = CaseStudies; sourceTree = ""; @@ -133,6 +146,11 @@ path = Reminders; sourceTree = ""; }; + DC1584A730363595009DD95C /* Integration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Integration; + sourceTree = ""; + }; DCBE89CD2D483FB90071F499 /* SyncUps */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -167,7 +185,7 @@ CAF836952D4735620047AEB5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( - CA2BDE302E71C480000974D3 /* SQLiteData in Frameworks */, + DCB8AD4E30363F2100ACF09E /* SQLiteData in Frameworks */, CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */, ); }; @@ -179,17 +197,24 @@ CAF836D52D4735AB0047AEB5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( - CA2BDE2E2E71C479000974D3 /* SQLiteData in Frameworks */, + DCB8AD5030363F2600ACF09E /* SQLiteData in Frameworks */, CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, ); }; + DC1584A330363595009DD95C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + DCB8AD4A30363D3800ACF09E /* SQLiteData in Frameworks */, + DCB8AD4C30363DB100ACF09E /* UIKitNavigation in Frameworks */, + ); + }; DCBE89C92D483FB90071F499 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */, DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */, - CA2BDE2C2E71C472000974D3 /* SQLiteData in Frameworks */, + DCB8AD5230363F2A00ACF09E /* SQLiteData in Frameworks */, DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */, ); }; @@ -207,6 +232,7 @@ DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, CA2BDD9E2E71C30B000974D3 /* CloudKitDemo */, + DC1584A730363595009DD95C /* Integration */, CAF837022D4735C00047AEB5 /* Frameworks */, CAF836992D4735620047AEB5 /* Products */, ); @@ -222,6 +248,7 @@ CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */, CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */, + DC1584A630363595009DD95C /* Integration.app */, ); name = Products; sourceTree = ""; @@ -229,6 +256,7 @@ CAF837022D4735C00047AEB5 /* Frameworks */ = { isa = PBXGroup; children = ( + DC55FADE30363BB000F2D8D3 /* sqlite-data */, CA2BDE272E71C42B000974D3 /* sqlite-data */, ); name = Frameworks; @@ -324,7 +352,7 @@ name = CaseStudies; packageProductDependencies = ( CA2908C82D4AF70E003F165F /* UIKitNavigation */, - CA2BDE2F2E71C480000974D3 /* SQLiteData */, + DCB8AD4D30363F2100ACF09E /* SQLiteData */, ); productName = Examples; productReference = CAF836982D4735620047AEB5 /* CaseStudies.app */; @@ -368,12 +396,34 @@ packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, - CA2BDE2D2E71C479000974D3 /* SQLiteData */, + DCB8AD4F30363F2600ACF09E /* SQLiteData */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; productType = "com.apple.product-type.application"; }; + DC1584A530363595009DD95C /* Integration */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC1584AE30363596009DD95C /* Build configuration list for PBXNativeTarget "Integration" */; + buildPhases = ( + DC1584A230363595009DD95C /* Sources */, + DC1584A330363595009DD95C /* Frameworks */, + DC1584A430363595009DD95C /* Resources */, + ); + buildRules = ( + ); + fileSystemSynchronizedGroups = ( + DC1584A730363595009DD95C /* Integration */, + ); + name = Integration; + packageProductDependencies = ( + DCB8AD4930363D3800ACF09E /* SQLiteData */, + DCB8AD4B30363DB100ACF09E /* UIKitNavigation */, + ); + productName = Integration; + productReference = DC1584A630363595009DD95C /* Integration.app */; + productType = "com.apple.product-type.application"; + }; DCBE89CB2D483FB90071F499 /* SyncUps */ = { isa = PBXNativeTarget; buildConfigurationList = DCBE89F32D483FBA0071F499 /* Build configuration list for PBXNativeTarget "SyncUps" */; @@ -392,7 +442,7 @@ DCBE8A132D4842BF0071F499 /* CasePaths */, DCF267382D48437300B680BE /* SwiftUINavigation */, DC5FA7472D4C63D60082743E /* DependenciesMacros */, - CA2BDE2B2E71C472000974D3 /* SQLiteData */, + DCB8AD5130363F2A00ACF09E /* SQLiteData */, ); productName = SyncUps; productReference = DCBE89CC2D483FB90071F499 /* SyncUps.app */; @@ -405,7 +455,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2700; LastUpgradeCheck = 2610; TargetAttributes = { CA2BDD9C2E71C30B000974D3 = { @@ -429,6 +479,9 @@ CAF836D72D4735AB0047AEB5 = { CreatedOnToolsVersion = 16.2; }; + DC1584A530363595009DD95C = { + CreatedOnToolsVersion = 27.0; + }; DCBE89CB2D483FB90071F499 = { CreatedOnToolsVersion = 16.2; }; @@ -448,7 +501,7 @@ DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, - CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */, + DCB8AD4830363D3800ACF09E /* XCLocalSwiftPackageReference "../../sqlite-data" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -462,6 +515,7 @@ DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, CA2BDD9C2E71C30B000974D3 /* CloudKitDemo */, + DC1584A530363595009DD95C /* Integration */, ); }; /* End PBXProject section */ @@ -497,6 +551,11 @@ files = ( ); }; + DC1584A430363595009DD95C /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; DCBE89CA2D483FB90071F499 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( @@ -535,6 +594,11 @@ files = ( ); }; + DC1584A230363595009DD95C /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + ); + }; DCBE89C82D483FB90071F499 /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( @@ -964,6 +1028,68 @@ }; name = Release; }; + DC1584AF30363596009DD95C /* Debug configuration for PBXNativeTarget "Integration" */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Integration; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC1584B030363596009DD95C /* Release configuration for PBXNativeTarget "Integration" */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.Integration; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; DCBE89ED2D483FBA0071F499 /* Debug configuration for PBXNativeTarget "SyncUps" */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1085,6 +1211,14 @@ ); defaultConfigurationName = Release; }; + DC1584AE30363596009DD95C /* Build configuration list for PBXNativeTarget "Integration" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC1584AF30363596009DD95C /* Debug configuration for PBXNativeTarget "Integration" */, + DC1584B030363596009DD95C /* Release configuration for PBXNativeTarget "Integration" */, + ); + defaultConfigurationName = Release; + }; DCBE89F32D483FBA0071F499 /* Build configuration list for PBXNativeTarget "SyncUps" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1096,10 +1230,12 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */ = { + DCB8AD4830363D3800ACF09E /* XCLocalSwiftPackageReference "../../sqlite-data" */ = { isa = XCLocalSwiftPackageReference; - relativePath = ..; + relativePath = "../../sqlite-data"; traits = ( + CasePaths, + ColumnCoding, LazyInitializableByDefault, StrictDecoding, ); @@ -1122,9 +1258,6 @@ kind = upToNextMajorVersion; minimumVersion = 1.7.0; }; - traits = ( - Clocks, - ); }; DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { isa = XCRemoteSwiftPackageReference; @@ -1141,8 +1274,6 @@ kind = upToNextMajorVersion; minimumVersion = 2.2.3; }; - traits = ( - ); }; /* End XCRemoteSwiftPackageReference section */ @@ -1161,21 +1292,6 @@ isa = XCSwiftPackageProductDependency; productName = SQLiteData; }; - CA2BDE2B2E71C472000974D3 /* SQLiteData */ = { - isa = XCSwiftPackageProductDependency; - package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; - productName = SQLiteData; - }; - CA2BDE2D2E71C479000974D3 /* SQLiteData */ = { - isa = XCSwiftPackageProductDependency; - package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; - productName = SQLiteData; - }; - CA2BDE2F2E71C480000974D3 /* SQLiteData */ = { - isa = XCSwiftPackageProductDependency; - package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; - productName = SQLiteData; - }; CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; @@ -1206,6 +1322,27 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesMacros; }; + DCB8AD4930363D3800ACF09E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; + DCB8AD4B30363DB100ACF09E /* UIKitNavigation */ = { + isa = XCSwiftPackageProductDependency; + package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = UIKitNavigation; + }; + DCB8AD4D30363F2100ACF09E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; + DCB8AD4F30363F2600ACF09E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; + DCB8AD5130363F2A00ACF09E /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; DCBE8A132D4842BF0071F499 /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8b742760c..8bdb0fcc3 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", + "originHash" : "c56e7b70de4fe8bcc798354797a8405c0eeb2e9bc68bc0f202c14a8b11a0a97f", "pins" : [ { "identity" : "combine-schedulers", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "206cbce3882b4de9aee19ce62ac5b7306cadd45b", - "version" : "1.7.3" + "revision" : "794f4b0a9cf32042592388d014f6a1ea987d323a", + "version" : "1.9.1" } }, { diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme index fbfd2df7b..3753f201a 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/CloudKitDemo.xcscheme @@ -17,6 +17,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "CA2BDD9C2E71C30B000974D3" BuildableName = "CloudKitDemo.app" + BlueprintName = "CloudKitDemo" ReferencedContainer = "container:Examples.xcodeproj"> @@ -46,6 +47,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "CA2BDD9C2E71C30B000974D3" BuildableName = "CloudKitDemo.app" + BlueprintName = "CloudKitDemo" ReferencedContainer = "container:Examples.xcodeproj"> @@ -62,6 +64,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "CA2BDD9C2E71C30B000974D3" BuildableName = "CloudKitDemo.app" + BlueprintName = "CloudKitDemo" ReferencedContainer = "container:Examples.xcodeproj"> diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme new file mode 100644 index 000000000..45608be57 --- /dev/null +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Integration.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/Integration/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Integration/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Examples/Integration/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/Examples/Integration/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/Assets.xcassets/Contents.json b/Examples/Integration/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/Integration/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Integration/IntegrationApp.swift b/Examples/Integration/IntegrationApp.swift new file mode 100644 index 000000000..abb52ec69 --- /dev/null +++ b/Examples/Integration/IntegrationApp.swift @@ -0,0 +1,9 @@ +import SwiftUI + +@main +struct IntegrationApp: App { + var body: some Scene { + WindowGroup { + } + } +} diff --git a/Examples/Integration/Regression Coverage/ChildIdentityReset.swift b/Examples/Integration/Regression Coverage/ChildIdentityReset.swift new file mode 100644 index 000000000..be7dc421b --- /dev/null +++ b/Examples/Integration/Regression Coverage/ChildIdentityReset.swift @@ -0,0 +1,105 @@ +import SQLiteData +import SwiftUI + +struct ChildIdentityResetCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that the `@Fetch*` tools behave like `@State` when a view's identity \ + changes: their state survives re-renders of the parent view, but is discarded and rebuilt \ + when the parent changes the child view's identity with the `id` view modifier. + + Toggle "Favorites only" in the child view to load a filtered query, then tap "Reset child \ + identity". The child view is rebuilt from scratch: the toggle returns to its default and \ + the list returns to the unfiltered query, keeping the UI and data consistent. + """ + let caseStudyTitle = "Resetting child identity" + + @State private var resetCount = 0 + + var body: some View { + List { + Section { + Button("Reset child identity: \(resetCount)") { + resetCount += 1 + } + } + FactsListView() + .id(resetCount) + } + } +} + +private struct FactsListView: View { + @State private var isFavoritesOnly = false + @FetchAll(Fact.all) private var facts + + var body: some View { + Section { + Toggle("Favorites only", isOn: $isFavoritesOnly) + ForEach(facts) { fact in + HStack { + Text(fact.body) + Spacer() + if fact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + } + } + .task(id: isFavoritesOnly) { + await withErrorReporting { + if isFavoritesOnly { + try await $facts.load(Fact.where(\.isFavorite)).task + } else { + try await $facts.load(Fact.all).task + } + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String + var isFavorite = false +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var childIdentityResetDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL, + "isFavorite" INTEGER NOT NULL DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.", isFavorite: true) + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.", isFavorite: true) + Fact.Draft(body: "SQLite databases are a single file on disk.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .childIdentityResetDatabase + } + NavigationStack { + CaseStudyView { + ChildIdentityResetCaseStudy() + } + } +} diff --git a/Examples/Integration/Regression Coverage/ParentDrivenQuery.swift b/Examples/Integration/Regression Coverage/ParentDrivenQuery.swift new file mode 100644 index 000000000..e6b4b8554 --- /dev/null +++ b/Examples/Integration/Regression Coverage/ParentDrivenQuery.swift @@ -0,0 +1,110 @@ +import SQLiteData +import SwiftUI + +struct ParentDrivenQueryCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates how to drive a child view's query from parent state by constructing the \ + `@FetchAll` in the child's initializer, analogous to constructing a SwiftData `@Query` with a \ + dynamic predicate in a view's initializer. + + Toggling "Favorites only" re-initializes the child view with a different query, and the child \ + should immediately display the results of the new query. Tapping "Re-render parent" \ + re-initializes the child with the same query, which should have no effect. + """ + let caseStudyTitle = "Parent-driven queries" + + @State private var isFavoritesOnly = false + @State private var rerenderCount = 0 + + var body: some View { + List { + Section { + Toggle("Favorites only", isOn: $isFavoritesOnly) + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + } + FactsListView(isFavoritesOnly: isFavoritesOnly) + } + } +} + +private struct FactsListView: View { + @FetchAll private var facts: [Fact] + + init(isFavoritesOnly: Bool) { + print("FactsListView.init") + if isFavoritesOnly { + _facts = FetchAll(Fact.where(\.isFavorite)) + } else { + _facts = FetchAll(Fact.all) + } + } + + var body: some View { + Section { + ForEach(facts) { fact in + HStack { + Text(fact.body) + Spacer() + if fact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String + var isFavorite = false +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentDrivenQueryDatabase: Self { + var configuration = Configuration() + configuration.prepareDatabase { + $0.trace { + print($0.description) + } + } + let databaseQueue = try! DatabaseQueue(configuration: configuration) + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL, + "isFavorite" INTEGER NOT NULL DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.", isFavorite: true) + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.", isFavorite: true) + Fact.Draft(body: "SQLite databases are a single file on disk.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentDrivenQueryDatabase + } + NavigationStack { + CaseStudyView { + ParentDrivenQueryCaseStudy() + } + } +} diff --git a/Examples/Integration/Regression Coverage/ParentRerenderAnimations.swift b/Examples/Integration/Regression Coverage/ParentRerenderAnimations.swift new file mode 100644 index 000000000..363017228 --- /dev/null +++ b/Examples/Integration/Regression Coverage/ParentRerenderAnimations.swift @@ -0,0 +1,94 @@ +import SQLiteData +import SwiftUI + +struct ParentRerenderAnimationsCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that animations provided to the `@Fetch*` tools continue to work after a \ + parent view re-renders. + + The list below is loaded in the child view's `task` with an `animation` parameter, and so \ + tapping "Add fact" animates the new fact into the list. Tapping "Re-render parent" changes \ + `@State` in the parent view, which causes the child view (and its `@FetchAll`) to be \ + re-initialized. Adding a fact should continue to animate afterwards. + """ + let caseStudyTitle = "Animations with re-rendered parent" + + @State private var rerenderCount = 0 + @Dependency(\.defaultDatabase) var database + + var body: some View { + List { + Section { + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + Button("Add fact") { + withErrorReporting { + try database.write { db in + try Fact.insert { + Fact.Draft(body: Date.now.formatted(date: .omitted, time: .standard)) + } + .execute(db) + } + } + } + } + FactsListView() + } + } +} + +private struct FactsListView: View { + @FetchAll(Fact.order { $0.id.desc() }) + private var facts + + var body: some View { + Section { + ForEach(facts) { fact in + Text(fact.body) + } + } + .task { + await withErrorReporting { + try await $facts.load(Fact.order { $0.id.desc() }, animation: .default).task + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderAnimationsDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderAnimationsDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderAnimationsCaseStudy() + } + } +} diff --git a/Examples/Integration/Regression Coverage/ParentRerenderCancellation.swift b/Examples/Integration/Regression Coverage/ParentRerenderCancellation.swift new file mode 100644 index 000000000..8bacecac4 --- /dev/null +++ b/Examples/Integration/Regression Coverage/ParentRerenderCancellation.swift @@ -0,0 +1,100 @@ +import SQLiteData +import SwiftUI + +struct ParentRerenderCancellationCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that a cancelled observation survives a parent view re-render. + + The child view below observes a list of facts that grows every second, and toggling "Live \ + updates" off cancels the observation using the subscription's `task`, freezing the list. \ + Tapping "Re-render parent" changes `@State` in the parent view, which causes the child view \ + (and its `@FetchAll`) to be re-initialized. The list should remain frozen, and should not \ + silently resume live updates while the toggle remains off. + """ + let caseStudyTitle = "Cancellation with re-rendered parent" + + @State private var rerenderCount = 0 + + var body: some View { + List { + Section { + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + } + FactsListView() + } + } +} + +private struct FactsListView: View { + @State private var isLive = true + @FetchAll(Fact.order { $0.id.desc() }) + private var facts + @Dependency(\.defaultDatabase) var database + + var body: some View { + Section { + Toggle("Live updates", isOn: $isLive) + ForEach(facts) { fact in + Text(fact.body) + } + } + .task(id: isLive) { + guard isLive else { return } + await withErrorReporting { + try await $facts.load(Fact.order { $0.id.desc() }).task + } + } + .task { + do { + while true { + try await Task.sleep(for: .seconds(1)) + try await database.write { db in + try Fact.insert { + Fact.Draft(body: Date.now.formatted(date: .omitted, time: .standard)) + } + .execute(db) + } + } + } catch {} + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderCancellationDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderCancellationDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderCancellationCaseStudy() + } + } +} diff --git a/Examples/Integration/Regression Coverage/ParentRerenderDynamicQuery.swift b/Examples/Integration/Regression Coverage/ParentRerenderDynamicQuery.swift new file mode 100644 index 000000000..3a2af0a95 --- /dev/null +++ b/Examples/Integration/Regression Coverage/ParentRerenderDynamicQuery.swift @@ -0,0 +1,104 @@ +import SQLiteData +import SwiftUI + +struct ParentRerenderDynamicQueryCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that a dynamically loaded query survives a parent view re-render. + + The child view below starts with a query for all facts, and toggling "Favorites only" loads \ + a filtered query. Tapping "Re-render parent" changes `@State` in the parent view, which \ + causes the child view (and its `@FetchAll`) to be re-initialized. The filtered facts should \ + remain on screen, and should not silently revert to the unfiltered query while the toggle \ + remains on. + """ + let caseStudyTitle = "Dynamic queries with re-rendered parent" + + @State private var rerenderCount = 0 + + var body: some View { + List { + Section { + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + } + FactsListView() + } + } +} + +private struct FactsListView: View { + @State private var isFavoritesOnly = false + @FetchAll(Fact.all) private var facts + + var body: some View { + Section { + Toggle("Favorites only", isOn: $isFavoritesOnly) + ForEach(facts) { fact in + HStack { + Text(fact.body) + Spacer() + if fact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + } + } + .task(id: isFavoritesOnly) { + await withErrorReporting { + if isFavoritesOnly { + try await $facts.load(Fact.where(\.isFavorite)).task + } else { + try await $facts.load(Fact.all).task + } + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String + var isFavorite = false +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderDynamicQueryDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL, + "isFavorite" INTEGER NOT NULL DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.", isFavorite: true) + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.", isFavorite: true) + Fact.Draft(body: "SQLite databases are a single file on disk.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderDynamicQueryDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderDynamicQueryCaseStudy() + } + } +} diff --git a/Examples/Integration/Regression Coverage/ParentRerenderLoadError.swift b/Examples/Integration/Regression Coverage/ParentRerenderLoadError.swift new file mode 100644 index 000000000..b156819a4 --- /dev/null +++ b/Examples/Integration/Regression Coverage/ParentRerenderLoadError.swift @@ -0,0 +1,75 @@ +import GRDB +import SQLiteData +import SwiftUI + +struct ParentRerenderLoadErrorCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that a load error survives a parent view re-render. + + The child view below loads a query that always fails, and so it renders the `loadError` of \ + its `@Fetch` property. Tapping the stepper changes `@State` in the parent view, which causes \ + the child view (and its `@Fetch`) to be re-initialized. The error should remain on screen, \ + and should not be silently discarded, which would make the view appear healthy even though \ + its query failed. + """ + let caseStudyTitle = "Load errors with re-rendered parent" + + @State private var count = 0 + + var body: some View { + List { + Section { + Stepper("Parent state: \(count)", value: $count) + } + FactsView(count: count) + } + } +} + +private struct FactsView: View { + let count: Int + @Fetch private var facts = Facts.Value() + + var body: some View { + Section("Facts (parent state: \(count))") { + if let loadError = $facts.loadError { + Label(loadError.localizedDescription, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } else { + Text("Facts: \(facts.count)") + } + } + .task { + _ = try? await $facts.load(Facts()) + } + } + + private struct Facts: FetchKeyRequest { + struct Value { + var count = 0 + } + func fetch(_ db: Database) throws -> Value { + struct QueryFailure: LocalizedError { + var errorDescription: String? { "Something went wrong." } + } + throw QueryFailure() + } + } +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderLoadErrorDatabase: Self { + try! DatabaseQueue() + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderLoadErrorDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderLoadErrorCaseStudy() + } + } +} diff --git a/Examples/Integration/Regression Coverage/ParentRerenderLoadedData.swift b/Examples/Integration/Regression Coverage/ParentRerenderLoadedData.swift new file mode 100644 index 000000000..1c689c0f7 --- /dev/null +++ b/Examples/Integration/Regression Coverage/ParentRerenderLoadedData.swift @@ -0,0 +1,100 @@ +import GRDB +import SQLiteData +import SwiftUI + +struct ParentRerenderLoadedDataCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that data loaded by the `@Fetch*` tools survives a parent view re-render. + + The child view below has a `@Fetch` property that begins with an empty default value and is \ + loaded in the view's `task`. Tapping the stepper changes `@State` in the parent view, which \ + causes the child view (and its `@Fetch`) to be re-initialized. The facts should remain on \ + screen, and should not revert to the empty default value. The same is true when any other \ + dynamic property in the parent changes, such as `@Environment`. + """ + let caseStudyTitle = "Loaded data with re-rendered parent" + + @State private var count = 0 + + var body: some View { + List { + Section { + Stepper("Parent state: \(count)", value: $count) + } + FactsListView(count: count) + } + } +} + +private struct FactsListView: View { + let count: Int + @Fetch private var facts = Facts.Value() + + var body: some View { + Section("Facts (parent state: \(count))") { + if facts.facts.isEmpty { + Text("No facts loaded") + } + ForEach(facts.facts) { fact in + Text(fact.body) + } + } + .task { + await withErrorReporting { + try await $facts.load(Facts()).task + } + } + } + + private struct Facts: FetchKeyRequest { + struct Value { + var facts: [Fact] = [] + } + func fetch(_ db: Database) throws -> Value { + try Value(facts: Fact.order { $0.id.desc() }.fetchAll(db)) + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderLoadedDataDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.") + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderLoadedDataDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderLoadedDataCaseStudy() + } + } +} diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index a41e28e0f..a15662494 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -14,7 +14,7 @@ struct ReminderFormView: View { @Environment(\.dismiss) var dismiss init(reminder: Reminder.Draft, remindersList: RemindersList) { - _remindersList = FetchOne(wrappedValue: remindersList, RemindersList.find(remindersList.id)) + _remindersList = FetchOne(wrappedValue: remindersList) self.reminder = reminder } diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 9818ff3e6..212f88cf0 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -37,7 +37,7 @@ final class SyncUpDetailModel: HashableObject { self.destination = destination _attendees = FetchAll(Attendee.where { $0.syncUpID.eq(syncUp.id) }) _meetings = FetchAll(Meeting.where { $0.syncUpID.eq(syncUp.id) }) - _syncUp = FetchOne(wrappedValue: syncUp, SyncUp.find(syncUp.id)) + _syncUp = FetchOne(wrappedValue: syncUp) } func deleteMeetings(atOffsets indices: IndexSet) { diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873a..ee36aa4ab 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,31 @@ public import Sharing @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 + #if canImport(SwiftUI) + /// 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 { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// 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 let sharedReader: SharedReader + #endif /// Data associated with the underlying query. public var wrappedValue: Value { @@ -93,6 +112,7 @@ public struct Fetch: Sendable { database: (any DatabaseReader)? = nil ) { sharedReader = SharedReader(wrappedValue: wrappedValue, .fetch(request, database: database)) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given request. @@ -110,6 +130,19 @@ public struct Fetch: Sendable { try await sharedReader.load(.fetch(request, database: database)) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension Fetch { @@ -132,6 +165,7 @@ extension Fetch { wrappedValue: wrappedValue, .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given request. @@ -169,7 +203,11 @@ extension Fetch: Equatable where Value: Equatable { #if canImport(SwiftUI) extension Fetch: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.update(from: box) + } + persisted.subscribe(generation: generation) } /// Initializes this property with a request associated with the wrapped value. @@ -192,6 +230,7 @@ extension Fetch: Equatable where Value: Equatable { wrappedValue: wrappedValue, .fetch(request, database: database, animation: animation) ) + setFetchKeyID(for: request, database: database, scheduler: .animation(animation)) } /// Replaces the wrapped value with data from the given request. diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift index e3e7adc7e..0a8229503 100644 --- a/Sources/SQLiteData/FetchAll+Sections.swift +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -503,9 +503,10 @@ extension FetchAll { scheduler: scheduler ) ) - self.sectionedReader = sectionedReader - self.sharedReader = sectionedReader.elements - self.sectioning.setValue(sectionBy) + sharedReader = sectionedReader.elements + self.sectionedReader.projectedValue = sectionedReader.projectedValue + sectioning.setValue(sectionBy) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } func loadSections( diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 839e8063f..af10326a5 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -22,16 +22,44 @@ public import StructuredQueriesCore @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 internal(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #if canImport(SwiftUI) + /// 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]> { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchAllBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + } - var sectionedReader: SharedReader> = - SharedReader(value: ResultsSectionCollection()) + var sectionedReader: SharedReader> { + state.wrappedValue.sectionedReader + } - let sectioning = LockIsolated<_Sectioning?>(nil) + var sectioning: LockIsolated<_Sectioning?> { + state.wrappedValue.sectioning + } + + private let box: FetchAllBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// 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 let sharedReader: SharedReader<[Element]> + + let sectionedReader: SharedReader> = + SharedReader(value: ResultsSectionCollection()) + + let sectioning = LockIsolated<_Sectioning?>(nil) + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -148,13 +176,12 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -173,13 +200,12 @@ public struct FetchAll: Sendable { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -230,6 +256,19 @@ public struct FetchAll: Sendable { return FetchSubscription(sharedReader: sharedReader) } + #if !canImport(SwiftUI) + @_transparent + #endif + func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } + func removeSections() { guard sectioning.value != nil else { return } sectioning.setValue(nil) @@ -238,7 +277,13 @@ public struct FetchAll: Sendable { } extension FetchAll { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, @@ -310,14 +355,12 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -339,14 +382,12 @@ extension FetchAll { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -420,11 +461,20 @@ extension FetchAll: Equatable where Element: Equatable { #if canImport(SwiftUI) extension FetchAll: DynamicProperty { public func update() { - sharedReader.update() - sectionedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.update(from: box) + } + persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 3fa698c66..7bb4491c5 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -21,11 +21,31 @@ public import StructuredQueriesCore @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 + #if canImport(SwiftUI) + /// 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 { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// 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 let sharedReader: SharedReader + #endif /// A value associated with the underlying query. public var wrappedValue: Value { @@ -106,10 +126,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query that fetches the first row from a table. @@ -128,10 +150,12 @@ public struct FetchOne: Sendable { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query that fetches the first row from a table. @@ -215,10 +239,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -236,10 +262,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -258,10 +286,12 @@ public struct FetchOne: Sendable { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -283,10 +313,12 @@ public struct FetchOne: Sendable { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -307,13 +339,12 @@ public struct FetchOne: Sendable { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -333,10 +364,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -473,10 +506,29 @@ public struct FetchOne: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchOne { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -489,7 +541,13 @@ extension FetchOne { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -534,14 +592,12 @@ extension FetchOne { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query that fetches the first row from a table. @@ -563,14 +619,12 @@ extension FetchOne { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query that fetches the first row from a table. @@ -674,14 +728,12 @@ extension FetchOne { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -702,14 +754,12 @@ extension FetchOne { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -731,14 +781,12 @@ extension FetchOne { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -763,14 +811,12 @@ extension FetchOne { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -794,14 +840,12 @@ extension FetchOne { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -824,14 +868,12 @@ extension FetchOne { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -1023,10 +1065,20 @@ extension FetchOne: Equatable where Value: Equatable { #if canImport(SwiftUI) extension FetchOne: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.update(from: box) + } + persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -1039,7 +1091,13 @@ extension FetchOne: Equatable where Value: Equatable { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift new file mode 100644 index 000000000..ed21e1c6b --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,96 @@ +#if canImport(SwiftUI) + import Combine + import ConcurrencyExtras + import Sharing + import SwiftUI + + final class FetchBox: Sendable { + let sharedReader: SharedReader + private let storage = LockIsolated(Storage()) + + var fetchKeyID: FetchKeyID? { + get { storage.withValue { $0.fetchKeyID } } + set { storage.withValue { $0.fetchKeyID = newValue } } + } + + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + } + + func update(from other: FetchBox) { + guard let otherFetchKeyID = other.fetchKeyID else { return } + let isAdopted = storage.withValue { + guard otherFetchKeyID != $0.fetchKeyID else { return false } + $0.fetchKeyID = otherFetchKeyID + return true + } + guard isAdopted else { return } + sharedReader.projectedValue = other.sharedReader.projectedValue + } + + func subscribe(generation: SwiftUI.State) { + guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } + _ = generation.wrappedValue + storage.withValue { + $0.swiftUICancellable = sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + } + } + + private struct Storage { + var fetchKeyID: FetchKeyID? + var swiftUICancellable: AnyCancellable? + } + } + + final class FetchAllBox: Sendable { + let sharedReader: SharedReader<[Element]> + let sectionedReader: SharedReader> + let sectioning = LockIsolated<_Sectioning?>(nil) + private let storage = LockIsolated(Storage()) + + var fetchKeyID: FetchKeyID? { + get { storage.withValue { $0.fetchKeyID } } + set { storage.withValue { $0.fetchKeyID = newValue } } + } + + init(sharedReader: SharedReader<[Element]>) { + self.sharedReader = sharedReader + self.sectionedReader = SharedReader(value: ResultsSectionCollection()) + } + + func update(from other: FetchAllBox) { + guard let otherFetchKeyID = other.fetchKeyID else { return } + let isAdopted = storage.withValue { + guard otherFetchKeyID != $0.fetchKeyID else { return false } + $0.fetchKeyID = otherFetchKeyID + return true + } + guard isAdopted else { return } + sharedReader.projectedValue = other.sharedReader.projectedValue + sectionedReader.projectedValue = other.sectionedReader.projectedValue + sectioning.setValue(other.sectioning.value) + } + + func subscribe(generation: SwiftUI.State) { + guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } + _ = generation.wrappedValue + storage.withValue { + $0.swiftUICancellables = [ + sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 }, + sectionedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 }, + ] + } + } + + private struct Storage { + var fetchKeyID: FetchKeyID? + var swiftUICancellables: [AnyCancellable] = [] + } + } +#endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift new file mode 100644 index 000000000..a0a598c73 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -0,0 +1,78 @@ +#if canImport(SwiftUI) + import GRDB + import Sharing + import Testing + + @testable import SQLiteData + + @Suite struct FetchBoxTests { + let database: any DatabaseReader + + init() throws { + database = try DatabaseQueue() + } + + @Test func keyedReinitializationWithNewQueryIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 2) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) + } + + @Test func keyedReinitializationWithSameQueryIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keyedToKeylessReinitializationIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + #expect(persisted.fetchKeyID != nil) + } + + @Test func keylessReinitializationIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessToKeyedReinitializationIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 2) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) + } + + @Test func keylessReinitializationAfterLocalLoadIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.sharedReader.projectedValue = SharedReader(value: [1, 2, 3]).projectedValue + let fresh = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) + } + + private func fetchKeyID(_ request: some FetchKeyRequest) -> FetchKeyID { + FetchKey(request: request, database: database, scheduler: nil).id + } + } + + private struct TestRequest: FetchKeyRequest, Hashable { + let id: Int + func fetch(_ db: Database) throws -> Int { + id + } + } +#endif