├── .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 |
--------------------------------------------------------------------------------