├── .spi.yml ├── Tests ├── SwiftUITestApp │ ├── BlackbirdSwiftUITest │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ ├── BlackbirdSwiftUITestApp.swift │ │ ├── ObservationContentViews.swift │ │ └── ContentViews.swift │ └── BlackbirdSwiftUITest.xcodeproj │ │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── project.pbxproj └── BlackbirdTests │ └── BlackbirdTestModels.swift ├── .gitignore ├── Sources └── Blackbird │ ├── Blackbird.docc │ └── Blackbird.md │ ├── BlackbirdCodingKey.swift │ ├── BlackbirdColumn.swift │ ├── BlackbirdRow.swift │ ├── BlackbirdPerformanceLogger.swift │ ├── BlackbirdObservation.swift │ ├── BlackbirdColumnTypes.swift │ ├── BlackbirdCodable.swift │ ├── BlackbirdCache.swift │ ├── Blackbird.swift │ ├── BlackbirdChanges.swift │ └── BlackbirdSwiftUI.swift ├── Package.swift ├── LICENSE ├── .github └── workflows │ └── RunTests.yaml └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Blackbird] 5 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/Blackbird/Blackbird.docc/Blackbird.md: -------------------------------------------------------------------------------- 1 | # ``Blackbird`` 2 | 3 | A lightweight, asynchronous SQLite wrapper and model layer. 4 | 5 | ## Overview 6 | 7 | A small, fast, lightweight SQLite database wrapper and model layer, based on modern Swift `async` concurrency and `Codable`. 8 | 9 | ## Topics 10 | 11 | ### Blackbird 12 | 13 | - ``Blackbird/Database`` 14 | - ``BlackbirdModel`` 15 | 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Blackbird", 7 | platforms: [ 8 | .macOS(.v12), 9 | .iOS(.v15), 10 | .watchOS(.v8), 11 | .tvOS(.v15), 12 | ], 13 | products: [ 14 | .library( 15 | name: "Blackbird", 16 | targets: ["Blackbird"]), 17 | ], 18 | dependencies: [ 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Blackbird", 23 | dependencies: [], 24 | ), 25 | .testTarget( 26 | name: "BlackbirdTests", 27 | dependencies: ["Blackbird"]), 28 | ], 29 | swiftLanguageModes: [.v6] 30 | ) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marco Arment 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/RunTests.yaml: -------------------------------------------------------------------------------- 1 | name: RunTests 2 | 3 | # only trigger on main or PR 4 | on: 5 | push: 6 | branches: 7 | - "main" 8 | pull_request: 9 | branches: 10 | - "**" 11 | 12 | jobs: 13 | run-tests: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-of-writing-an-environment-variable-to-github_env 20 | # get Package.swift file contents, find line with swift-tools-version, get string after :, trim whitespaces from string 21 | - name: Get swift version from Package.swift file 22 | shell: bash 23 | run: | 24 | echo "swift-tools-version=$( cat ./Package.swift | grep swift-tools-version | cut -d ":" -f2 | sed -e 's/^[[:space:]]*//' )" >> $GITHUB_ENV 25 | - uses: swift-actions/setup-swift@v1 26 | with: 27 | swift-version: "${{ env.swift-tools-version }}" 28 | - name: Verify swift version 29 | run: swift --version 30 | - name: Build 31 | run: swift build 32 | - name: Run tests 33 | run: swift test 34 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdCodingKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdCodingKey.swift 13 | // Created by Marco Arment on 4/26/23. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | 36 | /// For BlackbirdModel to work with custom `CodingKeys`, their `CodingKeys` enum must be declared as: `enum CodingKeys: String, BlackbirdCodingKey`. 37 | public protocol BlackbirdCodingKey: RawRepresentable, CodingKey, CaseIterable where RawValue == String { 38 | } 39 | 40 | extension BlackbirdCodingKey { 41 | internal static var allLabeledCases: [String: String] { 42 | var columnsToKeys: [String: String] = [:] 43 | for c in allCases { 44 | guard let value = Self(rawValue: c.stringValue) else { fatalError("Cannot parse CodingKey from string: \"\(c.stringValue)\"") } 45 | guard let label = _getEnumCaseName(for: value) else { fatalError("Cannot get CodingKey label from string: \"\(c.stringValue)\"") } 46 | columnsToKeys[label] = c.stringValue 47 | } 48 | return columnsToKeys 49 | } 50 | 51 | // This unfortunate hack is needed to get the name of a CodingKeys enum, e.g. in this example: 52 | // 53 | // struct CodingKeys: CodingKey { 54 | // case id = "customID" 55 | // } 56 | // 57 | // ...getting the string "id" when supplied with the rawValue of "customID". 58 | // 59 | // The synthesis of CodingKeys breaks the normal methods of getting enum names, such as String(describing:). 60 | // 61 | // So this hack, based on compiler internals that could break in the future, comes from: 62 | // https://forums.swift.org/t/getting-the-name-of-a-swift-enum-value/35654/18 63 | // 64 | // If it ever breaks, and no other method arrives to get those names, Blackbird can't support custom CodingKeys. 65 | // 66 | @_silgen_name("swift_EnumCaseName") private static func _getEnumCaseNameInternal(_ value: T) -> UnsafePointer? 67 | fileprivate static func _getEnumCaseName(for value: T) -> String? { 68 | guard let stringPtr = _getEnumCaseNameInternal(value) else { return nil } 69 | return String(validatingCString: stringPtr) 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/BlackbirdSwiftUITestApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdSwiftUITestApp.swift 13 | // Created by Marco Arment on 12/5/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import SwiftUI 35 | import Blackbird 36 | 37 | struct Post: BlackbirdModel { 38 | @BlackbirdColumn var id: Int64 39 | @BlackbirdColumn var title: String 40 | } 41 | 42 | 43 | @main 44 | struct BlackbirdSwiftUITestApp: App { 45 | 46 | // In-memory database 47 | @StateObject var database: Blackbird.Database = try! Blackbird.Database.inMemoryDatabase(options: [.debugPrintEveryQuery, .debugPrintEveryReportedChange, .debugPrintQueryParameterValues]) 48 | 49 | // On-disk database 50 | // var database: Blackbird.Database = try! Blackbird.Database(path: "\(FileManager.default.temporaryDirectory.path)/blackbird-swiftui-test.sqlite", options: [.debugPrintEveryQuery, .debugPrintEveryReportedChange, .debugPrintQueryParameterValues]) 51 | 52 | var firstPost = Post(id: 1, title: "First!") 53 | 54 | var body: some Scene { 55 | WindowGroup { 56 | NavigationView { 57 | List { 58 | Section { 59 | NavigationLink { 60 | ContentViewEnvironmentDB() 61 | } label: { 62 | Text("Model list") 63 | } 64 | 65 | NavigationLink { 66 | PostViewEnvironmentDB(post: firstPost.liveModel) 67 | } label: { 68 | Text("Single-model updater") 69 | } 70 | } header: { 71 | Text("Environment database") 72 | } 73 | 74 | Section { 75 | NavigationLink { 76 | ContentViewBoundDB(database: database) 77 | } label: { 78 | Text("Model list") 79 | } 80 | } header: { 81 | Text("Bound database") 82 | } 83 | 84 | if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) { 85 | Section { 86 | NavigationLink { 87 | ContentViewObservation() 88 | } label: { 89 | Text("Model list") 90 | } 91 | 92 | NavigationLink { 93 | PostViewObservation(post: firstPost.observer) 94 | } label: { 95 | Text("Single-model updater") 96 | } 97 | } header: { 98 | Text("Observation") 99 | } 100 | } 101 | } 102 | } 103 | .environment(\.blackbirdDatabase, database) 104 | .onAppear { 105 | Task { 106 | print("DB path: \(database.path ?? "in-memory")") 107 | 108 | // Iterative version: 109 | // try await firstPost.write(to: database) 110 | // for _ in 0..<5 { try! await Post(id: TestData.randomInt64(), title: TestData.randomTitle).write(to: database) } 111 | 112 | // Transaction version: 113 | let database = database 114 | let firstPost = firstPost 115 | try await database.transaction { core in 116 | try firstPost.write(to: database, core: core) 117 | for _ in 0..<5 { try! Post(id: TestData.randomInt64(), title: TestData.randomTitle).write(to: database, core: core) } 118 | } 119 | 120 | // For testing "loading" states: 121 | // await database.setArtificialQueryDelay(1.0) 122 | } 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/ObservationContentViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // ObservationContentViews.swift 13 | // Created by Marco Arment on 12/5/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import SwiftUI 35 | import Blackbird 36 | 37 | // MARK: - @Observable 38 | 39 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 40 | struct ContentViewObservation: View { 41 | @Environment(\.blackbirdDatabase) var database 42 | 43 | @State var posts = Post.QueryObserver { try await Post.read(from: $0, orderBy: .ascending(\.$id)) } 44 | @State var count = Post.QueryObserver { try await $0.query("SELECT COUNT(*) AS c FROM Post").first?["c"] ?? 0 } 45 | 46 | var body: some View { 47 | VStack { 48 | if let posts = posts.result { 49 | List { 50 | ForEach(posts) { post in 51 | NavigationLink(destination: PostViewObservation(post: post.observer)) { 52 | Text(post.title) 53 | } 54 | .transition(.move(edge: .leading)) 55 | } 56 | } 57 | .animation(.default, value: posts) 58 | } else { 59 | ProgressView() 60 | } 61 | } 62 | .toolbar { 63 | ToolbarItem(placement: .navigationBarTrailing) { 64 | Button { 65 | Task { if let db = database { try! await Post(id: TestData.randomInt64(), title: TestData.randomTitle).write(to: db) } } 66 | } label: { 67 | Image(systemName: "plus") 68 | } 69 | } 70 | } 71 | .navigationTitle( 72 | count.result != nil ? "\(count.result!.stringValue ?? "?") posts, db: \(database?.id ?? 0)" : "Loading…" 73 | ) 74 | .onAppear { 75 | posts.bind(to: database) 76 | count.bind(to: database) 77 | } 78 | } 79 | } 80 | 81 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 82 | struct PostViewObservation: View { 83 | @Environment(\.blackbirdDatabase) var database 84 | @State var post: Post.Observer 85 | 86 | @State var title: String = "" 87 | 88 | var body: some View { 89 | VStack { 90 | if let instance = post.instance { 91 | Text("Title") 92 | .font(.system(.title)) 93 | 94 | TextField("Title", text: $title) 95 | 96 | Button { 97 | Task { 98 | var post = instance 99 | post.title = title 100 | if let database { try await post.write(to: database) } 101 | } 102 | } label: { 103 | Text("Update") 104 | } 105 | } else { 106 | Text("Post not found") 107 | } 108 | 109 | PostViewTitleObservation(post: post) 110 | } 111 | .padding() 112 | .navigationTitle(post.instance?.title ?? "") 113 | .toolbar { 114 | ToolbarItem(placement: .navigationBarTrailing) { 115 | Button { 116 | Task { 117 | if let database, var post = post.instance { 118 | post.title = "✏️ \(post.title)" 119 | try? await post.write(to: database) 120 | } 121 | } 122 | } label: { 123 | Image(systemName: "scribble") 124 | } 125 | } 126 | 127 | ToolbarItem(placement: .navigationBarTrailing) { 128 | Button { 129 | Task { 130 | if let database { try? await post.instance?.delete(from: database) } 131 | } 132 | } label: { 133 | Image(systemName: "trash") 134 | } 135 | } 136 | } 137 | .onAppear { 138 | post.bind(to: database) 139 | } 140 | } 141 | } 142 | 143 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 144 | struct PostViewTitleObservation: View { 145 | let post: Post.Observer 146 | 147 | var body: some View { 148 | Text("Bound title: \(post.instance?.title ?? "(nil)")") 149 | } 150 | } 151 | 152 | 153 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdColumn.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdColumn.swift 13 | // Created by Marco Arment on 2/27/23. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | 36 | internal protocol ColumnWrapper: WrappedType { 37 | associatedtype ValueType: BlackbirdColumnWrappable 38 | var value: ValueType { get } 39 | var valueType: any BlackbirdColumnWrappable.Type { get } 40 | func hasChanged(in database: Blackbird.Database) -> Bool 41 | func clearHasChanged(in database: Blackbird.Database) 42 | var internalNameInSchemaGenerator: Blackbird.Locked { get } 43 | } 44 | 45 | /// Property wrapper for column variables in ``BlackbirdModel`` `struct` definitions. 46 | @propertyWrapper public struct BlackbirdColumn: ColumnWrapper, WrappedType, Equatable, Sendable, Codable where T: BlackbirdColumnWrappable { 47 | internal var valueType: any BlackbirdColumnWrappable.Type { T.self } 48 | 49 | public static func == (lhs: Self, rhs: Self) -> Bool { type(of: lhs) == type(of: rhs) && lhs.value == rhs.value } 50 | 51 | private var _value: T 52 | internal final class ColumnState: @unchecked Sendable /* unchecked due to external locking in all uses */ { 53 | var hasChanged: Bool 54 | weak var lastUsedDatabase: Blackbird.Database? 55 | 56 | init(hasChanged: Bool, lastUsedDatabase: Blackbird.Database? = nil) { 57 | self.hasChanged = hasChanged 58 | self.lastUsedDatabase = lastUsedDatabase 59 | } 60 | } 61 | 62 | private let state: Blackbird.Locked> 63 | let internalNameInSchemaGenerator = Blackbird.Locked(nil) 64 | 65 | public var value: T { 66 | get { state.withLock { _ in self._value } } 67 | set { self.wrappedValue = newValue } 68 | } 69 | 70 | public var projectedValue: BlackbirdColumn { self } 71 | static internal func schemaGeneratorWrappedType() -> Any.Type { T.self } 72 | 73 | public var wrappedValue: T { 74 | get { state.withLock { _ in self._value } } 75 | set { 76 | state.withLock { state in 77 | guard self._value != newValue else { return } 78 | self._value = newValue 79 | state.hasChanged = true 80 | } 81 | } 82 | } 83 | 84 | /// Whether this value has changed since last being saved or read. This errs on the side of over-reporting changes, and may return `true` if the value has not actually changed. 85 | public func hasChanged(in database: Blackbird.Database) -> Bool { 86 | state.withLock { state in 87 | if state.lastUsedDatabase != database { return true } 88 | return state.hasChanged 89 | } 90 | } 91 | 92 | internal func clearHasChanged(in database: Blackbird.Database) { 93 | state.withLock { state in 94 | state.lastUsedDatabase = database 95 | state.hasChanged = false 96 | } 97 | } 98 | 99 | public init(wrappedValue: T) { 100 | _value = wrappedValue 101 | state = Blackbird.Locked(ColumnState(hasChanged: true, lastUsedDatabase: nil)) 102 | } 103 | 104 | public init(from decoder: Decoder) throws { 105 | let container = try decoder.singleValueContainer() 106 | let value = try container.decode(T.self) 107 | _value = value 108 | if let sqliteDecoder = decoder as? BlackbirdSQLiteDecoder { 109 | state = Blackbird.Locked(ColumnState(hasChanged: false, lastUsedDatabase: sqliteDecoder.database)) 110 | } else { 111 | state = Blackbird.Locked(ColumnState(hasChanged: true, lastUsedDatabase: nil)) 112 | } 113 | } 114 | 115 | public func encode(to encoder: Encoder) throws { 116 | var container = encoder.singleValueContainer() 117 | try container.encode(self.value) 118 | } 119 | } 120 | 121 | // MARK: - Accessing wrapped types of optionals and column wrappers 122 | internal protocol OptionalProtocol { 123 | var wrappedOptionalValue: Any? { get } 124 | } 125 | extension Optional: OptionalProtocol { 126 | var wrappedOptionalValue: Any? { 127 | get { 128 | switch self { 129 | case .some(let w): return w 130 | default: return nil 131 | } 132 | } 133 | } 134 | } 135 | 136 | internal protocol OptionalCreatable { 137 | associatedtype Wrapped 138 | static func createFromNilValue() -> Self 139 | static func createFromValue(_ wrapped: Any) -> Self 140 | static func creatableWrappedType() -> Any.Type 141 | } 142 | 143 | extension Optional: OptionalCreatable { 144 | static func createFromNilValue() -> Self { .none } 145 | static func createFromValue(_ wrapped: Any) -> Self { .some(wrapped as! Wrapped) } 146 | static func creatableWrappedType() -> Any.Type { Wrapped.self } 147 | } 148 | 149 | internal protocol WrappedType { 150 | static func schemaGeneratorWrappedType() -> Any.Type 151 | } 152 | 153 | extension Optional: WrappedType { 154 | internal static func schemaGeneratorWrappedType() -> Any.Type { Wrapped.self } 155 | } 156 | 157 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdRow.swift 13 | // Created by Marco Arment on 2/27/23. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | 36 | // MARK: - Standard row 37 | 38 | extension Blackbird { 39 | /// A dictionary of a single table row's values, keyed by their column names. 40 | public typealias Row = Dictionary 41 | } 42 | 43 | extension Blackbird.Row { 44 | public subscript(_ keyPath: KeyPath>>) -> V? { 45 | let table = SchemaGenerator.shared.table(for: T.self) 46 | let columnName = table.keyPathToColumnName(keyPath: keyPath) 47 | 48 | guard let value = self[columnName], value != .null else { return nil } 49 | guard let typedValue = V.fromValue(value) else { fatalError("\(String(describing: T.self)).\(columnName) value in Blackbird.Row dictionary not convertible to \(String(describing: V.self))") } 50 | return typedValue 51 | } 52 | 53 | public subscript(_ keyPath: KeyPath>) -> V { 54 | let table = SchemaGenerator.shared.table(for: T.self) 55 | let columnName = table.keyPathToColumnName(keyPath: keyPath) 56 | 57 | guard let value = self[columnName] else { fatalError("\(String(describing: T.self)).\(columnName) value not present in Blackbird.Row dictionary") } 58 | guard let typedValue = V.fromValue(value) else { fatalError("\(String(describing: T.self)).\(columnName) value in Blackbird.Row dictionary not convertible to \(String(describing: V.self))") } 59 | return typedValue 60 | } 61 | } 62 | 63 | 64 | // MARK: - Model-specific row 65 | // This allows typed key-pair lookups without specifying the type name at the call site, e.g.: 66 | // 67 | // row[\.$title] 68 | // 69 | // instead of 70 | // 71 | // row[\MyModelName.$title] 72 | // 73 | extension Blackbird { 74 | /// A specialized version of ``Row`` associated with its source ``BlackbirdModel`` type for convenient access to its values with column key-paths. 75 | public struct ModelRow: Collection, Equatable, Sendable { 76 | private let table: Blackbird.Table 77 | 78 | internal init(_ row: Blackbird.Row, table: Blackbird.Table) { 79 | self.table = table 80 | dictionary = row 81 | } 82 | public var row: Blackbird.Row { 83 | get { dictionary } 84 | } 85 | 86 | public subscript(_ keyPath: KeyPath>>) -> V? { 87 | let columnName = table.keyPathToColumnName(keyPath: keyPath) 88 | 89 | guard let value = dictionary[columnName], value != .null else { return nil } 90 | guard let typedValue = V.fromValue(value) else { fatalError("\(String(describing: T.self)).\(columnName) value in Blackbird.Row dictionary not convertible to \(String(describing: V.self))") } 91 | return typedValue 92 | } 93 | 94 | public subscript(_ keyPath: KeyPath>) -> V { 95 | let columnName = table.keyPathToColumnName(keyPath: keyPath) 96 | 97 | guard let value = dictionary[columnName] else { fatalError("\(String(describing: T.self)).\(columnName) value not present in Blackbird.Row dictionary") } 98 | guard let typedValue = V.fromValue(value) else { fatalError("\(String(describing: T.self)).\(columnName) value in Blackbird.Row dictionary not convertible to \(String(describing: V.self))") } 99 | return typedValue 100 | } 101 | 102 | public func value(keyPath: PartialKeyPath) -> Blackbird.Value? { 103 | let columnName = table.keyPathToColumnName(keyPath: keyPath) 104 | guard let value = dictionary[columnName] else { fatalError("\(String(describing: T.self)).\(columnName) value not present in Blackbird.Row dictionary") } 105 | return value 106 | } 107 | 108 | // Collection conformance 109 | public typealias DictionaryType = Dictionary 110 | public typealias Index = DictionaryType.Index 111 | private var dictionary: DictionaryType = [:] 112 | public var keys: Dictionary.Keys { dictionary.keys } 113 | public typealias Indices = DictionaryType.Indices 114 | public typealias Iterator = DictionaryType.Iterator 115 | public typealias SubSequence = DictionaryType.SubSequence 116 | public var startIndex: Index { dictionary.startIndex } 117 | public var endIndex: DictionaryType.Index { dictionary.endIndex } 118 | public subscript(position: Index) -> Iterator.Element { dictionary[position] } 119 | public subscript(bounds: Range) -> SubSequence { dictionary[bounds] } 120 | public var indices: Indices { dictionary.indices } 121 | public subscript(key: String) -> Blackbird.Value? { 122 | get { dictionary[key] } 123 | set { dictionary[key] = newValue } 124 | } 125 | public func index(after i: Index) -> Index { dictionary.index(after: i) } 126 | public func makeIterator() -> DictionaryIterator { dictionary.makeIterator() } 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdPerformanceLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdPerformanceLogger.swift 13 | // Created by Guy English for Marco Arment on 12/03/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | import OSLog 36 | 37 | /// A logger that emits signposts and log events to the system logging stream. 38 | /// 39 | /// ``PerformanceLogger`` creates Logger and OSSignposter instances with the subsystem and category provided. 40 | /// 41 | /// The data provided by this logger is best examined in Instruments. To use Instruments to profile tests in Xcode select the 42 | /// tests tab (Command-6), right click on the test (or group of tests), and pick Profile from the popup menu. 43 | /// When Instruments starts pick the Logging profiling template then the Record button to start the profiling session. 44 | /// The `os_log` and `os_signpost` rows will fill up with data captured from the test being profile. You can expand those 45 | /// to pick the specific `subsystem` and `category` the `PerformanceLogger` was configured to use. 46 | /// Above the details pane at the bottom along the left hand side of the window there is a popup control labeled either `List` 47 | /// or `Summary`. By picking `Summary: Intervals` you can see how many of each measured interval took place, the 48 | /// total execution time, the average execution time, etc. 49 | /// 50 | /// ## Example 51 | /// ```swift 52 | /// 53 | /// let perfLogger = PerformanceLogger(subsytem: Blackbird.loggingSubsystem, category: "Database.Core") 54 | /// // ... 55 | /// let signpostState = perLogger.begin(signpost: .execute, message: "Some explanatory text") 56 | /// // ... 57 | /// // perfLogger.end(state: signpostState) 58 | /// 59 | /// } 60 | /// ``` 61 | /// 62 | extension Blackbird { 63 | static let loggingSubsystem = "org.marco.blackbird" 64 | 65 | internal struct PerformanceLogger: @unchecked Sendable /* waiting for Sendable compliance in OSLog components */ { 66 | let log: Logger // The logger object. Exposed so it can be used directly. 67 | let post: OSSignposter // The signposter object. Exposed so it can be used directly. 68 | 69 | // Enum of all signposts. Signpost IDs will be generate automatically. 70 | enum Signpost: CaseIterable { 71 | case openDatabase 72 | case closeDatabase 73 | case execute 74 | case rowsByPreparedFunc 75 | case cancellableTransaction 76 | } 77 | 78 | private var spidMap: [Signpost: OSSignpostID] 79 | 80 | init(subsystem: String, category: String) { 81 | log = Logger(subsystem: subsystem, category: category) 82 | post = OSSignposter(subsystem: subsystem, category: category) 83 | // Populate our signpost enum to signpost id table. 84 | spidMap = [:] 85 | for sp in Signpost.allCases { 86 | spidMap[sp] = post.makeSignpostID() 87 | } 88 | } 89 | 90 | /// Begins a measured time interval 91 | /// 92 | /// - Parameters: 93 | /// - signpost: A signpost from the Signpost enum. 94 | /// - message: An optional message that will be attached to the signpost interval start. 95 | /// - name: An optional name for this signpost. The default is the name of the calling function. 96 | /// Since intervals may start and end in different functions you may need to spcify your own 97 | /// and make sure to use the same name when you call `end()`. 98 | /// - Returns: An OSSignpostIntervalState instance which is required to end the measured interval. 99 | /// 100 | /// ## Examples 101 | /// ```swift 102 | /// let signpostState = perfLogger.begin(signpost: .execute, message: "Some Message", name: "MySignpost") 103 | /// let signpostState = perfLogger.begin(signpost: .execute, message: "Some Message") // name == #function 104 | /// let signpostState = perfLogger.begin(signpost: .execute) 105 | /// // ... do work here ... 106 | /// perfLogger.end(state: signpostState) 107 | /// ``` 108 | func begin(signpost: Signpost, message: String = "", name: StaticString = #function) -> OSSignpostIntervalState { 109 | return post.beginInterval(name, id: spidMap[signpost]!, "\(message)") 110 | } 111 | 112 | /// Ends a measured time interval 113 | /// 114 | /// - Parameters: 115 | /// - state: The OSSignpostIntervalState returned from calling `begin()` 116 | /// - name: The name matching what was passed to `begin`. Defaults to the name of the calling function. 117 | /// - Returns: None 118 | /// 119 | /// ## Examples 120 | /// ```swift 121 | /// // ... do work here ... 122 | /// perfLogger.end(state: signpostState, name: "MySignpost") 123 | /// perfLogger.end(state: signpostState) 124 | /// ``` 125 | func end(state: OSSignpostIntervalState, name: StaticString = #function) { 126 | post.endInterval(name, state) 127 | } 128 | 129 | // When using the signposter directly this will return the appropriate OSSignpostID 130 | /// Get an `OSSignpostID` for a given `PerformanceLogger.Signpost` 131 | /// 132 | /// - Parameters: 133 | /// - for: The signpost to return the underlying OSSignpostID for 134 | /// - Returns: None 135 | /// 136 | /// ## Examples 137 | /// ```swift 138 | /// let spid = perLogger.signpostId(for: .execute) 139 | /// ``` 140 | func signpostId(for sp: Signpost) -> OSSignpostID { 141 | // Force unwrap because if we don't have a match we're in big trouble and should crash. 142 | return spidMap[sp]! 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest/ContentViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // ContentView.swift 13 | // Created by Marco Arment on 12/5/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import SwiftUI 35 | import Blackbird 36 | 37 | // MARK: - @BlackbirdFetch with @Environment(\.blackbirdDatabase) 38 | 39 | struct ContentViewEnvironmentDB: View { 40 | @Environment(\.blackbirdDatabase) var database 41 | 42 | @BlackbirdLiveModels({ try await Post.read(from: $0, orderBy: .ascending(\.$id)) }) var posts 43 | 44 | @BlackbirdLiveQuery(tableName: "Post", { try await $0.query("SELECT COUNT(*) FROM Post") }) var count 45 | 46 | var body: some View { 47 | VStack { 48 | if posts.didLoad { 49 | List { 50 | ForEach(posts.results) { post in 51 | NavigationLink(destination: PostViewEnvironmentDB(post: post.liveModel)) { 52 | Text(post.title) 53 | } 54 | .transition(.move(edge: .leading)) 55 | } 56 | } 57 | .animation(.default, value: posts) 58 | } else { 59 | ProgressView() 60 | } 61 | } 62 | .toolbar { 63 | ToolbarItem(placement: .navigationBarTrailing) { 64 | Button { 65 | Task { if let db = database { try! await Post(id: TestData.randomInt64(), title: TestData.randomTitle).write(to: db) } } 66 | } label: { 67 | Image(systemName: "plus") 68 | } 69 | } 70 | } 71 | .navigationTitle(count.didLoad ? "\(count.results.first?["COUNT(*)"]?.stringValue ?? "?") posts, db: \(database?.id ?? 0)" : "Loading…") 72 | } 73 | } 74 | 75 | struct PostViewEnvironmentDB: View { 76 | @Environment(\.blackbirdDatabase) var database 77 | @BlackbirdLiveModel var post: Post? 78 | 79 | @State var title: String = "" 80 | 81 | var body: some View { 82 | VStack { 83 | if let post { 84 | Text("Title") 85 | .font(.system(.title)) 86 | 87 | TextField("Title", text: $title) 88 | 89 | Button { 90 | Task { 91 | var post = post 92 | post.title = title 93 | if let database { try await post.write(to: database) } 94 | } 95 | } label: { 96 | Text("Update") 97 | } 98 | } else { 99 | Text("Post not found") 100 | } 101 | 102 | PostViewTitle(post: $post) 103 | } 104 | .padding() 105 | .navigationTitle(post?.title ?? "") 106 | .toolbar { 107 | ToolbarItem(placement: .navigationBarTrailing) { 108 | Button { 109 | Task { 110 | if let database, var post { 111 | post.title = "✏️ \(post.title)" 112 | try? await post.write(to: database) 113 | } 114 | } 115 | } label: { 116 | Image(systemName: "scribble") 117 | } 118 | } 119 | 120 | ToolbarItem(placement: .navigationBarTrailing) { 121 | Button { 122 | Task { 123 | if let database { try? await post?.delete(from: database) } 124 | } 125 | } label: { 126 | Image(systemName: "trash") 127 | } 128 | } 129 | } 130 | .onChange(of: post) { newValue in 131 | title = newValue?.title ?? "" 132 | } 133 | .onAppear { 134 | if let post { title = post.title } 135 | } 136 | } 137 | } 138 | 139 | struct PostViewTitle: View { 140 | @Binding var post: Post? 141 | 142 | var body: some View { 143 | Text("Bound title: \(post?.title ?? "(nil)")") 144 | } 145 | } 146 | 147 | 148 | // MARK: - Locally bound database with .QueryUpdater 149 | 150 | struct ContentViewBoundDB: View { 151 | @State var database: Blackbird.Database 152 | @State var posts = Post.LiveResults() 153 | var postsUpdater = Post.ArrayUpdater() 154 | 155 | var body: some View { 156 | VStack { 157 | if posts.didLoad { 158 | List { 159 | ForEach(posts.results) { post in 160 | NavigationLink(destination: PostViewBoundDB(database: $database, post: post)) { 161 | Text(post.title) 162 | } 163 | .transition(.move(edge: .leading)) 164 | } 165 | } 166 | .animation(.default, value: posts) 167 | } else { 168 | ProgressView() 169 | } 170 | } 171 | .toolbar { 172 | ToolbarItem(placement: .navigationBarTrailing) { 173 | Button { 174 | Task { try? await Post(id: TestData.randomInt64(), title: TestData.randomTitle).write(to: database) } 175 | } label: { Image(systemName: "plus") } 176 | } 177 | } 178 | .onAppear { 179 | postsUpdater.bind(from: database, to: $posts) { try await Post.read(from: $0, orderBy: .ascending(\.$id)) } 180 | } 181 | } 182 | } 183 | 184 | 185 | struct PostViewBoundDB: View { 186 | @Binding var database: Blackbird.Database 187 | 188 | @State var post: Post? 189 | var postUpdater = Post.InstanceUpdater() 190 | @State var didLoad = false 191 | 192 | @State var title: String = "" 193 | 194 | var body: some View { 195 | VStack { 196 | if didLoad { 197 | if let post { 198 | Text("Title") 199 | .font(.system(.title)) 200 | 201 | TextField("Title", text: $title) 202 | 203 | Button { 204 | Task { 205 | var post = post 206 | post.title = title 207 | try await post.write(to: database) 208 | } 209 | } label: { 210 | Text("Update") 211 | } 212 | } else { 213 | Text("Post not found") 214 | } 215 | } else { 216 | ProgressView() 217 | } 218 | } 219 | .padding() 220 | .navigationTitle(post?.title ?? "") 221 | .toolbar { 222 | ToolbarItem(placement: .navigationBarTrailing) { 223 | Button { 224 | Task { try? await post?.delete(from: database) } 225 | } label: { 226 | Image(systemName: "trash") 227 | } 228 | } 229 | } 230 | .onAppear { 231 | if let post { 232 | title = post.title 233 | postUpdater.bind(from: database, to: $post, didLoad: $didLoad, id: post.id) 234 | } 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdObservation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdObservation.swift 13 | // Created by Marco Arment on 12/3/23. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Observation 35 | import Combine 36 | 37 | // MARK: - BlackbirdModelQueryObserver 38 | 39 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 40 | extension BlackbirdModel { 41 | public typealias QueryObserver = BlackbirdModelQueryObserver 42 | } 43 | 44 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 45 | @Observable 46 | @MainActor 47 | public final class BlackbirdModelQueryObserver { 48 | /// Whether this query is currently loading from the database, either initially or after an update. 49 | public var isLoading = false 50 | 51 | /// Whether this query has ever loaded from the database. After the initial load, it is set to `true` and remains true. 52 | public var didLoad = false 53 | 54 | /// The current result. 55 | public var result: R? 56 | 57 | @ObservationIgnored private var database: Blackbird.Database? 58 | @ObservationIgnored private var observer: AnyCancellable? = nil 59 | @ObservationIgnored private var multicolumnPrimaryKeyForInvalidation: [Blackbird.Value]? 60 | @ObservationIgnored private var columnsForInvalidation: [T.BlackbirdColumnKeyPath]? 61 | @ObservationIgnored private var fetcher: ((_ database: Blackbird.Database) async throws -> R) 62 | 63 | public init(in database: Blackbird.Database? = nil, multicolumnPrimaryKeyForInvalidation: [Any]? = nil, columnsForInvalidation: [T.BlackbirdColumnKeyPath]? = nil, _ fetcher: @escaping ((_ database: Blackbird.Database) async throws -> R)) { 64 | self.fetcher = fetcher 65 | self.multicolumnPrimaryKeyForInvalidation = multicolumnPrimaryKeyForInvalidation?.map { try! Blackbird.Value.fromAny($0) } ?? nil 66 | self.columnsForInvalidation = columnsForInvalidation 67 | bind(to: database) 68 | } 69 | 70 | public convenience init(in database: Blackbird.Database? = nil, primaryKeyForInvalidation: Any? = nil, columnsForInvalidation: [T.BlackbirdColumnKeyPath]? = nil, _ fetcher: @escaping ((_ database: Blackbird.Database) async throws -> R)) { 71 | self.init( 72 | in: database, 73 | multicolumnPrimaryKeyForInvalidation: primaryKeyForInvalidation != nil ? [primaryKeyForInvalidation!] : nil, 74 | columnsForInvalidation: columnsForInvalidation, 75 | fetcher 76 | ) 77 | } 78 | 79 | /// Set or change the ``Blackbird/Database`` to read from and monitor for changes. 80 | public func bind(to database: Blackbird.Database?) { 81 | guard let database else { return } 82 | if let oldValue = self.database, oldValue.id == database.id { return } 83 | 84 | self.database = database 85 | 86 | observer?.cancel() 87 | observer = nil 88 | result = nil 89 | 90 | observer = T.changePublisher(in: database, multicolumnPrimaryKey: multicolumnPrimaryKeyForInvalidation, columns: columnsForInvalidation ?? []).sink { _ in 91 | Task.detached { [weak self] in await self?.update() } 92 | } 93 | Task.detached { [weak self] in await self?.update() } 94 | } 95 | 96 | let updateSemaphore = Blackbird.Semaphore(value: 1) 97 | private func update() async { 98 | await updateSemaphore.wait() 99 | defer { updateSemaphore.signal() } 100 | 101 | await MainActor.run { 102 | self.isLoading = true 103 | } 104 | 105 | let result: R? = if let database, !database.isClosed { try? await fetcher(database) } else { nil } 106 | 107 | await MainActor.run { 108 | self.result = result 109 | self.isLoading = false 110 | if !self.didLoad { self.didLoad = true } 111 | } 112 | } 113 | } 114 | 115 | // MARK: - BlackbirdModelObserver 116 | 117 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 118 | extension BlackbirdModel { 119 | public typealias Observer = BlackbirdModelObserver 120 | 121 | public var observer: Observer { Observer(multicolumnPrimaryKey: try! primaryKeyValues()) } 122 | } 123 | 124 | @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) 125 | @Observable 126 | @MainActor 127 | public final class BlackbirdModelObserver { 128 | /// Whether this instance is currently loading from the database, either initially or after an update. 129 | public var isLoading = false 130 | 131 | /// Whether this instance has ever loaded from the database. After the initial load, it is set to `true` and remains true. 132 | public var didLoad = false 133 | 134 | /// The current instance matching the supplied primary-key value. 135 | public var instance: T? 136 | 137 | @ObservationIgnored private var database: Blackbird.Database? 138 | @ObservationIgnored private var multicolumnPrimaryKey: [Blackbird.Value]? 139 | @ObservationIgnored private var observer: AnyCancellable? = nil 140 | 141 | /// Initializer to track a single-column primary-key value. 142 | nonisolated 143 | public convenience init(in database: Blackbird.Database? = nil, primaryKey: Sendable? = nil) { 144 | self.init(in: database, multicolumnPrimaryKey: [primaryKey]) 145 | } 146 | 147 | /// Initializer to track a multi-column primary-key value. 148 | nonisolated 149 | public init(in database: Blackbird.Database? = nil, multicolumnPrimaryKey: [Sendable]? = nil) { 150 | self.multicolumnPrimaryKey = multicolumnPrimaryKey?.map { try! Blackbird.Value.fromAny($0) } ?? nil 151 | Task { await bind(to: database) } 152 | } 153 | 154 | /// Set or change the ``Blackbird/Database`` to read from and monitor for changes. 155 | public func bind(to database: Blackbird.Database?) { 156 | guard let database else { return } 157 | if let oldValue = self.database, oldValue.id == database.id { return } 158 | 159 | self.database = database 160 | updateDatabaseObserver() 161 | } 162 | 163 | /// Set or change the single-column primary-key value to observe. 164 | public func observe(primaryKey: Sendable? = nil) { observe(multicolumnPrimaryKey: primaryKey == nil ? nil : [primaryKey]) } 165 | 166 | /// Set or change the multi-column primary-key value to observe. 167 | public func observe(multicolumnPrimaryKey: [Sendable]? = nil) { 168 | let multicolumnPrimaryKey = multicolumnPrimaryKey?.map { try! Blackbird.Value.fromAny($0) } ?? nil 169 | if multicolumnPrimaryKey == self.multicolumnPrimaryKey { return } 170 | 171 | self.multicolumnPrimaryKey = multicolumnPrimaryKey 172 | updateDatabaseObserver() 173 | } 174 | 175 | private func updateDatabaseObserver() { 176 | observer?.cancel() 177 | observer = nil 178 | instance = nil 179 | 180 | guard let database, let multicolumnPrimaryKey else { return } 181 | 182 | observer = T.changePublisher(in: database, multicolumnPrimaryKey: multicolumnPrimaryKey).sink { _ in 183 | Task.detached { [weak self] in await self?.update() } 184 | } 185 | Task.detached { [weak self] in await self?.update() } 186 | } 187 | 188 | let updateSemaphore = Blackbird.Semaphore(value: 1) 189 | private func update() async { 190 | await updateSemaphore.wait() 191 | defer { updateSemaphore.signal() } 192 | 193 | await MainActor.run { 194 | self.isLoading = true 195 | } 196 | 197 | let newInstance: T? = if let database, let multicolumnPrimaryKey, !database.isClosed { try? await T.read(from: database, multicolumnPrimaryKey: multicolumnPrimaryKey) } else { nil } 198 | 199 | await MainActor.run { 200 | self.instance = newInstance 201 | self.isLoading = false 202 | if !self.didLoad { self.didLoad = true } 203 | } 204 | } 205 | } 206 | 207 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdColumnTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdColumnTypes.swift 13 | // Created by Marco Arment on 1/14/23. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | 36 | /// A wrapped data type supported by ``BlackbirdColumn``. 37 | public protocol BlackbirdColumnWrappable: Hashable, Codable, Sendable { 38 | static func fromValue(_ value: Blackbird.Value) -> Self? 39 | } 40 | 41 | // MARK: - Column storage-type protocols 42 | 43 | /// Internally represents data types compatible with SQLite's `INTEGER` type. 44 | /// 45 | /// `UInt` and `UInt64` are intentionally omitted since SQLite integers max out at 64-bit signed. 46 | public protocol BlackbirdStorableAsInteger: Codable { 47 | func unifiedRepresentation() -> Int64 48 | static func from(unifiedRepresentation: Int64) -> Self 49 | } 50 | 51 | /// Internally represents data types compatible with SQLite's `DOUBLE` type. 52 | public protocol BlackbirdStorableAsDouble: Codable { 53 | func unifiedRepresentation() -> Double 54 | static func from(unifiedRepresentation: Double) -> Self 55 | } 56 | 57 | /// Internally represents data types compatible with SQLite's `TEXT` type. 58 | public protocol BlackbirdStorableAsText: Codable { 59 | func unifiedRepresentation() -> String 60 | static func from(unifiedRepresentation: String) -> Self 61 | } 62 | 63 | /// Internally represents data types compatible with SQLite's `BLOB` type. 64 | public protocol BlackbirdStorableAsData: Codable { 65 | func unifiedRepresentation() -> Data 66 | static func from(unifiedRepresentation: Data) -> Self 67 | } 68 | 69 | extension Double: BlackbirdColumnWrappable, BlackbirdStorableAsDouble { 70 | public func unifiedRepresentation() -> Double { self } 71 | public static func from(unifiedRepresentation: Double) -> Self { unifiedRepresentation } 72 | public static func fromValue(_ value: Blackbird.Value) -> Self? { value.doubleValue } 73 | } 74 | 75 | extension Float: BlackbirdColumnWrappable, BlackbirdStorableAsDouble { 76 | public func unifiedRepresentation() -> Double { Double(self) } 77 | public static func from(unifiedRepresentation: Double) -> Self { Float(unifiedRepresentation) } 78 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let d = value.doubleValue { return Float(d) } else { return nil } } 79 | } 80 | 81 | extension Date: BlackbirdColumnWrappable, BlackbirdStorableAsDouble { 82 | public func unifiedRepresentation() -> Double { self.timeIntervalSince1970 } 83 | public static func from(unifiedRepresentation: Double) -> Self { Date(timeIntervalSince1970: unifiedRepresentation) } 84 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let d = value.doubleValue { return Date(timeIntervalSince1970: d) } else { return nil } } 85 | } 86 | 87 | extension Data: BlackbirdColumnWrappable, BlackbirdStorableAsData { 88 | public func unifiedRepresentation() -> Data { self } 89 | public static func from(unifiedRepresentation: Data) -> Self { unifiedRepresentation } 90 | public static func fromValue(_ value: Blackbird.Value) -> Self? { value.dataValue } 91 | } 92 | 93 | extension String: BlackbirdColumnWrappable, BlackbirdStorableAsText { 94 | public func unifiedRepresentation() -> String { self } 95 | public static func from(unifiedRepresentation: String) -> Self { unifiedRepresentation } 96 | public static func fromValue(_ value: Blackbird.Value) -> Self? { value.stringValue } 97 | } 98 | 99 | extension URL: BlackbirdColumnWrappable, BlackbirdStorableAsText { 100 | public func unifiedRepresentation() -> String { self.absoluteString } 101 | public static func from(unifiedRepresentation: String) -> Self { URL(string: unifiedRepresentation)! } 102 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let s = value.stringValue { return URL(string: s) } else { return nil } } 103 | } 104 | 105 | extension Bool: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 106 | public func unifiedRepresentation() -> Int64 { Int64(self ? 1 : 0) } 107 | public static func from(unifiedRepresentation: Int64) -> Self { unifiedRepresentation == 0 ? false : true } 108 | public static func fromValue(_ value: Blackbird.Value) -> Self? { value.boolValue } 109 | } 110 | 111 | extension Int: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 112 | public func unifiedRepresentation() -> Int64 { Int64(self) } 113 | public static func from(unifiedRepresentation: Int64) -> Self { Int(unifiedRepresentation) } 114 | public static func fromValue(_ value: Blackbird.Value) -> Self? { value.intValue } 115 | } 116 | 117 | extension Int8: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 118 | public func unifiedRepresentation() -> Int64 { Int64(self) } 119 | public static func from(unifiedRepresentation: Int64) -> Self { Int8(unifiedRepresentation) } 120 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.intValue { return Int8(i) } else { return nil } } 121 | } 122 | 123 | extension Int16: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 124 | public func unifiedRepresentation() -> Int64 { Int64(self) } 125 | public static func from(unifiedRepresentation: Int64) -> Self { Int16(unifiedRepresentation) } 126 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.intValue { return Int16(i) } else { return nil } } 127 | } 128 | 129 | extension Int32: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 130 | public func unifiedRepresentation() -> Int64 { Int64(self) } 131 | public static func from(unifiedRepresentation: Int64) -> Self { Int32(unifiedRepresentation) } 132 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.intValue { return Int32(i) } else { return nil } } 133 | } 134 | 135 | extension Int64: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 136 | public func unifiedRepresentation() -> Int64 { self } 137 | public static func from(unifiedRepresentation: Int64) -> Self { unifiedRepresentation } 138 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.int64Value { return Int64(i) } else { return nil } } 139 | } 140 | 141 | extension UInt8: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 142 | public func unifiedRepresentation() -> Int64 { Int64(self) } 143 | public static func from(unifiedRepresentation: Int64) -> Self { UInt8(unifiedRepresentation) } 144 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.intValue { return UInt8(i) } else { return nil } } 145 | } 146 | 147 | extension UInt16: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 148 | public func unifiedRepresentation() -> Int64 { Int64(self) } 149 | public static func from(unifiedRepresentation: Int64) -> Self { UInt16(unifiedRepresentation) } 150 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.intValue { return UInt16(i) } else { return nil } } 151 | } 152 | 153 | extension UInt32: BlackbirdColumnWrappable, BlackbirdStorableAsInteger { 154 | public func unifiedRepresentation() -> Int64 { Int64(self) } 155 | public static func from(unifiedRepresentation: Int64) -> Self { UInt32(unifiedRepresentation) } 156 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.int64Value { return UInt32(i) } else { return nil } } 157 | } 158 | 159 | // MARK: - Enums, hacks for optionals 160 | 161 | /// Declares an enum as compatible with Blackbird column storage, with a raw type of `String` or `URL`. 162 | public protocol BlackbirdStringEnum: RawRepresentable, CaseIterable, BlackbirdColumnWrappable where RawValue: BlackbirdStorableAsText { 163 | associatedtype RawValue 164 | } 165 | 166 | /// Declares an enum as compatible with Blackbird column storage, with a Blackbird-compatible raw integer type such as `Int`. 167 | public protocol BlackbirdIntegerEnum: RawRepresentable, CaseIterable, BlackbirdColumnWrappable where RawValue: BlackbirdStorableAsInteger { 168 | associatedtype RawValue 169 | static func unifiedRawValue(from unifiedRepresentation: Int64) -> RawValue 170 | } 171 | 172 | extension BlackbirdStringEnum { 173 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let s = value.stringValue { return Self(rawValue: RawValue.from(unifiedRepresentation: s)) } else { return nil } } 174 | 175 | internal static func defaultPlaceholderValue() -> Self { allCases.first! } 176 | } 177 | 178 | extension BlackbirdIntegerEnum { 179 | public static func unifiedRawValue(from unifiedRepresentation: Int64) -> RawValue { RawValue.from(unifiedRepresentation: unifiedRepresentation) } 180 | public static func fromValue(_ value: Blackbird.Value) -> Self? { if let i = value.int64Value { return Self(rawValue: Self.unifiedRawValue(from: i)) } else { return nil } } 181 | internal static func defaultPlaceholderValue() -> Self { allCases.first! } 182 | } 183 | 184 | extension Optional: BlackbirdColumnWrappable where Wrapped: BlackbirdColumnWrappable { 185 | public static func fromValue(_ value: Blackbird.Value) -> Self? { return Wrapped.fromValue(value) } 186 | } 187 | 188 | // Bad hack to make Optional conform to BlackbirdStorableAsInteger 189 | extension Optional: @retroactive RawRepresentable where Wrapped: RawRepresentable { 190 | public typealias RawValue = Wrapped.RawValue 191 | public init?(rawValue: Wrapped.RawValue) { 192 | if let w = Wrapped(rawValue: rawValue) { self = .some(w) } else { self = .none } 193 | } 194 | public var rawValue: Wrapped.RawValue { fatalError() } 195 | } 196 | 197 | extension Optional: @retroactive CaseIterable where Wrapped: CaseIterable { 198 | public static var allCases: [Optional] { Wrapped.allCases.map { Optional($0) } } 199 | } 200 | 201 | internal protocol BlackbirdIntegerOptionalEnum { 202 | static func nilInstance() -> Self 203 | } 204 | 205 | extension Optional: BlackbirdIntegerEnum, BlackbirdIntegerOptionalEnum where Wrapped: BlackbirdIntegerEnum { 206 | static func nilInstance() -> Self { .none } 207 | } 208 | 209 | internal protocol BlackbirdStringOptionalEnum { 210 | static func nilInstance() -> Self 211 | } 212 | 213 | extension Optional: BlackbirdStringEnum, BlackbirdStringOptionalEnum where Wrapped: BlackbirdStringEnum { 214 | static func nilInstance() -> Self { .none } 215 | } 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blackbird 2 | 3 | A SQLite database wrapper and model layer, using Swift concurrency and `Codable`, with no other dependencies. 4 | 5 | Philosophy: 6 | 7 | * Prioritize speed of development over all else. 8 | * No code generation. 9 | * No schema definitions. 10 | * Automatic migrations. 11 | * Async by default. 12 | * Use Swift’s type system and key-paths instead of strings whenever possible. 13 | 14 | ## Project status 15 | 16 | Blackbird is a __beta__. 17 | 18 | Minor changes may still occur that break backwards compatibility with code or databases. 19 | 20 | I'm using Blackbird in shipping software now, but do so at your own risk. 21 | 22 | ## BlackbirdModel 23 | 24 | A protocol to store structs in the [SQLite](https://www.sqlite.org/)-powered [Blackbird.Database](#blackbirddatabase), with compiler-checked key-paths for common operations. 25 | 26 | Here's how you define a table: 27 | 28 | ```swift 29 | import Blackbird 30 | 31 | struct Post: BlackbirdModel { 32 | @BlackbirdColumn var id: Int 33 | @BlackbirdColumn var title: String 34 | @BlackbirdColumn var url: URL? 35 | } 36 | ``` 37 | 38 | That's it. No `CREATE TABLE`, no separate table-definition logic, no additional steps. 39 | 40 | And __automatic migrations__. Want to add or remove columns or indexes, or start using more of Blackbird's features such as custom `enum` columns, unique indexes, or custom primary keys? Just change the code: 41 | 42 | ```swift 43 | struct Post: BlackbirdModel { 44 | static var primaryKey: [BlackbirdColumnKeyPath] = [ \.$guid, \.$id ] 45 | 46 | static var indexes: [[BlackbirdColumnKeyPath]] = [ 47 | [ \.$title ], 48 | [ \.$publishedDate, \.$format ], 49 | ] 50 | 51 | static var uniqueIndexes: [[BlackbirdColumnKeyPath]] = [ 52 | [ \.$guid ], 53 | ] 54 | 55 | enum Format: Int, BlackbirdIntegerEnum { 56 | case markdown 57 | case html 58 | } 59 | 60 | @BlackbirdColumn var id: Int 61 | @BlackbirdColumn var guid: String 62 | @BlackbirdColumn var title: String 63 | @BlackbirdColumn var publishedDate: Date? 64 | @BlackbirdColumn var format: Format 65 | @BlackbirdColumn var url: URL? 66 | @BlackbirdColumn var image: Data? 67 | } 68 | ``` 69 | 70 | …and Blackbird will automatically migrate the table to the new schema at runtime. 71 | 72 | ### Queries 73 | 74 | Write instances safely and easily to a [Blackbird.Database](#blackbird-database): 75 | 76 | ```swift 77 | let post = Post(id: 1, title: "What I had for breakfast") 78 | try await post.write(to: db) 79 | ``` 80 | 81 | Perform queries in many different ways, preferring structured queries using key-paths for compile-time checking, type safety, and convenience: 82 | 83 | ```swift 84 | // Fetch by primary key 85 | let post = try await Post.read(from: db, id: 2) 86 | 87 | // Or with a WHERE condition, using compiler-checked key-paths: 88 | let posts = try await Post.read(from: db, matching: \.$title == "Sports") 89 | 90 | // Select custom columns, with row dictionaries typed by key-path: 91 | for row in try await Post.query(in: db, columns: [\.$id, \.$image], matching: \.$url != nil) { 92 | let postID = row[\.$id] // returns Int 93 | let imageData = row[\.$image] // returns Data? 94 | } 95 | ``` 96 | 97 | SQL is never required, but it's always available: 98 | 99 | ```swift 100 | try await Post.query(in: db, "UPDATE $T SET format = ? WHERE date < ?", .html, date) 101 | 102 | let posts = try await Post.read(from: db, sqlWhere: "title LIKE ? ORDER BY RANDOM()", "Sports%") 103 | 104 | for row in try await Post.query(in: db, "SELECT MAX(id) AS max FROM $T WHERE url = ?", url) { 105 | let maxID = row["max"]?.intValue 106 | } 107 | ``` 108 | 109 | Monitor for row- and column-level changes with Combine: 110 | 111 | ```swift 112 | let listener = Post.changePublisher(in: db).sink { change in 113 | if change.hasPrimaryKeyChanged(7) { 114 | print("Post 7 has changed") 115 | } 116 | 117 | if change.hasColumnChanged(\.$title) { 118 | print("A title has changed") 119 | } 120 | } 121 | 122 | // Or monitor a single column by key-path: 123 | let listener = Post.changePublisher(in: db, columns: [\.$title]).sink { _ in 124 | print("A post's title changed") 125 | } 126 | 127 | // Or listen for changes for a specific primary key: 128 | let listener = Post.changePublisher(in: db, primaryKey: 3, columns: [\.$title]).sink { _ in 129 | print("Post 3's title changed") 130 | } 131 | ``` 132 | 133 | ### SwiftUI 134 | 135 | Blackbird is designed for SwiftUI, offering async-loading, automatically-updating result wrappers: 136 | 137 | ```swift 138 | struct RootView: View { 139 | // The database that all child views will automatically use 140 | @StateObject var database = try! Blackbird.Database.inMemoryDatabase() 141 | 142 | var body: some View { 143 | PostListView() 144 | .environment(\.blackbirdDatabase, database) 145 | } 146 | } 147 | 148 | struct PostListView: View { 149 | // Async-loading, auto-updating array of matching instances 150 | @BlackbirdLiveModels({ try await Post.read(from: $0, orderBy: .ascending(\.$id)) }) var posts 151 | 152 | // Async-loading, auto-updating rows from a custom query 153 | @BlackbirdLiveQuery(tableName: "Post", { try await $0.query("SELECT MAX(id) AS max FROM Post") }) var maxID 154 | 155 | var body: some View { 156 | VStack { 157 | if posts.didLoad { 158 | List { 159 | ForEach(posts.results) { post in 160 | NavigationLink(destination: PostView(post: post.liveModel)) { 161 | Text(post.title) 162 | } 163 | } 164 | } 165 | } else { 166 | ProgressView() 167 | } 168 | } 169 | .navigationTitle(maxID.didLoad ? "\(maxID.results.first?["max"]?.intValue ?? 0) posts" : "Loading…") 170 | } 171 | } 172 | 173 | struct PostView: View { 174 | // Auto-updating instance 175 | @BlackbirdLiveModel var post: Post? 176 | 177 | var body: some View { 178 | VStack { 179 | if let post { 180 | Text(post.title) 181 | } 182 | } 183 | } 184 | } 185 | ``` 186 | 187 | ## Blackbird.Database 188 | 189 | A lightweight async wrapper around [SQLite](https://www.sqlite.org/) that can be used with or without [BlackbirdModel](#BlackbirdModel). 190 | 191 | ```swift 192 | let db = try Blackbird.Database(path: "/tmp/db.sqlite") 193 | 194 | // SELECT with parameterized queries 195 | for row in try await db.query("SELECT id FROM posts WHERE state = ?", 1) { 196 | let id = row["id"]?.intValue 197 | // ... 198 | } 199 | 200 | // Run direct queries 201 | try await db.execute("UPDATE posts SET comments = NULL") 202 | 203 | // Transactions with synchronous queries 204 | try await db.transaction { core in 205 | try core.query("INSERT INTO posts VALUES (?, ?)", 16, "Sports!") 206 | try core.query("INSERT INTO posts VALUES (?, ?)", 17, "Dewey Defeats Truman") 207 | } 208 | ``` 209 | 210 | ## Wishlist for future Swift-language capabilities 211 | 212 | * __Static type reflection for cleaner schema detection:__ Swift currently has no way to reflect a type's properties without creating an instance — [Mirror](https://developer.apple.com/documentation/swift/mirror) only reflects property names and values of given instances. If the language adds static type reflection in the future, my schema detection wouldn't need to rely on a hack using a Decoder to generate empty instances. 213 | 214 | * __KeyPath to/from String, static reflection of a type's KeyPaths:__ With the abilities to get a type's available KeyPaths (without some [awful hacks](https://forums.swift.org/t/getting-keypaths-to-members-automatically-using-mirror/21207)) and create KeyPaths from strings at runtime, many of my hacks using Codable could be replaced with KeyPaths, which would be cleaner and probably much faster. 215 | 216 | * __Method to get CodingKeys enum names and custom values:__ It's currently impossible to get the names of `CodingKeys` cases without resorting to [this awful hack](https://forums.swift.org/t/getting-the-name-of-a-swift-enum-value/35654/18). Decoders must know these names to perform proper decoding to arbitrary types that may have custom `CodingKeys` declared. If this hack ever stops working, BlackbirdModel cannot support custom `CodingKeys`. 217 | 218 | * __Cleaner protocol name (`Blackbird.Model`):__ Protocols can't contain dots or be nested within another type. 219 | 220 | * __Nested struct definitions inside protocols__ could make a lot of my "BlackbirdModel…" names shorter. 221 | 222 | ## FAQ 223 | 224 | ### why is it called blackbird 225 | 226 | [The plane](https://en.wikipedia.org/wiki/Lockheed_SR-71_Blackbird), of course. 227 | 228 | It's old, awesome, and ridiculously fast. Well, this database library is based on old, awesome tech (SQLite), and it's ridiculously fast. 229 | 230 | (If I'm honest, though, it's mostly because it's a cool-ass plane. I don't even really care about planes, generally. Just that one.) 231 | 232 | ### you know there are lots of other things called that 233 | 234 | Of course [there are](https://en.wikipedia.org/wiki/Blackbird). Who cares? 235 | 236 | This is a database engine that'll be used by, at most, a handful of nerds. It doesn't matter what it's called. 237 | 238 | I like unique names (rather than generic or descriptive names, like `Model` or `SwiftSQLite`) because they're easier to search for and harder to confuse with other types. So I wanted something memorable. I suppose I could've called it something like `ButtDB` — memorable! — but as I use it over the coming years, I wanted to type something cooler after all of my `struct` definitions. 239 | 240 | ### why don't you support [SQLite feature] 241 | 242 | Blackbird is designed to make it very fast and easy to write apps that have the most common, straightforward database needs. 243 | 244 | Custom SQL is supported in many ways, but more advanced SQLite behavior like triggers, views, windows, foreign-key constraints, cascading writes, partial or expression indexes, virtual columns, etc. are not directly supported by Blackbird and may cause undefined behavior if used. 245 | 246 | By not supporting esoteric or specialized features that apps typically don't need, Blackbird is able to offer a cleaner API and more useful functionality for common cases. 247 | 248 | ### why didn't you just use [other SQLite library] 249 | 250 | I like to write my own libraries. 251 | 252 | My libraries can perfectly match my needs and the way I expect them to work. And if my needs or expectations change, I can change the libraries. 253 | 254 | I also learn a great deal when writing them, exercising and improving my skills to benefit the rest of my work. 255 | 256 | And when I write the libraries, I understand how everything works as I'm using them, therefore creating fewer bugs and writing more efficient software. 257 | 258 | ### you know [other SQLite library] is faster 259 | 260 | I know. Ironic, considering that I named this one after the fastest plane. 261 | 262 | Blackbird is optimized for speed of __development__. It's pretty fast in execution, too, but clarity, ease of use, reduced repetition, and simple tooling are higher priorities. 263 | 264 | Blackbird also offers automatic caching and fine-grained change reporting. This helps apps avoid many unnecessary queries, reloads, and UI refreshes, which can result in faster overall app performance. 265 | 266 | Other Swift SQLite libraries can be faster at raw database performance by omitting much of Blackbird's reflection, abstraction, and key-path usage. Some use code-generation methods, which can execute very efficiently but complicate development more than I'd like. Others take less-abstracted approaches that enable more custom behavior but make usage more complicated. 267 | 268 | I've chosen different trade-offs to better fit my needs. I've never written an app that was too slow to read its database, but I've frequently struggled with maintenance of large, complex codebases. 269 | 270 | Blackbird's goal is to achieve my ideal balance of ease-of-use and bug-avoidance, even though it's therefore not the fastest SQLite library in execution. 271 | 272 | Phones keep getting faster, but a bug is a bug forever. 273 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdCodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdCodable.swift 13 | // Created by Marco Arment on 11/7/22. 14 | // 15 | // With significant thanks to (and borrowing from): 16 | // https://shareup.app/blog/encoding-and-decoding-sqlite-in-swift/ 17 | // 18 | // Permission is hereby granted, free of charge, to any person obtaining a copy 19 | // of this software and associated documentation files (the "Software"), to deal 20 | // in the Software without restriction, including without limitation the rights 21 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 22 | // copies of the Software, and to permit persons to whom the Software is 23 | // furnished to do so, subject to the following conditions: 24 | // 25 | // The above copyright notice and this permission notice shall be included in all 26 | // copies or substantial portions of the Software. 27 | // 28 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 29 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 30 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 31 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 32 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 33 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 34 | // SOFTWARE. 35 | // 36 | 37 | import Foundation 38 | 39 | internal class BlackbirdSQLiteDecoder: Decoder { 40 | public enum Error: Swift.Error { 41 | case invalidValue(String, value: String) 42 | case missingValue(String) 43 | } 44 | 45 | public var codingPath: [CodingKey] = [] 46 | public var userInfo: [CodingUserInfoKey: Any] = [:] 47 | 48 | let database: Blackbird.Database 49 | let row: Blackbird.Row 50 | init(database: Blackbird.Database, row: Blackbird.Row, codingPath: [CodingKey] = []) { 51 | self.database = database 52 | self.row = row 53 | self.codingPath = codingPath 54 | } 55 | 56 | public func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key: CodingKey { 57 | if let iterableKey = Key.self as? any BlackbirdCodingKey.Type { 58 | // Custom CodingKeys are in use, so remap the row to use the expected keys instead of raw column names 59 | var newRow = Blackbird.Row() 60 | for (columnName, customFieldName) in iterableKey.allLabeledCases { 61 | if let rowValue = row[columnName] { 62 | newRow[customFieldName] = rowValue 63 | } 64 | } 65 | return KeyedDecodingContainer(BlackbirdSQLiteKeyedDecodingContainer(codingPath: codingPath, database: database, row: newRow)) 66 | } 67 | 68 | // Use default names without custom CodingKeys 69 | return KeyedDecodingContainer(BlackbirdSQLiteKeyedDecodingContainer(codingPath: codingPath, database: database, row: row)) 70 | } 71 | 72 | public func unkeyedContainer() throws -> UnkeyedDecodingContainer { fatalError("unsupported") } 73 | public func singleValueContainer() throws -> SingleValueDecodingContainer { BlackbirdSQLiteSingleValueDecodingContainer(codingPath: codingPath, database: database, row: row) } 74 | } 75 | 76 | fileprivate struct BlackbirdSQLiteSingleValueDecodingContainer: SingleValueDecodingContainer { 77 | public enum Error: Swift.Error { 78 | case invalidEnumValue(typeDescription: String, value: Sendable) 79 | } 80 | 81 | var codingPath: [CodingKey] = [] 82 | let database: Blackbird.Database 83 | var row: Blackbird.Row 84 | 85 | init(codingPath: [CodingKey], database: Blackbird.Database, row: Blackbird.Row) { 86 | self.codingPath = codingPath 87 | self.database = database 88 | self.row = row 89 | } 90 | 91 | private func value() throws -> Blackbird.Value { 92 | guard let key = codingPath.first?.stringValue, let v = row[key] else { 93 | throw BlackbirdSQLiteDecoder.Error.missingValue(codingPath.first?.stringValue ?? "(unknown key)") 94 | } 95 | return v 96 | } 97 | 98 | func decodeNil() -> Bool { true } 99 | func decode(_ type: Bool.Type) throws -> Bool { (try value()).boolValue ?? false } 100 | func decode(_ type: String.Type) throws -> String { (try value()).stringValue ?? "" } 101 | func decode(_ type: Double.Type) throws -> Double { (try value()).doubleValue ?? 0 } 102 | func decode(_ type: Float.Type) throws -> Float { Float((try value()).doubleValue ?? 0) } 103 | func decode(_ type: Int.Type) throws -> Int { (try value()).intValue ?? 0 } 104 | func decode(_ type: Int8.Type) throws -> Int8 { Int8((try value()).intValue ?? 0) } 105 | func decode(_ type: Int16.Type) throws -> Int16 { Int16((try value()).intValue ?? 0) } 106 | func decode(_ type: Int32.Type) throws -> Int32 { Int32((try value()).intValue ?? 0) } 107 | func decode(_ type: Int64.Type) throws -> Int64 { (try value()).int64Value ?? 0 } 108 | func decode(_ type: UInt.Type) throws -> UInt { UInt((try value()).int64Value ?? 0) } 109 | func decode(_ type: UInt8.Type) throws -> UInt8 { UInt8((try value()).intValue ?? 0) } 110 | func decode(_ type: UInt16.Type) throws -> UInt16 { UInt16((try value()).intValue ?? 0) } 111 | func decode(_ type: UInt32.Type) throws -> UInt32 { UInt32((try value()).int64Value ?? 0) } 112 | func decode(_ type: UInt64.Type) throws -> UInt64 { UInt64((try value()).int64Value ?? 0) } 113 | 114 | func decode(_ type: T.Type) throws -> T where T: Decodable { 115 | let value = try value() 116 | if T.self == Data.self { return (value.dataValue ?? Data()) as! T } 117 | if T.self == URL.self, let urlStr = value.stringValue, let url = URL(string: urlStr) { return url as! T } 118 | if T.self == Date.self { return Date(timeIntervalSince1970: value.doubleValue ?? 0) as! T } 119 | 120 | if let eT = T.self as? any BlackbirdIntegerOptionalEnum.Type, value.int64Value == nil { 121 | return (try decodeNilRepresentable(eT) as? T)! 122 | } 123 | 124 | if let eT = T.self as? any BlackbirdStringOptionalEnum.Type, value.stringValue == nil { 125 | return (try decodeNilRepresentable(eT) as? T)! 126 | } 127 | 128 | if let eT = T.self as? any BlackbirdIntegerEnum.Type { 129 | let rawValue = value.int64Value ?? 0 130 | guard let integerEnum = try decodeRepresentable(eT, unifiedRawValue: rawValue), let converted = integerEnum as? T else { 131 | throw Error.invalidEnumValue(typeDescription: String(describing: eT), value: rawValue) 132 | } 133 | return converted 134 | } 135 | 136 | if let eT = T.self as? any BlackbirdStringEnum.Type { 137 | let rawValue = value.stringValue ?? "" 138 | guard let stringEnum = try decodeRepresentable(eT, unifiedRawValue: rawValue), let converted = stringEnum as? T else { 139 | throw Error.invalidEnumValue(typeDescription: String(describing: eT), value: rawValue) 140 | } 141 | return converted 142 | } 143 | 144 | if let eT = T.self as? any OptionalCreatable.Type, let wrappedType = eT.creatableWrappedType() as? any Decodable.Type { 145 | if value == .null { 146 | return eT.createFromNilValue() as! T 147 | } else { 148 | let wrappedValue = try decode(wrappedType) 149 | return eT.createFromValue(wrappedValue) as! T 150 | } 151 | } 152 | 153 | return try T(from: BlackbirdSQLiteDecoder(database: database, row: row, codingPath: codingPath)) 154 | } 155 | 156 | func decodeRepresentable(_ type: T.Type, unifiedRawValue: Int64) throws -> T? where T: BlackbirdIntegerEnum { 157 | T.init(rawValue: T.RawValue.from(unifiedRepresentation: unifiedRawValue)) 158 | } 159 | 160 | func decodeRepresentable(_ type: T.Type, unifiedRawValue: String) throws -> T? where T: BlackbirdStringEnum { 161 | T.init(rawValue: T.RawValue.from(unifiedRepresentation: unifiedRawValue)) 162 | } 163 | 164 | func decodeNilRepresentable(_ type: T.Type) throws -> T where T: BlackbirdIntegerOptionalEnum { 165 | T.nilInstance() 166 | } 167 | 168 | func decodeNilRepresentable(_ type: T.Type) throws -> T where T: BlackbirdStringOptionalEnum { 169 | T.nilInstance() 170 | } 171 | } 172 | 173 | fileprivate class BlackbirdSQLiteKeyedDecodingContainer: KeyedDecodingContainerProtocol { 174 | typealias Key = K 175 | let codingPath: [CodingKey] 176 | let database: Blackbird.Database 177 | var row: Blackbird.Row 178 | 179 | init(codingPath: [CodingKey] = [], database: Blackbird.Database, row: Blackbird.Row) { 180 | self.database = database 181 | self.row = row 182 | self.codingPath = codingPath 183 | } 184 | 185 | var allKeys: [K] { row.keys.compactMap { K(stringValue: $0) } } 186 | func contains(_ key: K) -> Bool { row[key.stringValue] != nil } 187 | 188 | func decodeNil(forKey key: K) throws -> Bool { 189 | if let value = row[key.stringValue] { return value == .null } 190 | return true 191 | } 192 | 193 | func decode(_ type: Bool.Type, forKey key: K) throws -> Bool { row[key.stringValue]?.boolValue ?? false } 194 | func decode(_ type: String.Type, forKey key: K) throws -> String { row[key.stringValue]?.stringValue ?? "" } 195 | func decode(_ type: Double.Type, forKey key: K) throws -> Double { row[key.stringValue]?.doubleValue ?? 0 } 196 | func decode(_ type: Float.Type, forKey key: K) throws -> Float { Float(row[key.stringValue]?.doubleValue ?? 0) } 197 | func decode(_ type: Int.Type, forKey key: K) throws -> Int { row[key.stringValue]?.intValue ?? 0 } 198 | func decode(_ type: Int8.Type, forKey key: K) throws -> Int8 { Int8(row[key.stringValue]?.intValue ?? 0) } 199 | func decode(_ type: Int16.Type, forKey key: K) throws -> Int16 { Int16(row[key.stringValue]?.intValue ?? 0) } 200 | func decode(_ type: Int32.Type, forKey key: K) throws -> Int32 { Int32(row[key.stringValue]?.intValue ?? 0) } 201 | func decode(_ type: Int64.Type, forKey key: K) throws -> Int64 { row[key.stringValue]?.int64Value ?? 0 } 202 | func decode(_ type: UInt.Type, forKey key: K) throws -> UInt { UInt(row[key.stringValue]?.int64Value ?? 0) } 203 | func decode(_ type: UInt8.Type, forKey key: K) throws -> UInt8 { UInt8(row[key.stringValue]?.intValue ?? 0) } 204 | func decode(_ type: UInt16.Type, forKey key: K) throws -> UInt16 { UInt16(row[key.stringValue]?.intValue ?? 0) } 205 | func decode(_ type: UInt32.Type, forKey key: K) throws -> UInt32 { UInt32(row[key.stringValue]?.int64Value ?? 0) } 206 | func decode(_ type: UInt64.Type, forKey key: K) throws -> UInt64 { UInt64(row[key.stringValue]?.int64Value ?? 0) } 207 | func decode(_: Data.Type, forKey key: K) throws -> Data { row[key.stringValue]?.dataValue ?? Data() } 208 | 209 | func decode(_: Date.Type, forKey key: K) throws -> Date { 210 | let timeInterval = try decode(Double.self, forKey: key) 211 | return Date(timeIntervalSince1970: timeInterval) 212 | } 213 | 214 | func decode(_: URL.Type, forKey key: K) throws -> URL { 215 | let string = try decode(String.self, forKey: key) 216 | guard let url = URL(string: string) else { throw BlackbirdSQLiteDecoder.Error.invalidValue(key.stringValue, value: string) } 217 | return url 218 | } 219 | 220 | func decode(_ type: T.Type, forKey key: K) throws -> T where T: Decodable { 221 | if Data.self == T.self { return try decode(Data.self, forKey: key) as! T } 222 | if Date.self == T.self { return try decode(Date.self, forKey: key) as! T } 223 | if URL.self == T.self { return try decode(URL.self, forKey: key) as! T } 224 | 225 | var newPath = codingPath 226 | newPath.append(key) 227 | return try T(from: BlackbirdSQLiteDecoder(database: database, row: row, codingPath: newPath)) 228 | } 229 | 230 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: K) throws -> KeyedDecodingContainer where NestedKey: CodingKey { fatalError("unsupported") } 231 | func nestedUnkeyedContainer(forKey key: K) throws -> UnkeyedDecodingContainer { fatalError("unsupported") } 232 | func superDecoder() throws -> Decoder { fatalError("unsupported") } 233 | func superDecoder(forKey key: K) throws -> Decoder { fatalError("unsupported") } 234 | } 235 | -------------------------------------------------------------------------------- /Tests/BlackbirdTests/BlackbirdTestModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdTestModels.swift 13 | // Created by Marco Arment on 11/20/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | @testable import Blackbird 36 | 37 | struct TestModel: BlackbirdModel { 38 | static let indexes: [[BlackbirdColumnKeyPath]] = [ 39 | [ \.$title ] 40 | ] 41 | 42 | @BlackbirdColumn var id: Int64 43 | @BlackbirdColumn var title: String 44 | @BlackbirdColumn var url: URL 45 | 46 | var nonColumn: String = "" 47 | } 48 | 49 | struct TestModelWithCache: BlackbirdModel { 50 | static let indexes: [[BlackbirdColumnKeyPath]] = [ 51 | [ \.$title ] 52 | ] 53 | 54 | static let cacheLimit: Int = 100 55 | 56 | @BlackbirdColumn var id: Int64 57 | @BlackbirdColumn var title: String 58 | @BlackbirdColumn var url: URL 59 | } 60 | 61 | 62 | struct TestModelWithoutIDColumn: BlackbirdModel { 63 | static let primaryKey: [BlackbirdColumnKeyPath] = [ \.$pk ] 64 | 65 | @BlackbirdColumn var pk: Int 66 | @BlackbirdColumn var title: String 67 | } 68 | 69 | struct TestModelWithDescription: BlackbirdModel { 70 | static let cacheLimit: Int = 0 71 | 72 | static let indexes: [[BlackbirdColumnKeyPath]] = [ 73 | [ \.$title ], 74 | [ \.$url ] 75 | ] 76 | 77 | @BlackbirdColumn var id: Int 78 | @BlackbirdColumn var url: URL? 79 | @BlackbirdColumn var title: String 80 | @BlackbirdColumn var description: String 81 | } 82 | 83 | struct TestCodingKeys: BlackbirdModel { 84 | enum CodingKeys: String, BlackbirdCodingKey { 85 | case id 86 | case title = "customTitle" 87 | case description = "d" 88 | } 89 | 90 | @BlackbirdColumn var id: Int64 91 | @BlackbirdColumn var title: String 92 | @BlackbirdColumn var description: String 93 | } 94 | 95 | struct TestCustomDecoder: BlackbirdModel { 96 | @BlackbirdColumn var id: Int 97 | @BlackbirdColumn var name: String 98 | @BlackbirdColumn var thumbnail: URL 99 | 100 | enum CodingKeys: String, BlackbirdCodingKey { 101 | case id = "idStr" 102 | case name = "nameStr" 103 | case thumbnail = "thumbStr" 104 | } 105 | 106 | init(from decoder: Decoder) throws { 107 | let container = try decoder.container(keyedBy: CodingKeys.self) 108 | 109 | // Special-case handling for BlackbirdDefaultsDecoder: 110 | // supplies a valid numeric string instead of failing on 111 | // the empty string ("") returned by BlackbirdDefaultsDecoder 112 | 113 | if decoder is BlackbirdDefaultsDecoder { 114 | self.id = 0 115 | } else { 116 | let idStr = try container.decode(String.self, forKey: .id) 117 | guard let id = Int(idStr) else { 118 | throw DecodingError.dataCorruptedError(forKey: .id, in: container, debugDescription: "Expected numeric string") 119 | } 120 | self.id = id 121 | } 122 | 123 | self.name = try container.decode(String.self, forKey: .name) 124 | self.thumbnail = try container.decode(URL.self, forKey: .thumbnail) 125 | } 126 | } 127 | 128 | 129 | struct TypeTest: BlackbirdModel { 130 | @BlackbirdColumn var id: Int64 131 | 132 | @BlackbirdColumn var typeIntNull: Int64? 133 | @BlackbirdColumn var typeIntNotNull: Int64 134 | 135 | @BlackbirdColumn var typeTextNull: String? 136 | @BlackbirdColumn var typeTextNotNull: String 137 | 138 | @BlackbirdColumn var typeDoubleNull: Double? 139 | @BlackbirdColumn var typeDoubleNotNull: Double 140 | 141 | @BlackbirdColumn var typeDataNull: Data? 142 | @BlackbirdColumn var typeDataNotNull: Data 143 | 144 | enum RepresentableIntEnum: Int, BlackbirdIntegerEnum { 145 | typealias RawValue = Int 146 | 147 | case zero = 0 148 | case one = 1 149 | case two = 2 150 | } 151 | @BlackbirdColumn var typeIntEnum: RepresentableIntEnum 152 | @BlackbirdColumn var typeIntEnumNull: RepresentableIntEnum? 153 | @BlackbirdColumn var typeIntEnumNullWithValue: RepresentableIntEnum? 154 | 155 | enum RepresentableStringEnum: String, BlackbirdStringEnum { 156 | typealias RawValue = String 157 | 158 | case empty = "" 159 | case zero = "zero" 160 | case one = "one" 161 | case two = "two" 162 | } 163 | @BlackbirdColumn var typeStringEnum: RepresentableStringEnum 164 | @BlackbirdColumn var typeStringEnumNull: RepresentableStringEnum? 165 | @BlackbirdColumn var typeStringEnumNullWithValue: RepresentableStringEnum? 166 | 167 | enum RepresentableIntNonZero: Int, BlackbirdIntegerEnum { 168 | typealias RawValue = Int 169 | 170 | case one = 1 171 | case two = 2 172 | } 173 | @BlackbirdColumn var typeIntNonZeroEnum: RepresentableIntNonZero 174 | @BlackbirdColumn var typeIntNonZeroEnumWithDefault: RepresentableIntNonZero = .one 175 | @BlackbirdColumn var typeIntNonZeroEnumNull: RepresentableIntNonZero? 176 | @BlackbirdColumn var typeIntNonZeroEnumNullWithValue: RepresentableIntNonZero? 177 | 178 | enum RepresentableStringNonEmpty: String, BlackbirdStringEnum { 179 | typealias RawValue = String 180 | 181 | case one = "one" 182 | case two = "two" 183 | } 184 | @BlackbirdColumn var typeStringNonEmptyEnum: RepresentableStringNonEmpty 185 | @BlackbirdColumn var typeStringNonEmptyEnumWithDefault: RepresentableStringNonEmpty = .two 186 | @BlackbirdColumn var typeStringNonEmptyEnumNull: RepresentableStringNonEmpty? 187 | @BlackbirdColumn var typeStringNonEmptyEnumNullWithValue: RepresentableStringNonEmpty? 188 | 189 | @BlackbirdColumn var typeURLNull: URL? 190 | @BlackbirdColumn var typeURLNotNull: URL 191 | 192 | @BlackbirdColumn var typeDateNull: Date? 193 | @BlackbirdColumn var typeDateNotNull: Date 194 | } 195 | 196 | struct MulticolumnPrimaryKeyTest: BlackbirdModel { 197 | static let primaryKey: [BlackbirdColumnKeyPath] = [ \.$userID, \.$feedID, \.$episodeID ] 198 | 199 | @BlackbirdColumn var userID: Int64 200 | @BlackbirdColumn var feedID: Int64 201 | @BlackbirdColumn var episodeID: Int64 202 | } 203 | 204 | public struct TestModelWithOptionalColumns: BlackbirdModel { 205 | @BlackbirdColumn public var id: Int64 206 | @BlackbirdColumn public var date: Date 207 | @BlackbirdColumn public var name: String 208 | @BlackbirdColumn public var value: String? 209 | @BlackbirdColumn public var otherValue: Int? 210 | @BlackbirdColumn public var optionalDate: Date? 211 | @BlackbirdColumn public var optionalURL: URL? 212 | @BlackbirdColumn public var optionalData: Data? 213 | } 214 | 215 | public struct TestModelWithUniqueIndex: BlackbirdModel { 216 | public static let uniqueIndexes: [[BlackbirdColumnKeyPath]] = [ 217 | [ \.$a, \.$b, \.$c ], 218 | ] 219 | 220 | @BlackbirdColumn public var id: Int64 221 | @BlackbirdColumn public var a: String 222 | @BlackbirdColumn public var b: Int 223 | @BlackbirdColumn public var c: Date 224 | } 225 | 226 | public struct TestModelForUpdateExpressions: BlackbirdModel { 227 | @BlackbirdColumn public var id: Int64 228 | @BlackbirdColumn public var i: Int 229 | @BlackbirdColumn public var d: Double 230 | } 231 | 232 | // MARK: - Schema change: Add primary-key column 233 | 234 | struct SchemaChangeAddPrimaryKeyColumnInitial: BlackbirdModel { 235 | static let tableName = "SchemaChangeAddPrimaryKeyColumn" 236 | static let primaryKey: [BlackbirdColumnKeyPath] = [ \.$userID, \.$feedID ] 237 | 238 | @BlackbirdColumn var userID: Int64 239 | @BlackbirdColumn var feedID: Int64 240 | @BlackbirdColumn var subscribed: Bool 241 | } 242 | 243 | struct SchemaChangeAddPrimaryKeyColumnChanged: BlackbirdModel { 244 | static let tableName = "SchemaChangeAddPrimaryKeyColumn" 245 | static let primaryKey: [BlackbirdColumnKeyPath] = [ \.$userID, \.$feedID, \.$episodeID ] 246 | 247 | @BlackbirdColumn var userID: Int64 248 | @BlackbirdColumn var feedID: Int64 249 | @BlackbirdColumn var episodeID: Int64 250 | @BlackbirdColumn var subscribed: Bool 251 | } 252 | 253 | 254 | 255 | // MARK: - Schema change: Add columns 256 | 257 | struct SchemaChangeAddColumnsInitial: BlackbirdModel { 258 | static let tableName = "SchemaChangeAddColumns" 259 | 260 | @BlackbirdColumn var id: Int64 261 | @BlackbirdColumn var title: String 262 | } 263 | 264 | struct SchemaChangeAddColumnsChanged: BlackbirdModel { 265 | static let tableName = "SchemaChangeAddColumns" 266 | 267 | @BlackbirdColumn var id: Int64 268 | @BlackbirdColumn var title: String 269 | @BlackbirdColumn var description: String 270 | @BlackbirdColumn var url: URL? 271 | @BlackbirdColumn var art: Data 272 | } 273 | 274 | // MARK: - Schema change: Drop columns 275 | 276 | struct SchemaChangeRebuildTableInitial: BlackbirdModel { 277 | static let tableName = "SchemaChangeRebuild" 278 | static let primaryKey: [BlackbirdColumnKeyPath] = [ \.$id, \.$title ] 279 | 280 | @BlackbirdColumn var id: Int64 281 | @BlackbirdColumn var title: String 282 | @BlackbirdColumn var flags: Int 283 | } 284 | 285 | struct SchemaChangeRebuildTableChanged: BlackbirdModel { 286 | static let tableName = "SchemaChangeRebuild" 287 | 288 | @BlackbirdColumn var id: Int64 289 | @BlackbirdColumn var title: String 290 | @BlackbirdColumn var flags: String 291 | @BlackbirdColumn var description: String 292 | } 293 | 294 | // MARK: - Schema change: Add index 295 | 296 | struct SchemaChangeAddIndexInitial: BlackbirdModel { 297 | static let tableName = "SchemaChangeAddIndex" 298 | 299 | @BlackbirdColumn var id: Int64 300 | @BlackbirdColumn var title: String 301 | } 302 | 303 | struct SchemaChangeAddIndexChanged: BlackbirdModel { 304 | static let tableName = "SchemaChangeAddIndex" 305 | static let indexes: [[BlackbirdColumnKeyPath]] = [ 306 | [ \.$title ] 307 | ] 308 | 309 | @BlackbirdColumn var id: Int64 310 | @BlackbirdColumn var title: String 311 | } 312 | 313 | // MARK: - Invalid index definition 314 | 315 | struct DuplicateIndexesModel: BlackbirdModel { 316 | static let indexes: [[BlackbirdColumnKeyPath]] = [ 317 | [ \.$title ] 318 | ] 319 | 320 | static let uniqueIndexes: [[BlackbirdColumnKeyPath]] = [ 321 | [ \.$title ] 322 | ] 323 | 324 | @BlackbirdColumn var id: Int64 325 | @BlackbirdColumn var title: String 326 | } 327 | 328 | // MARK: - Full-text search 329 | 330 | struct FTSModel: BlackbirdModel { 331 | static let fullTextSearchableColumns: FullTextIndex = [ 332 | \.$title : .text(weight: 3.0), 333 | \.$description : .text, 334 | \.$category : .filterOnly, 335 | ] 336 | 337 | @BlackbirdColumn var id: Int 338 | @BlackbirdColumn var title: String 339 | @BlackbirdColumn var url: URL 340 | @BlackbirdColumn var description: String 341 | @BlackbirdColumn var keywords: String 342 | @BlackbirdColumn var category: Int 343 | } 344 | 345 | struct FTSModelAfterMigration: BlackbirdModel { 346 | static let tableName = "FTSModel" 347 | 348 | static let fullTextSearchableColumns: FullTextIndex = [ 349 | \.$title : .text(weight: 3.0), 350 | \.$description : .text, 351 | \.$category : .filterOnly, 352 | \.$keywords : .text(weight: 0.5), 353 | ] 354 | 355 | @BlackbirdColumn var id: Int 356 | @BlackbirdColumn var title: String 357 | @BlackbirdColumn var url: URL 358 | @BlackbirdColumn var description: String 359 | @BlackbirdColumn var keywords: String 360 | @BlackbirdColumn var category: Int 361 | } 362 | 363 | struct FTSModelAfterDeletion: BlackbirdModel { 364 | static let tableName = "FTSModel" 365 | 366 | @BlackbirdColumn var id: Int 367 | @BlackbirdColumn var title: String 368 | @BlackbirdColumn var url: URL 369 | @BlackbirdColumn var description: String 370 | @BlackbirdColumn var keywords: String 371 | @BlackbirdColumn var category: Int 372 | } 373 | -------------------------------------------------------------------------------- /Tests/SwiftUITestApp/BlackbirdSwiftUITest.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A9858C8C2B1D21FA00531BD9 /* ObservationContentViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9858C8B2B1D21FA00531BD9 /* ObservationContentViews.swift */; }; 11 | A9E94F87293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E94F86293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift */; }; 12 | A9E94F89293E2A8E00A89AC3 /* ContentViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E94F88293E2A8E00A89AC3 /* ContentViews.swift */; }; 13 | A9E94F8B293E2A8F00A89AC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9E94F8A293E2A8F00A89AC3 /* Assets.xcassets */; }; 14 | A9E94F8E293E2A8F00A89AC3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9E94F8D293E2A8F00A89AC3 /* Preview Assets.xcassets */; }; 15 | A9EE2E29293E2BFD00BCEBCE /* Blackbird in Frameworks */ = {isa = PBXBuildFile; productRef = A9EE2E28293E2BFD00BCEBCE /* Blackbird */; }; 16 | A9EE2E2B293E2C5600BCEBCE /* BlackbirdTestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9EE2E2A293E2C5600BCEBCE /* BlackbirdTestData.swift */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | A92E4F222951FBE700954C3F /* Blackbird */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Blackbird; path = ../..; sourceTree = ""; }; 21 | A9858C8B2B1D21FA00531BD9 /* ObservationContentViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationContentViews.swift; sourceTree = ""; }; 22 | A9E94F83293E2A8E00A89AC3 /* BlackbirdSwiftUITest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlackbirdSwiftUITest.app; sourceTree = BUILT_PRODUCTS_DIR; }; 23 | A9E94F86293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackbirdSwiftUITestApp.swift; sourceTree = ""; }; 24 | A9E94F88293E2A8E00A89AC3 /* ContentViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViews.swift; sourceTree = ""; }; 25 | A9E94F8A293E2A8F00A89AC3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | A9E94F8D293E2A8F00A89AC3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 27 | A9EE2E2A293E2C5600BCEBCE /* BlackbirdTestData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = BlackbirdTestData.swift; path = ../../BlackbirdTests/BlackbirdTestData.swift; sourceTree = ""; }; 28 | /* End PBXFileReference section */ 29 | 30 | /* Begin PBXFrameworksBuildPhase section */ 31 | A9E94F80293E2A8E00A89AC3 /* Frameworks */ = { 32 | isa = PBXFrameworksBuildPhase; 33 | buildActionMask = 2147483647; 34 | files = ( 35 | A9EE2E29293E2BFD00BCEBCE /* Blackbird in Frameworks */, 36 | ); 37 | runOnlyForDeploymentPostprocessing = 0; 38 | }; 39 | /* End PBXFrameworksBuildPhase section */ 40 | 41 | /* Begin PBXGroup section */ 42 | A9E94F7A293E2A8E00A89AC3 = { 43 | isa = PBXGroup; 44 | children = ( 45 | A9E94F94293E2ABF00A89AC3 /* Packages */, 46 | A9E94F85293E2A8E00A89AC3 /* BlackbirdSwiftUITest */, 47 | A9E94F84293E2A8E00A89AC3 /* Products */, 48 | A9EE2E25293E2B9200BCEBCE /* Frameworks */, 49 | ); 50 | sourceTree = ""; 51 | }; 52 | A9E94F84293E2A8E00A89AC3 /* Products */ = { 53 | isa = PBXGroup; 54 | children = ( 55 | A9E94F83293E2A8E00A89AC3 /* BlackbirdSwiftUITest.app */, 56 | ); 57 | name = Products; 58 | sourceTree = ""; 59 | }; 60 | A9E94F85293E2A8E00A89AC3 /* BlackbirdSwiftUITest */ = { 61 | isa = PBXGroup; 62 | children = ( 63 | A9EE2E2A293E2C5600BCEBCE /* BlackbirdTestData.swift */, 64 | A9E94F86293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift */, 65 | A9E94F88293E2A8E00A89AC3 /* ContentViews.swift */, 66 | A9858C8B2B1D21FA00531BD9 /* ObservationContentViews.swift */, 67 | A9E94F8A293E2A8F00A89AC3 /* Assets.xcassets */, 68 | A9E94F8C293E2A8F00A89AC3 /* Preview Content */, 69 | ); 70 | path = BlackbirdSwiftUITest; 71 | sourceTree = ""; 72 | }; 73 | A9E94F8C293E2A8F00A89AC3 /* Preview Content */ = { 74 | isa = PBXGroup; 75 | children = ( 76 | A9E94F8D293E2A8F00A89AC3 /* Preview Assets.xcassets */, 77 | ); 78 | path = "Preview Content"; 79 | sourceTree = ""; 80 | }; 81 | A9E94F94293E2ABF00A89AC3 /* Packages */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | A92E4F222951FBE700954C3F /* Blackbird */, 85 | ); 86 | name = Packages; 87 | sourceTree = ""; 88 | }; 89 | A9EE2E25293E2B9200BCEBCE /* Frameworks */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | ); 93 | name = Frameworks; 94 | sourceTree = ""; 95 | }; 96 | /* End PBXGroup section */ 97 | 98 | /* Begin PBXNativeTarget section */ 99 | A9E94F82293E2A8E00A89AC3 /* BlackbirdSwiftUITest */ = { 100 | isa = PBXNativeTarget; 101 | buildConfigurationList = A9E94F91293E2A8F00A89AC3 /* Build configuration list for PBXNativeTarget "BlackbirdSwiftUITest" */; 102 | buildPhases = ( 103 | A9E94F7F293E2A8E00A89AC3 /* Sources */, 104 | A9E94F80293E2A8E00A89AC3 /* Frameworks */, 105 | A9E94F81293E2A8E00A89AC3 /* Resources */, 106 | ); 107 | buildRules = ( 108 | ); 109 | dependencies = ( 110 | ); 111 | name = BlackbirdSwiftUITest; 112 | packageProductDependencies = ( 113 | A9EE2E28293E2BFD00BCEBCE /* Blackbird */, 114 | ); 115 | productName = BlackbirdSwiftUITest; 116 | productReference = A9E94F83293E2A8E00A89AC3 /* BlackbirdSwiftUITest.app */; 117 | productType = "com.apple.product-type.application"; 118 | }; 119 | /* End PBXNativeTarget section */ 120 | 121 | /* Begin PBXProject section */ 122 | A9E94F7B293E2A8E00A89AC3 /* Project object */ = { 123 | isa = PBXProject; 124 | attributes = { 125 | BuildIndependentTargetsInParallel = 1; 126 | LastSwiftUpdateCheck = 1410; 127 | LastUpgradeCheck = 1410; 128 | TargetAttributes = { 129 | A9E94F82293E2A8E00A89AC3 = { 130 | CreatedOnToolsVersion = 14.1; 131 | }; 132 | }; 133 | }; 134 | buildConfigurationList = A9E94F7E293E2A8E00A89AC3 /* Build configuration list for PBXProject "BlackbirdSwiftUITest" */; 135 | compatibilityVersion = "Xcode 14.0"; 136 | developmentRegion = en; 137 | hasScannedForEncodings = 0; 138 | knownRegions = ( 139 | en, 140 | Base, 141 | ); 142 | mainGroup = A9E94F7A293E2A8E00A89AC3; 143 | productRefGroup = A9E94F84293E2A8E00A89AC3 /* Products */; 144 | projectDirPath = ""; 145 | projectRoot = ""; 146 | targets = ( 147 | A9E94F82293E2A8E00A89AC3 /* BlackbirdSwiftUITest */, 148 | ); 149 | }; 150 | /* End PBXProject section */ 151 | 152 | /* Begin PBXResourcesBuildPhase section */ 153 | A9E94F81293E2A8E00A89AC3 /* Resources */ = { 154 | isa = PBXResourcesBuildPhase; 155 | buildActionMask = 2147483647; 156 | files = ( 157 | A9E94F8E293E2A8F00A89AC3 /* Preview Assets.xcassets in Resources */, 158 | A9E94F8B293E2A8F00A89AC3 /* Assets.xcassets in Resources */, 159 | ); 160 | runOnlyForDeploymentPostprocessing = 0; 161 | }; 162 | /* End PBXResourcesBuildPhase section */ 163 | 164 | /* Begin PBXSourcesBuildPhase section */ 165 | A9E94F7F293E2A8E00A89AC3 /* Sources */ = { 166 | isa = PBXSourcesBuildPhase; 167 | buildActionMask = 2147483647; 168 | files = ( 169 | A9E94F89293E2A8E00A89AC3 /* ContentViews.swift in Sources */, 170 | A9858C8C2B1D21FA00531BD9 /* ObservationContentViews.swift in Sources */, 171 | A9EE2E2B293E2C5600BCEBCE /* BlackbirdTestData.swift in Sources */, 172 | A9E94F87293E2A8E00A89AC3 /* BlackbirdSwiftUITestApp.swift in Sources */, 173 | ); 174 | runOnlyForDeploymentPostprocessing = 0; 175 | }; 176 | /* End PBXSourcesBuildPhase section */ 177 | 178 | /* Begin XCBuildConfiguration section */ 179 | A9E94F8F293E2A8F00A89AC3 /* Debug */ = { 180 | isa = XCBuildConfiguration; 181 | buildSettings = { 182 | ALWAYS_SEARCH_USER_PATHS = NO; 183 | CLANG_ANALYZER_NONNULL = YES; 184 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 185 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 186 | CLANG_ENABLE_MODULES = YES; 187 | CLANG_ENABLE_OBJC_ARC = YES; 188 | CLANG_ENABLE_OBJC_WEAK = YES; 189 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 190 | CLANG_WARN_BOOL_CONVERSION = YES; 191 | CLANG_WARN_COMMA = YES; 192 | CLANG_WARN_CONSTANT_CONVERSION = YES; 193 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 194 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 195 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 196 | CLANG_WARN_EMPTY_BODY = YES; 197 | CLANG_WARN_ENUM_CONVERSION = YES; 198 | CLANG_WARN_INFINITE_RECURSION = YES; 199 | CLANG_WARN_INT_CONVERSION = YES; 200 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 201 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 202 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 203 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 204 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 205 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 206 | CLANG_WARN_STRICT_PROTOTYPES = YES; 207 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 208 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 209 | CLANG_WARN_UNREACHABLE_CODE = YES; 210 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 211 | COPY_PHASE_STRIP = NO; 212 | DEBUG_INFORMATION_FORMAT = dwarf; 213 | ENABLE_STRICT_OBJC_MSGSEND = YES; 214 | ENABLE_TESTABILITY = YES; 215 | GCC_C_LANGUAGE_STANDARD = gnu11; 216 | GCC_DYNAMIC_NO_PIC = NO; 217 | GCC_NO_COMMON_BLOCKS = YES; 218 | GCC_OPTIMIZATION_LEVEL = 0; 219 | GCC_PREPROCESSOR_DEFINITIONS = ( 220 | "DEBUG=1", 221 | "$(inherited)", 222 | ); 223 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 224 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 225 | GCC_WARN_UNDECLARED_SELECTOR = YES; 226 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 227 | GCC_WARN_UNUSED_FUNCTION = YES; 228 | GCC_WARN_UNUSED_VARIABLE = YES; 229 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 230 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 231 | MTL_FAST_MATH = YES; 232 | ONLY_ACTIVE_ARCH = YES; 233 | SDKROOT = iphoneos; 234 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 235 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 236 | }; 237 | name = Debug; 238 | }; 239 | A9E94F90293E2A8F00A89AC3 /* Release */ = { 240 | isa = XCBuildConfiguration; 241 | buildSettings = { 242 | ALWAYS_SEARCH_USER_PATHS = NO; 243 | CLANG_ANALYZER_NONNULL = YES; 244 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 245 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 246 | CLANG_ENABLE_MODULES = YES; 247 | CLANG_ENABLE_OBJC_ARC = YES; 248 | CLANG_ENABLE_OBJC_WEAK = YES; 249 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 250 | CLANG_WARN_BOOL_CONVERSION = YES; 251 | CLANG_WARN_COMMA = YES; 252 | CLANG_WARN_CONSTANT_CONVERSION = YES; 253 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 254 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 255 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 256 | CLANG_WARN_EMPTY_BODY = YES; 257 | CLANG_WARN_ENUM_CONVERSION = YES; 258 | CLANG_WARN_INFINITE_RECURSION = YES; 259 | CLANG_WARN_INT_CONVERSION = YES; 260 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 261 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 262 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 263 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 264 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 265 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 266 | CLANG_WARN_STRICT_PROTOTYPES = YES; 267 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 268 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 269 | CLANG_WARN_UNREACHABLE_CODE = YES; 270 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 271 | COPY_PHASE_STRIP = NO; 272 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 273 | ENABLE_NS_ASSERTIONS = NO; 274 | ENABLE_STRICT_OBJC_MSGSEND = YES; 275 | GCC_C_LANGUAGE_STANDARD = gnu11; 276 | GCC_NO_COMMON_BLOCKS = YES; 277 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 278 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 279 | GCC_WARN_UNDECLARED_SELECTOR = YES; 280 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 281 | GCC_WARN_UNUSED_FUNCTION = YES; 282 | GCC_WARN_UNUSED_VARIABLE = YES; 283 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 284 | MTL_ENABLE_DEBUG_INFO = NO; 285 | MTL_FAST_MATH = YES; 286 | SDKROOT = iphoneos; 287 | SWIFT_COMPILATION_MODE = wholemodule; 288 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 289 | VALIDATE_PRODUCT = YES; 290 | }; 291 | name = Release; 292 | }; 293 | A9E94F92293E2A8F00A89AC3 /* Debug */ = { 294 | isa = XCBuildConfiguration; 295 | buildSettings = { 296 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 297 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 298 | CODE_SIGN_STYLE = Automatic; 299 | CURRENT_PROJECT_VERSION = 1; 300 | DEVELOPMENT_ASSET_PATHS = "\"BlackbirdSwiftUITest/Preview Content\""; 301 | DEVELOPMENT_TEAM = ""; 302 | ENABLE_PREVIEWS = YES; 303 | GENERATE_INFOPLIST_FILE = YES; 304 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 305 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 306 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 307 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 308 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 309 | LD_RUNPATH_SEARCH_PATHS = ( 310 | "$(inherited)", 311 | "@executable_path/Frameworks", 312 | ); 313 | MARKETING_VERSION = 1.0; 314 | PRODUCT_BUNDLE_IDENTIFIER = com.marcoarment.BlackbirdSwiftUITest; 315 | PRODUCT_NAME = "$(TARGET_NAME)"; 316 | SWIFT_EMIT_LOC_STRINGS = YES; 317 | SWIFT_VERSION = 5.0; 318 | TARGETED_DEVICE_FAMILY = "1,2"; 319 | }; 320 | name = Debug; 321 | }; 322 | A9E94F93293E2A8F00A89AC3 /* Release */ = { 323 | isa = XCBuildConfiguration; 324 | buildSettings = { 325 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 326 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 327 | CODE_SIGN_STYLE = Automatic; 328 | CURRENT_PROJECT_VERSION = 1; 329 | DEVELOPMENT_ASSET_PATHS = "\"BlackbirdSwiftUITest/Preview Content\""; 330 | DEVELOPMENT_TEAM = ""; 331 | ENABLE_PREVIEWS = YES; 332 | GENERATE_INFOPLIST_FILE = YES; 333 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 334 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 335 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 336 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 337 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 338 | LD_RUNPATH_SEARCH_PATHS = ( 339 | "$(inherited)", 340 | "@executable_path/Frameworks", 341 | ); 342 | MARKETING_VERSION = 1.0; 343 | PRODUCT_BUNDLE_IDENTIFIER = com.marcoarment.BlackbirdSwiftUITest; 344 | PRODUCT_NAME = "$(TARGET_NAME)"; 345 | SWIFT_EMIT_LOC_STRINGS = YES; 346 | SWIFT_VERSION = 5.0; 347 | TARGETED_DEVICE_FAMILY = "1,2"; 348 | }; 349 | name = Release; 350 | }; 351 | /* End XCBuildConfiguration section */ 352 | 353 | /* Begin XCConfigurationList section */ 354 | A9E94F7E293E2A8E00A89AC3 /* Build configuration list for PBXProject "BlackbirdSwiftUITest" */ = { 355 | isa = XCConfigurationList; 356 | buildConfigurations = ( 357 | A9E94F8F293E2A8F00A89AC3 /* Debug */, 358 | A9E94F90293E2A8F00A89AC3 /* Release */, 359 | ); 360 | defaultConfigurationIsVisible = 0; 361 | defaultConfigurationName = Release; 362 | }; 363 | A9E94F91293E2A8F00A89AC3 /* Build configuration list for PBXNativeTarget "BlackbirdSwiftUITest" */ = { 364 | isa = XCConfigurationList; 365 | buildConfigurations = ( 366 | A9E94F92293E2A8F00A89AC3 /* Debug */, 367 | A9E94F93293E2A8F00A89AC3 /* Release */, 368 | ); 369 | defaultConfigurationIsVisible = 0; 370 | defaultConfigurationName = Release; 371 | }; 372 | /* End XCConfigurationList section */ 373 | 374 | /* Begin XCSwiftPackageProductDependency section */ 375 | A9EE2E28293E2BFD00BCEBCE /* Blackbird */ = { 376 | isa = XCSwiftPackageProductDependency; 377 | productName = Blackbird; 378 | }; 379 | /* End XCSwiftPackageProductDependency section */ 380 | }; 381 | rootObject = A9E94F7B293E2A8E00A89AC3 /* Project object */; 382 | } 383 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdCache.swift 13 | // Created by Marco Arment on 11/17/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | @preconcurrency import Foundation // @preconcurrency due to DispatchSource not being annotated 35 | 36 | extension Blackbird.Database { 37 | public struct CachePerformanceMetrics: Sendable { 38 | public let hits: Int 39 | public let misses: Int 40 | public let writes: Int 41 | public let rowInvalidations: Int 42 | public let queryInvalidations: Int 43 | public let tableInvalidations: Int 44 | public let evictions: Int 45 | public let lowMemoryFlushes: Int 46 | } 47 | 48 | public func cachePerformanceMetricsByTableName() -> [String: CachePerformanceMetrics] { cache.performanceMetrics() } 49 | public func resetCachePerformanceMetrics(tableName: String) { cache.resetPerformanceMetrics(tableName: tableName) } 50 | 51 | public func debugPrintCachePerformanceMetrics() { 52 | print("===== Blackbird.Database cache performance metrics =====") 53 | for (tableName, metrics) in cache.performanceMetrics() { 54 | let totalRequests = metrics.hits + metrics.misses 55 | let hitPercentStr = 56 | totalRequests == 0 ? "0%" : 57 | "\(Int(100.0 * Double(metrics.hits) / Double(totalRequests)))%" 58 | 59 | print("\(tableName): \(metrics.hits) hits (\(hitPercentStr)), \(metrics.misses) misses, \(metrics.writes) writes, \(metrics.rowInvalidations) row invalidations, \(metrics.queryInvalidations) query invalidations, \(metrics.tableInvalidations) table invalidations, \(metrics.evictions) evictions, \(metrics.lowMemoryFlushes) low-memory flushes") 60 | } 61 | } 62 | 63 | internal final class Cache: Sendable { 64 | private class CacheEntry { 65 | typealias AccessTime = UInt64 66 | private let _value: T 67 | var lastAccessed: AccessTime 68 | 69 | init(_ value: T) { 70 | _value = value 71 | lastAccessed = mach_absolute_time() 72 | } 73 | 74 | public func value() -> T { 75 | lastAccessed = mach_absolute_time() 76 | return _value 77 | } 78 | } 79 | 80 | internal enum CachedQueryResult: Sendable { 81 | case miss 82 | case hit(value: Sendable?) 83 | } 84 | 85 | private let lowMemoryEventSource: DispatchSourceMemoryPressure 86 | public init() { 87 | lowMemoryEventSource = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical]) 88 | lowMemoryEventSource.setEventHandler { [weak self] in 89 | self?.entriesByTableName.withLock { entries in 90 | // 91 | // To avoid loading potentially-compressed memory pages and exacerbating memory pressure, 92 | // or taking precious time to walk the cache contents with the normal prune() operation, 93 | // just dump everything. 94 | // 95 | for (_, cache) in entries { cache.flushForLowMemory() } 96 | } 97 | } 98 | lowMemoryEventSource.resume() 99 | } 100 | 101 | deinit { 102 | lowMemoryEventSource.cancel() 103 | } 104 | 105 | private final class TableCache: @unchecked Sendable { /* unchecked due to internal locking */ 106 | private let lock = Blackbird.Lock() 107 | 108 | // Cached data 109 | private var modelsByPrimaryKey: [Blackbird.Value: CacheEntry] = [:] 110 | private var cachedQueries: [[Blackbird.Value]: CacheEntry] = [:] 111 | 112 | // Performance counters 113 | private var hits: Int = 0 114 | private var misses: Int = 0 115 | private var writes: Int = 0 116 | private var rowInvalidations: Int = 0 117 | private var queryInvalidations: Int = 0 118 | private var tableInvalidations: Int = 0 119 | private var evictions: Int = 0 120 | private var lowMemoryFlushes: Int = 0 121 | 122 | func get(primaryKey: Blackbird.Value) -> (any BlackbirdModel)? { 123 | lock.lock() 124 | defer { lock.unlock() } 125 | 126 | if let hit = modelsByPrimaryKey[primaryKey] { 127 | hit.lastAccessed = mach_absolute_time() 128 | hits += 1 129 | return hit.value() 130 | } else { 131 | misses += 1 132 | return nil 133 | } 134 | } 135 | 136 | func get(primaryKeys: [Blackbird.Value]) -> (hits: [any BlackbirdModel], missedKeys: [Blackbird.Value]) { 137 | lock.lock() 138 | defer { lock.unlock() } 139 | 140 | var hitResults: [any BlackbirdModel] = [] 141 | var missedKeys: [Blackbird.Value] = [] 142 | for key in primaryKeys { 143 | if let hit = modelsByPrimaryKey[key] { hitResults.append(hit.value()) } else { missedKeys.append(key) } 144 | } 145 | hits += hitResults.count 146 | misses += missedKeys.count 147 | return (hits: hitResults, missedKeys: missedKeys) 148 | } 149 | 150 | func getQuery(cacheKey: [Blackbird.Value]) -> CachedQueryResult { 151 | lock.lock() 152 | defer { lock.unlock() } 153 | 154 | if let hit = cachedQueries[cacheKey] { 155 | hits += 1 156 | return .hit(value: hit.value()) 157 | } else { 158 | misses += 1 159 | return .miss 160 | } 161 | } 162 | 163 | func add(primaryKey: Blackbird.Value, instance: any BlackbirdModel, pruneToLimit: Int? = nil) { 164 | lock.lock() 165 | defer { lock.unlock() } 166 | 167 | modelsByPrimaryKey[primaryKey] = CacheEntry(instance) 168 | writes += 1 169 | 170 | if let pruneToLimit { 171 | inLock_prune(entryLimit: pruneToLimit) 172 | } 173 | } 174 | 175 | func addQuery(cacheKey: [Blackbird.Value], result: Sendable?, pruneToLimit: Int? = nil) { 176 | lock.lock() 177 | defer { lock.unlock() } 178 | 179 | cachedQueries[cacheKey] = CacheEntry(result) 180 | writes += 1 181 | 182 | if let pruneToLimit { 183 | inLock_prune(entryLimit: pruneToLimit) 184 | } 185 | 186 | } 187 | 188 | func delete(primaryKey: Blackbird.Value) { 189 | lock.lock() 190 | defer { lock.unlock() } 191 | 192 | modelsByPrimaryKey.removeValue(forKey: primaryKey) 193 | writes += 1 194 | } 195 | 196 | private func inLock_prune(entryLimit: Int) { 197 | if modelsByPrimaryKey.count + cachedQueries.count <= entryLimit { return } 198 | 199 | // As a table hits its entry limit, to avoid running the expensive pruning operation after EVERY addition, 200 | // we prune the cache to HALF of its size limit to give it some headroom until the next prune is needed. 201 | let pruneToEntryLimit = entryLimit / 2 202 | 203 | if pruneToEntryLimit < 1 { 204 | modelsByPrimaryKey.removeAll() 205 | cachedQueries.removeAll() 206 | return 207 | } 208 | 209 | var accessTimes: [CacheEntry.AccessTime] = [] 210 | for (_, entry) in modelsByPrimaryKey { accessTimes.append(entry.lastAccessed) } 211 | for (_, entry) in cachedQueries { accessTimes.append(entry.lastAccessed) } 212 | accessTimes.sort(by: >) 213 | 214 | let evictionCount = accessTimes.count - pruneToEntryLimit 215 | guard evictionCount > 0 else { return } 216 | let accessTimeThreshold = accessTimes[pruneToEntryLimit] 217 | modelsByPrimaryKey = modelsByPrimaryKey.filter { (key, value) in value.lastAccessed > accessTimeThreshold } 218 | cachedQueries = cachedQueries.filter { (key, value) in value.lastAccessed > accessTimeThreshold } 219 | evictions += evictionCount 220 | } 221 | 222 | func invalidate(primaryKeyValue: Blackbird.Value? = nil) { 223 | lock.lock() 224 | defer { lock.unlock() } 225 | 226 | if let primaryKeyValue { 227 | if nil != modelsByPrimaryKey.removeValue(forKey: primaryKeyValue) { 228 | rowInvalidations += 1 229 | } 230 | } else { 231 | if !modelsByPrimaryKey.isEmpty { 232 | modelsByPrimaryKey.removeAll() 233 | tableInvalidations += 1 234 | } 235 | } 236 | 237 | if !cachedQueries.isEmpty { 238 | cachedQueries.removeAll() 239 | queryInvalidations += 1 240 | } 241 | } 242 | 243 | func flushForLowMemory() { 244 | lock.lock() 245 | defer { lock.unlock() } 246 | 247 | modelsByPrimaryKey.removeAll(keepingCapacity: false) 248 | cachedQueries.removeAll(keepingCapacity: false) 249 | lowMemoryFlushes += 1 250 | } 251 | 252 | func resetPerformanceMetrics() { 253 | lock.lock() 254 | defer { lock.unlock() } 255 | 256 | hits = 0 257 | misses = 0 258 | writes = 0 259 | evictions = 0 260 | rowInvalidations = 0 261 | queryInvalidations = 0 262 | tableInvalidations = 0 263 | lowMemoryFlushes = 0 264 | } 265 | 266 | func getPerformanceMetrics() -> CachePerformanceMetrics { 267 | lock.lock() 268 | defer { lock.unlock() } 269 | 270 | return CachePerformanceMetrics(hits: hits, misses: misses, writes: writes, rowInvalidations: rowInvalidations, queryInvalidations: queryInvalidations, tableInvalidations: tableInvalidations, evictions: evictions, lowMemoryFlushes: lowMemoryFlushes) 271 | } 272 | } 273 | 274 | private let entriesByTableName = Blackbird.Locked<[String: TableCache]>([:]) 275 | 276 | internal func invalidate(tableName: String? = nil, primaryKeyValue: Blackbird.Value? = nil) { 277 | entriesByTableName.withLock { 278 | if let tableName { 279 | $0[tableName]?.invalidate(primaryKeyValue: primaryKeyValue) 280 | } else { 281 | for (_, entry) in $0 { entry.invalidate() } 282 | } 283 | } 284 | } 285 | 286 | internal func readModel(tableName: String, primaryKey: Blackbird.Value) -> (any BlackbirdModel)? { 287 | entriesByTableName.withLock { 288 | let tableCache: TableCache 289 | if let existingCache = $0[tableName] { tableCache = existingCache } 290 | else { 291 | tableCache = TableCache() 292 | $0[tableName] = tableCache 293 | } 294 | 295 | return tableCache.get(primaryKey: primaryKey) 296 | } 297 | } 298 | 299 | internal func readModels(tableName: String, primaryKeys: [Blackbird.Value]) -> (hits: [any BlackbirdModel], missedKeys: [Blackbird.Value]) { 300 | entriesByTableName.withLock { 301 | let tableCache: TableCache 302 | if let existingCache = $0[tableName] { tableCache = existingCache } 303 | else { 304 | tableCache = TableCache() 305 | $0[tableName] = tableCache 306 | } 307 | 308 | return tableCache.get(primaryKeys: primaryKeys) 309 | } 310 | } 311 | 312 | internal func writeModel(tableName: String, primaryKey: Blackbird.Value, instance: any BlackbirdModel, entryLimit: Int) { 313 | entriesByTableName.withLock { 314 | let tableCache: TableCache 315 | if let existingCache = $0[tableName] { tableCache = existingCache } 316 | else { 317 | tableCache = TableCache() 318 | $0[tableName] = tableCache 319 | } 320 | 321 | tableCache.add(primaryKey: primaryKey, instance: instance, pruneToLimit: entryLimit) 322 | } 323 | } 324 | 325 | internal func deleteModel(tableName: String, primaryKey: Blackbird.Value) { 326 | entriesByTableName.withLock { 327 | let tableCache: TableCache 328 | if let existingCache = $0[tableName] { tableCache = existingCache } 329 | else { 330 | tableCache = TableCache() 331 | $0[tableName] = tableCache 332 | } 333 | 334 | tableCache.delete(primaryKey: primaryKey) 335 | } 336 | } 337 | 338 | internal func readQueryResult(tableName: String, cacheKey: [Blackbird.Value]) -> CachedQueryResult { 339 | entriesByTableName.withLock { 340 | let tableCache: TableCache 341 | if let existingCache = $0[tableName] { tableCache = existingCache } 342 | else { 343 | tableCache = TableCache() 344 | $0[tableName] = tableCache 345 | } 346 | return tableCache.getQuery(cacheKey: cacheKey) 347 | } 348 | } 349 | 350 | internal func writeQueryResult(tableName: String, cacheKey: [Blackbird.Value], result: Sendable, entryLimit: Int) { 351 | entriesByTableName.withLock { 352 | let tableCache: TableCache 353 | if let existingCache = $0[tableName] { tableCache = existingCache } 354 | else { 355 | tableCache = TableCache() 356 | $0[tableName] = tableCache 357 | } 358 | 359 | tableCache.addQuery(cacheKey: cacheKey, result: result, pruneToLimit: entryLimit) 360 | } 361 | } 362 | 363 | internal func performanceMetrics() -> [String: CachePerformanceMetrics] { 364 | entriesByTableName.withLock { tableCaches in 365 | tableCaches.mapValues { $0.getPerformanceMetrics() } 366 | } 367 | } 368 | 369 | internal func resetPerformanceMetrics(tableName: String) { 370 | entriesByTableName.withLock { $0[tableName]?.resetPerformanceMetrics() } 371 | } 372 | } 373 | } 374 | 375 | 376 | extension BlackbirdModel { 377 | internal func _saveCachedInstance(for database: Blackbird.Database) { 378 | let cacheLimit = Self.cacheLimit 379 | if cacheLimit > 0, let pkValues = try? self.primaryKeyValues(), pkValues.count == 1, let pk = try? Blackbird.Value.fromAny(pkValues.first!) { 380 | database.cache.writeModel(tableName: Self.tableName, primaryKey: pk, instance: self, entryLimit: cacheLimit) 381 | } 382 | } 383 | 384 | internal func _deleteCachedInstance(for database: Blackbird.Database) { 385 | if Self.cacheLimit > 0, let pkValues = try? self.primaryKeyValues(), pkValues.count == 1, let pk = try? Blackbird.Value.fromAny(pkValues.first!) { 386 | database.cache.deleteModel(tableName: Self.tableName, primaryKey: pk) 387 | } 388 | } 389 | 390 | internal static func _cachedInstance(for database: Blackbird.Database, primaryKeyValue: Blackbird.Value) -> Self? { 391 | guard Self.cacheLimit > 0 else { return nil } 392 | return database.cache.readModel(tableName: Self.tableName, primaryKey: primaryKeyValue) as? Self 393 | } 394 | 395 | internal static func _cachedInstances(for database: Blackbird.Database, primaryKeyValues: [Blackbird.Value]) -> (hits: [Self], missedKeys: [Blackbird.Value]) { 396 | guard Self.cacheLimit > 0 else { return (hits: [], missedKeys: primaryKeyValues) } 397 | let results = database.cache.readModels(tableName: Self.tableName, primaryKeys: primaryKeyValues) 398 | 399 | var hits: [Self] = [] 400 | for hit in results.hits { 401 | guard let hit = hit as? Self else { return (hits: [], missedKeys: primaryKeyValues) } 402 | hits.append(hit) 403 | } 404 | return (hits: hits, missedKeys: results.missedKeys) 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /Sources/Blackbird/Blackbird.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // Blackbird.swift 13 | // Created by Marco Arment on 11/6/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | import SQLite3 36 | 37 | /// A small, fast, lightweight SQLite database wrapper and model layer. 38 | public class Blackbird { 39 | /// A dictionary of argument values for a database query, keyed by column names. 40 | public typealias Arguments = Dictionary 41 | 42 | /// A set of primary-key values, where each is an array of values (to support multi-column primary keys). 43 | public typealias PrimaryKeyValues = Set<[Blackbird.Value]> 44 | 45 | /// A set of column names. 46 | public typealias ColumnNames = Set 47 | 48 | /// Basic info for a ``BlackbirdColumn`` as returned by ``BlackbirdModel/columnInfoFromKeyPaths(_:)``. 49 | public struct ColumnInfo { 50 | /// The column's name. 51 | public let name: String 52 | 53 | /// The column's type. 54 | public let type: any BlackbirdColumnWrappable.Type 55 | } 56 | 57 | public enum Error: Swift.Error { 58 | /// Throw this within a `cancellableTransaction` block to cancel and roll back the transaction. It will not propagate further up the call stack. 59 | case cancelTransaction 60 | } 61 | 62 | /// A wrapper for SQLite's column data types. 63 | public enum Value: Sendable, ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, ExpressibleByIntegerLiteral, Hashable, Comparable { 64 | public static func < (lhs: Blackbird.Value, rhs: Blackbird.Value) -> Bool { 65 | switch lhs { 66 | case .null: return false 67 | case let .integer(i): return i < rhs.int64Value ?? 0 68 | case let .double(d): return d < rhs.doubleValue ?? 0 69 | case let .text(s): return s < rhs.stringValue ?? "" 70 | case let .data(b): return b.count < rhs.dataValue?.count ?? 0 71 | } 72 | } 73 | 74 | case null 75 | case integer(Int64) 76 | case double(Double) 77 | case text(String) 78 | case data(Data) 79 | 80 | public enum Error: Swift.Error { 81 | case cannotConvertToValue 82 | } 83 | 84 | public func hash(into hasher: inout Hasher) { 85 | hasher.combine(sqliteLiteral()) 86 | } 87 | 88 | public static func fromAny(_ value: Any?) throws -> Value { 89 | guard var value else { return .null } 90 | 91 | if let optional = value as? any OptionalProtocol { 92 | if let wrapped = optional.wrappedOptionalValue { 93 | value = wrapped 94 | } else { 95 | return .null 96 | } 97 | } 98 | 99 | switch value { 100 | case _ as NSNull: return .null 101 | case let v as Value: return v 102 | case let v as any StringProtocol: return .text(String(v)) 103 | case let v as any BlackbirdStorableAsInteger: return .integer(v.unifiedRepresentation()) 104 | case let v as any BlackbirdStorableAsDouble: return .double(v.unifiedRepresentation()) 105 | case let v as any BlackbirdStorableAsText: return .text(v.unifiedRepresentation()) 106 | case let v as any BlackbirdStorableAsData: return .data(v.unifiedRepresentation()) 107 | case let v as any BlackbirdIntegerEnum: return .integer(v.rawValue.unifiedRepresentation()) 108 | case let v as any BlackbirdStringEnum: return .text(v.rawValue.unifiedRepresentation()) 109 | default: throw Error.cannotConvertToValue 110 | } 111 | } 112 | 113 | public init(stringLiteral value: String) { self = .text(value) } 114 | public init(floatLiteral value: Double) { self = .double(value) } 115 | public init(integerLiteral value: Int64) { self = .integer(value) } 116 | public init(booleanLiteral value: Bool) { self = .integer(value ? 1 : 0) } 117 | 118 | public func sqliteLiteral() -> String { 119 | switch self { 120 | case let .integer(i): return String(i) 121 | case let .double(d): return String(d) 122 | case let .text(s): return "'\(s.replacingOccurrences(of: "'", with: "''"))'" 123 | case let .data(b): return "X'\(b.map { String(format: "%02hhX", $0) }.joined())'" 124 | case .null: return "NULL" 125 | } 126 | } 127 | 128 | public static func fromSQLiteLiteral(_ literalString: String) -> Self? { 129 | if literalString == "NULL" { return .null } 130 | 131 | if literalString.hasPrefix("'"), literalString.hasSuffix("'") { 132 | let start = literalString.index(literalString.startIndex, offsetBy: 1) 133 | let end = literalString.index(literalString.endIndex, offsetBy: -1) 134 | return .text(literalString[start.. 0 157 | case let .double(d): return d > 0 158 | case let .text(s): return (Int(s) ?? 0) != 0 159 | case let .data(b): if let str = String(data: b, encoding: .utf8), let i = Int(str) { return i != 0 } else { return nil } 160 | } 161 | } 162 | 163 | public var dataValue: Data? { 164 | switch self { 165 | case .null: return nil 166 | case let .data(b): return b 167 | case let .integer(i): return String(i).data(using: .utf8) 168 | case let .double(d): return String(d).data(using: .utf8) 169 | case let .text(s): return s.data(using: .utf8) 170 | } 171 | } 172 | 173 | public var doubleValue: Double? { 174 | switch self { 175 | case .null: return nil 176 | case let .double(d): return d 177 | case let .integer(i): return Double(i) 178 | case let .text(s): return Double(s) 179 | case let .data(b): if let str = String(data: b, encoding: .utf8) { return Double(str) } else { return nil } 180 | } 181 | } 182 | 183 | public var intValue: Int? { 184 | switch self { 185 | case .null: return nil 186 | case let .integer(i): return Int(i) 187 | case let .double(d): return Int(d) 188 | case let .text(s): return Int(s) 189 | case let .data(b): if let str = String(data: b, encoding: .utf8) { return Int(str) } else { return nil } 190 | } 191 | } 192 | 193 | public var int64Value: Int64? { 194 | switch self { 195 | case .null: return nil 196 | case let .integer(i): return Int64(i) 197 | case let .double(d): return Int64(d) 198 | case let .text(s): return Int64(s) 199 | case let .data(b): if let str = String(data: b, encoding: .utf8) { return Int64(str) } else { return nil } 200 | } 201 | } 202 | 203 | public var stringValue: String? { 204 | switch self { 205 | case .null: return nil 206 | case let .text(s): return s 207 | case let .integer(i): return String(i) 208 | case let .double(d): return String(d) 209 | case let .data(b): return String(data: b, encoding: .utf8) 210 | } 211 | } 212 | 213 | internal func objcValue() -> NSObject { 214 | switch self { 215 | case .null: return NSNull() 216 | case let .integer(i): return NSNumber(value: i) 217 | case let .double(d): return NSNumber(value: d) 218 | case let .text(s): return NSString(string: s) 219 | case let .data(d): return NSData(data: d) 220 | } 221 | } 222 | 223 | private static let copyValue = unsafeBitCast(-1, to: sqlite3_destructor_type.self) // a.k.a. SQLITE_TRANSIENT 224 | 225 | internal func bind(database: isolated Blackbird.Database.Core, statement: OpaquePointer, index: Int32, for query: String) throws { 226 | var result: Int32 227 | switch self { 228 | case .null: result = sqlite3_bind_null(statement, index) 229 | case let .integer(i): result = sqlite3_bind_int64(statement, index, i) 230 | case let .double(d): result = sqlite3_bind_double(statement, index, d) 231 | case let .text(s): result = sqlite3_bind_text(statement, index, s, -1, Blackbird.Value.copyValue) 232 | case let .data(d): result = d.withUnsafeBytes { bytes in sqlite3_bind_blob(statement, index, bytes.baseAddress, Int32(bytes.count), Blackbird.Value.copyValue) } 233 | } 234 | if result != SQLITE_OK { throw Blackbird.Database.Error.queryArgumentValueError(query: query, description: database.errorDesc(database.dbHandle.pointer)) } 235 | } 236 | 237 | internal func bind(database: isolated Blackbird.Database.Core, statement: OpaquePointer, name: String, for query: String) throws { 238 | let idx = sqlite3_bind_parameter_index(statement, name) 239 | if idx == 0 { throw Blackbird.Database.Error.queryArgumentNameError(query: query, name: name) } 240 | return try bind(database: database, statement: statement, index: idx, for: query) 241 | } 242 | } 243 | } 244 | 245 | // MARK: - Utilities 246 | 247 | /// A basic locking utility. 248 | public protocol BlackbirdLock: Sendable { 249 | func lock() 250 | func unlock() 251 | @discardableResult func withLock(_ body: () throws -> R) rethrows -> R where R: Sendable 252 | } 253 | extension BlackbirdLock { 254 | @discardableResult public func withLock(_ body: () throws -> R) rethrows -> R where R: Sendable { 255 | lock() 256 | defer { unlock() } 257 | return try body() 258 | } 259 | } 260 | 261 | import os 262 | extension Blackbird { 263 | /// Blackbird's lock primitive, offered for public use. 264 | public static func Lock() -> BlackbirdLock { 265 | if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { 266 | return UnfairLock() 267 | } else { 268 | return LegacyUnfairLock() 269 | } 270 | } 271 | 272 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 273 | fileprivate final class UnfairLock: BlackbirdLock { 274 | private let _lock = OSAllocatedUnfairLock() 275 | internal func lock() { _lock.lock() } 276 | internal func unlock() { _lock.unlock() } 277 | } 278 | 279 | fileprivate final class LegacyUnfairLock: BlackbirdLock, @unchecked Sendable /* unchecked due to known-safe use of an UnsafeMutablePointer */ { 280 | private var _lock: UnsafeMutablePointer 281 | internal func lock() { os_unfair_lock_lock(_lock) } 282 | internal func unlock() { os_unfair_lock_unlock(_lock) } 283 | 284 | internal init() { 285 | _lock = UnsafeMutablePointer.allocate(capacity: 1) 286 | _lock.initialize(to: os_unfair_lock()) 287 | } 288 | deinit { _lock.deallocate() } 289 | } 290 | 291 | /// Blackbird's locked-value utility, offered for public use. Useful when conforming to `Sendable`. 292 | public final class Locked: @unchecked Sendable /* unchecked due to use of internal locking */ { 293 | public var value: T { 294 | get { 295 | return lock.withLock { _value } 296 | } 297 | set { 298 | lock.withLock { _value = newValue } 299 | } 300 | } 301 | 302 | private let lock = Lock() 303 | private var _value: T 304 | 305 | public init(_ initialValue: T) { 306 | _value = initialValue 307 | } 308 | 309 | @discardableResult 310 | public func withLock(_ body: (inout T) -> R) -> R where R: Sendable { 311 | return lock.withLock { return body(&_value) } 312 | } 313 | } 314 | 315 | internal final class FileChangeMonitor: @unchecked Sendable /* unchecked due to use of internal locking */ { 316 | private var sources: [DispatchSourceFileSystemObject] = [] 317 | 318 | private var changeHandler: (() -> Void)? 319 | private var isClosed = false 320 | private var currentExpectedChanges = Set() 321 | 322 | private let lock = Lock() 323 | 324 | public func addFile(filePath: String) { 325 | let fsPath = (filePath as NSString).fileSystemRepresentation 326 | let fd = open(fsPath, O_EVTONLY) 327 | guard fd >= 0 else { return } 328 | 329 | let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fd, eventMask: [.write, .extend, .delete, .rename, .revoke], queue: nil) 330 | source.setCancelHandler { Darwin.close(fd) } 331 | 332 | source.setEventHandler { [weak self] in 333 | guard let self else { return } 334 | self.lock.lock() 335 | if self.currentExpectedChanges.isEmpty, !self.isClosed, let handler = self.changeHandler { handler() } 336 | self.lock.unlock() 337 | } 338 | 339 | source.activate() 340 | 341 | self.lock.lock() 342 | self.sources.append(source) 343 | self.lock.unlock() 344 | } 345 | 346 | deinit { 347 | cancel() 348 | } 349 | 350 | public func onChange(_ handler: @escaping (() -> Void)) { 351 | self.lock.lock() 352 | self.changeHandler = handler 353 | self.lock.unlock() 354 | } 355 | 356 | public func cancel() { 357 | self.lock.lock() 358 | self.isClosed = true 359 | for source in sources { source.cancel() } 360 | self.lock.unlock() 361 | 362 | } 363 | 364 | public func beginExpectedChange(_ changeID: Int64) { 365 | self.lock.lock() 366 | self.currentExpectedChanges.insert(changeID) 367 | self.lock.unlock() 368 | } 369 | 370 | public func endExpectedChange(_ changeID: Int64) { 371 | self.lock.lock() 372 | self.currentExpectedChanges.remove(changeID) 373 | self.lock.unlock() 374 | } 375 | } 376 | 377 | 378 | /// Blackbird's async-sempahore utility, offered for public use. 379 | /// 380 | /// Suggested use: 381 | /// 382 | /// ```swift 383 | /// class MyClass { 384 | /// let myMethodSemaphore = Blackbird.Semaphore(value: 1) 385 | /// 386 | /// func myMethod() async { 387 | /// await myMethodSemaphore.wait() 388 | /// defer { myMethodSemaphore.signal() } 389 | /// 390 | /// // do async work... 391 | /// } 392 | /// } 393 | /// ``` 394 | /// Inspired by [Sebastian Toivonen's approach](https://forums.swift.org/t/semaphore-alternatives-for-structured-concurrency/59353/3). 395 | /// Consider using the Gwendal Roué's more-featured [Semaphore](https://github.com/groue/Semaphore) instead. 396 | public final class Semaphore: Sendable { 397 | private struct State: Sendable { 398 | var value = 0 399 | var waiting: [CheckedContinuation] = [] 400 | } 401 | private let state: Locked 402 | 403 | public init(value: Int = 0) { state = Locked(State(value: value)) } 404 | 405 | public func wait() async { 406 | let wait = state.withLock { state in 407 | state.value -= 1 408 | return state.value < 0 409 | } 410 | 411 | if wait { 412 | await withCheckedContinuation { continuation in 413 | state.withLock { $0.waiting.append(continuation) } 414 | } 415 | } 416 | } 417 | 418 | public func signal() { 419 | state.withLock { state in 420 | state.value += 1 421 | if state.waiting.isEmpty { return } 422 | state.waiting.removeFirst().resume() 423 | } 424 | } 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdChanges.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdChanges.swift 13 | // Created by Marco Arment on 11/17/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import Foundation 35 | @preconcurrency import Combine 36 | 37 | public extension Blackbird { 38 | /// A change to a table in a Blackbird database, as published by a ``ChangePublisher``. 39 | /// 40 | /// For `BlackbirdModel` tables, ``BlackbirdModel/changePublisher(in:)`` provides a typed ``ModelChange`` instead. 41 | struct Change: Sendable { 42 | internal let table: String 43 | internal let primaryKeys: PrimaryKeyValues? 44 | internal let columnNames: Blackbird.ColumnNames? 45 | 46 | /// Determine if a specific primary-key value may have changed. 47 | /// - Parameter key: The single-column primary-key value in question. 48 | /// - Returns: Whether the row with this primary-key value may have changed. Note that changes may be over-reported. 49 | /// 50 | /// For tables with multi-column primary keys, use ``hasMulticolumnPrimaryKeyChanged(_:)``. 51 | public func hasPrimaryKeyChanged(_ key: Any) -> Bool { 52 | guard let primaryKeys else { return true } 53 | return primaryKeys.contains([try! Blackbird.Value.fromAny(key)]) 54 | } 55 | 56 | /// Determine if a specific primary-key value may have changed in a table with a multi-column primary key. 57 | /// - Parameter key: The multi-column primary-key value array in question. 58 | /// - Returns: Whether the row with these primary-key values may have changed. Note that changes may be over-reported. 59 | /// 60 | /// For tables with single-column primary keys, use ``hasPrimaryKeyChanged(_:)``. 61 | public func hasMulticolumnPrimaryKeyChanged(_ key: [Any]) -> Bool { 62 | guard let primaryKeys else { return true } 63 | return primaryKeys.contains(key.map { try! Blackbird.Value.fromAny($0) }) 64 | } 65 | 66 | /// Determine if a specific column may have changed. 67 | /// - Parameter columnName: The column name. 68 | /// - Returns: Whether this column may have changed in any rows. Note that changes may be over-reported. 69 | public func hasColumnChanged(_ columnName: String) -> Bool { 70 | guard let columnNames else { return true } 71 | return columnNames.contains(columnName) 72 | } 73 | } 74 | 75 | /// A Publisher that emits when data in a Blackbird table has changed. 76 | /// 77 | /// The ``Blackbird/Change`` passed indicates which rows and columns in the table have changed. 78 | typealias ChangePublisher = AnyPublisher 79 | 80 | /// A change to a table in a Blackbird database, as published by a ``ChangePublisher``. 81 | struct ModelChange: Sendable { 82 | internal let type: T.Type 83 | internal let primaryKeys: PrimaryKeyValues? 84 | internal let columnNames: Blackbird.ColumnNames? 85 | 86 | /// Determine if a specific primary-key value may have changed. 87 | /// - Parameter key: The single-column primary-key value in question. 88 | /// - Returns: Whether the row with this primary-key value may have changed. Note that changes may be over-reported. 89 | /// 90 | /// For tables with multi-column primary keys, use ``hasMulticolumnPrimaryKeyChanged(_:)``. 91 | public func hasPrimaryKeyChanged(_ key: Any) -> Bool { 92 | guard let primaryKeys else { return true } 93 | return primaryKeys.contains([try! Blackbird.Value.fromAny(key)]) 94 | } 95 | 96 | /// Determine if a specific primary-key value may have changed in a table with a multi-column primary key. 97 | /// - Parameter key: The multi-column primary-key value array in question. 98 | /// - Returns: Whether the row with these primary-key values may have changed. Note that changes may be over-reported. 99 | /// 100 | /// For tables with single-column primary keys, use ``hasPrimaryKeyChanged(_:)``. 101 | public func hasMulticolumnPrimaryKeyChanged(_ key: [Any]) -> Bool { 102 | guard let primaryKeys else { return true } 103 | return primaryKeys.contains(key.map { try! Blackbird.Value.fromAny($0) }) 104 | } 105 | 106 | /// Determine if a specific column name may have changed. 107 | /// - Parameter columnName: The column name. 108 | /// - Returns: Whether this column may have changed in any rows. Note that changes may be over-reported. 109 | public func hasColumnChanged(_ columnName: String) -> Bool { 110 | guard let columnNames else { return true } 111 | return columnNames.contains(columnName) 112 | } 113 | 114 | /// Determine if a specific column key-path may have changed. 115 | /// - Parameter keyPath: The column key-path using its `$`-prefixed wrapper, e.g. `\.$title`. 116 | /// - Returns: Whether this column may have changed in any rows. Note that changes may be over-reported. 117 | public func hasColumnChanged(_ keyPath: T.BlackbirdColumnKeyPath) -> Bool { 118 | guard let columnNames else { return true } 119 | return columnNames.contains(T.table.keyPathToColumnName(keyPath: keyPath)) 120 | } 121 | 122 | /// The set of primary-key values that may have changed, or `nil` if any primary key may have changed. 123 | public var changedPrimaryKeys: PrimaryKeyValues? { 124 | if let primaryKeys, primaryKeys.count > 0 { return primaryKeys } 125 | return nil 126 | } 127 | 128 | internal init(type: T.Type, from change: Change) { 129 | self.type = type 130 | self.primaryKeys = change.primaryKeys 131 | self.columnNames = change.columnNames 132 | } 133 | } 134 | 135 | /// A Publisher that emits when data in a BlackbirdModel table has changed. 136 | /// 137 | /// The ``Blackbird/ModelChange`` passed indicates which rows and columns in the table have changed. 138 | typealias ModelChangePublisher = AnyPublisher, Never> 139 | 140 | internal static func isRelevantPrimaryKeyChange(watchedPrimaryKeys: Blackbird.PrimaryKeyValues?, changedPrimaryKeys: Blackbird.PrimaryKeyValues?) -> Bool { 141 | guard let watchedPrimaryKeys else { 142 | // Not watching any particular keys -- always update for any table change 143 | return true 144 | } 145 | 146 | guard let changedPrimaryKeys else { 147 | // Change sent for unknown/all keys -- always update 148 | return true 149 | } 150 | 151 | if !watchedPrimaryKeys.isDisjoint(with: changedPrimaryKeys) { 152 | // Overlapping keys -- update 153 | return true 154 | } 155 | 156 | return false 157 | } 158 | } 159 | 160 | // MARK: - Change publisher 161 | 162 | extension Blackbird.Database { 163 | 164 | /// The ``Blackbird/ChangePublisher`` for the specified table. 165 | /// - Parameter tableName: The table name. 166 | /// - Returns: A ``Blackbird/ChangePublisher`` that publishes ``Blackbird/Change`` objects for each change in the specified table. 167 | /// 168 | /// For `BlackbirdModel` tables, ``BlackbirdModel/changePublisher(in:)`` provides a typed ``Blackbird/ModelChange`` instead. 169 | /// 170 | /// > - The publisher may send from any thread. 171 | /// > - Changes may be over-reported. 172 | public func changePublisher(for tableName: String) -> Blackbird.ChangePublisher { changeReporter.changePublisher(for: tableName) } 173 | 174 | internal final class ChangeReporter: @unchecked Sendable /* unchecked due to use of internal locking */ { 175 | internal final class AccumulatedChanges { 176 | var primaryKeys: Blackbird.PrimaryKeyValues? = Blackbird.PrimaryKeyValues() 177 | var columnNames: Blackbird.ColumnNames? = Blackbird.ColumnNames() 178 | static func entireTableChange(columnsIfKnown: Blackbird.ColumnNames? = nil) -> Self { 179 | let s = Self.init() 180 | s.primaryKeys = nil 181 | s.columnNames = columnsIfKnown 182 | return s 183 | } 184 | } 185 | 186 | private let lock = Blackbird.Lock() 187 | private var activeTransactions = Set() 188 | private var ignoreWritesToTableName: String? = nil 189 | private var bufferRowIDsForIgnoredTable = false 190 | private var bufferedRowIDsForIgnoredTable = Set() 191 | 192 | private var accumulatedChangesByTable: [String: AccumulatedChanges] = [:] 193 | private var tableChangePublishers: [String: PassthroughSubject] = [:] 194 | 195 | private var debugPrintEveryReportedChange = false 196 | 197 | private var cache: Blackbird.Database.Cache 198 | 199 | internal var numChangesReportedByUpdateHook: UInt64 = 0 200 | 201 | init(options: Options, cache: Blackbird.Database.Cache) { 202 | debugPrintEveryReportedChange = options.contains(.debugPrintEveryReportedChange) 203 | self.cache = cache 204 | } 205 | 206 | internal func changePublisher(for tableName: String) -> Blackbird.ChangePublisher { 207 | lock.withLock { 208 | if let existing = tableChangePublishers[tableName] { return existing.eraseToAnyPublisher() } 209 | let publisher = PassthroughSubject() 210 | tableChangePublishers[tableName] = publisher 211 | return publisher.receive(on: DispatchQueue.main).eraseToAnyPublisher() 212 | } 213 | } 214 | 215 | internal func ignoreWritesToTable(_ name: String, beginBufferingRowIDs: Bool = false) { 216 | lock.lock() 217 | ignoreWritesToTableName = name 218 | bufferRowIDsForIgnoredTable = beginBufferingRowIDs 219 | bufferedRowIDsForIgnoredTable.removeAll() 220 | lock.unlock() 221 | } 222 | 223 | @discardableResult 224 | internal func stopIgnoringWrites() -> Set { 225 | lock.lock() 226 | ignoreWritesToTableName = nil 227 | bufferRowIDsForIgnoredTable = false 228 | let rowIDs = bufferedRowIDsForIgnoredTable 229 | bufferedRowIDsForIgnoredTable.removeAll() 230 | lock.unlock() 231 | return rowIDs 232 | } 233 | 234 | internal func beginTransaction(_ transactionID: Int64) { 235 | lock.lock() 236 | activeTransactions.insert(transactionID) 237 | lock.unlock() 238 | } 239 | 240 | internal func endTransaction(_ transactionID: Int64) { 241 | lock.lock() 242 | activeTransactions.remove(transactionID) 243 | let needsFlush = activeTransactions.isEmpty && !accumulatedChangesByTable.isEmpty 244 | lock.unlock() 245 | if needsFlush { flush() } 246 | } 247 | 248 | internal func reportEntireDatabaseChange() { 249 | if debugPrintEveryReportedChange { print("[Blackbird.ChangeReporter] ⚠️ database changed externally, reporting changes to all tables!") } 250 | 251 | cache.invalidate() 252 | 253 | lock.lock() 254 | for tableName in tableChangePublishers.keys { accumulatedChangesByTable[tableName] = AccumulatedChanges.entireTableChange() } 255 | let needsFlush = activeTransactions.isEmpty 256 | lock.unlock() 257 | if needsFlush { flush() } 258 | } 259 | 260 | internal func reportChange(tableName: String, primaryKeys: [[Blackbird.Value]]? = nil, rowID: Int64? = nil, changedColumns: Blackbird.ColumnNames?) { 261 | lock.lock() 262 | let needsFlush: Bool 263 | if tableName == ignoreWritesToTableName { 264 | if let rowID, bufferRowIDsForIgnoredTable { bufferedRowIDsForIgnoredTable.insert(rowID) } 265 | needsFlush = false 266 | } else { 267 | if let primaryKeys, !primaryKeys.isEmpty { 268 | if accumulatedChangesByTable[tableName] == nil { accumulatedChangesByTable[tableName] = AccumulatedChanges() } 269 | accumulatedChangesByTable[tableName]!.primaryKeys?.formUnion(primaryKeys) 270 | 271 | if let changedColumns { 272 | accumulatedChangesByTable[tableName]!.columnNames?.formUnion(changedColumns) 273 | } else { 274 | accumulatedChangesByTable[tableName]!.columnNames = nil 275 | } 276 | 277 | for primaryKey in primaryKeys { 278 | if primaryKey.count == 1 { cache.invalidate(tableName: tableName, primaryKeyValue: primaryKey.first) } 279 | else { cache.invalidate(tableName: tableName) } 280 | } 281 | } else { 282 | accumulatedChangesByTable[tableName] = AccumulatedChanges.entireTableChange(columnsIfKnown: changedColumns) 283 | cache.invalidate(tableName: tableName) 284 | } 285 | 286 | needsFlush = activeTransactions.isEmpty 287 | } 288 | lock.unlock() 289 | if needsFlush { flush() } 290 | } 291 | 292 | private func flush() { 293 | lock.lock() 294 | let publishers = tableChangePublishers 295 | let changesByTable = accumulatedChangesByTable 296 | accumulatedChangesByTable.removeAll() 297 | lock.unlock() 298 | 299 | DispatchQueue.main.async { 300 | for (tableName, accumulatedChanges) in changesByTable { 301 | if let keys = accumulatedChanges.primaryKeys { 302 | if self.debugPrintEveryReportedChange { 303 | print("[Blackbird.ChangeReporter] changed \(tableName) (\(keys.count) keys, fields: \(accumulatedChanges.columnNames?.joined(separator: ",") ?? "(all/unknown)"))") 304 | } 305 | if let publisher = publishers[tableName] { publisher.send(Blackbird.Change(table: tableName, primaryKeys: keys, columnNames: accumulatedChanges.columnNames)) } 306 | } else { 307 | if self.debugPrintEveryReportedChange { print("[Blackbird.ChangeReporter] changed \(tableName) (unknown keys, fields: \(accumulatedChanges.columnNames?.joined(separator: ",") ?? "(all/unknown)"))") } 308 | if let publisher = publishers[tableName] { publisher.send(Blackbird.Change(table: tableName, primaryKeys: nil, columnNames: accumulatedChanges.columnNames)) } 309 | } 310 | } 311 | } 312 | } 313 | } 314 | } 315 | 316 | // MARK: - General query cache with Combine publisher 317 | 318 | extension Blackbird { 319 | 320 | /// A function to generate arbitrary results from a database, called from an async throwing context and passed the ``Blackbird/Database`` as its sole argument. 321 | /// 322 | /// Used by Blackbird's SwiftUI property wrappers. 323 | /// 324 | /// ## Examples 325 | /// 326 | /// ```swift 327 | /// { try await Post.read(from: $0, id: 123) } 328 | /// ``` 329 | /// ```swift 330 | /// { try await $0.query("SELECT COUNT(*) FROM Post") } 331 | /// ``` 332 | public typealias CachedResultGenerator = (@Sendable (_ db: Blackbird.Database) async throws -> T) 333 | 334 | internal final class CachedResultPublisher: Sendable { 335 | public let valuePublisher: CurrentValueSubject 336 | 337 | private struct State: Sendable { 338 | fileprivate var cachedResults: T? = nil 339 | fileprivate var tableName: String? = nil 340 | fileprivate var database: Blackbird.Database? = nil 341 | fileprivate var generator: CachedResultGenerator? = nil 342 | fileprivate var tableChangePublisher: AnyCancellable? = nil 343 | } 344 | 345 | private let config = Locked(State()) 346 | 347 | public init(initialValue: T? = nil) { 348 | valuePublisher = CurrentValueSubject(initialValue) 349 | } 350 | 351 | public func subscribe(to tableName: String, in database: Blackbird.Database?, generator: CachedResultGenerator?) { 352 | config.withLock { 353 | $0.tableName = tableName 354 | $0.generator = generator 355 | } 356 | self.changeDatabase(database) 357 | enqueueUpdate() 358 | } 359 | 360 | private func update(_ cachedResults: T?) async throws { 361 | let state = config.value 362 | let results: T? 363 | if let cachedResults = state.cachedResults { 364 | results = cachedResults 365 | } else { 366 | results = (state.generator != nil && state.database != nil ? try await state.generator!(state.database!) : nil) 367 | config.withLock { $0.cachedResults = results } 368 | valuePublisher.send(results) 369 | } 370 | } 371 | 372 | private func changeDatabase(_ newDatabase: Database?) { 373 | config.withLock { 374 | if newDatabase == $0.database { return } 375 | 376 | $0.database = newDatabase 377 | $0.cachedResults = nil 378 | 379 | if let database = $0.database, let tableName = $0.tableName { 380 | $0.tableChangePublisher = database.changeReporter.changePublisher(for: tableName).sink { [weak self] _ in 381 | guard let self else { return } 382 | self.config.withLock { $0.cachedResults = nil } 383 | self.enqueueUpdate() 384 | } 385 | } else { 386 | $0.tableChangePublisher = nil 387 | } 388 | } 389 | } 390 | 391 | private func enqueueUpdate() { 392 | let cachedResults = config.withLock { $0.cachedResults } 393 | Task.detached { [weak self] in 394 | do { try await self?.update(cachedResults) } 395 | catch { print("[Blackbird.CachedResultPublisher<\(String(describing: T.self))>] ⚠️ Error updating: \(error.localizedDescription)") } 396 | } 397 | } 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /Sources/Blackbird/BlackbirdSwiftUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // /\ 3 | // | | Blackbird 4 | // | | 5 | // .| |. https://github.com/marcoarment/Blackbird 6 | // $ $ 7 | // /$ $\ Copyright 2022–2023 Marco Arment 8 | // / $| |$ \ Released under the MIT License 9 | // .__$| |$__. 10 | // \/ 11 | // 12 | // BlackbirdSwiftUI.swift 13 | // Created by Marco Arment on 12/5/22. 14 | // 15 | // Permission is hereby granted, free of charge, to any person obtaining a copy 16 | // of this software and associated documentation files (the "Software"), to deal 17 | // in the Software without restriction, including without limitation the rights 18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | // copies of the Software, and to permit persons to whom the Software is 20 | // furnished to do so, subject to the following conditions: 21 | // 22 | // The above copyright notice and this permission notice shall be included in all 23 | // copies or substantial portions of the Software. 24 | // 25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | // SOFTWARE. 32 | // 33 | 34 | import SwiftUI 35 | @preconcurrency import Combine 36 | 37 | // Required to use with the @StateObject wrapper 38 | extension Blackbird.Database: ObservableObject { } 39 | 40 | struct EnvironmentBlackbirdDatabaseKey: EnvironmentKey { 41 | static let defaultValue: Blackbird.Database? = nil 42 | } 43 | 44 | extension EnvironmentValues { 45 | /// The ``Blackbird/Database`` to use with `@BlackbirdLive…` property wrappers. 46 | public var blackbirdDatabase: Blackbird.Database? { 47 | get { self[EnvironmentBlackbirdDatabaseKey.self] } 48 | set { self[EnvironmentBlackbirdDatabaseKey.self] = newValue } 49 | } 50 | } 51 | 52 | extension Blackbird { 53 | /// The results wrapper for @BlackbirdLiveQuery and @BlackbirdLiveModels. 54 | public struct LiveResults: Sendable, Equatable where T: Equatable { 55 | public static func == (lhs: Blackbird.LiveResults, rhs: Blackbird.LiveResults) -> Bool { lhs.didLoad == rhs.didLoad && lhs.results == rhs.results } 56 | 57 | /// The latest results fetched. 58 | public var results: [T] = [] 59 | 60 | /// Whether this result set has **ever** completed loading. 61 | /// 62 | /// When used by ``BlackbirdLiveModels`` or ``BlackbirdLiveQuery``, this will only be set to `false` during their initial load. 63 | /// It will **not** be set to `false` during subsequent updates triggered by changes to the underlying database. 64 | public var didLoad = false 65 | 66 | public init(results: [T] = [], didLoad: Bool = false) { 67 | self.results = results 68 | self.didLoad = didLoad 69 | } 70 | } 71 | } 72 | 73 | // MARK: - Fetch property wrappers 74 | 75 | /// An array of database rows produced by a generator function, kept up-to-date as data changes in the specified table. 76 | /// 77 | /// Set `@Environment(\.blackbirdDatabase)` to the desired database instance to read. 78 | /// 79 | /// The generator is passed the current database as its sole argument (`$0`). 80 | /// 81 | /// ## Example 82 | /// 83 | /// ```swift 84 | /// @BlackbirdLiveQuery(tableName: "Post", { 85 | /// try await $0.query("SELECT COUNT(*) AS c FROM Post") 86 | /// }) var count 87 | /// ``` 88 | /// 89 | /// `count` is a ``Blackbird/LiveResults`` object: 90 | /// * `count.results.first["c"]` will be the resulting ``Blackbird/Value`` 91 | /// * `count.didLoad` will be `false` during the initial load (useful for displaying a loading state in the UI) 92 | /// 93 | @propertyWrapper public struct BlackbirdLiveQuery: DynamicProperty { 94 | @State private var results = Blackbird.LiveResults() 95 | @Environment(\.blackbirdDatabase) var environmentDatabase 96 | 97 | public var wrappedValue: Blackbird.LiveResults { 98 | get { results } 99 | set { } 100 | } 101 | 102 | private let queryUpdater: Blackbird.QueryUpdater 103 | private let generator: Blackbird.CachedResultGenerator<[Blackbird.Row]> 104 | private let tableName: String 105 | 106 | public init(tableName: String, _ generator: @escaping Blackbird.CachedResultGenerator<[Blackbird.Row]>) { 107 | self.tableName = tableName 108 | self.generator = generator 109 | self.queryUpdater = Blackbird.QueryUpdater() 110 | } 111 | 112 | public func update() { 113 | queryUpdater.bind(from: environmentDatabase, tableName: tableName, to: $results, generator: generator) 114 | } 115 | } 116 | 117 | /// An array of ``BlackbirdModel`` instances produced by a generator function, kept up-to-date as their table's data changes in the database. 118 | /// 119 | /// Set `@Environment(\.blackbirdDatabase)` to the desired database instance to read. 120 | /// 121 | /// The generator is passed the current database as its sole argument (`$0`). 122 | /// 123 | /// ## Example 124 | /// 125 | /// ```swift 126 | /// @BlackbirdLiveModels({ 127 | /// try await Post.read(from: $0, where: "id > 3 ORDER BY date") 128 | /// }) var posts 129 | /// ``` 130 | /// 131 | /// `posts` is a ``Blackbird/LiveResults`` object: 132 | /// * `posts.results` will be an array of Post models matching the query 133 | /// * `posts.didLoad` will be `false` during the initial load (useful for displaying a loading state in the UI) 134 | /// 135 | @propertyWrapper public struct BlackbirdLiveModels: DynamicProperty { 136 | @State private var result = Blackbird.LiveResults() 137 | @Environment(\.blackbirdDatabase) var environmentDatabase 138 | 139 | public var wrappedValue: Blackbird.LiveResults { 140 | get { result } 141 | set { } 142 | } 143 | 144 | public var projectedValue: Binding> { $result } 145 | 146 | private let queryUpdater = Blackbird.ModelArrayUpdater() 147 | private let generator: Blackbird.CachedResultGenerator<[T]> 148 | 149 | public init(_ generator: @escaping Blackbird.CachedResultGenerator<[T]>) { 150 | self.generator = generator 151 | 152 | } 153 | 154 | public func update() { 155 | queryUpdater.bind(from: environmentDatabase, to: $result, generator: generator) 156 | } 157 | } 158 | 159 | /// A single ``BlackbirdModel`` instance, kept up-to-date as its data changes in the database. 160 | /// 161 | /// Set `@Environment(\.blackbirdDatabase)` to the desired database instance to read. 162 | /// 163 | /// The ``BlackbirdModel/liveModel-swift.property`` property is helpful when initializing child views with a specific instance. 164 | /// 165 | /// Example: 166 | /// 167 | /// ```swift 168 | /// // In a parent view: 169 | /// ForEach(posts) { post in 170 | /// NavigationLink(destination: PostView(post: post.liveModel)) { 171 | /// Text(post.title) 172 | /// } 173 | /// } 174 | /// 175 | /// // Child view: 176 | /// struct PostView: View { 177 | /// @BlackbirdLiveModel var post: Post? 178 | /// // will be kept up-to-date 179 | /// } 180 | /// ``` 181 | @propertyWrapper public struct BlackbirdLiveModel: DynamicProperty { 182 | @State private var instance: T? 183 | private var instanceObserver: BlackbirdModelInstanceChangeObserver 184 | @Environment(\.blackbirdDatabase) var environmentDatabase 185 | 186 | public var changePublisher: AnyPublisher { instanceObserver.changePublisher } 187 | 188 | public var updatesEnabled: Bool { 189 | get { instanceObserver.updatesEnabled } 190 | nonmutating set { instanceObserver.updatesEnabled = newValue } 191 | } 192 | 193 | public var wrappedValue: T? { 194 | get { instance } 195 | nonmutating set { instance = newValue } 196 | } 197 | 198 | public var projectedValue: Binding { $instance } 199 | 200 | public init(_ instance: T, updatesEnabled: Bool = true) { 201 | _instance = State(initialValue: instance) 202 | instanceObserver = BlackbirdModelInstanceChangeObserver(primaryKeyValues: try! instance.primaryKeyValues().map { try! Blackbird.Value.fromAny($0) }) 203 | instanceObserver.updatesEnabled = updatesEnabled 204 | } 205 | 206 | public init(type: T.Type, primaryKeyValues: [Any], updatesEnabled: Bool = true) { 207 | _instance = State(initialValue: nil) 208 | instanceObserver = BlackbirdModelInstanceChangeObserver(primaryKeyValues: primaryKeyValues.map { try! Blackbird.Value.fromAny($0) } ) 209 | instanceObserver.updatesEnabled = updatesEnabled 210 | } 211 | 212 | public mutating func update() { 213 | instanceObserver.observe(database: environmentDatabase, currentInstance: $instance) 214 | } 215 | } 216 | 217 | extension View { 218 | /// Automatically enable updates on the supplied ``BlackbirdLiveModel`` when the view appears and suspend updates when it disappears. 219 | public func blackbirdUpdateWhenVisible(_ liveModel: BlackbirdLiveModel) -> some View { 220 | self 221 | .onAppear { 222 | liveModel.updatesEnabled = true 223 | } 224 | .onDisappear { 225 | liveModel.updatesEnabled = false 226 | } 227 | } 228 | } 229 | 230 | 231 | public final class BlackbirdModelInstanceChangeObserver: @unchecked Sendable { /* unchecked due to internal locking */ 232 | private let primaryKeyValues: [Blackbird.Value] 233 | private let changeObserver = Blackbird.Locked(nil) 234 | 235 | private let currentDatabase = Blackbird.Locked(nil) 236 | private let hasEverUpdated = Blackbird.Locked(false) 237 | 238 | private let _changePublisher = PassthroughSubject() 239 | public var changePublisher: AnyPublisher { _changePublisher.eraseToAnyPublisher() } 240 | 241 | private let _updatesEnabled = Blackbird.Locked(true) 242 | public var updatesEnabled: Bool { 243 | get { _updatesEnabled.value } 244 | set { 245 | _updatesEnabled.value = newValue 246 | Task.detached { [weak self] in await self?.update() } 247 | } 248 | } 249 | 250 | private let cachedInstance = Blackbird.Locked(nil) 251 | @Binding public var currentInstance: T? 252 | 253 | public init(primaryKeyValues: [Blackbird.Value]) { 254 | self.primaryKeyValues = primaryKeyValues 255 | _currentInstance = Binding(get: { nil }, set: { _ in }) 256 | } 257 | 258 | public func observe(database: Blackbird.Database?, currentInstance: Binding) { 259 | _currentInstance = currentInstance 260 | guard let database, database != currentDatabase.value else { return } 261 | currentDatabase.value = database 262 | cachedInstance.value = nil 263 | 264 | if updatesEnabled { 265 | Task.detached { [weak self] in await self?.update() } 266 | } 267 | } 268 | 269 | public func update() async { 270 | guard let currentDatabase = currentDatabase.value, updatesEnabled else { return } 271 | 272 | changeObserver.withLock { observer in 273 | if observer != nil { return } 274 | 275 | let primaryKeyValues = primaryKeyValues 276 | observer = T.changePublisher(in: currentDatabase, multicolumnPrimaryKey: primaryKeyValues) 277 | .sink { _ in 278 | Task.detached { [weak self] in 279 | await MainActor.run { [weak self] in self?.cachedInstance.value = nil } 280 | await self?.update() 281 | } 282 | } 283 | } 284 | 285 | if let cachedInstance = cachedInstance.value { 286 | currentInstance = cachedInstance 287 | await MainActor.run { 288 | _changePublisher.send(cachedInstance) 289 | } 290 | return 291 | } 292 | 293 | let instance = try? await T.read(from: currentDatabase, multicolumnPrimaryKey: primaryKeyValues) 294 | await MainActor.run { 295 | cachedInstance.value = instance 296 | currentInstance = instance 297 | _changePublisher.send(instance) 298 | } 299 | } 300 | } 301 | 302 | extension BlackbirdModel { 303 | /// A convenience accessor to a ``BlackbirdLiveModel`` instance with the given single-column primary-key value. Useful for SwiftUI. 304 | /// 305 | /// For models with multi-column primary keys, see ``liveModel(multicolumnPrimaryKey:updatesEnabled:)``. 306 | public static func liveModel(primaryKey: Any, updatesEnabled: Bool = true) -> BlackbirdLiveModel { 307 | BlackbirdLiveModel(type: Self.self, primaryKeyValues: [primaryKey], updatesEnabled: updatesEnabled) 308 | } 309 | 310 | /// A convenience accessor to a ``BlackbirdLiveModel`` instance with the given multi-column primary-key value. Useful for SwiftUI. 311 | /// 312 | /// For models with single-column primary keys, see ``liveModel(primaryKey:updatesEnabled:)``. 313 | public static func liveModel(multicolumnPrimaryKey: [Any], updatesEnabled: Bool = true) -> BlackbirdLiveModel { 314 | BlackbirdLiveModel(type: Self.self, primaryKeyValues: multicolumnPrimaryKey, updatesEnabled: updatesEnabled) 315 | } 316 | 317 | 318 | /// A convenience accessor to this instance's ``BlackbirdLiveModel``. Useful for SwiftUI. 319 | public var liveModel: BlackbirdLiveModel { get { BlackbirdLiveModel(self) } } 320 | 321 | /// Shorthand for this model's ``Blackbird/LiveResults`` type. 322 | public typealias LiveResults = Blackbird.LiveResults 323 | 324 | /// Shorthand for this model's ``BlackbirdLiveModel`` type. 325 | public typealias LiveModel = BlackbirdLiveModel 326 | } 327 | 328 | // MARK: - Multi-row query updaters 329 | 330 | extension Blackbird { 331 | /// Used in Blackbird's SwiftUI primitives. 332 | public final class QueryUpdater: @unchecked Sendable { // unchecked due to internal locking 333 | @Binding public var results: Blackbird.LiveResults 334 | 335 | private let resultPublisher = CachedResultPublisher<[Blackbird.Row]>() 336 | private var changePublishers: [AnyCancellable] = [] 337 | private let lock = Blackbird.Lock() 338 | 339 | public init() { 340 | _results = Binding>(get: { Blackbird.LiveResults() }, set: { _ in }) 341 | } 342 | 343 | public func bind(from database: Blackbird.Database?, tableName: String, to results: Binding>, generator: CachedResultGenerator<[Blackbird.Row]>?) { 344 | lock.lock() 345 | defer { lock.unlock() } 346 | 347 | changePublishers.removeAll() 348 | resultPublisher.subscribe(to: tableName, in: database, generator: generator) 349 | _results = results 350 | 351 | changePublishers.append(resultPublisher.valuePublisher.sink { [weak self] value in 352 | guard let self else { return } 353 | let results: Blackbird.LiveResults 354 | if let value { 355 | results = Blackbird.LiveResults(results: value, didLoad: true) 356 | } else { 357 | results = Blackbird.LiveResults(results: [], didLoad: false) 358 | } 359 | 360 | DispatchQueue.main.async { // kicking this to the next runloop to prevent state updates from happening while building the view 361 | self.results = results 362 | } 363 | }) 364 | } 365 | } 366 | 367 | /// Used in Blackbird's SwiftUI primitives. 368 | public final class ModelArrayUpdater: @unchecked Sendable { // unchecked due to internal locking 369 | @Binding public var results: Blackbird.LiveResults 370 | 371 | private let resultPublisher: CachedResultPublisher<[T]> 372 | private var changePublishers: [AnyCancellable] = [] 373 | private let lock = Blackbird.Lock() 374 | 375 | public init(initialValue: [T]? = nil) { 376 | _results = Binding>(get: { Blackbird.LiveResults(results: initialValue ?? [], didLoad: initialValue != nil) }, set: { _ in }) 377 | resultPublisher = CachedResultPublisher<[T]>(initialValue: initialValue) 378 | } 379 | 380 | public func bind(from database: Blackbird.Database?, to results: Binding>, generator: CachedResultGenerator<[T]>?) { 381 | lock.lock() 382 | defer { lock.unlock() } 383 | 384 | changePublishers.removeAll() 385 | resultPublisher.subscribe(to: T.table.name, in: database, generator: generator) 386 | _results = results 387 | 388 | changePublishers.append(resultPublisher.valuePublisher.sink { [weak self] value in 389 | guard let self else { return } 390 | DispatchQueue.main.async { 391 | if let value { 392 | self.results = Blackbird.LiveResults(results: value, didLoad: true) 393 | } else { 394 | self.results = Blackbird.LiveResults(results: [], didLoad: false) 395 | } 396 | } 397 | }) 398 | } 399 | } 400 | } 401 | 402 | // MARK: - Single-instance updater 403 | 404 | extension Blackbird { 405 | /// Used in Blackbird's SwiftUI primitives. 406 | public final class ModelInstanceUpdater: @unchecked Sendable { // unchecked due to internal locking 407 | @Binding public var instance: T? 408 | @Binding public var didLoad: Bool 409 | private let bindingLock = Blackbird.Lock() 410 | 411 | private struct State { 412 | var changeObserver: AnyCancellable? = nil 413 | var database: Blackbird.Database? = nil 414 | var primaryKeyValues: [Blackbird.Value]? = nil 415 | } 416 | 417 | private let state = Blackbird.Locked(State()) 418 | 419 | public init() { 420 | _instance = Binding(get: { nil }, set: { _ in }) 421 | _didLoad = Binding(get: { false }, set: { _ in }) 422 | } 423 | 424 | /// Update a binding with the current instance matching a single-column primary-key value named `"id"`, and keep it updated over time. 425 | /// - Parameters: 426 | /// - database: The database to read from and monitor for changes. 427 | /// - instance: A binding to store the matching instance in. Will be set to `nil` if the database does not contain a matching instance. 428 | /// - didLoad: An optional binding that will be set to `true` after the **first** load of the specified instance has completed. 429 | /// - id: The ID value to match, assuming the table has a single-column primary key named `"id"`. 430 | /// 431 | /// See also: ``bind(from:to:didLoad:primaryKey:)`` and ``bind(from:to:didLoad:multicolumnPrimaryKey:)`` . 432 | public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, id: Sendable) { 433 | bind(from: database, to: instance, didLoad: didLoad, multicolumnPrimaryKey: [id]) 434 | } 435 | 436 | /// Update a binding with the current instance matching a single-column primary-key value, and keep it updated over time. 437 | /// - Parameters: 438 | /// - database: The database to read from and monitor for changes. 439 | /// - instance: A binding to store the matching instance in. Will be set to `nil` if the database does not contain a matching instance. 440 | /// - didLoad: An optional binding that will be set to `true` after the **first** load of the specified instance has completed. 441 | /// - primaryKey: The single-column primary-key value to match. 442 | /// 443 | /// See also: ``bind(from:to:didLoad:multicolumnPrimaryKey:)`` and ``bind(from:to:didLoad:id:)``. 444 | public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, primaryKey: Sendable) { 445 | bind(from: database, to: instance, didLoad: didLoad, multicolumnPrimaryKey: [primaryKey]) 446 | } 447 | 448 | /// Update a binding with the current instance matching a multi-column primary-key value, and keep it updated over time. 449 | /// - Parameters: 450 | /// - database: The database to read from and monitor for changes. 451 | /// - instance: A binding to store the matching instance in. Will be set to `nil` if the database does not contain a matching instance. 452 | /// - didLoad: An optional binding that will be set to `true` after the **first** load of the specified instance has completed. 453 | /// - multicolumnPrimaryKey: The multi-column primary-key values to match. 454 | /// 455 | /// See also: ``bind(from:to:didLoad:primaryKey:)`` and ``bind(from:to:didLoad:id:)``. 456 | public func bind(from database: Blackbird.Database?, to instance: Binding, didLoad: Binding? = nil, multicolumnPrimaryKey: [Sendable]) { 457 | bindingLock.withLock { 458 | self._instance = instance 459 | if let didLoad { self._didLoad = didLoad } 460 | } 461 | 462 | state.withLock { state in 463 | state.database = database 464 | state.primaryKeyValues = multicolumnPrimaryKey.map { try! Blackbird.Value.fromAny($0) } 465 | if let database, let primaryKeyValues = state.primaryKeyValues { 466 | state.changeObserver = T.changePublisher(in: database, multicolumnPrimaryKey: primaryKeyValues) 467 | .sink { _ in 468 | Task.detached { [weak self] in 469 | guard let self else { return } 470 | let instance = try? await T.read(from: database, multicolumnPrimaryKey: primaryKeyValues) 471 | await MainActor.run { 472 | self.instance = instance 473 | self.didLoad = true 474 | } 475 | } 476 | } 477 | } else { 478 | state.changeObserver = nil 479 | } 480 | } 481 | 482 | update() 483 | } 484 | 485 | internal func update() { 486 | let (database, primaryKeyValues) = state.withLock { state in (state.database, state.primaryKeyValues) } 487 | guard let database, let primaryKeyValues else { return } 488 | 489 | Task.detached { [weak self] in 490 | guard let self else { return } 491 | let instance = try? await T.read(from: database, multicolumnPrimaryKey: primaryKeyValues) 492 | await MainActor.run { 493 | self.instance = instance 494 | self.didLoad = true 495 | } 496 | } 497 | } 498 | } 499 | } 500 | 501 | // MARK: - Single-column updater 502 | 503 | /// SwiftUI property wrapper for automatic updating of a single column value for the specified primary-key value. 504 | /// 505 | /// ## Example 506 | /// 507 | /// Given a model defined with this column: 508 | /// ```swift 509 | /// struct MyModel: BlackbirdModel { 510 | /// // ... 511 | /// @BlackbirdColumn var title: String 512 | /// // ... 513 | /// } 514 | /// ``` 515 | /// 516 | /// Then, in a SwiftUI view: 517 | /// 518 | /// ```swift 519 | /// struct MyView: View { 520 | /// // title will be of type: String? 521 | /// @BlackbirdColumnObserver(\MyModel.$title, primaryKey: 123) var title 522 | /// 523 | /// var body: some View { 524 | /// Text(title ?? "Loading…") 525 | /// } 526 | /// ``` 527 | /// 528 | /// Or, to provide the primary-key value dynamically: 529 | /// 530 | /// ```swift 531 | /// struct MyView: View { 532 | /// // title will be of type: String? 533 | /// @BlackbirdColumnObserver(\MyModel.$title) var title 534 | /// 535 | /// init(primaryKey: Any) { 536 | /// _title = BlackbirdColumnObserver(\MyModel.$title, primaryKey: primaryKey) 537 | /// } 538 | /// 539 | /// var body: some View { 540 | /// Text(title ?? "Loading…") 541 | /// } 542 | /// ``` 543 | @propertyWrapper public struct BlackbirdColumnObserver: DynamicProperty { 544 | @Environment(\.blackbirdDatabase) var environmentDatabase 545 | 546 | @State private var primaryKey: Any? 547 | @State private var currentValue: V? = nil 548 | 549 | public var wrappedValue: V? { 550 | get { currentValue } 551 | nonmutating set { fatalError() } 552 | } 553 | 554 | public var projectedValue: Binding { $currentValue } 555 | 556 | private var observer: BlackbirdColumnObserverInternal? 557 | 558 | public init(_ column: KeyPath>, primaryKey: Any? = nil) { 559 | self.observer = BlackbirdColumnObserverInternal(modelType: T.self, column: column) 560 | _primaryKey = State(initialValue: primaryKey) 561 | } 562 | 563 | public mutating func update() { 564 | observer?.bind(to: environmentDatabase, primaryKey: $primaryKey, result: $currentValue) 565 | } 566 | } 567 | 568 | internal final class BlackbirdColumnObserverInternal: @unchecked Sendable /* unchecked due to internal locking */ { 569 | @Binding private var primaryKey: Any? 570 | @Binding private var result: V? 571 | 572 | private let configLock = Blackbird.Lock() 573 | private var column: T.BlackbirdColumnKeyPath 574 | private var database: Blackbird.Database? = nil 575 | private var listeners: [AnyCancellable] = [] 576 | private var lastPrimaryKeyValue: Blackbird.Value? = nil 577 | 578 | public init(modelType: T.Type, column: T.BlackbirdColumnKeyPath) { 579 | self.column = column 580 | _primaryKey = Binding(get: { nil }, set: { _ in }) 581 | _result = Binding(get: { nil }, set: { _ in }) 582 | } 583 | 584 | public func bind(to database: Blackbird.Database? = nil, primaryKey: Binding, result: Binding) { 585 | _result = result 586 | _primaryKey = primaryKey 587 | 588 | let needsUpdate = configLock.withLock { 589 | let newPK = primaryKey.wrappedValue != nil ? try! Blackbird.Value.fromAny(primaryKey.wrappedValue) : nil 590 | if let existingDB = self.database, let newDB = database, existingDB == newDB, lastPrimaryKeyValue == newPK { return false } 591 | 592 | listeners.removeAll() 593 | self.database = database 594 | self.lastPrimaryKeyValue = newPK 595 | if let database, let primaryKey = newPK { 596 | T.changePublisher(in: database, primaryKey: primaryKey, columns: [column]) 597 | .sink { [weak self] _ in self?.update() } 598 | .store(in: &listeners) 599 | } 600 | 601 | return true 602 | } 603 | 604 | if needsUpdate { 605 | update() 606 | } 607 | } 608 | 609 | private func setResult(_ value: V?) { 610 | Task { 611 | await MainActor.run { 612 | if value != result { 613 | result = value 614 | } 615 | } 616 | } 617 | } 618 | 619 | private func update() { 620 | guard let database else { 621 | setResult(nil) 622 | return 623 | } 624 | 625 | Task.detached { [weak self] in 626 | do { 627 | try await self?.fetch(in: database) 628 | } catch { 629 | self?.setResult(nil) 630 | } 631 | } 632 | } 633 | 634 | private func fetch(in database: Blackbird.Database) async throws { 635 | configLock.lock() 636 | let primaryKey = primaryKey 637 | let column = column 638 | let database = database 639 | configLock.unlock() 640 | 641 | guard let primaryKey else { return } 642 | 643 | guard let row = try await T.query(in: database, columns: [column], primaryKey: primaryKey) else { 644 | setResult(nil) 645 | return 646 | } 647 | 648 | let newValue = V.fromValue(row.value(keyPath: column)!)! 649 | setResult(newValue) 650 | } 651 | } 652 | 653 | --------------------------------------------------------------------------------