From cac2ef81c8accd4b8e3fa0787c5c046b91aa0049 Mon Sep 17 00:00:00 2001 From: Chris Laganiere Date: Sun, 21 Apr 2024 15:12:30 -0700 Subject: [PATCH] 1.0.0 --- .github/workflows/swift.yml | 22 + .gitignore | 9 + Example/MoreDrama.xcodeproj/project.pbxproj | 723 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 15 + .../xcshareddata/xcschemes/MoreDrama.xcscheme | 106 +++ Example/MoreDrama.xctestplan | 35 + .../Data/Models/Person+CoreDataClass.swift | 7 + .../Models/Person+CoreDataProperties.swift | 51 ++ .../Data/Models/Person+Fetchable.swift | 11 + .../MoreDrama/Data/Models/Person+Filter.swift | 30 + .../MoreDrama/Data/Models/Person+Sort.swift | 35 + .../Data/Models/Statement+CoreDataClass.swift | 7 + .../Models/Statement+CoreDataProperties.swift | 33 + .../Data/Models/Statement+Fetchable.swift | 11 + .../Data/Models/Statement+Filter.swift | 27 + .../Data/Models/Statement+Sort.swift | 24 + Example/MoreDrama/MoreDramaApp.swift | 19 + .../MoreDramaAppDataDependencies.swift | 64 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Resources/Assets.xcassets/Contents.json | 6 + .../MoreDrama.xcdatamodeld/.xccurrentversion | 8 + .../MoreDrama.xcdatamodel/contents | 16 + .../Preview Assets.xcassets/Contents.json | 6 + Example/MoreDrama/Views/ContentView.swift | 99 +++ Example/MoreDramaTests/MoreDramaTests.swift | 29 + .../MoreDramaUITests/MoreDramaUITests.swift | 34 + .../MoreDramaUITestsLaunchTests.swift | 25 + LICENSE | 21 + Package.swift | 30 + README.md | 208 +++++ .../CoreDataPersistenceController.swift | 139 ++++ .../CoreData/CoreDataPersisting.swift | 32 + Sources/MoreData/Fetchable/Fetchable.swift | 150 ++++ .../Fetchable/FetchableResultsPublisher.swift | 209 +++++ .../FetchableResultsPublishing.swift | 35 + Sources/MoreData/Fetchable/Filtering.swift | 60 ++ Sources/MoreData/Fetchable/Sorting.swift | 9 + .../MoreData/SwiftUI/FetchableRequest.swift | 64 ++ .../CoreDataPersistenceControllerTests.swift | 98 +++ .../CoreData/makeInMemoryViewContext.swift | 11 + .../Fetchable/FetchableTests.swift | 141 ++++ .../Fetchable/FilteringTests.swift | 93 +++ .../MockData/ManagedObjectModel+Test.swift | 69 ++ .../MockData/Models/TestEntity.swift | 59 ++ 47 files changed, 2919 insertions(+) create mode 100644 .github/workflows/swift.yml create mode 100644 .gitignore create mode 100644 Example/MoreDrama.xcodeproj/project.pbxproj create mode 100644 Example/MoreDrama.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Example/MoreDrama.xcodeproj/xcshareddata/xcschemes/MoreDrama.xcscheme create mode 100644 Example/MoreDrama.xctestplan create mode 100644 Example/MoreDrama/Data/Models/Person+CoreDataClass.swift create mode 100644 Example/MoreDrama/Data/Models/Person+CoreDataProperties.swift create mode 100644 Example/MoreDrama/Data/Models/Person+Fetchable.swift create mode 100644 Example/MoreDrama/Data/Models/Person+Filter.swift create mode 100644 Example/MoreDrama/Data/Models/Person+Sort.swift create mode 100644 Example/MoreDrama/Data/Models/Statement+CoreDataClass.swift create mode 100644 Example/MoreDrama/Data/Models/Statement+CoreDataProperties.swift create mode 100644 Example/MoreDrama/Data/Models/Statement+Fetchable.swift create mode 100644 Example/MoreDrama/Data/Models/Statement+Filter.swift create mode 100644 Example/MoreDrama/Data/Models/Statement+Sort.swift create mode 100644 Example/MoreDrama/MoreDramaApp.swift create mode 100644 Example/MoreDrama/MoreDramaAppDataDependencies.swift create mode 100644 Example/MoreDrama/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/MoreDrama/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/MoreDrama/Resources/Assets.xcassets/Contents.json create mode 100644 Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/.xccurrentversion create mode 100644 Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/MoreDrama.xcdatamodel/contents create mode 100644 Example/MoreDrama/Resources/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Example/MoreDrama/Views/ContentView.swift create mode 100644 Example/MoreDramaTests/MoreDramaTests.swift create mode 100644 Example/MoreDramaUITests/MoreDramaUITests.swift create mode 100644 Example/MoreDramaUITests/MoreDramaUITestsLaunchTests.swift create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/MoreData/CoreData/CoreDataPersistenceController.swift create mode 100644 Sources/MoreData/CoreData/CoreDataPersisting.swift create mode 100644 Sources/MoreData/Fetchable/Fetchable.swift create mode 100644 Sources/MoreData/Fetchable/FetchableResultsPublisher.swift create mode 100644 Sources/MoreData/Fetchable/FetchableResultsPublishing.swift create mode 100644 Sources/MoreData/Fetchable/Filtering.swift create mode 100644 Sources/MoreData/Fetchable/Sorting.swift create mode 100644 Sources/MoreData/SwiftUI/FetchableRequest.swift create mode 100644 Tests/MoreDataTests/CoreData/CoreDataPersistenceControllerTests.swift create mode 100644 Tests/MoreDataTests/CoreData/makeInMemoryViewContext.swift create mode 100644 Tests/MoreDataTests/Fetchable/FetchableTests.swift create mode 100644 Tests/MoreDataTests/Fetchable/FilteringTests.swift create mode 100644 Tests/MoreDataTests/MockData/ManagedObjectModel+Test.swift create mode 100644 Tests/MoreDataTests/MockData/Models/TestEntity.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..21ae770 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,22 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaa25db --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +.netrc diff --git a/Example/MoreDrama.xcodeproj/project.pbxproj b/Example/MoreDrama.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f479021 --- /dev/null +++ b/Example/MoreDrama.xcodeproj/project.pbxproj @@ -0,0 +1,723 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 8335A99D2C65C9450077EE3F /* Person+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A99C2C65C9450077EE3F /* Person+Filter.swift */; }; + 8335A99F2C65CA2B0077EE3F /* Person+Sort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A99E2C65CA2B0077EE3F /* Person+Sort.swift */; }; + 8335A9A12C6852860077EE3F /* Statement+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9A02C6852860077EE3F /* Statement+Filter.swift */; }; + 8335A9A32C68528D0077EE3F /* Statement+Sort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9A22C68528D0077EE3F /* Statement+Sort.swift */; }; + 8335A9A52C6853260077EE3F /* Person+Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9A42C6853260077EE3F /* Person+Fetchable.swift */; }; + 8335A9A72C6853630077EE3F /* Statement+Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9A62C6853630077EE3F /* Statement+Fetchable.swift */; }; + 8335A9AC2C6853850077EE3F /* Statement+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9A82C6853850077EE3F /* Statement+CoreDataClass.swift */; }; + 8335A9AD2C6853850077EE3F /* Statement+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9A92C6853850077EE3F /* Statement+CoreDataProperties.swift */; }; + 8335A9AE2C6853850077EE3F /* Person+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9AA2C6853850077EE3F /* Person+CoreDataClass.swift */; }; + 8335A9AF2C6853850077EE3F /* Person+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8335A9AB2C6853850077EE3F /* Person+CoreDataProperties.swift */; }; + 83A189002C69AC2600646C38 /* MoreData in Frameworks */ = {isa = PBXBuildFile; productRef = 83A188FF2C69AC2600646C38 /* MoreData */; }; + 83BA342F2C65B2C40044A149 /* MoreDramaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BA342E2C65B2C40044A149 /* MoreDramaApp.swift */; }; + 83BA34312C65B2C40044A149 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BA34302C65B2C40044A149 /* ContentView.swift */; }; + 83BA34332C65B2C50044A149 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 83BA34322C65B2C50044A149 /* Assets.xcassets */; }; + 83BA34362C65B2C50044A149 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 83BA34352C65B2C50044A149 /* Preview Assets.xcassets */; }; + 83BA343B2C65B2C50044A149 /* MoreDrama.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 83BA34392C65B2C50044A149 /* MoreDrama.xcdatamodeld */; }; + 83BA34452C65B2C50044A149 /* MoreDramaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BA34442C65B2C50044A149 /* MoreDramaTests.swift */; }; + 83BA344F2C65B2C50044A149 /* MoreDramaUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BA344E2C65B2C50044A149 /* MoreDramaUITests.swift */; }; + 83BA34512C65B2C50044A149 /* MoreDramaUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BA34502C65B2C50044A149 /* MoreDramaUITestsLaunchTests.swift */; }; + 83BA34642C65B7C40044A149 /* MoreDramaAppDataDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BA34632C65B7C40044A149 /* MoreDramaAppDataDependencies.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 83BA34412C65B2C50044A149 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83BA34232C65B2C40044A149 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 83BA342A2C65B2C40044A149; + remoteInfo = MoreDrama; + }; + 83BA344B2C65B2C50044A149 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83BA34232C65B2C40044A149 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 83BA342A2C65B2C40044A149; + remoteInfo = MoreDrama; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 8335A99C2C65C9450077EE3F /* Person+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Person+Filter.swift"; sourceTree = ""; }; + 8335A99E2C65CA2B0077EE3F /* Person+Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Person+Sort.swift"; sourceTree = ""; }; + 8335A9A02C6852860077EE3F /* Statement+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Statement+Filter.swift"; sourceTree = ""; }; + 8335A9A22C68528D0077EE3F /* Statement+Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Statement+Sort.swift"; sourceTree = ""; }; + 8335A9A42C6853260077EE3F /* Person+Fetchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Person+Fetchable.swift"; sourceTree = ""; }; + 8335A9A62C6853630077EE3F /* Statement+Fetchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Statement+Fetchable.swift"; sourceTree = ""; }; + 8335A9A82C6853850077EE3F /* Statement+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Statement+CoreDataClass.swift"; sourceTree = ""; }; + 8335A9A92C6853850077EE3F /* Statement+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Statement+CoreDataProperties.swift"; sourceTree = ""; }; + 8335A9AA2C6853850077EE3F /* Person+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Person+CoreDataClass.swift"; sourceTree = ""; }; + 8335A9AB2C6853850077EE3F /* Person+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Person+CoreDataProperties.swift"; sourceTree = ""; }; + 83A189012C69C29A00646C38 /* MoreDrama.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MoreDrama.xctestplan; sourceTree = ""; }; + 83BA342B2C65B2C40044A149 /* MoreDrama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MoreDrama.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 83BA342E2C65B2C40044A149 /* MoreDramaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreDramaApp.swift; sourceTree = ""; }; + 83BA34302C65B2C40044A149 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 83BA34322C65B2C50044A149 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 83BA34352C65B2C50044A149 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 83BA343A2C65B2C50044A149 /* MoreDrama.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MoreDrama.xcdatamodel; sourceTree = ""; }; + 83BA34402C65B2C50044A149 /* MoreDramaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoreDramaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 83BA34442C65B2C50044A149 /* MoreDramaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreDramaTests.swift; sourceTree = ""; }; + 83BA344A2C65B2C50044A149 /* MoreDramaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoreDramaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 83BA344E2C65B2C50044A149 /* MoreDramaUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreDramaUITests.swift; sourceTree = ""; }; + 83BA34502C65B2C50044A149 /* MoreDramaUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreDramaUITestsLaunchTests.swift; sourceTree = ""; }; + 83BA34632C65B7C40044A149 /* MoreDramaAppDataDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreDramaAppDataDependencies.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 83A188FD2C69ABA000646C38 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 83A189002C69AC2600646C38 /* MoreData in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83BA343D2C65B2C50044A149 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83BA34472C65B2C50044A149 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 8335A98E2C65C6FD0077EE3F /* Models */ = { + isa = PBXGroup; + children = ( + 8335A9AA2C6853850077EE3F /* Person+CoreDataClass.swift */, + 8335A9AB2C6853850077EE3F /* Person+CoreDataProperties.swift */, + 8335A9A42C6853260077EE3F /* Person+Fetchable.swift */, + 8335A99C2C65C9450077EE3F /* Person+Filter.swift */, + 8335A99E2C65CA2B0077EE3F /* Person+Sort.swift */, + 8335A9A82C6853850077EE3F /* Statement+CoreDataClass.swift */, + 8335A9A92C6853850077EE3F /* Statement+CoreDataProperties.swift */, + 8335A9A62C6853630077EE3F /* Statement+Fetchable.swift */, + 8335A9A02C6852860077EE3F /* Statement+Filter.swift */, + 8335A9A22C68528D0077EE3F /* Statement+Sort.swift */, + ); + path = Models; + sourceTree = ""; + }; + 8335A98F2C65C7090077EE3F /* Data */ = { + isa = PBXGroup; + children = ( + 8335A98E2C65C6FD0077EE3F /* Models */, + ); + path = Data; + sourceTree = ""; + }; + 8335A9902C65C72B0077EE3F /* Resources */ = { + isa = PBXGroup; + children = ( + 83BA34342C65B2C50044A149 /* Preview Content */, + 83BA34322C65B2C50044A149 /* Assets.xcassets */, + 83BA34392C65B2C50044A149 /* MoreDrama.xcdatamodeld */, + ); + path = Resources; + sourceTree = ""; + }; + 8335A9912C65C7450077EE3F /* Views */ = { + isa = PBXGroup; + children = ( + 83BA34302C65B2C40044A149 /* ContentView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 83BA34222C65B2C40044A149 = { + isa = PBXGroup; + children = ( + 83A189012C69C29A00646C38 /* MoreDrama.xctestplan */, + 83BA342D2C65B2C40044A149 /* MoreDrama */, + 83BA34432C65B2C50044A149 /* MoreDramaTests */, + 83BA344D2C65B2C50044A149 /* MoreDramaUITests */, + 83BA342C2C65B2C40044A149 /* Products */, + 83BA34602C65B7250044A149 /* Frameworks */, + ); + sourceTree = ""; + }; + 83BA342C2C65B2C40044A149 /* Products */ = { + isa = PBXGroup; + children = ( + 83BA342B2C65B2C40044A149 /* MoreDrama.app */, + 83BA34402C65B2C50044A149 /* MoreDramaTests.xctest */, + 83BA344A2C65B2C50044A149 /* MoreDramaUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 83BA342D2C65B2C40044A149 /* MoreDrama */ = { + isa = PBXGroup; + children = ( + 8335A98F2C65C7090077EE3F /* Data */, + 8335A9902C65C72B0077EE3F /* Resources */, + 8335A9912C65C7450077EE3F /* Views */, + 83BA342E2C65B2C40044A149 /* MoreDramaApp.swift */, + 83BA34632C65B7C40044A149 /* MoreDramaAppDataDependencies.swift */, + ); + path = MoreDrama; + sourceTree = ""; + }; + 83BA34342C65B2C50044A149 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 83BA34352C65B2C50044A149 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 83BA34432C65B2C50044A149 /* MoreDramaTests */ = { + isa = PBXGroup; + children = ( + 83BA34442C65B2C50044A149 /* MoreDramaTests.swift */, + ); + path = MoreDramaTests; + sourceTree = ""; + }; + 83BA344D2C65B2C50044A149 /* MoreDramaUITests */ = { + isa = PBXGroup; + children = ( + 83BA344E2C65B2C50044A149 /* MoreDramaUITests.swift */, + 83BA34502C65B2C50044A149 /* MoreDramaUITestsLaunchTests.swift */, + ); + path = MoreDramaUITests; + sourceTree = ""; + }; + 83BA34602C65B7250044A149 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 83BA342A2C65B2C40044A149 /* MoreDrama */ = { + isa = PBXNativeTarget; + buildConfigurationList = 83BA34542C65B2C50044A149 /* Build configuration list for PBXNativeTarget "MoreDrama" */; + buildPhases = ( + 83A188FD2C69ABA000646C38 /* Frameworks */, + 83BA34272C65B2C40044A149 /* Sources */, + 83BA34292C65B2C40044A149 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MoreDrama; + packageProductDependencies = ( + 83A188FF2C69AC2600646C38 /* MoreData */, + ); + productName = MoreDrama; + productReference = 83BA342B2C65B2C40044A149 /* MoreDrama.app */; + productType = "com.apple.product-type.application"; + }; + 83BA343F2C65B2C50044A149 /* MoreDramaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 83BA34572C65B2C50044A149 /* Build configuration list for PBXNativeTarget "MoreDramaTests" */; + buildPhases = ( + 83BA343C2C65B2C50044A149 /* Sources */, + 83BA343D2C65B2C50044A149 /* Frameworks */, + 83BA343E2C65B2C50044A149 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 83BA34422C65B2C50044A149 /* PBXTargetDependency */, + ); + name = MoreDramaTests; + productName = MoreDramaTests; + productReference = 83BA34402C65B2C50044A149 /* MoreDramaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 83BA34492C65B2C50044A149 /* MoreDramaUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 83BA345A2C65B2C50044A149 /* Build configuration list for PBXNativeTarget "MoreDramaUITests" */; + buildPhases = ( + 83BA34462C65B2C50044A149 /* Sources */, + 83BA34472C65B2C50044A149 /* Frameworks */, + 83BA34482C65B2C50044A149 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 83BA344C2C65B2C50044A149 /* PBXTargetDependency */, + ); + name = MoreDramaUITests; + productName = MoreDramaUITests; + productReference = 83BA344A2C65B2C50044A149 /* MoreDramaUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83BA34232C65B2C40044A149 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 83BA342A2C65B2C40044A149 = { + CreatedOnToolsVersion = 15.4; + }; + 83BA343F2C65B2C50044A149 = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 83BA342A2C65B2C40044A149; + }; + 83BA34492C65B2C50044A149 = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 83BA342A2C65B2C40044A149; + }; + }; + }; + buildConfigurationList = 83BA34262C65B2C40044A149 /* Build configuration list for PBXProject "MoreDrama" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83BA34222C65B2C40044A149; + packageReferences = ( + 83A188FE2C69AC2600646C38 /* XCRemoteSwiftPackageReference "MoreData" */, + ); + productRefGroup = 83BA342C2C65B2C40044A149 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 83BA342A2C65B2C40044A149 /* MoreDrama */, + 83BA343F2C65B2C50044A149 /* MoreDramaTests */, + 83BA34492C65B2C50044A149 /* MoreDramaUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 83BA34292C65B2C40044A149 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 83BA34362C65B2C50044A149 /* Preview Assets.xcassets in Resources */, + 83BA34332C65B2C50044A149 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83BA343E2C65B2C50044A149 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83BA34482C65B2C50044A149 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 83BA34272C65B2C40044A149 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 83BA34312C65B2C40044A149 /* ContentView.swift in Sources */, + 8335A99D2C65C9450077EE3F /* Person+Filter.swift in Sources */, + 83BA343B2C65B2C50044A149 /* MoreDrama.xcdatamodeld in Sources */, + 8335A9A32C68528D0077EE3F /* Statement+Sort.swift in Sources */, + 8335A9A72C6853630077EE3F /* Statement+Fetchable.swift in Sources */, + 83BA342F2C65B2C40044A149 /* MoreDramaApp.swift in Sources */, + 8335A9A52C6853260077EE3F /* Person+Fetchable.swift in Sources */, + 8335A9A12C6852860077EE3F /* Statement+Filter.swift in Sources */, + 8335A9AC2C6853850077EE3F /* Statement+CoreDataClass.swift in Sources */, + 8335A9AD2C6853850077EE3F /* Statement+CoreDataProperties.swift in Sources */, + 8335A9AE2C6853850077EE3F /* Person+CoreDataClass.swift in Sources */, + 8335A9AF2C6853850077EE3F /* Person+CoreDataProperties.swift in Sources */, + 8335A99F2C65CA2B0077EE3F /* Person+Sort.swift in Sources */, + 83BA34642C65B7C40044A149 /* MoreDramaAppDataDependencies.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83BA343C2C65B2C50044A149 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 83BA34452C65B2C50044A149 /* MoreDramaTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83BA34462C65B2C50044A149 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 83BA344F2C65B2C50044A149 /* MoreDramaUITests.swift in Sources */, + 83BA34512C65B2C50044A149 /* MoreDramaUITestsLaunchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 83BA34422C65B2C50044A149 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 83BA342A2C65B2C40044A149 /* MoreDrama */; + targetProxy = 83BA34412C65B2C50044A149 /* PBXContainerItemProxy */; + }; + 83BA344C2C65B2C50044A149 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 83BA342A2C65B2C40044A149 /* MoreDrama */; + targetProxy = 83BA344B2C65B2C50044A149 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 83BA34522C65B2C50044A149 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 83BA34532C65B2C50044A149 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 83BA34552C65B2C50044A149 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"MoreDrama/Resources/Preview Content\""; + DEVELOPMENT_TEAM = SB224468CV; + 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 = com.chrislaganiere.MoreDrama; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 83BA34562C65B2C50044A149 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"MoreDrama/Resources/Preview Content\""; + DEVELOPMENT_TEAM = SB224468CV; + 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 = com.chrislaganiere.MoreDrama; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 83BA34582C65B2C50044A149 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SB224468CV; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.chrislaganiere.MoreDramaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MoreDrama.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MoreDrama"; + }; + name = Debug; + }; + 83BA34592C65B2C50044A149 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SB224468CV; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.chrislaganiere.MoreDramaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MoreDrama.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MoreDrama"; + }; + name = Release; + }; + 83BA345B2C65B2C50044A149 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SB224468CV; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.chrislaganiere.MoreDramaUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MoreDrama; + }; + name = Debug; + }; + 83BA345C2C65B2C50044A149 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SB224468CV; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.chrislaganiere.MoreDramaUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MoreDrama; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 83BA34262C65B2C40044A149 /* Build configuration list for PBXProject "MoreDrama" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83BA34522C65B2C50044A149 /* Debug */, + 83BA34532C65B2C50044A149 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83BA34542C65B2C50044A149 /* Build configuration list for PBXNativeTarget "MoreDrama" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83BA34552C65B2C50044A149 /* Debug */, + 83BA34562C65B2C50044A149 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83BA34572C65B2C50044A149 /* Build configuration list for PBXNativeTarget "MoreDramaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83BA34582C65B2C50044A149 /* Debug */, + 83BA34592C65B2C50044A149 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83BA345A2C65B2C50044A149 /* Build configuration list for PBXNativeTarget "MoreDramaUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83BA345B2C65B2C50044A149 /* Debug */, + 83BA345C2C65B2C50044A149 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 83A188FE2C69AC2600646C38 /* XCRemoteSwiftPackageReference "MoreData" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ChrisLaganiere/MoreData.git"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 83A188FF2C69AC2600646C38 /* MoreData */ = { + isa = XCSwiftPackageProductDependency; + package = 83A188FE2C69AC2600646C38 /* XCRemoteSwiftPackageReference "MoreData" */; + productName = MoreData; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 83BA34392C65B2C50044A149 /* MoreDrama.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 83BA343A2C65B2C50044A149 /* MoreDrama.xcdatamodel */, + ); + currentVersion = 83BA343A2C65B2C50044A149 /* MoreDrama.xcdatamodel */; + path = MoreDrama.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = 83BA34232C65B2C40044A149 /* Project object */; +} diff --git a/Example/MoreDrama.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/MoreDrama.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/MoreDrama.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..581dd2e --- /dev/null +++ b/Example/MoreDrama.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "3db1a6534bd7464db4b917e5151dfe4eb86510ceee615e90bea0bb016f510217", + "pins" : [ + { + "identity" : "moredata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ChrisLaganiere/MoreData.git", + "state" : { + "branch" : "main", + "revision" : "1fa7ed28df65e04b3d747d7e6d803bfdc40b31c2" + } + } + ], + "version" : 3 +} diff --git a/Example/MoreDrama.xcodeproj/xcshareddata/xcschemes/MoreDrama.xcscheme b/Example/MoreDrama.xcodeproj/xcshareddata/xcschemes/MoreDrama.xcscheme new file mode 100644 index 0000000..da6f8d3 --- /dev/null +++ b/Example/MoreDrama.xcodeproj/xcshareddata/xcschemes/MoreDrama.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/MoreDrama.xctestplan b/Example/MoreDrama.xctestplan new file mode 100644 index 0000000..182ff5a --- /dev/null +++ b/Example/MoreDrama.xctestplan @@ -0,0 +1,35 @@ +{ + "configurations" : [ + { + "id" : "88DC3F81-D79F-4B05-89B5-914FBA839B56", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:MoreDrama.xcodeproj", + "identifier" : "83BA342A2C65B2C40044A149", + "name" : "MoreDrama" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:MoreDrama.xcodeproj", + "identifier" : "83BA343F2C65B2C50044A149", + "name" : "MoreDramaTests" + } + }, + { + "target" : { + "containerPath" : "container:MoreDrama.xcodeproj", + "identifier" : "83BA34492C65B2C50044A149", + "name" : "MoreDramaUITests" + } + } + ], + "version" : 1 +} diff --git a/Example/MoreDrama/Data/Models/Person+CoreDataClass.swift b/Example/MoreDrama/Data/Models/Person+CoreDataClass.swift new file mode 100644 index 0000000..2c290ea --- /dev/null +++ b/Example/MoreDrama/Data/Models/Person+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(Person) +public class Person: NSManagedObject { + +} diff --git a/Example/MoreDrama/Data/Models/Person+CoreDataProperties.swift b/Example/MoreDrama/Data/Models/Person+CoreDataProperties.swift new file mode 100644 index 0000000..d78fa4d --- /dev/null +++ b/Example/MoreDrama/Data/Models/Person+CoreDataProperties.swift @@ -0,0 +1,51 @@ +import Foundation +import CoreData + + +extension Person { + + @NSManaged public var birthdate: Date + @NSManaged public var name: String + @NSManaged public var personID: String + @NSManaged public var heard: NSSet/**/ + @NSManaged public var stated: NSSet/**/ + +} + +// MARK: Generated accessors for heard +extension Person { + + @objc(addHeardObject:) + @NSManaged public func addToHeard(_ value: Statement) + + @objc(removeHeardObject:) + @NSManaged public func removeFromHeard(_ value: Statement) + + @objc(addHeard:) + @NSManaged public func addToHeard(_ values: NSSet) + + @objc(removeHeard:) + @NSManaged public func removeFromHeard(_ values: NSSet) + +} + +// MARK: Generated accessors for stated +extension Person { + + @objc(addStatedObject:) + @NSManaged public func addToStated(_ value: Statement) + + @objc(removeStatedObject:) + @NSManaged public func removeFromStated(_ value: Statement) + + @objc(addStated:) + @NSManaged public func addToStated(_ values: NSSet) + + @objc(removeStated:) + @NSManaged public func removeFromStated(_ values: NSSet) + +} + +extension Person : Identifiable { + +} diff --git a/Example/MoreDrama/Data/Models/Person+Fetchable.swift b/Example/MoreDrama/Data/Models/Person+Fetchable.swift new file mode 100644 index 0000000..1f03a28 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Person+Fetchable.swift @@ -0,0 +1,11 @@ +import CoreData +import MoreData + +extension Person: Fetchable { + public typealias Filter = PersonFilter + public typealias Sort = PersonSort + + public static var entityName: String { + "Person" + } +} diff --git a/Example/MoreDrama/Data/Models/Person+Filter.swift b/Example/MoreDrama/Data/Models/Person+Filter.swift new file mode 100644 index 0000000..55041c9 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Person+Filter.swift @@ -0,0 +1,30 @@ +import CoreData +import MoreData + +/// Filters which specify particular entities to fetch +public enum PersonFilter: Filtering { + + /// maximum age, in years + case maximumAge(Int) + /// minimum age, in years + case minimumAge(Int) + + /// particular individual + case personID(String) + + public var predicate: NSPredicate? { + switch self { + + case .maximumAge(let maximumAge): + let minimumDate = Date.now.addingTimeInterval(Double(-365 * 60 * 60 * maximumAge)) + return NSPredicate(format: "%K >= %@", #keyPath(Person.birthdate), minimumDate as CVarArg) + + case .minimumAge(let minimumAge): + let maximumDate = Date.now.addingTimeInterval(Double(-365 * 60 * 60 * minimumAge)) + return NSPredicate(format: "%K <= %@", #keyPath(Person.birthdate), maximumDate as CVarArg) + + case .personID(let personID): + return NSPredicate(format: "%K == %@", #keyPath(Person.personID), personID) + } + } +} diff --git a/Example/MoreDrama/Data/Models/Person+Sort.swift b/Example/MoreDrama/Data/Models/Person+Sort.swift new file mode 100644 index 0000000..513bf02 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Person+Sort.swift @@ -0,0 +1,35 @@ +import CoreData +import MoreData + +/// Sort descriptors which specify how to sort fetched results +public enum PersonSort: Sorting { + + /// Sort by age + case youngest + case oldest + /// Sort by name, A-Z + case name + + public var sortDescriptors: [NSSortDescriptor] { + switch self { + case .youngest: + [ + NSSortDescriptor(keyPath: \Person.birthdate, ascending: false), + // Fall back to ensure consistent sort order + NSSortDescriptor(keyPath: \Person.name, ascending: true), + ] + case .oldest: + [ + NSSortDescriptor(keyPath: \Person.birthdate, ascending: true), + // Fall back to ensure consistent sort order + NSSortDescriptor(keyPath: \Person.name, ascending: true), + ] + case .name: + [ + NSSortDescriptor(keyPath: \Person.name, ascending: true), + // Fall back to ensure consistent sort order + NSSortDescriptor(keyPath: \Person.birthdate, ascending: false), + ] + } + } +} diff --git a/Example/MoreDrama/Data/Models/Statement+CoreDataClass.swift b/Example/MoreDrama/Data/Models/Statement+CoreDataClass.swift new file mode 100644 index 0000000..5174fc6 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Statement+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(Statement) +public class Statement: NSManagedObject { + +} diff --git a/Example/MoreDrama/Data/Models/Statement+CoreDataProperties.swift b/Example/MoreDrama/Data/Models/Statement+CoreDataProperties.swift new file mode 100644 index 0000000..f1fa550 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Statement+CoreDataProperties.swift @@ -0,0 +1,33 @@ +import Foundation +import CoreData + + +extension Statement { + + @NSManaged public var content: String + @NSManaged public var time: Date + @NSManaged public var by: Person + @NSManaged public var to: NSSet/**/ + +} + +// MARK: Generated accessors for to +extension Statement { + + @objc(addToObject:) + @NSManaged public func addToTo(_ value: Person) + + @objc(removeToObject:) + @NSManaged public func removeFromTo(_ value: Person) + + @objc(addTo:) + @NSManaged public func addToTo(_ values: NSSet) + + @objc(removeTo:) + @NSManaged public func removeFromTo(_ values: NSSet) + +} + +extension Statement : Identifiable { + +} diff --git a/Example/MoreDrama/Data/Models/Statement+Fetchable.swift b/Example/MoreDrama/Data/Models/Statement+Fetchable.swift new file mode 100644 index 0000000..e9487d0 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Statement+Fetchable.swift @@ -0,0 +1,11 @@ +import CoreData +import MoreData + +extension Statement: Fetchable { + public typealias Filter = StatementFilter + public typealias Sort = StatementSort + + public static var entityName: String { + "Statement" + } +} diff --git a/Example/MoreDrama/Data/Models/Statement+Filter.swift b/Example/MoreDrama/Data/Models/Statement+Filter.swift new file mode 100644 index 0000000..99d3e25 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Statement+Filter.swift @@ -0,0 +1,27 @@ +import CoreData +import MoreData + +/// Filters which specify particular entities to fetch +public enum StatementFilter: Filtering { + + /// Look up subtext + case fuzzySearch(String) + + /// relation to particular individual + case toldBy(Person) + case toldTo(Person) + + public var predicate: NSPredicate? { + switch self { + + case .fuzzySearch(let query): + return NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(Statement.content), query) + + case .toldBy(let person): + return NSPredicate(format: "%K == %@", #keyPath(Statement.by), person) + + case .toldTo(let person): + return NSPredicate(format: "%@ IN %K", #keyPath(Statement.to), person) + } + } +} diff --git a/Example/MoreDrama/Data/Models/Statement+Sort.swift b/Example/MoreDrama/Data/Models/Statement+Sort.swift new file mode 100644 index 0000000..baa3cc2 --- /dev/null +++ b/Example/MoreDrama/Data/Models/Statement+Sort.swift @@ -0,0 +1,24 @@ +import CoreData +import MoreData + +/// Sort descriptors which specify how to sort fetched results +public enum StatementSort: Sorting { + + /// Time when statement was made, newest first + case newest + /// Time when statement was made, oldest first + case oldest + + public var sortDescriptors: [NSSortDescriptor] { + switch self { + case .newest: + return [ + NSSortDescriptor(keyPath: \Statement.time, ascending: false) + ] + case .oldest: + return [ + NSSortDescriptor(keyPath: \Statement.time, ascending: true) + ] + } + } +} diff --git a/Example/MoreDrama/MoreDramaApp.swift b/Example/MoreDrama/MoreDramaApp.swift new file mode 100644 index 0000000..e51f58d --- /dev/null +++ b/Example/MoreDrama/MoreDramaApp.swift @@ -0,0 +1,19 @@ +import SwiftUI + +@MainActor +@main +struct MoreDramaApp: App { + + let dependencies = MoreDramaAppDataDependencies.default() + + init() { + try! self.dependencies.setUp() + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.managedObjectContext, dependencies.persistenceController.viewContext) + } + } +} diff --git a/Example/MoreDrama/MoreDramaAppDataDependencies.swift b/Example/MoreDrama/MoreDramaAppDataDependencies.swift new file mode 100644 index 0000000..462b744 --- /dev/null +++ b/Example/MoreDrama/MoreDramaAppDataDependencies.swift @@ -0,0 +1,64 @@ +import CoreData +import MoreData + +/// Data services which make the app go +@MainActor +final class MoreDramaAppDataDependencies { + + /// Controller of persistent app cache + let persistenceController: CoreDataPersistenceController + + init(persistenceController: CoreDataPersistenceController) { + self.persistenceController = persistenceController + } + + /// Do initial set up as required for dependencies + func setUp() throws { + try persistenceController.load() + } +} + +// MARK: Available configurations +extension MoreDramaAppDataDependencies { + + // Here is an easy spot to provide different configurations for the app. + // You might set up different debug situations here by mocking out various + // services. Since they are all referenced by protocol, any part of the app + // can be mocked here for any purpose. + + /// Configuration for production apps connecting to server + static func `default`() -> MoreDramaAppDataDependencies { + + let managedObjectModel = NSManagedObjectModel.mergedModel( + from: [Bundle(for: self)] + )! + + let persistenceController = try! CoreDataPersistenceController( + config: .defaultURL, + name: "AmbientRecordingApp", + managedObjectModel: managedObjectModel + ) + + return MoreDramaAppDataDependencies( + persistenceController: persistenceController + ) + } + + /// Configuration for production apps connecting to server + static func preview() -> MoreDramaAppDataDependencies { + + let managedObjectModel = NSManagedObjectModel.mergedModel( + from: [Bundle(for: self)] + )! + + let persistenceController = try! CoreDataPersistenceController( + config: .inMemory, + name: "AmbientRecordingApp-Preview", + managedObjectModel: managedObjectModel + ) + + return MoreDramaAppDataDependencies( + persistenceController: persistenceController + ) + } +} diff --git a/Example/MoreDrama/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/MoreDrama/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/MoreDrama/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MoreDrama/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/MoreDrama/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Example/MoreDrama/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MoreDrama/Resources/Assets.xcassets/Contents.json b/Example/MoreDrama/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/MoreDrama/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/.xccurrentversion b/Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000..0e711d4 --- /dev/null +++ b/Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + MoreDrama.xcdatamodel + + diff --git a/Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/MoreDrama.xcdatamodel/contents b/Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/MoreDrama.xcdatamodel/contents new file mode 100644 index 0000000..6367c54 --- /dev/null +++ b/Example/MoreDrama/Resources/MoreDrama.xcdatamodeld/MoreDrama.xcdatamodel/contents @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Example/MoreDrama/Resources/Preview Content/Preview Assets.xcassets/Contents.json b/Example/MoreDrama/Resources/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/MoreDrama/Resources/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MoreDrama/Views/ContentView.swift b/Example/MoreDrama/Views/ContentView.swift new file mode 100644 index 0000000..5bd1f93 --- /dev/null +++ b/Example/MoreDrama/Views/ContentView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import CoreData +import MoreData + +@MainActor +struct ContentView: View { + + @Environment(\.managedObjectContext) private var viewContext + + @FetchableRequest( + entity: Person.self, + filter: .none, + sort: .name) + private var persons: FetchedResults + + @FetchableRequest( + entity: Statement.self, + filter: .none, + sort: .newest) + private var statements: FetchedResults + + var body: some View { + NavigationView { + List { + ForEach(persons) { person in + NavigationLink { + Text(person.name) + Text("Person born at \(person.birthdate, formatter: dateFormatter)") + } label: { + Text(person.name) + Text(person.birthdate, formatter: dateFormatter) + } + } + .onDelete(perform: deleteItems) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + EditButton() + } + ToolbarItem { + Button(action: addItem) { + Label("Add Person", systemImage: "plus") + } + } + } + Text("Select a Person") + } + } + + private func addItem() { + withAnimation { + let newItem = Person(context: viewContext) + newItem.personID = "\(persons.count + 1)" + newItem.birthdate = .now + newItem.name = [ + "Althea", + "Betsy", + "Cassidy", + "John", + "Stella" + ].randomElement()! + + do { + try viewContext.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } + } + + private func deleteItems(offsets: IndexSet) { + withAnimation { + offsets.map { persons[$0] }.forEach(viewContext.delete) + + do { + try viewContext.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } + } +} + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter +}() + +//#Preview { +// ContentView().environment(\.managedObjectContext, MoreDramaAppDataDependencies.preview().persistenceController.viewContext) +//} diff --git a/Example/MoreDramaTests/MoreDramaTests.swift b/Example/MoreDramaTests/MoreDramaTests.swift new file mode 100644 index 0000000..770180b --- /dev/null +++ b/Example/MoreDramaTests/MoreDramaTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import MoreDrama + +final class MoreDramaTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/Example/MoreDramaUITests/MoreDramaUITests.swift b/Example/MoreDramaUITests/MoreDramaUITests.swift new file mode 100644 index 0000000..65436ad --- /dev/null +++ b/Example/MoreDramaUITests/MoreDramaUITests.swift @@ -0,0 +1,34 @@ +import XCTest + +final class MoreDramaUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/Example/MoreDramaUITests/MoreDramaUITestsLaunchTests.swift b/Example/MoreDramaUITests/MoreDramaUITestsLaunchTests.swift new file mode 100644 index 0000000..a7d6fe1 --- /dev/null +++ b/Example/MoreDramaUITests/MoreDramaUITestsLaunchTests.swift @@ -0,0 +1,25 @@ +import XCTest + +final class MoreDramaUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3734231 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Chris Laganiere + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..16431ce --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MoreData", + platforms: [ + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8), + .visionOS(.v1) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MoreData", + targets: ["MoreData"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MoreData"), + .testTarget( + name: "MoreDataTests", + dependencies: ["MoreData"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c5e542 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# MoreData + +[![Swift](https://img.shields.io/badge/Swift-5.5%2B-orange.svg)](https://swift.org) +[![Platforms](https://img.shields.io/badge/iOS-15.0%2B-blue.svg)](https://developer.apple.com/ios/) +[![Platforms](https://img.shields.io/badge/macOS-12.0%2B-blue.svg)](https://developer.apple.com/macos/) +[![Platforms](https://img.shields.io/badge/tvOS-15.0%2B-blue.svg)](https://developer.apple.com/tvos/) +[![Platforms](https://img.shields.io/badge/watchOS-8.0%2B-blue.svg)](https://developer.apple.com/watchos/) +[![Platforms](https://img.shields.io/badge/visionOS-1.0%2B-blue.svg)](https://developer.apple.com/visionos/) +[![License](https://img.shields.io/badge/License-MIT-lightgrey.svg)](https://opensource.org/licenses/MIT) + +Helpers for integrating Core Data with a modern app, using Swift enums, Combine publishers, and structured concurrency. + +**MoreData** is designed to streamline working with Core Data in Swift projects. Core Data is a powerful and mature framework, but is clunky and not Swift-native. This collection of protocols and utilities simplify fetching, filtering, and observing Core Data entities using a more reactive and Swift-friendly approach. + +## Features + +- **Filtering**: Simplify the creation and combination of `NSPredicate` objects used to specify filter criteria. +- **Sorting**: Simplify the creation and combination of `NSSortDescriptor` objects for sorting query results. +- **@FetchableRequest Property Wrapper**: A better way to power SwiftUI views backed by Core Data. +- **FetchableResultsPublisher**: Reactive fetching and observing of Core Data entities using Combine. +- **CoreDataPersistenceController**: Wrapper for boilerplate Core Data setup, providing easy initialization for common patterns. + +## Installation + +### Swift Package Manager + +To integrate `MoreData` into your project using [Swift Package Manager](https://swift.org/package-manager/), add the following dependency to your `Package.swift` file: + +```swift +dependencies: [ + .package(url: "https://github.com/ChrisLaganiere/MoreData.git", from: "1.0.0") +] +``` + +## Usage + +### Fetchable Protocol + +The `Fetchable` protocol simplifies the process of fetching Core Data entities. Conform your NSManagedObject subclasses to Fetchable and use the provided helper methods to perform fetches. + +#### Example + +```swift +import CoreData +import MoreData + +class Person: NSManagedObject { + @NSManaged var name: String? + @NSManaged var age: Int16 + + static var entityName: String { + return "Person" + } +} + +// MARK: Fetchable +extension Person: Fetchable { + typealias Filter = PersonFilter + typealias Sort = PersonSort + + static var entityName: String { + "Person" + } +} + +let moc: NSManagedObjectContext = // your managed object context +let kyles = try? Person.all(predicate: NSPredicate(format: "%K CONTAINS[cd] %@", #keyPath(Person.name), "Kyle"), moc: moc) +``` + +### Filtering Protocol + +The `Filtering` protocol allows you to define reusable and composable filters for Core Data queries. + +#### Example + +```swift +enum PersonFilter: Filtering { + case nameContains(String) + case ageGreaterThan(Int) + + var predicate: NSPredicate? { + switch self { + case .nameContains(let name): + return NSPredicate(format: "name CONTAINS[cd] %@", name) + case .ageGreaterThan(let age): + return NSPredicate(format: "age > %d", age) + } + } +} + +let kylesFilter = PersonFilter.nameContains("Kyle") +let kyles = try? Person.all(matching: kylesFilter, moc: moc) +``` + +### Sorting Protocol + +The `Sorting` protocol allows you to define Swift-friendly sort criteria for Core Data fetch results. + +#### Example + +```swift +enum PersonSort: Sorting { + /// Sort alphabetically, A-Z + case nameAscending(Bool) + + var sortDescriptors: [NSSortDescriptor] { + switch self { + case .nameAscending: + return [NSSortDescriptor(key: "name", ascending: true)] + } + } +} + +let kyles = try? Person.all(matching: .nameContains("Kyle"), sortedBy: .nameAscending, moc: moc) +``` + +### FetchableResultsPublisher + +The `FetchableResultsPublisher` class provides a Combine-based interface to fetch and observe Core Data entities, making it easy to integrate with SwiftUI or other reactive UI frameworks. + +#### Example + +```swift +import Combine +import CoreData +import MoreData + +let kylePublisher = FetchableResultsPublisher( + filter: .nameContains("Kyle"), + sort: .nameAscending, + moc: moc +) + +kylePublisher.fetchedObjectsPublisher + .sink { fetchedObjects in + // Update your UI with the fetched objects + } + .store(in: &cancellables) +``` + +### @FetchableRequest Property Wrapper + +The `@FetchableRequest` property wrapper simplifies the process of retrieving and observing `NSManagedObject` entities that conform to the `Fetchable` protocol within SwiftUI views. It provides a declarative interface for fetching Core Data entities while integrating seamlessly with SwiftUI's state-driven UI updates. + +Features +* **Declarative Fetching**: Easily fetch entities based on filter and sort criteria directly within your SwiftUI views. +* **Reactive Updates**: Automatically updates your view when the underlying Core Data changes. +* **Dynamic Querying**: Modify filter and sort criteria dynamically, and the results will update automatically. + +#### Usage +To use `@FetchableRequest`, simply declare it in your SwiftUI view, specifying the entity type, filter, and sort criteria. The fetched results will be automatically available to your view. + +```swift +import SwiftUI +import MoreData + +struct PersonListView: View { + + @FetchableRequest( + entity: Person.self, + filter: .none, + sort: .nameAscending + ) var people: FetchedResults + + @State private var showKylesOnly = false + + var body: some View { + VStack { + Toggle("Show Kyles Only", isOn: $showKylesOnly) + .padding() + + List(people) { person in + VStack(alignment: .leading) { + Text(person.name ?? "Unknown Name") + Text("Age: \(person.age)") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .navigationTitle(showKylesOnly ? "Kyles" : "All People") + } + .onChange(of: showKylesOnly) { newValue in + if newValue { + _people.filter = .nameContains("Kyle") + } else { + _people.filter = .none + } + } + } +} +``` + +In this example, the list dynamically updates based on the toggle state, switching between showing all people or only people named Kyle. + +### Background + +[Core Data is a long-serving Apple framework](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/index.html) reducing the amount of code you need to write for a robust, persistent entity graph. + +[Core Data entities are not thread-safe](https://developer.apple.com/documentation/coredata/using_core_data_in_the_background) and should only be used in the context where they were fetched. + +### License +This project is licensed under the MIT License. + +### Acknowledgements + +Written by: Chris L 🫎 + +Contributions are welcome! If you have any ideas, suggestions, or bug reports, please open an issue or submit a pull request. diff --git a/Sources/MoreData/CoreData/CoreDataPersistenceController.swift b/Sources/MoreData/CoreData/CoreDataPersistenceController.swift new file mode 100644 index 0000000..f51302e --- /dev/null +++ b/Sources/MoreData/CoreData/CoreDataPersistenceController.swift @@ -0,0 +1,139 @@ +import CoreData + +/** + # CoreDataPersistenceController + Sets up a persistent container for a Core Data stack. + */ +public final class CoreDataPersistenceController: CoreDataPersisting { + + /// A context where entities live, for use displayingn views on the main thread + @MainActor + public var viewContext: NSManagedObjectContext { + persistentContainer.viewContext + } + + /// Shared writer moc where write changes to store are serialized in a private worker queue. + /// Write changes are serialized to avoid merge conflicts: + /// https://stackoverflow.com/a/45206964 + lazy var backgroundWriteMoc = newBackgroundContext() + + // MARK: Private Properties + + /// Manager with responsibilities that cover fetching and saving entities in Core Data + let persistentContainer: NSPersistentContainer + + /// Schema for entities that can live in persistent container. + let managedObjectModel: NSManagedObjectModel + + // MARK: Life Cycle + + public init( + config: Configuration = .defaultURL, + name: String, + managedObjectModel: NSManagedObjectModel + ) throws { + let container = NSPersistentContainer( + name: name, + managedObjectModel: managedObjectModel + ) + + // Configure store description + guard let description = container.persistentStoreDescriptions.first else { + throw PersistenceControllerError.missingPersistentStoreDescription + } + + switch config { + case .inMemory: + description.url = URL(fileURLWithPath: "/dev/null") + case .url(let url): + description.url = url + case .defaultURL: + // NO-OP: No change needed + break + } + + // Setting necessary for CoreDataSpotlightDelegate and Swift Data interop + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + + // Setting necessary for CoreDataSpotlightDelegate + description.type = NSSQLiteStoreType + + self.managedObjectModel = managedObjectModel + self.persistentContainer = container + } + + // MARK: API + + /// Set up controller. You must call this before making use of this controller. + public func load() throws { + // Load stores + var loadError: NSError? + + persistentContainer.loadPersistentStores { [weak self] storeDescription, error in + if let error = error as NSError? { + /* + Typical reasons for an error here include: + * The parent directory does not exist, cannot be created, or disallows writing. + * The persistent store is not accessible, due to permissions or data protection when the device is locked. + * The device is out of space. + * The store could not be migrated to the current model version. + Check the error message to determine what the actual problem was. + */ + loadError = error + } + + // Configure contexts + self?.persistentContainer.viewContext.automaticallyMergesChangesFromParent = true + } + + if let loadError { + throw loadError + } + } + + /// Perform actions with a private-queue managed object context that loads into persistent store + public func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { + let moc = backgroundWriteMoc + moc.perform { + block(moc) + } + } + + /// Perform actions with a private-queue managed object context that feeds into persistent store (async) + public func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) throws -> T) async rethrows -> T { + let moc = backgroundWriteMoc + return try await moc.perform { + try block(moc) + } + } + + /// Build writing context for persistent container that can be used in a private background worker thread. + public func newBackgroundContext(merge: NSMergePolicyType) -> NSManagedObjectContext { + let backgroundContext = persistentContainer.newBackgroundContext() + backgroundContext.automaticallyMergesChangesFromParent = true + backgroundContext.mergePolicy = NSMergePolicy(merge: merge) + return backgroundContext + } +} + +// MARK: - Definitions +public extension CoreDataPersistenceController { + /// Configuration options for persistence controller + enum Configuration { + /// In-memory only; do not persist + case inMemory + /// Persist at custom location, with file URL + case url(URL) + /// Persist at default app location + case defaultURL + } + + /// Errors that can occur during persistence + enum PersistenceControllerError: String, Error, CustomDebugStringConvertible { + case missingPersistentStoreDescription + + public var debugDescription: String { + rawValue + } + } +} diff --git a/Sources/MoreData/CoreData/CoreDataPersisting.swift b/Sources/MoreData/CoreData/CoreDataPersisting.swift new file mode 100644 index 0000000..061eb28 --- /dev/null +++ b/Sources/MoreData/CoreData/CoreDataPersisting.swift @@ -0,0 +1,32 @@ +import CoreData + +/** + # CoreDataPersisting + Protocol for app data layer persistence controller. + You must call `load()` before making use of controller. + */ +public protocol CoreDataPersisting { + + /// A context where entities live, for use displaying views on the main thread. + /// Use this to read data for building features! + @MainActor + var viewContext: NSManagedObjectContext { get } + + /// Perform actions with a private-queue managed object context that loads into persistent store + func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) + + /// Perform actions with a private-queue managed object context that feeds into persistent store (async) + func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) throws -> T) async rethrows -> T + + /// Build writing context for persistent container that can be used in a private background worker thread. + func newBackgroundContext(merge: NSMergePolicyType) -> NSManagedObjectContext +} + +// MARK: Helpers +public extension CoreDataPersisting { + /// Build writing context for persistent container that can be used in a private background worker thread. + /// (With default merge policy: `.mergeByPropertyStoreTrumpMergePolicyType`) + func newBackgroundContext() -> NSManagedObjectContext { + newBackgroundContext(merge: .mergeByPropertyStoreTrumpMergePolicyType) + } +} diff --git a/Sources/MoreData/Fetchable/Fetchable.swift b/Sources/MoreData/Fetchable/Fetchable.swift new file mode 100644 index 0000000..0e1dc09 --- /dev/null +++ b/Sources/MoreData/Fetchable/Fetchable.swift @@ -0,0 +1,150 @@ +import CoreData + +/** + # Fetchable + Protocol for Core Data entities which can be fetched with a fetch request. + Implementing `Fetchable` adds a bunch of useful helper methods to your managed entity class. + */ +public protocol Fetchable: NSManagedObject { + associatedtype Filter: Filtering + associatedtype Sort: Sorting + + /// Name of entity model + static var entityName: String { get } +} + +// MARK: - Helpers +public extension Fetchable { + + /// Finds all entities matching an optional predicate + static func all( + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor] = [], + moc: NSManagedObjectContext + ) throws -> [Self] { + let fetchRequest = fetchRequest(predicate: predicate) + fetchRequest.sortDescriptors = sortDescriptors + return try moc.fetch(fetchRequest) + } + + /// Finds all entities matching a filter case + @_disfavoredOverload + static func all( + matching filter: Filter? = nil, + sortedBy sort: Sort? = nil, + moc: NSManagedObjectContext + ) throws -> [Self] { + try all( + predicate: filter?.predicate, + sortDescriptors: sort?.sortDescriptors ?? [], + moc: moc + ) + } + + /// Finds number of entities matching an optional predicate + static func count( + matching predicate: NSPredicate? = nil, + moc: NSManagedObjectContext + ) throws -> Int { + try moc.count(for: fetchRequest(predicate: predicate)) + } + + /// Finds number of entities matching a filter case + static func count( + matching filter: Filter, + moc: NSManagedObjectContext + ) throws -> Int { + try count(matching: filter.predicate, moc: moc) + } + + /// Deletes all entities matching an optional predicate + static func deleteAll( + predicate: NSPredicate? = nil, + moc: NSManagedObjectContext + ) throws { + let entities = try all(predicate: predicate, moc: moc) + for entity in entities { + moc.delete(entity) + } + } + + /// Deletes all entities matching a filter case + static func deleteAll( + matching filter: Filter, + moc: NSManagedObjectContext + ) throws { + try deleteAll(predicate: filter.predicate, moc: moc) + } + + /// Finds one entity matching an optional predicate + static func unique( + predicate: NSPredicate?, + moc: NSManagedObjectContext + ) throws -> Self? { + let results = try all(predicate: predicate, moc: moc) + + if results.count > 1 { + throw FetchableError.tooManyResults(results.count) + } + + return results.first + } + + /// Finds one entity matching a filter case + static func unique( + matching filter: Filter, + moc: NSManagedObjectContext + ) throws -> Self? { + try unique(predicate: filter.predicate, moc: moc) + } +} + +// MARK: - FetchRequest Builders +public extension Fetchable { + + /// Form `NSFetchRequest` for this entity, with optional predicate + static func fetchRequest( + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor] = [], + fetchLimit: Int? = nil, + fetchOffset: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = predicate + fetchRequest.sortDescriptors = sortDescriptors + if let fetchLimit { + fetchRequest.fetchLimit = fetchLimit + } + if let fetchOffset { + fetchRequest.fetchOffset = fetchOffset + } + return fetchRequest + } + + /// Form `NSFetchRequest` for this entity, with optional predicate + static func fetchRequest( + filter: Filter? = nil, + sort: Sort? = nil, + fetchLimit: Int? = nil, + fetchOffset: Int? = nil + ) -> NSFetchRequest { + fetchRequest( + predicate: filter?.predicate, + sortDescriptors: sort?.sortDescriptors ?? [], + fetchLimit: fetchLimit, + fetchOffset: fetchOffset + ) + } +} + +// MARK: - Definitions +enum FetchableError: Error { + /// Thrown when more results are found than were expected + case tooManyResults(Int) +} + +/// Configuration options for fetch request +public struct FetchConfiguration { + public var filter: Result.Filter? + public var sort: Result.Sort? +} diff --git a/Sources/MoreData/Fetchable/FetchableResultsPublisher.swift b/Sources/MoreData/Fetchable/FetchableResultsPublisher.swift new file mode 100644 index 0000000..1564e0e --- /dev/null +++ b/Sources/MoreData/Fetchable/FetchableResultsPublisher.swift @@ -0,0 +1,209 @@ +import CoreData +import Combine + +/** + # FetchableResultsPublisher + + Publisher which fetches `Fetchable` items from a Core Data managed object context and publishes results using `Combine`, allowing for reactive updates in your UI. + + By encapsulating the complexities of `NSFetchedResultsController` and `Combine`, this publisher simplifies the development process and improves code maintainability. + + ### Usage + + 1. **Initialization:** + Create an instance of `FetchableResultsPublisher` with the desired filter, sort criteria, and managed object context. Call `beginFetch()` to start publishing fetch results. + + ```swift + let publisher = FetchableResultsPublisher( + filter: .nameContains("Alice"), + sort: .nameAscending, + moc: myManagedObjectContext + ) + publisher.beginFetch() + ``` + + 2. **Subscribing to Updates:** + Subscribe to the `fetchedObjectsPublisher` or `diffPublisher` to receive updates whenever the underlying data changes. + + ```swift + let cancellable = publisher.fetchedObjectsPublisher + .sink { fetchedObjects in + // Update your UI with the fetched objects + } + ``` + + 3. **Dynamic Updates:** + You can dynamically update the filter, sort, fetch limit, or fetch offset, and the publisher will automatically fetch the updated data. + + ```swift + publisher.filter = .ageGreaterThan(25) + publisher.sort = .nameAscending + ``` + + 4. **Lifecycle Management:** + Control fetching lifecycle using `beginFetch()` and `endFetch()` as needed. + + ### Thread Safety + The FetchableResultsPublisher is designed to be used on the main thread, as indicated by the @MainActor attribute. + + ### Advanced Usage + + The `diffPublisher` allows you to observe only the differences (changes) in the fetched entities, which can be more efficient for certain scenarios, such as when throttling or combining publishers. + + ```swift + let diffCancellable = publisher.diffPublisher + .sink { diff in + // Handle changes in the fetched objects + } + */ +@MainActor +public class FetchableResultsPublisher: NSObject, NSFetchedResultsControllerDelegate, FetchableResultsPublishing where ResultType : NSManagedObject, ResultType : Fetchable { + + /// The filter criteria used to determine which entities are fetched. + /// Updating this property will trigger a new fetch. + public var filter: ResultType.Filter? { + didSet { + performFetchIfNeeded() + } + } + + /// The sort criteria used to order the fetched entities. + /// Updating this property will trigger a new fetch. + public var sort: ResultType.Sort? { + didSet { + performFetchIfNeeded() + } + } + + /// The maximum number of entities to fetch. + /// Updating this property will trigger a new fetch. + public var fetchLimit: Int { + didSet { + performFetchIfNeeded() + } + } + + /// The offset for the fetch request, allowing for pagination. + /// Updating this property will trigger a new fetch. + public var fetchOffset: Int { + didSet { + performFetchIfNeeded() + } + } + + /// An array of the currently fetched entities, reflecting the latest data + /// based on the applied filter and sort criteria. + @MainActor + public var fetchedObjects: [ResultType] { + return frc.fetchedObjects ?? [] + } + + /// A Combine publisher that emits an array of fetched entities + /// whenever the underlying data changes. + public var fetchedObjectsPublisher: any Publisher<[ResultType], Never> { + $diff.map { [frc] _ in + frc.fetchedObjects ?? [] + } + } + + /// Publisher vending only the identifiers for results, which are smaller and thread-safe. + /// This is a less-expensive publisher which will still notify you whenever results change. + public var diffPublisher: any Publisher?, Never> { + $diff + } + + /// Block notifying when an error has occurred during fetch + public var fetchErrorHandler: ((Error) -> Void)? + + // MARK: Private Properties + + /// Context with objects to search + private let moc: NSManagedObjectContext + /// Controller fetching Core Data objects + private let frc: NSFetchedResultsController + + /// Results of fetch request + @Published + private var diff: CollectionDifference? = nil + + // MARK: Life Cycle + + public init( + filter: ResultType.Filter? = nil, + sort: ResultType.Sort? = nil, + moc: NSManagedObjectContext, + fetchLimit: Int = 0, + fetchOffset: Int = 0 + ) { + self.filter = filter + self.sort = sort + self.moc = moc + self.fetchLimit = fetchLimit + self.fetchOffset = fetchOffset + + frc = NSFetchedResultsController( + fetchRequest: ResultType.fetchRequest( + filter: filter, + sort: sort, + fetchLimit: fetchLimit, + fetchOffset: fetchOffset + ), + managedObjectContext: moc, + sectionNameKeyPath: nil, + cacheName: nil + ) + + super.init() + } + + // MARK: API + + /// Starts the fetch operation and begins monitoring changes in the Core Data context. + public func beginFetch() throws { + // Reset state + self.diff = nil + + // Activates underlying FRC monitoring for changes by setting delegate + // https://developer.apple.com/documentation/coredata/nsfetchedresultscontroller#overview + frc.delegate = self + try frc.performFetch() + } + + /// Stops monitoring changes and pauses the fetch operation. + public func endFetch() { + // Pause underlying FRC by setting delegate to nil + // https://developer.apple.com/documentation/coredata/nsfetchedresultscontroller#overview + frc.delegate = nil + } + + // MARK: NSFetchedResultsControllerDelegate + + nonisolated public func controller( + _ controller: NSFetchedResultsController, + didChangeContentWith diff: CollectionDifference + ) { + Task { @MainActor in + self.diff = diff + } + } + + // MARK: Private Methods + + /// Apply updated configuration to fetch request + private func updateFetchRequest() { + frc.fetchRequest.predicate = filter?.predicate + frc.fetchRequest.sortDescriptors = sort?.sortDescriptors ?? [] + frc.fetchRequest.fetchLimit = fetchLimit + frc.fetchRequest.fetchOffset = fetchOffset + } + + /// Execute fetch request with latest configuration + private func performFetchIfNeeded() { + updateFetchRequest() + do { + try beginFetch() + } catch { + fetchErrorHandler?(error) + } + } +} diff --git a/Sources/MoreData/Fetchable/FetchableResultsPublishing.swift b/Sources/MoreData/Fetchable/FetchableResultsPublishing.swift new file mode 100644 index 0000000..c360ce8 --- /dev/null +++ b/Sources/MoreData/Fetchable/FetchableResultsPublishing.swift @@ -0,0 +1,35 @@ +import CoreData +import Combine + +/** + # FetchableResultsPublishing + Protocol for a publisher which vends up-to-date entities matching filter and sort criteria. + Call `beginFetch()` and `endFetch()` to manage publisher life cycle. + */ +@MainActor +public protocol FetchableResultsPublishing { + + /// Entity type to query for and observe + associatedtype ResultType: Fetchable + + /// Which entities to fetch for + var filter: ResultType.Filter? { get set } + /// How results should be stored + var sort: ResultType.Sort? { get set } + + /// Cache of last-known entities matching filter and sort + @MainActor + var fetchedObjects: [ResultType] { get } + + /// Publisher vending entities matching filter and sort + var fetchedObjectsPublisher: any Publisher<[ResultType], Never> { get } + /// Publisher vending only the identifiers for results, which are smaller and thread-safe. + /// This is a less-expensive publisher which will still notify you whenever results change. + var diffPublisher: any Publisher?, Never> { get } + + /// Start fetching for results, and publish when fetched objects change + func beginFetch() throws + /// Pause fetching for results + func endFetch() + +} diff --git a/Sources/MoreData/Fetchable/Filtering.swift b/Sources/MoreData/Fetchable/Filtering.swift new file mode 100644 index 0000000..337ba93 --- /dev/null +++ b/Sources/MoreData/Fetchable/Filtering.swift @@ -0,0 +1,60 @@ +import CoreData + +/** + # Filtering + Protocol for Core Data filters which specify particular entities to fetch. + */ +public protocol Filtering: Equatable { + var predicate: NSPredicate? { get } +} + +// MARK: - Helpers +public extension Filtering { + + /// Require match of all subpredicates + static func all(_ predicates: [NSPredicate?]) -> NSPredicate? { + NSCompoundPredicate( + andPredicateWithSubpredicates: predicates.compactMap { + $0 + } + ) + } + + /// Require match of all subpredicates + static func all(_ filters: [Self]) -> NSPredicate? { + all(filters.map({ $0.predicate })) + } + + /// Require match of at least one subpredicate + static func any(_ predicates: [NSPredicate?]) -> NSPredicate? { + NSCompoundPredicate( + orPredicateWithSubpredicates: predicates.compactMap { + $0 + } + ) + } + + /// Require match of at least one subpredicate + static func any(_ filters: [Self]) -> NSPredicate? { + any(filters.map({ $0.predicate })) + } + + /// Require match of at least one subpredicate + static func by(_ filter: Self) -> NSPredicate? { + filter.predicate + } + + /// Inverse a predicate + static func not(_ predicate: NSPredicate?) -> NSPredicate? { + guard let predicate else { + return nil + } + return NSCompoundPredicate(notPredicateWithSubpredicate: predicate) + } + + /// Inverse a filter + static func not(_ filter: Self) -> NSPredicate? { + not(filter.predicate) + } + +} diff --git a/Sources/MoreData/Fetchable/Sorting.swift b/Sources/MoreData/Fetchable/Sorting.swift new file mode 100644 index 0000000..f33b24a --- /dev/null +++ b/Sources/MoreData/Fetchable/Sorting.swift @@ -0,0 +1,9 @@ +import CoreData + +/** + # Sorting + Protocol for Core Data sort descriptors which specify how to sort fetched results. + */ +public protocol Sorting: Equatable { + var sortDescriptors: [NSSortDescriptor] { get } +} diff --git a/Sources/MoreData/SwiftUI/FetchableRequest.swift b/Sources/MoreData/SwiftUI/FetchableRequest.swift new file mode 100644 index 0000000..3a959c4 --- /dev/null +++ b/Sources/MoreData/SwiftUI/FetchableRequest.swift @@ -0,0 +1,64 @@ +import CoreData +import SwiftUI + +/** + # FetchableRequest + A property wrapper type that retrieves entities from a Core Data persistent + store, for entity types which adopt the `Fetchable` protocol for easy fetching. + */ +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +@propertyWrapper +public struct FetchableRequest: DynamicProperty where Result: Fetchable { + + /// The filter criteria used to determine which entities are fetched. + @State + public var filter: Result.Filter? { + didSet { + updateFetchRequest() + } + } + + /// The sort criteria used to order the fetched entities. + @State + public var sort: Result.Sort? { + didSet { + updateFetchRequest() + } + } + + /// The fetched results that the property wrapper exposes. + public var wrappedValue: FetchedResults { + fetchResults + } + + // MARK: Private Properties + + /// The fetched results that the property wrapper will expose. + @FetchRequest + private var fetchResults: FetchedResults + + // MARK: Life Cycle + + public init( + entity: Result.Type, + filter: Result.Filter?, + sort: Result.Sort?, + animation: Animation? = nil + ) { + self._filter = State(initialValue: filter) + self._sort = State(initialValue: sort) + self._fetchResults = FetchRequest( + sortDescriptors: sort?.sortDescriptors ?? [], + predicate: filter?.predicate, + animation: animation + ) + } + + // MARK: Private Methods + + private func updateFetchRequest() { + fetchResults.nsPredicate = filter?.predicate + fetchResults.nsSortDescriptors = sort?.sortDescriptors ?? [] + } +} diff --git a/Tests/MoreDataTests/CoreData/CoreDataPersistenceControllerTests.swift b/Tests/MoreDataTests/CoreData/CoreDataPersistenceControllerTests.swift new file mode 100644 index 0000000..bdda476 --- /dev/null +++ b/Tests/MoreDataTests/CoreData/CoreDataPersistenceControllerTests.swift @@ -0,0 +1,98 @@ +import XCTest +import CoreData +@testable import MoreData + +final class CoreDataPersistenceControllerTests: XCTestCase { + + var sut: CoreDataPersistenceController! + var managedObjectModel: NSManagedObjectModel! + + override func setUpWithError() throws { + try super.setUpWithError() + + // Initialize the managed object model + managedObjectModel = NSManagedObjectModel.makeTestModel() + XCTAssertNotNil(managedObjectModel, "Managed Object Model could not be loaded.") + } + + override func tearDownWithError() throws { + sut = nil + managedObjectModel = nil + + try super.tearDownWithError() + } + + func testInitInMemoryConfiguration() throws { + sut = try CoreDataPersistenceController(config: .inMemory, name: "TestModel", managedObjectModel: managedObjectModel) + + let storeDescription = sut.persistentContainer.persistentStoreDescriptions.first + XCTAssertEqual(storeDescription?.url?.absoluteString, "file:///dev/null") + } + + func testInitWithCustomURLConfiguration() throws { + let customURL = URL(fileURLWithPath: "/tmp/test.sqlite") + sut = try CoreDataPersistenceController(config: .url(customURL), name: "TestModel", managedObjectModel: managedObjectModel) + + let storeDescription = sut.persistentContainer.persistentStoreDescriptions.first + XCTAssertEqual(storeDescription?.url, customURL) + } + + func testInitWithDefaultURLConfiguration() throws { + sut = try CoreDataPersistenceController(config: .defaultURL, name: "TestModel", managedObjectModel: managedObjectModel) + + let storeDescription = sut.persistentContainer.persistentStoreDescriptions.first + XCTAssertNotNil(storeDescription?.url) + XCTAssertNoThrow(try sut.load()) + } + + @MainActor + func testLoadPersistentStores() throws { + sut = try CoreDataPersistenceController(config: .inMemory, name: "TestModel", managedObjectModel: managedObjectModel) + + XCTAssertNoThrow(try sut.load()) + + let storeDescription = sut.viewContext.persistentStoreCoordinator?.persistentStores.first + XCTAssertNotNil(storeDescription, "Persistent store should be loaded") + } + + func testLoadPersistentStoresFailure() throws { + // Simulate a failure by providing an invalid URL + let invalidURL = URL(fileURLWithPath: "/invalid/path/test.sqlite") + sut = try CoreDataPersistenceController(config: .url(invalidURL), name: "TestModel", managedObjectModel: managedObjectModel) + + XCTAssertThrowsError(try sut.load(), "Loading should fail due to an invalid URL") + } + + func testPerformBackgroundTask() throws { + sut = try CoreDataPersistenceController(config: .inMemory, name: "TestModel", managedObjectModel: managedObjectModel) + try sut.load() + + let expectation = self.expectation(description: "Background task should complete") + + sut.performBackgroundTask { context in + XCTAssertFalse(context.concurrencyType == .mainQueueConcurrencyType) + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testNewBackgroundContextMergePolicy() throws { + sut = try CoreDataPersistenceController(config: .inMemory, name: "TestModel", managedObjectModel: managedObjectModel) + + let backgroundContext = sut.newBackgroundContext(merge: .mergeByPropertyObjectTrumpMergePolicyType) + XCTAssertEqual((backgroundContext.mergePolicy as? NSMergePolicy)?.mergeType, .mergeByPropertyObjectTrumpMergePolicyType) + } + + func testPerformBackgroundTaskAsync() async throws { + sut = try CoreDataPersistenceController(config: .inMemory, name: "TestModel", managedObjectModel: managedObjectModel) + try sut.load() + + let result = await sut.performBackgroundTask { context -> Bool in + XCTAssertFalse(context.concurrencyType == .mainQueueConcurrencyType) + return true + } + + XCTAssertTrue(result) + } +} diff --git a/Tests/MoreDataTests/CoreData/makeInMemoryViewContext.swift b/Tests/MoreDataTests/CoreData/makeInMemoryViewContext.swift new file mode 100644 index 0000000..f1d6b3d --- /dev/null +++ b/Tests/MoreDataTests/CoreData/makeInMemoryViewContext.swift @@ -0,0 +1,11 @@ +import CoreData +import MoreData + +/// Helper function to make an in-memory moc for unit tests +@MainActor +func makeInMemoryViewContext() throws -> NSManagedObjectContext { + let managedObjectModel = NSManagedObjectModel.makeTestEntityModel() + let controller = try CoreDataPersistenceController(config: .inMemory, name: "TestEntityModel", managedObjectModel: managedObjectModel) + try controller.load() + return controller.viewContext +} diff --git a/Tests/MoreDataTests/Fetchable/FetchableTests.swift b/Tests/MoreDataTests/Fetchable/FetchableTests.swift new file mode 100644 index 0000000..aadeda2 --- /dev/null +++ b/Tests/MoreDataTests/Fetchable/FetchableTests.swift @@ -0,0 +1,141 @@ +import XCTest +import CoreData +@testable import MoreData + +final class FetchableTests: XCTestCase { + + var moc: NSManagedObjectContext! + + @MainActor + override func setUpWithError() throws { + moc = try makeInMemoryViewContext() + } + + override func tearDownWithError() throws { + moc = nil + } + + func testAllEntitiesFetch() throws { + // Given + let entity1 = TestEntity(context: moc) + entity1.name = "Alice" + + let entity2 = TestEntity(context: moc) + entity2.name = "Bob" + + try moc.save() + + // When + let fetchedEntities = try TestEntity.all(moc: moc) + + // Then + XCTAssertEqual(fetchedEntities.count, 2) + XCTAssertTrue(fetchedEntities.contains { $0.name == "Alice" }) + XCTAssertTrue(fetchedEntities.contains { $0.name == "Bob" }) + } + + func testFetchWithPredicate() throws { + // Given + let entity = TestEntity(context: moc) + entity.name = "Alice" + + try moc.save() + + // When + let predicate = NSPredicate(format: "name == %@", "Alice") + let fetchedEntities = try TestEntity.all(predicate: predicate, moc: moc) + + // Then + XCTAssertEqual(fetchedEntities.count, 1) + XCTAssertEqual(fetchedEntities.first?.name, "Alice") + } + + func testFetchSortedByName() throws { + // Given + let entity1 = TestEntity(context: moc) + entity1.name = "Charlie" + + let entity2 = TestEntity(context: moc) + entity2.name = "Betty" + + let entity3 = TestEntity(context: moc) + entity3.name = "Alice" + + try moc.save() + + // When + let sortedEntities = try TestEntity.all( + sortedBy: .nameAscending, + moc: moc + ) + + // Then + XCTAssertEqual(sortedEntities.map { $0.name }, ["Alice", "Betty", "Charlie"]) + } + + func testCountEntities() throws { + // Given + let entity1 = TestEntity(context: moc) + entity1.name = "Alice" + + let entity2 = TestEntity(context: moc) + entity2.name = "Bob" + + try moc.save() + + // When + let count = try TestEntity.count(moc: moc) + + // Then + XCTAssertEqual(count, 2) + } + + func testDeleteAllEntities() throws { + // Given + let entity = TestEntity(context: moc) + entity.name = "Alice" + + try moc.save() + + // When + try TestEntity.deleteAll(moc: moc) + let remainingEntities = try TestEntity.all(moc: moc) + + // Then + XCTAssertTrue(remainingEntities.isEmpty) + } + + func testUniqueEntityFetch() throws { + // Given + let entity = TestEntity(context: moc) + entity.name = "UniqueEntity" + + try moc.save() + + // When + let fetchedEntity = try TestEntity.unique(predicate: NSPredicate(format: "name == %@", "UniqueEntity"), moc: moc) + + // Then + XCTAssertNotNil(fetchedEntity) + XCTAssertEqual(fetchedEntity?.name, "UniqueEntity") + } + + func testUniqueEntityFetchTooManyResults() throws { + // Given + let entity1 = TestEntity(context: moc) + entity1.name = "DuplicateEntity" + + let entity2 = TestEntity(context: moc) + entity2.name = "DuplicateEntity" + + try moc.save() + + // When/Then + XCTAssertThrowsError(try TestEntity.unique(predicate: NSPredicate(format: "name == %@", "DuplicateEntity"), moc: moc)) { error in + guard case FetchableError.tooManyResults(let count) = error else { + return XCTFail("Expected tooManyResults error, got \(error)") + } + XCTAssertEqual(count, 2) + } + } +} diff --git a/Tests/MoreDataTests/Fetchable/FilteringTests.swift b/Tests/MoreDataTests/Fetchable/FilteringTests.swift new file mode 100644 index 0000000..df5395f --- /dev/null +++ b/Tests/MoreDataTests/Fetchable/FilteringTests.swift @@ -0,0 +1,93 @@ +import XCTest +import CoreData +@testable import MoreData + +final class FilteringTests: XCTestCase { + + func testAllPredicates() { + // Given + let nameFilter = TestEntityFilter.nameContains("Alice") + let ageFilter = TestEntityFilter.ageGreaterThan(25) + + // When + let allPredicate = TestEntityFilter.all([nameFilter, ageFilter]) + + // Then + XCTAssertEqual(allPredicate?.predicateFormat, "name CONTAINS[cd] \"Alice\" AND age > 25") + } + + func testAllFilters() { + // Given + let filters: [TestEntityFilter] = [ + .nameContains("Alice"), + .ageGreaterThan(25), + .isActive(true) + ] + + // When + let allPredicate = TestEntityFilter.all(filters) + + // Then + XCTAssertEqual(allPredicate?.predicateFormat, "name CONTAINS[cd] \"Alice\" AND age > 25 AND isActive == 1") + } + + func testAnyPredicates() { + // Given + let nameFilter = TestEntityFilter.nameContains("Alice") + let ageFilter = TestEntityFilter.ageGreaterThan(25) + + // When + let anyPredicate = TestEntityFilter.any([nameFilter.predicate, ageFilter.predicate]) + + // Then + XCTAssertEqual(anyPredicate?.predicateFormat, "name CONTAINS[cd] \"Alice\" OR age > 25") + } + + func testAnyFilters() { + // Given + let filters: [TestEntityFilter] = [ + .nameContains("Alice"), + .ageGreaterThan(25), + .isActive(true) + ] + + // When + let anyPredicate = TestEntityFilter.any(filters) + + // Then + XCTAssertEqual(anyPredicate?.predicateFormat, "name CONTAINS[cd] \"Alice\" OR age > 25 OR isActive == 1") + } + + func testByFilter() { + // Given + let filter = TestEntityFilter.nameContains("Alice") + + // When + let predicate = TestEntityFilter.by(filter) + + // Then + XCTAssertEqual(predicate?.predicateFormat, "name CONTAINS[cd] \"Alice\"") + } + + func testNotPredicate() { + // Given + let filter = TestEntityFilter.nameContains("Alice") + + // When + let notPredicate = TestEntityFilter.not(filter.predicate) + + // Then + XCTAssertEqual(notPredicate?.predicateFormat, "NOT name CONTAINS[cd] \"Alice\"") + } + + func testNotFilter() { + // Given + let filter = TestEntityFilter.isActive(true) + + // When + let notPredicate = TestEntityFilter.not(filter) + + // Then + XCTAssertEqual(notPredicate?.predicateFormat, "NOT isActive == 1") + } +} diff --git a/Tests/MoreDataTests/MockData/ManagedObjectModel+Test.swift b/Tests/MoreDataTests/MockData/ManagedObjectModel+Test.swift new file mode 100644 index 0000000..4b5e986 --- /dev/null +++ b/Tests/MoreDataTests/MockData/ManagedObjectModel+Test.swift @@ -0,0 +1,69 @@ +import CoreData + +extension NSManagedObjectModel { + + static func makeTestModel() -> NSManagedObjectModel { + let model = NSManagedObjectModel() + + // Person entity + let personEntity = NSEntityDescription() + personEntity.name = "Person" + personEntity.managedObjectClassName = "Person" + + let nameAttribute = NSAttributeDescription() + nameAttribute.name = "name" + nameAttribute.attributeType = .stringAttributeType + nameAttribute.isOptional = false + + let ageAttribute = NSAttributeDescription() + ageAttribute.name = "age" + ageAttribute.attributeType = .integer16AttributeType + ageAttribute.isOptional = false + + personEntity.properties = [nameAttribute, ageAttribute] + + // Address entity + let addressEntity = NSEntityDescription() + addressEntity.name = "Address" + addressEntity.managedObjectClassName = "Address" + + let streetAttribute = NSAttributeDescription() + streetAttribute.name = "street" + streetAttribute.attributeType = .stringAttributeType + streetAttribute.isOptional = false + + let cityAttribute = NSAttributeDescription() + cityAttribute.name = "city" + cityAttribute.attributeType = .stringAttributeType + cityAttribute.isOptional = false + + addressEntity.properties = [streetAttribute, cityAttribute] + + // Relationship: Person to Address (one-to-one) + let addressRelationship = NSRelationshipDescription() + addressRelationship.name = "address" + addressRelationship.destinationEntity = addressEntity + addressRelationship.minCount = 0 + addressRelationship.maxCount = 1 + addressRelationship.deleteRule = .nullifyDeleteRule + + // Relationship: Address to Person (one-to-one) + let personRelationship = NSRelationshipDescription() + personRelationship.name = "person" + personRelationship.destinationEntity = personEntity + personRelationship.minCount = 0 + personRelationship.maxCount = 1 + personRelationship.deleteRule = .nullifyDeleteRule + + // Inverse relationships + addressRelationship.inverseRelationship = personRelationship + personRelationship.inverseRelationship = addressRelationship + + personEntity.properties.append(addressRelationship) + addressEntity.properties.append(personRelationship) + + model.entities = [personEntity, addressEntity] + + return model + } +} diff --git a/Tests/MoreDataTests/MockData/Models/TestEntity.swift b/Tests/MoreDataTests/MockData/Models/TestEntity.swift new file mode 100644 index 0000000..94cd9f5 --- /dev/null +++ b/Tests/MoreDataTests/MockData/Models/TestEntity.swift @@ -0,0 +1,59 @@ +import CoreData +@testable import MoreData +import XCTest + +enum TestEntityFilter: Filtering { + case nameContains(String) + case ageGreaterThan(Int) + case isActive(Bool) + + var predicate: NSPredicate? { + switch self { + case .nameContains(let name): + return NSPredicate(format: "name CONTAINS[cd] %@", name) + case .ageGreaterThan(let age): + return NSPredicate(format: "age > %d", age) + case .isActive(let isActive): + return NSPredicate(format: "isActive == %@", NSNumber(value: isActive)) + } + } +} + +enum TestEntitySort: Sorting { + case nameAscending + + var sortDescriptors: [NSSortDescriptor] { + switch self { + case .nameAscending: + return [NSSortDescriptor(key: "name", ascending: true)] + } + } +} + +// MARK: - TestEntity +class TestEntity: NSManagedObject, Fetchable { + @NSManaged var name: String? + + static var entityName: String { "TestEntity" } + + typealias Filter = TestEntityFilter + typealias Sort = TestEntitySort +} + +extension NSManagedObjectModel { + static func makeTestEntityModel() -> NSManagedObjectModel { + let model = NSManagedObjectModel() + let entity = NSEntityDescription() + entity.name = "TestEntity" + entity.managedObjectClassName = NSStringFromClass(TestEntity.self) + + let nameAttribute = NSAttributeDescription() + nameAttribute.name = "name" + nameAttribute.attributeType = .stringAttributeType + nameAttribute.isOptional = true + + entity.properties = [nameAttribute] + model.entities = [entity] + return model + } +}