├── .editorconfig
├── .github
├── FUNDING.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .gitmodules
├── .spi.yml
├── Benchmarks
├── .gitignore
├── Benchmarks
│ ├── CoreDataBenchmarks
│ │ ├── TestData
│ │ │ └── CoreDataThing.momd
│ │ │ │ ├── CoreDataThing.mom
│ │ │ │ ├── CoreDataThing.omo
│ │ │ │ └── VersionInfo.plist
│ │ └── test.swift
│ └── EmpireBenchmarks
│ │ └── test.swift
├── Package.resolved
├── Package.swift
├── Sources
│ ├── CoreDataBenchmarks
│ │ └── CoreDataBenchmarks.swift
│ └── EmpireBenchmarks
│ │ └── EmpireBenchmarks.swift
└── Tests
│ └── EmpireBenchmarksTests
│ └── EmpireBenchmarksTests.swift
├── CODE_OF_CONDUCT.md
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
├── Empire
│ ├── BackgroundStore.swift
│ ├── Buffers.swift
│ ├── CloudKitRecord.swift
│ ├── Empire.docc
│ │ ├── CloudKitSupport.md
│ │ ├── DataManipulation.md
│ │ ├── DataModeling.md
│ │ ├── Empire.md
│ │ └── Migrations.md
│ ├── IndexKeyRecord.swift
│ ├── LMDB+Extensions.swift
│ ├── Macros.swift
│ ├── Query+LDMBQuery.swift
│ ├── Query.swift
│ ├── Store.swift
│ ├── TransactionContext.swift
│ ├── Tuple.swift
│ └── UnsafeRawBufferPointer+Extensions.swift
├── EmpireMacros
│ ├── Array+ReplaceLast.swift
│ ├── CloudKitRecordMacro.swift
│ ├── IndexKeyRecordMacro.swift
│ ├── Plugin.swift
│ ├── RecordMacroArguments.swift
│ └── String+hash.swift
├── EmpireSwiftData
│ └── DataStoreAdapter.swift
├── LMDB
│ ├── CLMDB+Extensions.swift
│ ├── Cursor.swift
│ ├── Environment.swift
│ ├── Transaction.swift
│ └── Types+Values.swift
└── PackedSerialize
│ ├── Array+Serialization.swift
│ ├── Bool+Serialization.swift
│ ├── Data+Serialization.swift
│ ├── Date+Serialization.swift
│ ├── EmptyValue.swift
│ ├── FixedWidthInteger+Serialization.swift
│ ├── Float+Serialization.swift
│ ├── Int+Serialization.swift
│ ├── Int64+Serialization.swift
│ ├── Optional+Serialization.swift
│ ├── RawRepresentable+Serialization.swift
│ ├── Serializable.swift
│ ├── String+Serialization.swift
│ └── UUID+Serialization.swift
└── Tests
├── EmpireMacrosTests
├── CloudKitRecordMacroTests.swift
├── FailureHandler.swift
└── IndexKeyRecordMacroTests.swift
├── EmpireSwiftDataTests
└── DataStoreAdapterTests.swift
├── EmpireTests
├── BackgroundTests.swift
├── CloudKitRecordTests.swift
├── ExampleTests.swift
├── IndexKeyRecordTests.swift
├── InsertTests.swift
├── MigrationTests.swift
├── RelationshipTests.swift
├── SelectTests.swift
└── TupleTests.swift
├── LMDBTests
└── LMDBTests.swift
└── PackedSerializeTests
├── ComparableData.swift
└── PackedSerializeTests.swift
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [mattmassicotte]
2 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | paths-ignore:
8 | - 'README.md'
9 | - '**/*.docc/**'
10 | - 'CODE_OF_CONDUCT.md'
11 | - '.editorconfig'
12 | - '.spi.yml'
13 | pull_request:
14 | branches:
15 | - main
16 |
17 | concurrency:
18 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
19 | cancel-in-progress: true
20 |
21 | jobs:
22 | test:
23 | name: Test
24 | runs-on: macOS-15
25 | env:
26 | DEVELOPER_DIR: /Applications/Xcode_16.3.app
27 | strategy:
28 | matrix:
29 | destination:
30 | - "platform=macOS"
31 | - "platform=macOS,variant=Mac Catalyst"
32 | - "platform=iOS Simulator,name=iPhone 16"
33 | - "platform=tvOS Simulator,name=Apple TV"
34 | - "platform=watchOS Simulator,name=Apple Watch Series 10 (42mm)"
35 | - "platform=visionOS Simulator,name=Apple Vision Pro"
36 | swift-syntax-version:
37 | - "601.0.1"
38 | configuration:
39 | - "debug"
40 | include:
41 | - destination: "platform=macOS"
42 | swift-syntax-version: "600.0.0"
43 | configuration: "debug"
44 | - destination: "platform=macOS"
45 | swift-syntax-version: "600.0.1"
46 | configuration: "debug"
47 | - destination: "platform=macOS"
48 | swift-syntax-version: "601.0.0"
49 | configuration: "debug"
50 | - destination: "platform=macOS"
51 | swift-syntax-version: "601.0.1"
52 | configuration: "release"
53 | steps:
54 | - uses: actions/checkout@v4
55 | with:
56 | submodules: true
57 | - name: Test platform ${{ matrix.destination }} / Xcode ${{ matrix.xcode-version }} / swift-syntax ${{ matrix.swift-syntax-version }} / ${{ matrix.configuration }}
58 | run: |
59 | swift --version
60 | swift package reset
61 | swift package resolve
62 | swift package resolve --version ${{ matrix.swift-syntax-version }} swift-syntax
63 | set -o pipefail && xcodebuild -scheme Empire-Package -destination "${{ matrix.destination }}" -configuration ${{ matrix.configuration }} test | xcbeautify
64 |
65 | linux_test:
66 | name: Test Linux
67 | runs-on: ubuntu-latest
68 | strategy:
69 | matrix:
70 | swift-version:
71 | - '6.0.0'
72 | - '6.0.1'
73 | - '6.0.2'
74 | - '6.0.3'
75 | - '6.1.0'
76 | - '6.1.1'
77 | - '6.1.2'
78 | swift-syntax-version:
79 | - '600.0.0'
80 | - '600.0.1'
81 | - '601.0.0'
82 | - '601.0.1'
83 | configuration:
84 | - "debug"
85 | include:
86 | - swift-version: "6.1.2"
87 | swift-syntax-version: "601.0.1"
88 | configuration: "release"
89 | steps:
90 | - name: Checkout
91 | uses: actions/checkout@v4
92 | with:
93 | submodules: true
94 | - name: Swiftly
95 | uses: vapor/swiftly-action@v0.2.0
96 | with:
97 | toolchain: ${{ matrix.swift-version }}
98 | - name: Test Swift ${{ matrix.swift-version }} / swift-syntax ${{ matrix.swift-syntax-version }} / ${{ matrix.configuration }}
99 | run: |
100 | swift --version
101 | swift package reset
102 | swift package resolve
103 | swift package resolve --version ${{ matrix.swift-syntax-version }} swift-syntax
104 | swift test --configuration ${{ matrix.configuration }}
105 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "lmdb"]
2 | path = lmdb
3 | url = https://github.com/LMDB/lmdb.git
4 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [Empire]
5 |
6 |
--------------------------------------------------------------------------------
/Benchmarks/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/Benchmarks/Benchmarks/CoreDataBenchmarks/TestData/CoreDataThing.momd/CoreDataThing.mom:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattmassicotte/Empire/97450540a8e3ab93a5d066ff7ea569b1728e71c8/Benchmarks/Benchmarks/CoreDataBenchmarks/TestData/CoreDataThing.momd/CoreDataThing.mom
--------------------------------------------------------------------------------
/Benchmarks/Benchmarks/CoreDataBenchmarks/TestData/CoreDataThing.momd/CoreDataThing.omo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattmassicotte/Empire/97450540a8e3ab93a5d066ff7ea569b1728e71c8/Benchmarks/Benchmarks/CoreDataBenchmarks/TestData/CoreDataThing.momd/CoreDataThing.omo
--------------------------------------------------------------------------------
/Benchmarks/Benchmarks/CoreDataBenchmarks/TestData/CoreDataThing.momd/VersionInfo.plist:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattmassicotte/Empire/97450540a8e3ab93a5d066ff7ea569b1728e71c8/Benchmarks/Benchmarks/CoreDataBenchmarks/TestData/CoreDataThing.momd/VersionInfo.plist
--------------------------------------------------------------------------------
/Benchmarks/Benchmarks/CoreDataBenchmarks/test.swift:
--------------------------------------------------------------------------------
1 | import Benchmark
2 | import Foundation
3 |
4 | #if canImport(CoreData)
5 | import CoreData
6 |
7 | @objc(SmallRecord)
8 | public class SmallRecord: NSManagedObject, Identifiable {
9 | @NSManaged public var value: String?
10 | }
11 |
12 | func configureCoreDataStack() -> NSManagedObjectContext {
13 | let url = Bundle.module.url(forResource: "CoreDataThing", withExtension: "momd", subdirectory: "TestData")!
14 | let model = NSManagedObjectModel(contentsOf: url)!
15 | let container = NSPersistentContainer(name: "CoreDataThing", managedObjectModel: model)
16 |
17 | try? FileManager.default.removeItem(atPath: "/tmp/core-data-test")
18 | try? FileManager.default.removeItem(atPath: "/tmp/core-data-test-shm")
19 | try? FileManager.default.removeItem(atPath: "/tmp/core-data-test-wal")
20 |
21 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/tmp/core-data-test")
22 |
23 | container.loadPersistentStores(completionHandler: { _, error in
24 | if let error {
25 | fatalError("error: \(error)")
26 | }
27 | })
28 |
29 | return container.newBackgroundContext()
30 | }
31 |
32 | let benchmarks : @Sendable () -> Void = {
33 | Benchmark("CoreData Insert records per transaction") { benchmark in
34 | let context = configureCoreDataStack()
35 |
36 | let description = NSEntityDescription.entity(forEntityName: "SmallRecord", in: context)!
37 |
38 | for i in 0..<1000 {
39 | let record = SmallRecord(entity: description, insertInto: context)
40 |
41 | record.value = "\(i)"
42 |
43 | try context.save()
44 | }
45 | }
46 |
47 | Benchmark("CoreData Insert records in transaction") { benchmark in
48 | let context = configureCoreDataStack()
49 |
50 | let description = NSEntityDescription.entity(forEntityName: "SmallRecord", in: context)!
51 |
52 | for i in 0..<1000 {
53 | let record = SmallRecord(entity: description, insertInto: context)
54 |
55 | record.value = "\(i)"
56 | }
57 |
58 | try context.save()
59 | }
60 | }
61 | #endif
62 |
--------------------------------------------------------------------------------
/Benchmarks/Benchmarks/EmpireBenchmarks/test.swift:
--------------------------------------------------------------------------------
1 | import Benchmark
2 | import Foundation
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord("key")
7 | struct SmallRecord {
8 | let key: Int
9 | let value: String
10 | }
11 |
12 | let benchmarks : @Sendable () -> Void = {
13 | Benchmark("Insert records per transaction") { benchmark in
14 | let storeURL = URL(fileURLWithPath: "/tmp/empire_benchmark_store", isDirectory: true)
15 | try? FileManager.default.removeItem(at: storeURL)
16 | try FileManager.default.createDirectory(at: storeURL, withIntermediateDirectories: false)
17 |
18 | let store = try Store(url: storeURL)
19 |
20 | benchmark.startMeasurement()
21 |
22 | for i in 0..<1000 {
23 | let record = SmallRecord(key: i, value: "\(i)")
24 |
25 | try store.withTransaction { ctx in
26 | try ctx.insert(record)
27 | }
28 | }
29 | }
30 |
31 | Benchmark("Insert records in transaction") { benchmark in
32 | let storeURL = URL(fileURLWithPath: "/tmp/empire_benchmark_store", isDirectory: true)
33 | try? FileManager.default.removeItem(at: storeURL)
34 | try FileManager.default.createDirectory(at: storeURL, withIntermediateDirectories: false)
35 |
36 | let store = try Store(url: storeURL)
37 |
38 | benchmark.startMeasurement()
39 |
40 | try store.withTransaction { ctx in
41 | for i in 0..<1000 {
42 | let record = SmallRecord(key: i, value: "\(i)")
43 |
44 | try ctx.insert(record)
45 | }
46 | }
47 | }
48 |
49 | Benchmark("Select records in transaction") { benchmark in
50 | let storeURL = URL(fileURLWithPath: "/tmp/empire_benchmark_store", isDirectory: true)
51 | try? FileManager.default.removeItem(at: storeURL)
52 | try FileManager.default.createDirectory(at: storeURL, withIntermediateDirectories: false)
53 |
54 | let store = try Store(url: storeURL)
55 |
56 | try store.withTransaction { ctx in
57 | for i in 0..<1000 {
58 | let record = SmallRecord(key: i, value: "\(i)")
59 |
60 | try ctx.insert(record)
61 | }
62 | }
63 |
64 | benchmark.startMeasurement()
65 |
66 | _ = try store.withTransaction { ctx in
67 | try SmallRecord.select(in: ctx, key: .greaterOrEqual(0))
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Benchmarks/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "be24b0cadb9526d716e334ce1d8a788bfe6dae8e33b2ea95dd3d1e2694987e66",
3 | "pins" : [
4 | {
5 | "identity" : "hdrhistogram-swift",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/HdrHistogram/hdrhistogram-swift",
8 | "state" : {
9 | "revision" : "93a1618c8aa20f6a521a9da656a3e0591889e9dc",
10 | "version" : "0.1.3"
11 | }
12 | },
13 | {
14 | "identity" : "package-benchmark",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/ordo-one/package-benchmark",
17 | "state" : {
18 | "revision" : "ec06262cf296aaa4bd40ed654ccdf9a87fe1879f",
19 | "version" : "1.29.2"
20 | }
21 | },
22 | {
23 | "identity" : "package-jemalloc",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/ordo-one/package-jemalloc",
26 | "state" : {
27 | "revision" : "e8a5db026963f5bfeac842d9d3f2cc8cde323b49",
28 | "version" : "1.0.0"
29 | }
30 | },
31 | {
32 | "identity" : "swift-argument-parser",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/apple/swift-argument-parser",
35 | "state" : {
36 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
37 | "version" : "1.5.0"
38 | }
39 | },
40 | {
41 | "identity" : "swift-atomics",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/apple/swift-atomics",
44 | "state" : {
45 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985",
46 | "version" : "1.2.0"
47 | }
48 | },
49 | {
50 | "identity" : "swift-numerics",
51 | "kind" : "remoteSourceControl",
52 | "location" : "https://github.com/apple/swift-numerics",
53 | "state" : {
54 | "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8",
55 | "version" : "1.0.3"
56 | }
57 | },
58 | {
59 | "identity" : "swift-syntax",
60 | "kind" : "remoteSourceControl",
61 | "location" : "https://github.com/swiftlang/swift-syntax.git",
62 | "state" : {
63 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
64 | "version" : "601.0.1"
65 | }
66 | },
67 | {
68 | "identity" : "swift-system",
69 | "kind" : "remoteSourceControl",
70 | "location" : "https://github.com/apple/swift-system",
71 | "state" : {
72 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
73 | "version" : "1.4.2"
74 | }
75 | },
76 | {
77 | "identity" : "texttable",
78 | "kind" : "remoteSourceControl",
79 | "location" : "https://github.com/ordo-one/TextTable",
80 | "state" : {
81 | "revision" : "a27a07300cf4ae322e0079ca0a475c5583dd575f",
82 | "version" : "0.0.2"
83 | }
84 | }
85 | ],
86 | "version" : 3
87 | }
88 |
--------------------------------------------------------------------------------
/Benchmarks/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "EmpireBenchmarks",
7 | platforms: [.macOS(.v14)],
8 | products: [
9 | .executable(name: "EmpireBenchmarks", targets: ["EmpireBenchmarks"]),
10 | .executable(name: "CoreDataBenchmarks", targets: ["CoreDataBenchmarks"]),
11 | ],
12 | dependencies: [
13 | .package(url: "https://github.com/ordo-one/package-benchmark", .upToNextMajor(from: "1.4.0")),
14 | .package(path: "../../Empire"),
15 | ],
16 | targets: [
17 | .executableTarget(
18 | name: "EmpireBenchmarks",
19 | dependencies: [
20 | .product(name: "Benchmark", package: "package-benchmark"),
21 | "Empire",
22 | ],
23 | path: "Benchmarks/EmpireBenchmarks",
24 | plugins: [
25 | .plugin(name: "BenchmarkPlugin", package: "package-benchmark"),
26 | ]
27 | ),
28 | .executableTarget(
29 | name: "CoreDataBenchmarks",
30 | dependencies: [
31 | .product(name: "Benchmark", package: "package-benchmark"),
32 | ],
33 | path: "Benchmarks/CoreDataBenchmarks",
34 | resources: [
35 | .copy("TestData"),
36 | ],
37 | plugins: [
38 | .plugin(name: "BenchmarkPlugin", package: "package-benchmark"),
39 | ]
40 | ),
41 | ]
42 | )
43 |
--------------------------------------------------------------------------------
/Benchmarks/Sources/CoreDataBenchmarks/CoreDataBenchmarks.swift:
--------------------------------------------------------------------------------
1 | // The Swift Programming Language
2 | // https://docs.swift.org/swift-book
3 |
4 |
--------------------------------------------------------------------------------
/Benchmarks/Sources/EmpireBenchmarks/EmpireBenchmarks.swift:
--------------------------------------------------------------------------------
1 | // The Swift Programming Language
2 | // https://docs.swift.org/swift-book
3 |
--------------------------------------------------------------------------------
/Benchmarks/Tests/EmpireBenchmarksTests/EmpireBenchmarksTests.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 | @testable import EmpireBenchmarks
3 |
4 | @Test func example() async throws {
5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions.
6 | }
7 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | We as members, contributors, and leaders pledge to make participation in our
7 | community a harassment-free experience for everyone, regardless of age, body
8 | size, visible or invisible disability, ethnicity, sex characteristics, gender
9 | identity and expression, level of experience, education, socio-economic status,
10 | nationality, personal appearance, race, caste, color, religion, or sexual
11 | identity and orientation.
12 |
13 | We pledge to act and interact in ways that contribute to an open, welcoming,
14 | diverse, inclusive, and healthy community.
15 |
16 | ## Our Standards
17 |
18 | Examples of behavior that contributes to a positive environment for our
19 | community include:
20 |
21 | * Demonstrating empathy and kindness toward other people
22 | * Being respectful of differing opinions, viewpoints, and experiences
23 | * Giving and gracefully accepting constructive feedback
24 | * Accepting responsibility and apologizing to those affected by our mistakes,
25 | and learning from the experience
26 | * Focusing on what is best not just for us as individuals, but for the overall
27 | community
28 |
29 | Examples of unacceptable behavior include:
30 |
31 | * The use of sexualized language or imagery, and sexual attention or advances of
32 | any kind
33 | * Trolling, insulting or derogatory comments, and personal or political attacks
34 | * Public or private harassment
35 | * Publishing others' private information, such as a physical or email address,
36 | without their explicit permission
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at https://mastodon.social/@mattiem.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2024, Matt Massicotte
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "a8c8fb128f5d359abf0b40a2558bd21c8d2f25709a6e550702aa49fae3a10168",
3 | "pins" : [
4 | {
5 | "identity" : "swift-syntax",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/swiftlang/swift-syntax.git",
8 | "state" : {
9 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
10 | "version" : "601.0.1"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 |
3 | import PackageDescription
4 | import CompilerPluginSupport
5 |
6 | let package = Package(
7 | name: "Empire",
8 | platforms: [
9 | .macOS(.v14),
10 | .iOS(.v17),
11 | .macCatalyst(.v17),
12 | .watchOS(.v10),
13 | .tvOS(.v17),
14 | .visionOS(.v1),
15 | ],
16 | products: [
17 | .library(name: "Empire", targets: ["Empire"]),
18 | .library(name: "LMDB", targets: ["LMDB"]),
19 | ],
20 | dependencies: [
21 | .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"602.0.0"),
22 | ],
23 | targets: [
24 | .target(
25 | name: "CLMDB",
26 | path: "lmdb/libraries/liblmdb",
27 | sources: [
28 | "mdb.c",
29 | "midl.c"
30 | ],
31 | publicHeadersPath: ".",
32 | cSettings: [
33 | .define("MDB_USE_POSIX_MUTEX", to: "1"),
34 | .define("MDB_USE_ROBUST", to: "0"),
35 | ]
36 | ),
37 | .target(name: "LMDB", dependencies: ["CLMDB"]),
38 | .testTarget(name: "LMDBTests", dependencies: ["LMDB"]),
39 | .target(name: "PackedSerialize"),
40 | .testTarget(name: "PackedSerializeTests", dependencies: ["PackedSerialize"]),
41 | .macro(
42 | name: "EmpireMacros",
43 | dependencies: [
44 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
45 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
46 | ]
47 | ),
48 | .testTarget(
49 | name: "EmpireMacrosTests",
50 | dependencies: [
51 | "EmpireMacros",
52 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
53 | ]
54 | ),
55 | .target(
56 | name: "Empire",
57 | dependencies: ["LMDB", "EmpireMacros", "PackedSerialize"]
58 | ),
59 | .testTarget(name: "EmpireTests", dependencies: ["Empire"]),
60 | .target(name: "EmpireSwiftData", dependencies: ["Empire"]),
61 | .testTarget(name: "EmpireSwiftDataTests", dependencies: ["EmpireSwiftData"]),
62 | ]
63 | )
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [![Build Status][build status badge]][build status]
4 | [![Platforms][platforms badge]][platforms]
5 | [![Documentation][documentation badge]][documentation]
6 | [![Matrix][matrix badge]][matrix]
7 |
8 |
9 |
10 | # Empire
11 |
12 | It's a record store for Swift.
13 |
14 | Empire is a minimalistic local persistence system with an emphasis on type safety and low overhead. It is backed by a sorted key-value store that is far simpler than a full SQL database. SQL is very powerful, but for many problem domains you just don't need it.
15 |
16 | This might be appealing to you if need more than just a flat file, more than `UserDefaults`, but not an entire relational database.
17 |
18 | - Schema is defined by your types
19 | - Macro-based API that is both typesafe and low-overhead
20 | - Built for concurrency and cancellation
21 | - Support for CloudKit's `CKRecord`
22 | - Backed by a sorted-key index data store ([LMDB][LMDB])
23 |
24 | The core LMDB support is also available as a standalone library within the package, just in case that's of interest as well.
25 |
26 | > [!WARNING]
27 | > This library is still pretty new and doesn't have great deal of real-world testing yet.
28 |
29 | ```swift
30 | import Empire
31 |
32 | // define your records with types
33 | @IndexKeyRecord("name")
34 | struct Person {
35 | let name: String
36 | let age: Int
37 | }
38 |
39 | // create a local database
40 | let store = try BackgroundableStore(url: Self.storeURL)
41 |
42 | // interact with it using transactions
43 | try store.main.withTransaction { context in
44 | try context.insert(Person(name: "Korben", age: 45))
45 | try context.insert(Person(name: "Leeloo", age: 2000))
46 | }
47 |
48 | // run queries
49 | let records = try store.main.withTransaction { context in
50 | try Person.select(in: context, limit: 1, name: .lessThan("Zorg"))
51 | }
52 |
53 | print(records.first!) // Person(name: "Leeloo", age: 2000)
54 |
55 | // move work to the background
56 | try await store.background.withTransaction { ctx in
57 | try Person.delete(in: ctx, name: "Korben")
58 | }
59 | ```
60 |
61 | ## Integration
62 |
63 | ```swift
64 | dependencies: [
65 | .package(url: "https://github.com/mattmassicotte/Empire", branch: "main")
66 | ]
67 | ```
68 |
69 | ## Concept
70 |
71 | Empire stores records in a sorted-key index, which resembles an ordered-map data structure. This has profound implications on query capabilities and how data is modeled. Ordered maps offer much less flexibility than the table-based system of an SQL database. Because of this, the queries you need to support can heavily influence how you model your data.
72 |
73 | ### Record Structure
74 |
75 | Conceptually, you can think of each record as being split into two components: the "index key" and "fields".
76 |
77 | The "index key" component is the **only** means of retrieving data efficiently. It is **not possible** to run queries against non-key fields without doing a full scan of the data. This makes index keys a critical part of your design.
78 |
79 | Consider the following record definition. It has a composite key, defined by the two arguments to the `@IndexKeyRecord` macro.
80 |
81 | ```swift
82 | @IndexKeyRecord("lastName", "firstName")
83 | struct Person {
84 | let lastName: String
85 | let firstName: String
86 | let age: Int
87 | }
88 | ```
89 |
90 | These records are stored in order, first by `lastName` and then by `firstName`. The ordering of key components is very important. Only the last component of a query can be a non-equality comparison. If you want to look for a range of a key component, you must restrict all previous components.
91 |
92 | ```swift
93 | // scan query on the first component
94 | store.select(lastName: .greaterThan("Dallas"))
95 |
96 | // constrain first component, scan query on the second
97 | store.select(lastName: "Dallas", firstName: .lessThanOrEqual("Korben"))
98 |
99 | // ERROR: an unsupported key arrangement
100 | store.select(lastName: .lessThan("Zorg"), firstName: .lessThanOrEqual("Jean-Baptiste"))
101 | ```
102 |
103 | The code generated by the `@IndexKeyRecord` macro makes it a compile-time error to write invalid queries. Because it is not part of the index key, it is not possible to run efficient queries that involve the `age` property.
104 |
105 | ## Concurrency Support
106 |
107 | You have a few different options for controlling the thread-safety and execution of your database.
108 |
109 | ```swift
110 | // A non-Sendable type
111 | let store = try Store(path: "/path/to/store")
112 |
113 | // An actor-isolated and Sendable store
114 | let backgroundStore = try BackgroundStore(path: "/path/to/store")
115 |
116 | // A hybrid that is synchronously accessible from MainActor
117 | // and also supports executing transactions in the background
118 | let backgroundableStore = try BackgroundableStore(path: "/path/to/store")
119 |
120 | backgroundableStore.main.withTransaction { ... }
121 | await backgroundableStore.background.withTransaction { ... }
122 | ```
123 |
124 | You can also make your own arragements by either hanging onto a `Store` directly within an actor you control, or by creating and using the primitive `LockingDatabase` type directly.
125 |
126 | The transaction mechanism supports cancellation. If the executing `Task` is cancelled, a transaction will be aborted.
127 |
128 | ## Building
129 |
130 | This is only necessary if you are interested in working on the project yourself.
131 |
132 | **Note**: requires Xcode 16
133 |
134 | - clone the repo
135 | - `git submodule update --init --recursive`
136 |
137 | To run the benchmarks:
138 |
139 | - `cd Benchmarks`
140 | - `swift package --disable-sandbox benchmark --target CoreDataBenchmarks`
141 | - `swift package benchmark --target EmpireBenchmarks`
142 |
143 | ## Questions
144 |
145 | ### Why does this exist?
146 |
147 | I'm not sure! I haven't used [Core Data](https://developer.apple.com/documentation/coredata) or [SwiftData](https://developer.apple.com/documentation/swiftdata) too much. But I have used the distributed database [Cassandra](https://cassandra.apache.org) quite a lot and [DynamoDB](https://aws.amazon.com/dynamodb/) a bit. Then one day I discovered [LMDB][LMDB]. Its data model is quite similar to Cassandra and I got interested in playing around with it. This just kinda materialized from those experiments.
148 |
149 | ### Can I use this?
150 |
151 | Sure!
152 |
153 | ### *Should* I use this?
154 |
155 | User data is important. This library has a bunch of tests, but it has no real-world testing. I plan on using this myself, but even I haven't gotten to that yet. It should be considered *functional*, but experimental.
156 |
157 | ## Contributing and Collaboration
158 |
159 | I would love to hear from you! Issues or pull requests work great. Both a [Matrix space][matrix] and [Discord][discord] are available for live help, but I have a strong bias towards answering in the form of documentation. You can also find me on [the web](https://www.massicotte.org).
160 |
161 | I prefer collaboration, and would love to find ways to work together if you have a similar project.
162 |
163 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace.
164 |
165 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md).
166 |
167 | [build status]: https://github.com/mattmassicotte/Empire/actions
168 | [build status badge]: https://github.com/mattmassicotte/Empire/workflows/CI/badge.svg
169 | [platforms]: https://swiftpackageindex.com/mattmassicotte/Empire
170 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmattmassicotte%2FEmpire%2Fbadge%3Ftype%3Dplatforms
171 | [documentation]: https://swiftpackageindex.com/mattmassicotte/Empire/main/documentation
172 | [documentation badge]: https://img.shields.io/badge/Documentation-DocC-blue
173 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org
174 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix
175 | [discord]: https://discord.gg/esFpX6sErJ
176 | [LMDB]: https://www.symas.com/lmdb
177 |
--------------------------------------------------------------------------------
/Sources/Empire/BackgroundStore.swift:
--------------------------------------------------------------------------------
1 | /// Asynchronous interface to an Empire database that executes transactions on non-main threads.
2 | public actor BackgroundStore {
3 | let store: Store
4 |
5 | public init(database: LockingDatabase) {
6 | self.store = Store(database: database)
7 | }
8 |
9 | public init(path: String) throws {
10 | let db = try LockingDatabase(path: path)
11 |
12 | self.init(database: db)
13 | }
14 |
15 | #if compiler(>=6.1)
16 | /// Execute a transation on a database.
17 | public func withTransaction(
18 | _ block: sending (TransactionContext) throws -> sending T
19 | ) throws -> sending T {
20 | try store.withTransaction { ctx in
21 | try block(ctx)
22 | }
23 | }
24 | #else
25 | /// Execute a transation on a database.
26 | public func withTransaction(
27 | _ block: sending (TransactionContext) throws -> sending T
28 | ) throws -> T {
29 | try store.withTransaction { ctx in
30 | try block(ctx)
31 | }
32 | }
33 | #endif
34 | }
35 |
36 | @MainActor
37 | struct MainActorStore {
38 | let store: Store
39 |
40 | init(database: LockingDatabase) {
41 | self.store = Store(database: database)
42 | }
43 | }
44 |
45 | /// An interface to an Empire database that supports both synchronous and asynchronous accesses.
46 | public final class BackgroundableStore: Sendable {
47 | let mainStore: MainActorStore
48 | /// A `Store` instance that executes transactions on non-main threads.
49 | public let background: BackgroundStore
50 |
51 | @MainActor
52 | public init(database: LockingDatabase) {
53 | self.mainStore = MainActorStore(database: database)
54 | self.background = BackgroundStore(database: database)
55 | }
56 |
57 | @MainActor
58 | convenience init(path: String) throws {
59 | let db = try LockingDatabase(path: path)
60 |
61 | self.init(database: db)
62 | }
63 |
64 | /// A `Store` instance that executes transactions on the `MainActor`.
65 | @MainActor
66 | public var main: Store {
67 | mainStore.store
68 | }
69 | }
70 |
71 | #if canImport(Foundation)
72 | import Foundation
73 |
74 | extension BackgroundableStore {
75 | @MainActor
76 | public convenience init(url: URL) throws {
77 | let db = try LockingDatabase(url: url)
78 |
79 | self.init(database: db)
80 | }
81 | }
82 | #endif
83 |
--------------------------------------------------------------------------------
/Sources/Empire/Buffers.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 |
3 | /// A mutable data buffer for reading and writing serialized data.
4 | public struct SerializationBuffer {
5 | public var keyBuffer: UnsafeMutableRawBufferPointer
6 | public var valueBuffer: UnsafeMutableRawBufferPointer
7 |
8 | init(keySize: Int, valueSize: Int) {
9 | self.keyBuffer = UnsafeMutableRawBufferPointer.allocate(
10 | byteCount: keySize,
11 | alignment: MemoryLayout.alignment
12 | )
13 | self.valueBuffer = UnsafeMutableRawBufferPointer.allocate(
14 | byteCount: valueSize,
15 | alignment: MemoryLayout.alignment
16 | )
17 | }
18 | }
19 |
20 | /// An immutable data buffer for reading serialized data.
21 | public struct DeserializationBuffer {
22 | public var keyBuffer: UnsafeRawBufferPointer
23 | public var valueBuffer: UnsafeRawBufferPointer
24 |
25 | init(keyBuffer: UnsafeRawBufferPointer, valueBuffer: UnsafeRawBufferPointer) {
26 | self.keyBuffer = keyBuffer
27 | self.valueBuffer = valueBuffer
28 | }
29 |
30 | init(key: MDB_val, value: MDB_val) {
31 | self.keyBuffer = key.bufferPointer
32 | self.valueBuffer = value.bufferPointer
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Empire/CloudKitRecord.swift:
--------------------------------------------------------------------------------
1 | public protocol CloudKitRecordNameRepresentable {
2 | var ckRecordName: String { get }
3 | }
4 |
5 | #if canImport(CloudKit)
6 | import CloudKit
7 |
8 | enum CloudKitRecordError: Error {
9 | case missingField(String)
10 | case recordTypeMismatch(String, String)
11 | }
12 |
13 | extension CKRecord {
14 | public func getTypedValue(for key: String) throws -> T {
15 | guard let field = self[key] as? T else {
16 | throw CloudKitRecordError.missingField(key)
17 | }
18 |
19 | return field
20 | }
21 |
22 | public func validateRecordType(_ expectedType: String) throws {
23 | if recordType != expectedType {
24 | throw CloudKitRecordError.recordTypeMismatch(recordType, expectedType)
25 | }
26 | }
27 | }
28 |
29 | public protocol CloudKitRecord {
30 | static var ckRecordType: String { get }
31 |
32 | func ckRecord(with recordId: CKRecord.ID) -> CKRecord
33 |
34 | init(ckRecord: CKRecord) throws
35 | }
36 |
37 | extension CloudKitRecord {
38 | public static var ckRecordType: String {
39 | String(describing: Self.self)
40 | }
41 | }
42 |
43 | extension CloudKitRecord where Self: IndexKeyRecord, Self.IndexKey: CloudKitRecordNameRepresentable {
44 | public func ckRecord(in zoneId: CKRecordZone.ID) -> CKRecord {
45 | let recordName = indexKey.ckRecordName
46 | let recordId = CKRecord.ID(recordName: recordName, zoneID: zoneId)
47 |
48 | return ckRecord(with: recordId)
49 | }
50 | }
51 |
52 | #endif
53 |
--------------------------------------------------------------------------------
/Sources/Empire/Empire.docc/CloudKitSupport.md:
--------------------------------------------------------------------------------
1 | # CloudKit Support
2 |
3 | Integrate your records with CloudKit.
4 |
5 | ## Overview
6 |
7 | Empire includes support to integrate types into CloudKit.
8 |
9 | > Important: CloudKit support is experimental.
10 |
11 | ## Using `CloudKitRecord`
12 |
13 | You can add support for CloudKit's `CKRecord` type via the ``CloudKitRecord`` macro. You can also use the associated protocol independently.
14 |
15 | ```swift
16 | @CloudKitRecord
17 | struct Person {
18 | let name: String
19 | let age: Int
20 | }
21 |
22 | // Equivalent to this:
23 | extension Person: CloudKitRecord {
24 | public init(ckRecord: CKRecord) throws {
25 | try ckRecord.validateRecordType(Self.ckRecordType)
26 |
27 | self.name = try ckRecord.getTypedValue(for: "name")
28 | self.age = try ckRecord.getTypedValue(for: "age")
29 | }
30 |
31 | public func ckRecord(with recordId: CKRecord.ID) -> CKRecord {
32 | let record = CKRecord(recordType: Self.ckRecordType, recordID: recordId)
33 |
34 | record["name"] = name
35 | record["age"] = age
36 |
37 | return record
38 | }
39 | }
40 | ```
41 |
42 | Optionally, you can override `ckRecordType` to customize the name of the CloudKit record used. If your type also uses ``IndexKeyRecord``, you get access to:
43 |
44 | ```swift
45 | func ckRecord(in zoneId: CKRecordZone.ID)
46 | ```
47 |
--------------------------------------------------------------------------------
/Sources/Empire/Empire.docc/DataManipulation.md:
--------------------------------------------------------------------------------
1 | # Data Manipulation
2 |
3 | Learn how to read and write data to a store.
4 |
5 | ## Overview
6 |
7 | There are a variety of APIs available to read and write data to a ``Store``. All operations must be done within a transaction and most are defined in terms of a ``TransactionContext``. However, there are some convenience APIs available to work with a ``Store`` directly.
8 |
9 | ### Writing
10 |
11 | It's important to keep in mind that Empire is fundamentally a key-value store. Because of this, there really isn't a distinction between an "insert" and "update" operation.
12 |
13 | Inserting into a context:
14 |
15 | ```swift
16 | try store.withTransaction { context in
17 | try context.insert(Person(name: "Korben", age: 45))
18 | }
19 | ```
20 |
21 | Inserting using a record:
22 |
23 | ```swift
24 | try store.withTransaction { context in
25 | try Person(name: "Korben", age: 45).insert(in: context)
26 | }
27 | ```
28 |
29 | Single-record insert directly into a store instance:
30 |
31 | ```swift
32 | try Person(name: "Korben", age: 45).insert(in: store)
33 | ```
34 |
35 | ### Reading
36 |
37 | ### Deleting
38 |
39 | Deleting from a context:
40 |
41 | ```swift
42 | try store.withTransaction { context in
43 | try context.delete(Person(name: "Korben", age: 45))
44 | }
45 | ```
46 |
47 | Deleting using a record:
48 |
49 | ```swift
50 | try store.withTransaction { context in
51 | try Person(name: "Korben", age: 45).delete(in: context)
52 | }
53 | ```
54 |
55 | Deleting using a record key:
56 |
57 | ```swift
58 | try store.withTransaction { context in
59 | try Person.delete(in: context, key: Person.IndexKey("Korben", 45))
60 | }
61 | ```
62 |
63 | Single-record delete directly from a store instance:
64 |
65 | ```swift
66 | try store.delete(Person(name: "Korben", age: 45))
67 | ```
68 |
--------------------------------------------------------------------------------
/Sources/Empire/Empire.docc/DataModeling.md:
--------------------------------------------------------------------------------
1 | # Data Modeling
2 |
3 | Understand how to model and query your data.
4 |
5 | ## Overview
6 |
7 | Empire stores records in a sorted-key index, which resembles an ordered-map data structure. This has profound implications on query capabilities and how data is modeled. Ordered maps offer much less flexibility than the table-based system of an SQL database. Because of this, the queries you need to support can heavily influence how you model your data.
8 |
9 | ## Record Structure
10 |
11 | Conceptually, you can think of each record as being split into two components: the "index key" and "fields".
12 |
13 | The "index key" component is the **only** means of retrieving data efficiently. It is **not possible** to run queries against non-key fields without doing a full scan of the data. This makes index keys a critical part of your design.
14 |
15 | Consider the following record definition. It has a composite key, defined by the two arguments to the `@IndexKeyRecord` macro.
16 |
17 | ```swift
18 | @IndexKeyRecord("lastName", "firstName")
19 | struct Person {
20 | let lastName: String
21 | let firstName: String
22 | let age: Int
23 | }
24 | ```
25 |
26 | These records are stored in order, first by `lastName` and then by `firstName`.
27 |
28 | `lastName`, `firstName` (Key) | `age` (Fields)
29 | --------------------- | ----
30 | `Cornelius`, `Vito` | 58
31 | `Dallas`, `Korben` | 45
32 | `Dallas`, `Mother` | 67
33 | `Rhod`, `Ruby` | 32
34 | `Zorg`, `Jean-Baptiste Emanuel` | 2000
35 |
36 | ## Key Ordering
37 |
38 | The ordering of key components is very important. Only the last component of a query can be a non-equality comparison. If you want to look for a range of a key component, you must restrict all previous components.
39 |
40 | ```swift
41 | // scan query on the first component
42 | store.select(lastName: .greaterThan("Dallas"))
43 |
44 | // constrain first component, scan query on the second
45 | store.select(lastName: "Dallas", firstName: .lessThanOrEqual("Korben"))
46 |
47 | // ERROR: an unsupported key arrangement
48 | store.select(lastName: .lessThan("Zorg"), firstName: .lessThanOrEqual("Jean-Baptiste"))
49 | ```
50 |
51 | The code generated by the `@IndexKeyRecord` macro makes it a compile-time error to write invalid queries. Because it is not part of the index key, it is not possible to run efficient queries that involve the `age` property.
52 |
53 | ## Query-First Design
54 |
55 | As a consequence of the limited query capability, you must model your data by starting with the queries you need to support. This isn't typically straightforward. One way to go about this is by writing out all of the possible queries your model needs.
56 |
57 | Many kinds of query patterns can require denormalization. For example, if we needed to support querying `Person` by age, a **second** entity would be necessary.
58 |
59 | ```swift
60 | @IndexKeyRecord("age")
61 | struct PersonByAge {
62 | let age: Int
63 | let lastName: String
64 | let firstName: String
65 | }
66 | ```
67 |
68 | ## Relationships
69 |
70 | Relationships are not as easy to model with a sorted-key index as they are with a relational database. Like with all other aspects of data modeling, index key selection is very important.
71 |
72 | With a sorted-key index, your models represent queries. This is a very important thing to keep in mind when working with relationships.
73 |
74 | Suppose we wanted to model both a `Person` and their relationship to a `Planet`.
75 |
76 | ```swift
77 | @IndexKeyRecord("lastName", "firstName")
78 | struct Person {
79 | let lastName: String
80 | let firstName: String
81 | let age: Int
82 | let homePlanet: Planet.IndexKey
83 | }
84 | ```
85 |
86 | ```swift
87 | @IndexKeyRecord("name")
88 | struct Planet {
89 | let name: String
90 | let leader: Person.IndexKey
91 | }
92 | ```
93 |
94 | This is a one-to-one relationship that supports querying the home planet of a `Person`, or the leader of a `Planet`. It does not, however, support finding all people on a given planet. That requires another record structure.
95 |
96 | ```swift
97 | @IndexKeyRecord("planet", "lastName", "firstName")
98 | struct Inhabitant {
99 | let planet: Planet.IndexKey
100 | let lastName: String
101 | let firstName: String
102 | let age: Int
103 | }
104 | ```
105 |
106 | Note here how the first element of the `Inhabitant` index key is `Planet.IndexKey`. This orders the data by planet and allows us to efficiently query for all the records that match.
107 |
108 | Perhaps, instead, we need to query for all the planets visited by a single person.
109 |
110 | ```swift
111 | @IndexKeyRecord("lastName", "firstName", "planet")
112 | struct VisitedPlanets {
113 | let lastName: String
114 | let firstName: String
115 | let planet: Planet.IndexKey
116 | let age: Int
117 | }
118 | ```
119 |
120 | This record permits efficiently querying the planets visited by a `lastName`-`firstName` pair.
121 |
122 | It is quite common to duplicate, or denormalize, data across records to support queries. How much denormalization you do comes with trade-offs in convenience and efficiency. Thinking carefully about the queries you need to support is critical.
123 |
124 | ## Type Constraints
125 |
126 | The properties of an ``IndexKeyRecord`` type are serialized directly to a binary form. To do this, their types must conform to both the `Serialization` and `Deserialization` protocols.
127 |
128 | However, there is an important additional constraint on types that compose an index key. All of these **must** also be sortable via direct binary comparison when serialized. This is not a property all types have, but can be expressed with a conformance to `IndexKeyComparable`.
129 |
130 | | Type | Key | Notes |
131 | | --- | --- | --- |
132 | | `Array` | no | none |
133 | | `Bool` | yes | none |
134 | | `Data` | yes | none |
135 | | `Date` | no | millisecond precision |
136 | | `Optional`| no | none |
137 | | `Int` | yes | none |
138 | | `Int64` | yes | none |
139 | | `RawRepresentable` | no | none |
140 | | `String` | yes | none |
141 | | `UInt` | yes | none |
142 | | `UInt32` | yes | none |
143 | | `UUID` | yes | none |
144 |
145 | It is possible to add support for custom types using these protocols.
146 |
147 | ```swift
148 | enum MyEnum: Int, IndexKeyComparable, Serializable, Deserializable {
149 | case a = 1
150 | case b = 2
151 | case c = 3
152 |
153 | static func < (lhs: Self, rhs: Self) -> Bool {
154 | lhs.rawValue < rhs.rawValue
155 | }
156 | }
157 | ```
158 |
159 | Preserving the binary ordering semantics required isn't always straightforward. Adding a conformance to `IndexKeyComparable` should be done with care. An inappropriate binary representation will result in undefined querying behavior.
160 |
161 | ## Manual Conformance
162 |
163 | The ``IndexKeyRecord(validated:keyPrefix:fieldsVersion:_:_:)`` macro expands to a conformance to the ``IndexKeyRecord`` protocol. You can use this directly, but it isn't easy. You have to handle binary serialization and deserialization of all your fields. It's also **critical** that you correctly version your type's schema.
164 |
165 | ```swift
166 | @IndexKeyRecord("name")
167 | struct Person {
168 | let name: String
169 | let age: Int
170 | }
171 |
172 | // Equivalent to this:
173 | extension Person: IndexKeyRecord {
174 | public typealias IndexKey = Tuple
175 | public typealias Fields: Tuple
176 |
177 | public static var keyPrefix: IndexKeyRecordHash {
178 | 1
179 | }
180 |
181 | public static var fieldsVersion: IndexKeyRecordHash {
182 | 1
183 | }
184 |
185 | public var indexKey: IndexKey {
186 | Tuple(name)
187 | }
188 |
189 | public var fields: Fields {
190 | Tuple(age)
191 | }
192 |
193 | public func serialize(into buffer: inout SerializationBuffer) {
194 | name.serialize(into: &buffer.keyBuffer)
195 | age.serialize(into: &buffer.valueBuffer)
196 | }
197 |
198 | public init(_ buffer: inout DeserializationBuffer) throws {
199 | self.name = try String(buffer: &buffer.keyBuffer)
200 | self.age = try UInt(buffer: &buffer.valueBuffer)
201 | }
202 | }
203 |
204 | extension Person {
205 | public static func select(in context: TransactionContext, limit: Int? = nil, name: ComparisonOperator) throws -> [Self] {
206 | try context.select(query: Query(last: name, limit: limit))
207 | }
208 | public static func select(in context: TransactionContext, limit: Int? = nil, name: String) throws -> [Self] {
209 | try context.select(query: Query(last: .equals(name), limit: limit))
210 | }
211 | public static func delete(in context: TransactionContext, name: String) throws {
212 | try context.delete(recordType: Self.self, key: Tuple(name))
213 | }
214 | }
215 | ```
216 |
--------------------------------------------------------------------------------
/Sources/Empire/Empire.docc/Empire.md:
--------------------------------------------------------------------------------
1 | # ``Empire``
2 |
3 | Empire is a compile-time defined persistance system backed by a sorted key-value store.
4 |
5 | ## Overview
6 |
7 | Empire is pretty different from many other local persistance systems. First, it uses your Swift types to define the storage schema. And second, its query capabilities are limited to sorted keys. These two properties have a dramatic effect on how you model and query for your data.
8 |
9 | These constraints come from the underlying data storage system, [LMDB](https://www.symas.com/mdb), which uses an ordered-map interface that is very similar to many NoSQL-style databases.
10 |
--------------------------------------------------------------------------------
/Sources/Empire/Empire.docc/Migrations.md:
--------------------------------------------------------------------------------
1 | # Migrations
2 |
3 | Manage changes to your data models over time.
4 |
5 | ## Overview
6 |
7 | Types that conform to ``IndexKeyRecord``, either via the macro or manually, **define** the on-disk serialization format. Any changes to these types will invalidate the data within the storage. This is detected using the `fieldsVersion` and `keyPrefix` properties, and
8 | fixing it requires migrations. These are run incrementally as mismatches are detected on load.
9 |
10 | ## Migrating Data
11 |
12 | Because the database schema is defined by your types, any changes to these types will invalidate the data within the storage. This is detected using the `fieldsVersion` property, and fixing it requires migrations. These are run incrementally as mismatches are detected on load.
13 |
14 | The only factors that affect data compatibility are definition order and data type.
15 |
16 | To support migration, you must implement a custom initializer.
17 |
18 | ```swift
19 | extension struct Person {
20 | init(_ buffer: inout DeserializationBuffer, version: Int) throws {
21 | // `version` here is the `fieldVersion` of the data actually serialized in storage
22 | switch version {
23 | case 1:
24 | // this version didn't support the `age` field
25 | self.lastName = try String(buffer: &buffer.keyBuffer)
26 | self.firstName = try String(buffer: &buffer.keyBuffer)
27 | self.age = 0 // a reasonable placeholder I guess?
28 | default:
29 | throw Self.unsupportedMigrationError(for: version)
30 | }
31 | }
32 | }
33 | ```
34 |
35 | ## Migration Strategy
36 |
37 | Here's a possible approach to managing migrations for your record types over time in a more structured way.
38 |
39 | ```swift
40 | // This represents your current schema
41 | @IndexKeyRecord("key")
42 | struct MyRecord {
43 | let key: Int
44 | let a: String
45 | let b: String
46 | let c: String
47 | }
48 |
49 | extension MyRecord {
50 | // Here, you keep previous versions of your records.
51 | @IndexKeyRecord("key")
52 | private struct MyRecord1 {
53 | let key: Int
54 | let a: String
55 | }
56 |
57 | @IndexKeyRecord("key")
58 | private struct MyRecord2 {
59 | let key: Int
60 | let a: String
61 | let b: String
62 | }
63 |
64 | // implement the migration initializer
65 | public init(_ buffer: inout DeserializationBuffer, version: IndexKeyRecordHash) throws {
66 | // switch over the possible previous field versions and migrate as necessary
67 | switch version {
68 | case MyRecord1.fieldsVersion:
69 | let record1 = try MyRecord1(&buffer)
70 |
71 | self.key = record1.key
72 | self.a = record1.a
73 | self.b = ""
74 | self.c = ""
75 | case MyRecord2.fieldsVersion:
76 | let record2 = try MyRecord2(&buffer)
77 |
78 | self.key = record2.key
79 | self.a = record2.a
80 | self.b = record2.b
81 | self.c = ""
82 | default:
83 | throw Self.unsupportedMigrationError(for: version)
84 | }
85 | }
86 | }
87 | ```
88 |
89 | ## Schema Version Management
90 |
91 | Changing your record types can result in catastrophic failure, so you want to be really careful with them. The `IndexKeyRecord` macro supports a number of other features that can help with migration and schema management.
92 |
93 | You can manually encode the field hash into your types to get build-time verification that your schema hasn't changed. What you can do here is inspect the macro output, grab the hash, and then supply it as an argument to the macro. If anything is accidentally changed, an error will be generated.
94 |
95 | ```swift
96 | @IndexKeyRecord(validated: 8366809093122785258, "key")
97 | public struct VerifiedVersion: Sendable {
98 | let key: Int
99 | }
100 | ```
101 |
102 | You can also manually manage the key prefix and field version. This can be useful for easier version management, but you are giving up automated checks by doing this. It is **absolutely critical** that you correctly change this value when you make serialization-incompatible changes.
103 |
104 | ```swift
105 | @IndexKeyRecord(keyPrefix: 1, fieldsVersion: 2, "key")
106 | struct ManuallyVersionedRecord: Sendable {
107 | let key: Int
108 | let value: String
109 | }
110 | ```
111 |
112 | For reference, the hash algorithm used by the automated system is [sdbm](https://www.partow.net/programming/hashfunctions/#SDBMHashFunction).
113 |
--------------------------------------------------------------------------------
/Sources/Empire/IndexKeyRecord.swift:
--------------------------------------------------------------------------------
1 | import PackedSerialize
2 |
3 | /// A hash used for versioning serialized data.
4 | ///
5 | /// The built-in hash function used is SDBM. You can customize the schema versioning system either with macro arguments or with a manual conformance to the `IndexKeyRecord` protocol.
6 | public typealias IndexKeyRecordHash = UInt32
7 |
8 | /// Requirements for a type stored in an Empire database.
9 | public protocol IndexKeyRecord {
10 | /// The `Tuple` that defines the record index key.
11 | associatedtype IndexKey: Serializable & Deserializable & IndexKeyComparable
12 |
13 | /// The `Tuple` that defines the record fields.
14 | associatedtype Fields: Serializable & Deserializable
15 |
16 | /// A prefix used for all records of the same type to distinguish them by key type alone.
17 | ///
18 | /// By default, this value is the same as `keySchemaHashValue`. You can override this value to control the key prefixing strategy.
19 | static var keyPrefix: IndexKeyRecordHash { get }
20 |
21 | /// An identifier used to ensure serialized data matches the record structure.
22 | ///
23 | /// By default, this value is the same as `fieldSchemaHashValue`. You can override this value to use a custom field versioning strategy.
24 | static var fieldsVersion: IndexKeyRecordHash { get }
25 |
26 | var indexKey: IndexKey { get }
27 | var fields: Fields { get }
28 |
29 | /// Writes out the representation of this instance into a buffer.
30 | ///
31 | /// - Parameter buffer: A buffer to the seralized field data.
32 | func serialize(into buffer: inout SerializationBuffer)
33 |
34 | /// Creates a new instance from the data in a buffer.
35 | ///
36 | /// - Parameter buffer: The buffer that holds the raw data within the backing storage.
37 | init(_ buffer: inout DeserializationBuffer) throws
38 |
39 | /// Create an instance with data that requires a migration.
40 | ///
41 | /// This intializer is used if the `fieldsVersion` value in storage does not match the current value for the type. When these values do match, `init(_ buffer:)` is used instead.
42 | ///
43 | /// - Parameter buffer: A buffer to the seralized field data.
44 | /// - Parameter version: The `fieldsVersion` value for the serialized data.
45 | init(_ buffer: inout DeserializationBuffer, version: IndexKeyRecordHash) throws
46 | }
47 |
48 | extension IndexKeyRecord {
49 | /// Create a new instance from the data in a buffer that does not match the current schema.
50 | ///
51 | /// By default, this initializer will throw `StoreError.migrationUnsupported`.
52 | ///
53 | /// - Parameter buffer: The buffer that holds the raw data within the backing storage.
54 | /// - Parameter version: The `fieldsVersion` value corresponding to the undering raw data currently in storage.
55 | public init(_ buffer: inout DeserializationBuffer, version: IndexKeyRecordHash) throws {
56 | throw Self.unsupportedMigrationError(for: version)
57 | }
58 |
59 | /// Create a new `StoreError.migrationUnsupported` corresponding to `Self` and the current `fieldsVersion`.
60 | public static func unsupportedMigrationError(for version: IndexKeyRecordHash) -> StoreError {
61 | StoreError.migrationUnsupported(String(describing: Self.self), Self.fieldsVersion, version)
62 | }
63 | }
64 |
65 | extension IndexKeyRecord {
66 | /// Delete a record using an IndexKey argument.
67 | public static func delete(in context: TransactionContext, key: IndexKey) throws {
68 | try context.delete(recordType: Self.self, key: key)
69 | }
70 |
71 | /// Delete this record using its `indexKey` property.
72 | public func delete(in context: TransactionContext) throws {
73 | try Self.delete(in: context, key: indexKey)
74 | }
75 |
76 | public func insert(in context: TransactionContext) throws {
77 | try context.insert(self)
78 | }
79 |
80 | /// Insert a record directly into a `Store`.
81 | ///
82 | /// This operation is wrapped in a transaction internally.
83 | public func insert(in store: Store) throws {
84 | try store.withTransaction { ctx in
85 | try ctx.insert(self)
86 | }
87 | }
88 | }
89 |
90 | extension IndexKeyRecord where IndexKey: Hashable {
91 | public var id: IndexKey { indexKey }
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/Empire/LMDB+Extensions.swift:
--------------------------------------------------------------------------------
1 | import LMDB
2 | import PackedSerialize
3 |
4 | import CLMDB
5 |
6 | extension MDB_val {
7 | init(_ value: Value, using buffer: UnsafeMutableRawBufferPointer) throws {
8 | let size = value.serializedSize
9 |
10 | guard size <= buffer.count else {
11 | throw StoreError.keyBufferOverflow
12 | }
13 |
14 | var localBuffer = buffer
15 |
16 | value.serialize(into: &localBuffer)
17 |
18 | self.init(mv_size: size, mv_data: buffer.baseAddress)
19 | }
20 |
21 | init(_ value: Value, prefix: Prefix, using buffer: UnsafeMutableRawBufferPointer) throws {
22 | let size = value.serializedSize + prefix.serializedSize
23 |
24 | guard size <= buffer.count else {
25 | throw StoreError.keyBufferOverflow
26 | }
27 |
28 | var localBuffer = buffer
29 |
30 | prefix.serialize(into: &localBuffer)
31 | value.serialize(into: &localBuffer)
32 |
33 | self.init(mv_size: size, mv_data: buffer.baseAddress)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/Empire/Macros.swift:
--------------------------------------------------------------------------------
1 | @_exported
2 | import PackedSerialize
3 |
4 | /// Adds conformance to the `IndexKeyRecord` protocol and associated query functions.
5 | @attached(
6 | extension,
7 | conformances: IndexKeyRecord,
8 | names:
9 | named(IndexKey),
10 | named(Fields),
11 | named(indexKey),
12 | named(fields),
13 | named(serialize),
14 | named(init),
15 | named(fieldsVersion),
16 | named(keyPrefix),
17 | named(select),
18 | named(delete)
19 | )
20 | public macro IndexKeyRecord(
21 | validated: Int? = nil,
22 | keyPrefix: Int? = nil,
23 | fieldsVersion: Int? = nil,
24 | _ first: StaticString,
25 | _ remaining: StaticString...
26 | ) = #externalMacro(module: "EmpireMacros", type: "IndexKeyRecordMacro")
27 |
28 | #if canImport(CloudKit)
29 | /// Adds conformance to the `CloudKitRecord` protocol.
30 | @attached(
31 | extension,
32 | conformances: CloudKitRecord,
33 | names:
34 | named(ckRecord),
35 | named(init)
36 | )
37 | public macro CloudKitRecord() = #externalMacro(module: "EmpireMacros", type: "CloudKitRecordMacro")
38 | #endif
39 |
--------------------------------------------------------------------------------
/Sources/Empire/Query+LDMBQuery.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 | import LMDB
3 |
4 | extension Query {
5 | func buildLMDDBQuery(buffer: SerializationBuffer, prefix: some Serializable & IndexKeyComparable) throws -> LMDB.Query {
6 | switch last {
7 | case let .greaterThan(value):
8 | let key = Tuple(prefix, repeat each components, value)
9 | let keyVal = try MDB_val(key, using: buffer.keyBuffer)
10 | let endKey = Tuple(prefix, repeat each components)
11 | let endKeyVal = try MDB_val(endKey, using: buffer.valueBuffer)
12 |
13 | return LMDB.Query(comparison: .greater(endKeyVal), key: keyVal, limit: limit, truncating: true)
14 | case let .greaterOrEqual(value):
15 | let key = Tuple(prefix, repeat each components, value)
16 | let keyVal = try MDB_val(key, using: buffer.keyBuffer)
17 | let endKey = Tuple(prefix, repeat each components)
18 | let endKeyVal = try MDB_val(endKey, using: buffer.valueBuffer)
19 |
20 | return LMDB.Query(comparison: .greaterOrEqual(endKeyVal), key: keyVal, limit: limit, truncating: true)
21 | case let .lessThan(value):
22 | let key = Tuple(prefix, repeat each components, value)
23 | let keyVal = try MDB_val(key, using: buffer.keyBuffer)
24 | let endKey = Tuple(prefix, repeat each components)
25 | let endKeyVal = try MDB_val(endKey, using: buffer.valueBuffer)
26 |
27 | return LMDB.Query(comparison: .less(endKeyVal), key: keyVal, limit: limit, truncating: true)
28 | case let .lessOrEqual(value):
29 | let key = Tuple(prefix, repeat each components, value)
30 | let keyVal = try MDB_val(key, using: buffer.keyBuffer)
31 | let endKey = Tuple(prefix, repeat each components)
32 | let endKeyVal = try MDB_val(endKey, using: buffer.valueBuffer)
33 |
34 | return LMDB.Query(comparison: .lessOrEqual(endKeyVal), key: keyVal, limit: limit, truncating: true)
35 | case let .range(range):
36 | let key = Tuple(prefix, repeat each components, range.lowerBound)
37 | let keyVal = try MDB_val(key, using: buffer.keyBuffer)
38 | let endKey = Tuple(prefix, repeat each components, range.upperBound)
39 | let endKeyVal = try MDB_val(endKey, using: buffer.valueBuffer)
40 |
41 | return LMDB.Query(comparison: .range(endKeyVal), key: keyVal, limit: limit, truncating: true)
42 | case let .closedRange(range):
43 | let key = Tuple(prefix, repeat each components, range.lowerBound)
44 | let keyVal = try MDB_val(key, using: buffer.keyBuffer)
45 | let endKey = Tuple(prefix, repeat each components, range.upperBound)
46 | let endKeyVal = try MDB_val(endKey, using: buffer.valueBuffer)
47 |
48 | return LMDB.Query(comparison: .closedRange(endKeyVal), key: keyVal, limit: limit, truncating: true)
49 | case .within:
50 | throw QueryError.unsupportedQuery
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Sources/Empire/Query.swift:
--------------------------------------------------------------------------------
1 | import LMDB
2 | import PackedSerialize
3 |
4 | /// A type that preserves ordering when serialized.
5 | ///
6 | /// A comformance to this type asserts that when serialized, the comparison order is preserved when evaluating the binary representation from MSB to LSB.
7 | public protocol IndexKeyComparable: Comparable {
8 | }
9 |
10 | extension Int: IndexKeyComparable {}
11 | extension Int64: IndexKeyComparable {}
12 | extension String: IndexKeyComparable {}
13 | extension UInt: IndexKeyComparable {}
14 | extension UInt32: IndexKeyComparable {}
15 | extension UInt64: IndexKeyComparable {}
16 |
17 | #if canImport(Foundation)
18 | import Foundation
19 | extension Date: IndexKeyComparable {}
20 | extension UUID: IndexKeyComparable {}
21 | #endif
22 |
23 | /// Expresses a query comparsion operation in terms of a type argument.
24 | public enum ComparisonOperator {
25 | case greaterThan(Value)
26 | case greaterOrEqual(Value)
27 | case lessThan(Value)
28 | case lessOrEqual(Value)
29 | case within([Value])
30 | case range(Range)
31 | case closedRange(ClosedRange)
32 |
33 | public static func equals(_ value: Value) -> Self {
34 | Self.closedRange(value...value)
35 | }
36 | }
37 |
38 | extension ComparisonOperator: Equatable {
39 | }
40 |
41 | extension ComparisonOperator: Hashable where Value: Hashable {
42 | }
43 |
44 | public typealias QueryComponent = IndexKeyComparable & Serializable & Deserializable
45 |
46 | /// A query operation that is not associated with a specific `IndexKeyRecord` type.
47 | public struct Query {
48 | public let last: ComparisonOperator
49 | public let components: (repeat each Component)
50 | public let limit: Int?
51 |
52 | public init(_ value: repeat each Component, last: ComparisonOperator, limit: Int? = nil) {
53 | self.components = (repeat each value)
54 | self.last = last
55 | self.limit = limit
56 | }
57 | }
58 |
59 | public enum QueryError: Error {
60 | case limitInvalid(Int)
61 | case unsupportedQuery
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/Empire/Store.swift:
--------------------------------------------------------------------------------
1 | import LMDB
2 | import CLMDB
3 | import PackedSerialize
4 |
5 | public enum StoreError: Error, Hashable {
6 | case noActiveContext
7 | case noActiveStore
8 | case keyBufferOverflow
9 | case valueBufferOverflow
10 | case recordPrefixMismatch(String, IndexKeyRecordHash, IndexKeyRecordHash)
11 | case migrationUnsupported(String, IndexKeyRecordHash, IndexKeyRecordHash)
12 | }
13 |
14 | /// Represents the on-disk storage that supports concurrent accesses.
15 | public struct LockingDatabase: Sendable {
16 | let environment: Environment
17 | let dbi: MDB_dbi
18 |
19 | /// Create an instance with a path to the on-disk database file.
20 | ///
21 | /// If there is no file at the specified path, one will be created.
22 | public init(path: String) throws {
23 | self.environment = try Environment(
24 | path: path,
25 | maxDatabases: 1,
26 | locking: true
27 | )
28 |
29 | self.dbi = try Transaction.with(env: environment) { txn in
30 | try txn.open(name: "empiredb")
31 | }
32 | }
33 | }
34 |
35 | /// Represents the on-disk storage.
36 | public struct Database {
37 | let environment: Environment
38 | let dbi: MDB_dbi
39 |
40 | /// Create an instance with a path to the on-disk database file.
41 | ///
42 | /// If there is no file at the specified path, one will be created.
43 | public init(path: String) throws {
44 | self.environment = try Environment(
45 | path: path,
46 | maxDatabases: 1,
47 | locking: false
48 | )
49 |
50 | self.dbi = try Transaction.with(env: environment) { txn in
51 | try txn.open(name: "empiredb")
52 | }
53 | }
54 | }
55 |
56 | /// Interface to an Empire database.
57 | ///
58 | /// The `Store` is the main interface to a single database file.
59 | public final class Store {
60 | private static let minimumFieldBufferSize = 1024 * 32
61 |
62 | private let environment: Environment
63 | private let dbi: MDB_dbi
64 | private let buffer: SerializationBuffer
65 |
66 | init(environment: Environment, dbi: MDB_dbi) {
67 | self.environment = environment
68 | self.dbi = dbi
69 | self.buffer = SerializationBuffer(
70 | keySize: environment.maximumKeySize,
71 | valueSize: Self.minimumFieldBufferSize
72 | )
73 | }
74 |
75 | /// Create an instance with a path to the on-disk database file.
76 | ///
77 | /// If there is no file at the specified path, one will be created.
78 | public convenience init(database: Database) {
79 | self.init(environment: database.environment, dbi: database.dbi)
80 | }
81 |
82 | /// Create an instance with a path to the on-disk database file.
83 | ///
84 | /// If there is no file at the specified path, one will be created.
85 | public convenience init(database: LockingDatabase) {
86 | self.init(environment: database.environment, dbi: database.dbi)
87 | }
88 |
89 | public convenience init(path: String) throws {
90 | let db = try Database(path: path)
91 |
92 | self.init(database: db)
93 | }
94 |
95 | #if compiler(>=6.1)
96 | /// Execute a transation on a database.
97 | public func withTransaction(
98 | _ block: (TransactionContext) throws -> sending T
99 | ) throws -> sending T {
100 | let value = try Transaction.with(env: environment) { txn in
101 | let context = TransactionContext(
102 | transaction: txn,
103 | dbi: dbi,
104 | buffer: buffer
105 | )
106 |
107 | return try block(context)
108 | }
109 |
110 | return value
111 | }
112 | #else
113 | /// Execute a transation on a database.
114 | public func withTransaction(
115 | _ block: (TransactionContext) throws -> sending T
116 | ) throws -> T {
117 | let value = try Transaction.with(env: environment) { txn in
118 | let context = TransactionContext(
119 | transaction: txn,
120 | dbi: dbi,
121 | buffer: buffer
122 | )
123 |
124 | return try block(context)
125 | }
126 |
127 | return value
128 | }
129 | #endif
130 | }
131 |
132 | extension Store {
133 | /// Insert a single record into the store.
134 | public func insert(_ record: Record) throws {
135 | try withTransaction { ctx in
136 | try ctx.insert(record)
137 | }
138 | }
139 |
140 | #if compiler(>=6.1)
141 | /// Retrieve a single record from the store.
142 | ///
143 | /// This is currently implemented with a `selectCopy` internally.
144 | public func select(
145 | key: Record.IndexKey
146 | ) throws -> Record? {
147 | try withTransaction { ctx in
148 | // this must be a copy to work around sending the result
149 | try ctx.selectCopy(key: key)
150 | }
151 | }
152 | #else
153 | /// Retrieve a single record from the store.
154 | public func select(
155 | key: Record.IndexKey
156 | ) throws -> Record? where Record: Sendable {
157 | try withTransaction { ctx in
158 | // this must be a copy to work around sending the result
159 | try ctx.select(key: key)
160 | }
161 | }
162 | #endif
163 |
164 | /// Delete a single record from the store.
165 | public func delete(_ record: Record) throws {
166 | try withTransaction { ctx in
167 | try ctx.delete(record)
168 | }
169 | }
170 | }
171 |
172 | #if canImport(Foundation)
173 | import Foundation
174 |
175 | extension Store {
176 | /// Create an instance with a URL to the on-disk database file.
177 | ///
178 | /// If there is no file at the specified url, one will be created.
179 | public convenience init(url: URL) throws {
180 | try self.init(path: url.path)
181 | }
182 | }
183 |
184 | extension Database {
185 | public init(url: URL) throws {
186 | try self.init(path: url.path)
187 | }
188 | }
189 |
190 | extension LockingDatabase {
191 | public init(url: URL) throws {
192 | try self.init(path: url.path)
193 | }
194 | }
195 | #endif
196 |
--------------------------------------------------------------------------------
/Sources/Empire/TransactionContext.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 | import LMDB
3 |
4 | /// Represents a database transaction.
5 | ///
6 | /// Transactions have full ACID sematics.
7 | public struct TransactionContext {
8 | var transaction: Transaction
9 | let dbi: MDB_dbi
10 | let buffer: SerializationBuffer
11 |
12 | init(transaction: Transaction, dbi: MDB_dbi, buffer: SerializationBuffer) {
13 | self.transaction = transaction
14 | self.dbi = dbi
15 | self.buffer = buffer
16 | }
17 | }
18 |
19 | extension TransactionContext {
20 | public func insert(_ record: Record) throws {
21 | let prefix = Record.keyPrefix
22 | let version = Record.fieldsVersion
23 |
24 | let keySize = record.indexKey.serializedSize + prefix.serializedSize
25 | guard keySize <= buffer.keyBuffer.count else {
26 | throw StoreError.keyBufferOverflow
27 | }
28 |
29 | let valueSize = record.fields.serializedSize + version.serializedSize
30 | guard valueSize <= buffer.valueBuffer.count else {
31 | throw StoreError.valueBufferOverflow
32 | }
33 |
34 | var localBuffer = self.buffer
35 |
36 | prefix.serialize(into: &localBuffer.keyBuffer)
37 | version.serialize(into: &localBuffer.valueBuffer)
38 |
39 | record.serialize(into: &localBuffer)
40 |
41 | let keyData = UnsafeRawBufferPointer(start: buffer.keyBuffer.baseAddress, count: keySize)
42 | let fieldsData = UnsafeRawBufferPointer(start: buffer.valueBuffer.baseAddress, count: valueSize)
43 |
44 | try transaction.set(dbi: dbi, keyBuffer: keyData, valueBuffer: fieldsData)
45 | }
46 | }
47 |
48 | extension TransactionContext {
49 | enum DeserializationResult {
50 | case success(Record)
51 | case migrated(Record)
52 | case prefixMismatch(IndexKeyRecordHash)
53 |
54 | var record: Record? {
55 | switch self {
56 | case let .success(value), let .migrated(value):
57 | return value
58 | case .prefixMismatch:
59 | return nil
60 | }
61 | }
62 |
63 | var recordIfMatching: Record {
64 | get throws {
65 | switch self {
66 | case let .success(value), let .migrated(value):
67 | return value
68 | case let .prefixMismatch(readPrefix):
69 | throw StoreError.recordPrefixMismatch(String(describing: Record.self), Record.keyPrefix, readPrefix)
70 | }
71 | }
72 | }
73 | }
74 |
75 | private func deserialize(
76 | keyValue: MDB_val,
77 | buffer: inout DeserializationBuffer
78 | ) throws -> DeserializationResult {
79 | let prefix = Record.keyPrefix
80 |
81 | let readPrefix = try IndexKeyRecordHash(buffer: &buffer.keyBuffer)
82 | if prefix != readPrefix {
83 | return .prefixMismatch(readPrefix)
84 | }
85 |
86 | let version = try IndexKeyRecordHash(buffer: &buffer.valueBuffer)
87 | if version != Record.fieldsVersion {
88 | // create the new migrated record
89 | let newRecord = try Record(&buffer, version: version)
90 |
91 | // delete the existing record
92 | try transaction.delete(dbi: dbi, key: keyValue)
93 |
94 | // insert the new one
95 | try insert(newRecord)
96 |
97 | return .migrated(newRecord)
98 | }
99 |
100 | let record = try Record(&buffer)
101 |
102 | return .success(record)
103 | }
104 | }
105 |
106 | extension TransactionContext {
107 | public func select(key: some Serializable) throws -> Record? {
108 | let prefix = Record.keyPrefix
109 |
110 | let keyVal = try MDB_val(key, prefix: prefix, using: buffer.keyBuffer)
111 |
112 | guard let valueVal = try transaction.get(dbi: dbi, key: keyVal) else {
113 | return nil
114 | }
115 |
116 | var localBuffer = DeserializationBuffer(key: keyVal, value: valueVal)
117 |
118 | return try deserialize(keyValue: keyVal, buffer: &localBuffer).recordIfMatching
119 | }
120 |
121 | /// Perform a select and copy the resulting data into a new Record.
122 | ///
123 | /// This version is useful if the underlying IndexKeyRecord is not Sendable but you want to transfer it out of a transaction context.
124 | public func selectCopy(key: some Serializable) throws -> sending Record? {
125 | let prefix = Record.keyPrefix
126 | let keyVal = try MDB_val(key, prefix: prefix, using: buffer.keyBuffer)
127 |
128 | guard let valueVal = try transaction.get(dbi: dbi, key: keyVal) else {
129 | return nil
130 | }
131 |
132 | let keyData = keyVal.bufferPointer.copyToByteArray()
133 | let valueData = valueVal.bufferPointer.copyToByteArray()
134 |
135 | return try keyData.withUnsafeBufferPointer { keyBuffer in
136 | try valueData.withUnsafeBufferPointer { valueBuffer in
137 | var localBuffer = DeserializationBuffer(
138 | keyBuffer: UnsafeRawBufferPointer(keyBuffer),
139 | valueBuffer: UnsafeRawBufferPointer(valueBuffer)
140 | )
141 |
142 | let readPrefix = try IndexKeyRecordHash(buffer: &localBuffer.keyBuffer)
143 | if prefix != readPrefix {
144 | throw StoreError.recordPrefixMismatch(String(describing: Record.self), prefix, readPrefix)
145 | }
146 |
147 | let version = try IndexKeyRecordHash(buffer: &localBuffer.valueBuffer)
148 | if version != Record.fieldsVersion {
149 | throw StoreError.migrationUnsupported(String(describing: Record.self), Record.fieldsVersion, version)
150 | }
151 |
152 | return try Record(&localBuffer)
153 | }
154 | }
155 | }
156 |
157 | // I think this can be further improved with a copying version. But, that may also be affected by:
158 | // https://github.com/swiftlang/swift/issues/74845
159 | public func select(
160 | query: Query
161 | ) throws -> sending [Record] where Record: Sendable {
162 | let prefix = Record.keyPrefix
163 | let bufferPair = self.buffer
164 |
165 | switch query.last {
166 | case .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual:
167 | let lmdbQuery = try query.buildLMDDBQuery(buffer: bufferPair, prefix: prefix)
168 | let cursor = try Cursor(transaction: transaction, dbi: dbi, query: lmdbQuery)
169 |
170 | var records: [Record] = []
171 |
172 | for pair in cursor {
173 | var localBuffer = DeserializationBuffer(key: pair.0, value: pair.1)
174 |
175 | let result: DeserializationResult = try deserialize(keyValue: pair.0, buffer: &localBuffer)
176 |
177 | switch result {
178 | case let .migrated(record), let .success(record):
179 | records.append(record)
180 | case .prefixMismatch:
181 | return records
182 | }
183 | }
184 |
185 | return records
186 | case .range:
187 | let lmdbQuery = try query.buildLMDDBQuery(buffer: bufferPair, prefix: prefix)
188 | let cursor = try Cursor(transaction: transaction, dbi: dbi, query: lmdbQuery)
189 |
190 | return try cursor.map { pair in
191 | var localBuffer = DeserializationBuffer(key: pair.0, value: pair.1)
192 |
193 | return try deserialize(keyValue: pair.0, buffer: &localBuffer).recordIfMatching
194 | }
195 | case .closedRange:
196 | let lmdbQuery = try query.buildLMDDBQuery(buffer: bufferPair, prefix: prefix)
197 | let cursor = try Cursor(transaction: transaction, dbi: dbi, query: lmdbQuery)
198 |
199 | return try cursor.map { pair in
200 | var localBuffer = DeserializationBuffer(key: pair.0, value: pair.1)
201 |
202 | return try deserialize(keyValue: pair.0, buffer: &localBuffer).recordIfMatching
203 | }
204 | case let .within(values):
205 | return try values.map { value in
206 | let key = Tuple(repeat each query.components, value)
207 |
208 | guard let record: Record = try select(key: key) else {
209 | throw MDBError.recordNotFound
210 | }
211 |
212 | return record
213 | }
214 | }
215 | }
216 | }
217 |
218 | extension TransactionContext {
219 | private func delete(prefix: Int, key: some Serializable) throws {
220 | let keyVal = try MDB_val(key, prefix: prefix, using: buffer.keyBuffer)
221 |
222 | try transaction.delete(dbi: dbi, key: keyVal)
223 | }
224 |
225 | public func delete(_ record: Record) throws {
226 | try delete(recordType: Record.self, key: record.indexKey)
227 | }
228 |
229 | public func delete(recordType: Record.Type, key: Record.IndexKey) throws {
230 | let keyVal = try MDB_val(key, prefix: recordType.keyPrefix, using: buffer.keyBuffer)
231 |
232 | try transaction.delete(dbi: dbi, key: keyVal)
233 | }
234 | }
235 |
--------------------------------------------------------------------------------
/Sources/Empire/Tuple.swift:
--------------------------------------------------------------------------------
1 | import PackedSerialize
2 |
3 | /// A static collection of values.
4 | public struct Tuple {
5 | public let elements: (repeat each Element)
6 |
7 | public init(_ value: repeat each Element) {
8 | self.elements = (repeat each value)
9 | }
10 | }
11 |
12 | extension Tuple: Equatable where repeat each Element: Equatable {
13 | public static func == (lhs: Self, rhs: Self) -> Bool {
14 | for (left, right) in repeat (each lhs.elements, each rhs.elements) {
15 | guard left == right else {
16 | return false
17 | }
18 | }
19 |
20 | return true
21 | }
22 | }
23 |
24 | extension Tuple: Hashable where repeat each Element: Hashable {
25 | public func hash(into hasher: inout Hasher) {
26 | for element in repeat each elements {
27 | element.hash(into: &hasher)
28 | }
29 | }
30 | }
31 |
32 | extension Tuple: Sendable where repeat each Element: Sendable {
33 | }
34 |
35 | extension Tuple: Serializable where repeat each Element: Serializable {
36 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
37 | for element in repeat each elements {
38 | element.serialize(into: &buffer)
39 | }
40 | }
41 |
42 | public var serializedSize: Int {
43 | var length = 0
44 |
45 | for element in repeat each elements {
46 | length += element.serializedSize
47 | }
48 |
49 | return length
50 | }
51 | }
52 |
53 | extension Tuple: Deserializable where repeat each Element: Deserializable {
54 | public init(buffer: inout UnsafeRawBufferPointer) throws {
55 | self.elements = (repeat try (each Element).init(buffer: &buffer))
56 | }
57 | }
58 |
59 | extension Tuple: Comparable where repeat each Element: Comparable {
60 | public static func < (lhs: Tuple, rhs: Tuple) -> Bool {
61 | for (left, right) in repeat (each lhs.elements, each rhs.elements) {
62 | if left > right {
63 | return false
64 | }
65 | }
66 |
67 | // I cannot figure out how to do this more efficiently
68 | return lhs != rhs
69 | }
70 | }
71 |
72 | extension Tuple: IndexKeyComparable where repeat each Element: IndexKeyComparable {
73 |
74 | }
75 |
76 | extension Tuple: CustomStringConvertible where repeat each Element: CustomStringConvertible {
77 | public var description: String {
78 | var strings = [String]()
79 |
80 | for element in repeat each elements {
81 | strings.append(element.description)
82 | }
83 |
84 | return "(" + strings.joined(separator: ", ") + ")"
85 | }
86 | }
87 |
88 | extension Tuple: CloudKitRecordNameRepresentable where repeat each Element: CustomStringConvertible {
89 | public var ckRecordName: String {
90 | var string = ""
91 |
92 | for element in repeat each elements {
93 | string += element.description
94 | }
95 |
96 | return string
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/Empire/UnsafeRawBufferPointer+Extensions.swift:
--------------------------------------------------------------------------------
1 | extension UnsafeRawBufferPointer {
2 | func copyToByteArray() -> [UInt8] {
3 | .init(unsafeUninitializedCapacity: count) { destBuffer, initializedCount in
4 | let data = UnsafeMutableRawBufferPointer(start: destBuffer.baseAddress, count: count)
5 |
6 | data.copyMemory(from: self)
7 |
8 | initializedCount = count
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/EmpireMacros/Array+ReplaceLast.swift:
--------------------------------------------------------------------------------
1 | extension Array {
2 | mutating func replaceLast(_ block: (Element) throws -> Element) rethrows {
3 | let count = self.count
4 |
5 | guard count > 0 else { return }
6 |
7 | let value = self[count - 1]
8 |
9 | self[count - 1] = try block(value)
10 | }
11 |
12 | func replacingLast(_ block: (Element) throws -> Element) rethrows -> Self {
13 | var new = self
14 |
15 | try new.replaceLast(block)
16 |
17 | return new
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/EmpireMacros/CloudKitRecordMacro.swift:
--------------------------------------------------------------------------------
1 | import SwiftSyntax
2 | import SwiftSyntaxBuilder
3 | import SwiftSyntaxMacros
4 |
5 | public struct CloudKitRecordMacro: ExtensionMacro {
6 | public static func expansion(
7 | of node: AttributeSyntax,
8 | attachedTo declaration: some DeclGroupSyntax,
9 | providingExtensionsOf type: some TypeSyntaxProtocol,
10 | conformingTo protocols: [TypeSyntax],
11 | in context: some MacroExpansionContext
12 | ) throws -> [ExtensionDeclSyntax] {
13 | let memberNames = declaration.propertyMemberNames
14 |
15 | let setters = memberNames
16 | .map {
17 | "record[\"\($0)\"] = \($0)"
18 | }
19 | .joined(separator: "\n")
20 |
21 | let initers = memberNames
22 | .map {
23 | "self.\($0) = try ckRecord.getTypedValue(for: \"\($0)\")"
24 | }
25 | .joined(separator: "\n")
26 |
27 |
28 |
29 | let inheritance = InheritanceClauseSyntax {
30 | InheritedTypeListSyntax {
31 | InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "CloudKitRecord"))
32 | }
33 | }
34 |
35 | let ext = ExtensionDeclSyntax(extendedType: type, inheritanceClause: inheritance) {
36 | DeclSyntax(
37 | """
38 | public init(ckRecord: CKRecord) throws {
39 | try ckRecord.validateRecordType(Self.ckRecordType)
40 |
41 | \(raw: initers)
42 | }
43 | """
44 | )
45 |
46 | DeclSyntax(
47 | """
48 | public func ckRecord(with recordId: CKRecord.ID) -> CKRecord {
49 | let record = CKRecord(recordType: Self.ckRecordType, recordID: recordId)
50 |
51 | \(raw: setters)
52 |
53 | return record
54 | }
55 | """
56 | )
57 | }
58 |
59 | return [
60 | ext
61 | ]
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Sources/EmpireMacros/IndexKeyRecordMacro.swift:
--------------------------------------------------------------------------------
1 | import SwiftCompilerPlugin
2 | import SwiftSyntax
3 | import SwiftSyntaxBuilder
4 | import SwiftSyntaxMacros
5 |
6 | enum IndexKeyRecordMacroError: Error, CustomStringConvertible {
7 | case invalidType
8 | case invalidArguments
9 | case recordValidationFailure
10 | case missingTypes
11 |
12 | var description: String {
13 | switch self {
14 | case .invalidType:
15 | return "Record macro can only be attached to a struct"
16 | case .recordValidationFailure:
17 | return "Record fieldsVersion validation failed"
18 | case .invalidArguments:
19 | return "Record macro requires static string arguments"
20 | case .missingTypes:
21 | return "Mssing type annotations"
22 | }
23 | }
24 | }
25 |
26 | public enum RecordVersion {
27 | case automatic
28 | case custom(key: UInt32, fields: UInt32)
29 | case customFields(UInt32)
30 | case customKey(UInt32)
31 | case validated(UInt32)
32 | }
33 |
34 | public struct IndexKeyRecordMacro: ExtensionMacro {
35 | public static func expansion(
36 | of node: AttributeSyntax,
37 | attachedTo declaration: some DeclGroupSyntax,
38 | providingExtensionsOf type: some TypeSyntaxProtocol,
39 | conformingTo protocols: [TypeSyntax],
40 | in context: some MacroExpansionContext
41 | ) throws -> [ExtensionDeclSyntax] {
42 | let keyMemberNames = try keyMemberNames(node: node)
43 | let version = try recordValidation(node: node)
44 | let args = try RecordMacroArguments(type: type, declaration: declaration, keyMemberNames: keyMemberNames)
45 |
46 | return [
47 | try extensionDecl(argument: args, version: version),
48 | try dataManipulationExtensionDecl(argument: args),
49 | ]
50 | }
51 |
52 | private static func decodeInteger(_ element: LabeledExprListSyntax.Element) -> UInt32? {
53 | let prefixOp = element
54 | .expression
55 | .as(PrefixOperatorExprSyntax.self)
56 |
57 | let intExp = prefixOp?.expression ?? element.expression
58 |
59 | let value = intExp
60 | .as(IntegerLiteralExprSyntax.self)
61 | .flatMap {
62 | UInt32($0.literal.text)
63 | }
64 |
65 | return value
66 | }
67 |
68 | private static func recordValidation(node: AttributeSyntax) throws -> RecordVersion {
69 | guard
70 | case let .argumentList(arguments) = node.arguments,
71 | arguments.isEmpty == false
72 | else {
73 | throw IndexKeyRecordMacroError.invalidArguments
74 | }
75 |
76 | var validatedValue: UInt32?
77 | var keyPrefixValue: UInt32?
78 | var fieldsVersionValue: UInt32?
79 |
80 | for argument in arguments {
81 | switch argument.label?.text {
82 | case "validated":
83 | validatedValue = decodeInteger(argument)
84 | case "keyPrefix":
85 | keyPrefixValue = decodeInteger(argument)
86 | case "fieldsVersion":
87 | fieldsVersionValue = decodeInteger(argument)
88 | default:
89 | break
90 | }
91 | }
92 |
93 | switch (validatedValue, keyPrefixValue, fieldsVersionValue) {
94 | case let (nil, b?, c?):
95 | return RecordVersion.custom(key: b, fields: c)
96 | case let (a?, nil, nil):
97 | return RecordVersion.validated(a)
98 | case let (nil, nil, c?):
99 | return RecordVersion.customFields(c)
100 | case let (nil, b?, nil):
101 | return RecordVersion.customKey(b)
102 | case (nil, nil, nil):
103 | return .automatic
104 | default:
105 | throw IndexKeyRecordMacroError.invalidArguments
106 | }
107 | }
108 |
109 | private static func keyMemberNames(node: AttributeSyntax) throws -> [String] {
110 | guard
111 | case let .argumentList(arguments) = node.arguments,
112 | arguments.isEmpty == false
113 | else {
114 | throw IndexKeyRecordMacroError.invalidArguments
115 | }
116 |
117 | return arguments
118 | .compactMap { $0.expression.as(StringLiteralExprSyntax.self) }
119 | .compactMap {
120 | switch $0.segments.first {
121 | case let .stringSegment(segment):
122 | return segment
123 | default:
124 | return nil
125 | }
126 | }
127 | .map { $0.content.text }
128 | }
129 | }
130 |
131 | extension IndexKeyRecordMacro {
132 | private static func keyPrefixAccessor(
133 | argument: RecordMacroArguments,
134 | version: RecordVersion
135 | ) throws -> VariableDeclSyntax {
136 | let output = argument.type.trimmedDescription
137 |
138 | let schemaHash: UInt32
139 |
140 | switch version {
141 | case .automatic, .validated, .customFields:
142 | schemaHash = output.sdbmHashValue
143 | case let .custom(key: value, fields: _):
144 | schemaHash = value
145 | case let .customKey(value):
146 | schemaHash = value
147 | }
148 |
149 | let literal = IntegerLiteralExprSyntax(Int(schemaHash))
150 |
151 | return try VariableDeclSyntax(
152 | """
153 | /// Input: "\(raw: output)"
154 | public static var keyPrefix: IndexKeyRecordHash { \(literal) }
155 | """
156 | )
157 | }
158 |
159 | /// Have to preserve types and order
160 | private static func fieldsVersionAccessor(
161 | argument: RecordMacroArguments,
162 | version: RecordVersion
163 | ) throws -> VariableDeclSyntax {
164 | let output = argument.fieldMemberTypeNames.joined(separator: ",")
165 |
166 | let schemaHash: UInt32
167 |
168 | switch version {
169 | case .automatic, .customKey:
170 | schemaHash = output.sdbmHashValue
171 | case let .custom(key: _, fields: value):
172 | schemaHash = value
173 | case let .customFields(value):
174 | schemaHash = value
175 | case let .validated(value):
176 | schemaHash = output.sdbmHashValue
177 |
178 | if value != schemaHash {
179 | throw IndexKeyRecordMacroError.recordValidationFailure
180 | }
181 | }
182 |
183 | let literal = IntegerLiteralExprSyntax(Int(schemaHash))
184 |
185 | return try VariableDeclSyntax(
186 | """
187 | /// Input: "\(raw: output)"
188 | public static var fieldsVersion: IndexKeyRecordHash { \(literal) }
189 | """
190 | )
191 | }
192 |
193 | private static func extensionDecl(
194 | argument: RecordMacroArguments,
195 | version: RecordVersion
196 | ) throws -> ExtensionDeclSyntax {
197 | let keyTupleArguments = argument.keyMemberNames
198 | .joined(separator: ", ")
199 | let fieldsTupleArguments = argument.fieldMemberNames.isEmpty ? "EmptyValue()" : argument.fieldMemberNames
200 | .joined(separator: ", ")
201 |
202 | let keySerialize = argument.keyMemberNames
203 | .map { "\($0).serialize(into: &buffer.keyBuffer)" }
204 | .joined(separator: "\n")
205 |
206 | let fieldsSerialize = argument.fieldMemberNames
207 | .map { "\($0).serialize(into: &buffer.valueBuffer)" }
208 | .joined(separator: "\n")
209 |
210 | let keyInit = argument.primaryKeyTypeNamePairs
211 | .map { "self.\($0) = try \($1)(buffer: &buffer.keyBuffer)" }
212 | .joined(separator: "\n")
213 |
214 | let keyTypes = argument.primaryKeyTypeNamePairs
215 | .map { $1 }
216 | .joined(separator: ", ")
217 | let fieldTypes = argument.fieldMemberTypeNames.isEmpty ? "EmptyValue" : argument.fieldMemberTypeNames.joined(separator: ", ")
218 |
219 | let fieldsInit = argument.fieldTypeNamePairs
220 | .map { "self.\($0) = try \($1)(buffer: &buffer.valueBuffer)" }
221 | .joined(separator: "\n")
222 |
223 | let fullInit = [keyInit, fieldsInit].joined(separator: "")
224 | let fullSerialize = [keySerialize, fieldsSerialize].joined(separator: "")
225 |
226 | let serializeFunction = try FunctionDeclSyntax(
227 | """
228 | public func serialize(into buffer: inout SerializationBuffer) {
229 | \(raw: fullSerialize)
230 | }
231 | """
232 | )
233 |
234 | return try ExtensionDeclSyntax(
235 | """
236 | extension \(argument.type.trimmed): IndexKeyRecord {
237 | public typealias IndexKey = Tuple<\(raw: keyTypes)>
238 | public typealias Fields = Tuple<\(raw: fieldTypes)>
239 |
240 | \(try keyPrefixAccessor(argument: argument, version: version))
241 |
242 | \(try fieldsVersionAccessor(argument: argument, version: version))
243 |
244 | public var indexKey: IndexKey { Tuple(\(raw: keyTupleArguments)) }
245 |
246 | public var fields: Fields { Tuple(\(raw: fieldsTupleArguments)) }
247 |
248 | \(serializeFunction)
249 |
250 | public init(_ buffer: inout DeserializationBuffer) throws {
251 | \(raw: fullInit)
252 | }
253 | }
254 | """
255 | )
256 | }
257 |
258 | private static func dataManipulationExtensionDecl(
259 | argument: RecordMacroArguments
260 | ) throws -> ExtensionDeclSyntax {
261 | let pairs = argument.primaryKeyTypeNamePairs
262 |
263 | var selectFunctions = [FunctionDeclSyntax]()
264 |
265 | for (pair, index) in zip(pairs, pairs.indices) {
266 | let prefix = pairs.prefix(index)
267 | let prefixParams = prefix.map({ "\($0.0): \($0.1)" })
268 |
269 | let comparisonParams = prefixParams + ["\(pair.0): ComparisonOperator<\(pair.1)>"]
270 | let comparisonParamString = comparisonParams.joined(separator: ", ")
271 |
272 | let comparisonArgs = prefix.map({ $0.0 }) + ["last: \(pair.0)"]
273 | let comparisonArgString = comparisonArgs.joined(separator: ", ")
274 |
275 | let comparisonFuncDecl = try FunctionDeclSyntax(
276 | """
277 | public static func select(in context: TransactionContext, limit: Int? = nil, \(raw: comparisonParamString)) throws -> [Self] {
278 | try context.select(query: Query(\(raw: comparisonArgString), limit: limit))
279 | }
280 | """
281 | )
282 |
283 | selectFunctions.append(comparisonFuncDecl)
284 |
285 | let equalityParams = prefixParams + ["\(pair.0): \(pair.1)"]
286 | let equalityParamString = equalityParams.joined(separator: ", ")
287 |
288 | let equalityArgs = prefix.map({ $0.0 }) + ["last: .equals(\(pair.0))"]
289 | let equalityArgString = equalityArgs.joined(separator: ", ")
290 |
291 | let equalityFuncDecl = try FunctionDeclSyntax(
292 | """
293 | public static func select(in context: TransactionContext, limit: Int? = nil, \(raw: equalityParamString)) throws -> [Self] {
294 | try context.select(query: Query(\(raw: equalityArgString), limit: limit))
295 | }
296 | """
297 | )
298 |
299 | selectFunctions.append(equalityFuncDecl)
300 |
301 | }
302 |
303 | let deleteParams = pairs
304 | .map { "\($0.0): \($0.1)" }
305 | .joined(separator: ", ")
306 | let deleteArgs = pairs
307 | .map { $0.0 }
308 | .joined(separator: ", ")
309 |
310 | let deleteFunction = try FunctionDeclSyntax(
311 | """
312 | public static func delete(in context: TransactionContext, \(raw: deleteParams)) throws {
313 | try context.delete(recordType: Self.self, key: Tuple(\(raw: deleteArgs)))
314 | }
315 | """
316 | )
317 |
318 | return ExtensionDeclSyntax(
319 | extendedType: argument.type,
320 | memberBlock: MemberBlockSyntax(
321 | members: MemberBlockItemListSyntax(
322 | selectFunctions.map { MemberBlockItemSyntax(decl: $0) } +
323 | [MemberBlockItemSyntax(decl: deleteFunction)]
324 | )
325 | )
326 | )
327 | }
328 | }
329 |
--------------------------------------------------------------------------------
/Sources/EmpireMacros/Plugin.swift:
--------------------------------------------------------------------------------
1 | import SwiftCompilerPlugin
2 | import SwiftSyntaxMacros
3 |
4 | @main
5 | struct Plugin: CompilerPlugin {
6 | let providingMacros: [Macro.Type] = [
7 | IndexKeyRecordMacro.self,
8 | CloudKitRecordMacro.self,
9 | ]
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/Sources/EmpireMacros/RecordMacroArguments.swift:
--------------------------------------------------------------------------------
1 | import SwiftCompilerPlugin
2 | import SwiftSyntax
3 |
4 | extension DeclGroupSyntax {
5 | var propertyMembers: [PatternBindingSyntax] {
6 | memberBlock.members
7 | .compactMap {
8 | $0.decl.as(VariableDeclSyntax.self)
9 | }
10 | .compactMap {
11 | $0.bindings.first
12 | }
13 | }
14 |
15 | var propertyMemberNames: [String] {
16 | propertyMembers.compactMap {
17 | $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
18 | }
19 | }
20 | }
21 |
22 | struct RecordMacroArguments {
23 | let type: Type
24 | let declaration: Declaration
25 | let members: [PatternBindingSyntax]
26 | let keyMemberNames: [String]
27 |
28 | init(type: Type, declaration: Declaration, keyMemberNames: [String]) throws {
29 | self.type = type
30 | self.declaration = declaration
31 | self.keyMemberNames = keyMemberNames
32 |
33 | self.members = try Self.members(of: declaration)
34 |
35 | if keyMemberTypeNames.contains("") || keyMemberTypeNames.isEmpty || fieldMemberTypeNames.contains("") {
36 | throw IndexKeyRecordMacroError.missingTypes
37 | }
38 | }
39 |
40 | private var memberNames: [String] {
41 | members.compactMap {
42 | $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text
43 | }
44 | }
45 |
46 | var primaryKeyTypeNamePairs: [(String, String)] {
47 | members.compactMap { member in
48 | guard let name = member.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
49 | return nil
50 | }
51 |
52 | if keyMemberNames.contains(name) == false {
53 | return nil
54 | }
55 |
56 | guard let typeName = member.typeAnnotation?.type.description else {
57 | return nil
58 | }
59 |
60 | return (name, typeName)
61 | }
62 | }
63 |
64 | var fieldTypeNamePairs: [(String, String)] {
65 | members.compactMap { member in
66 | guard let name = member.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
67 | return nil
68 | }
69 |
70 | if keyMemberNames.contains(name) {
71 | return nil
72 | }
73 |
74 | guard let typeName = member.typeAnnotation?.type.description else {
75 | return nil
76 | }
77 |
78 | return (name, typeName)
79 | }
80 | }
81 |
82 | var primaryKeyMembers: [PatternBindingSyntax] {
83 | members.filter { member in
84 | guard let name = member.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
85 | return false
86 | }
87 |
88 | return keyMemberNames.contains(name)
89 | }
90 | }
91 |
92 | var fieldMembers: [PatternBindingSyntax] {
93 | members.filter { member in
94 | guard let name = member.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
95 | return false
96 | }
97 |
98 | return keyMemberNames.contains(name) == false
99 | }
100 | }
101 |
102 | var keyMemberTypeNames: [String] {
103 | primaryKeyMembers.compactMap { $0.typeAnnotation?.type.description }
104 | }
105 |
106 | var fieldMemberTypeNames: [String] {
107 | return fieldMembers.compactMap { $0.typeAnnotation?.type.description }
108 | }
109 |
110 | var fieldMemberNames: [String] {
111 | memberNames.filter { name in
112 | return keyMemberNames.contains(name) == false
113 | }
114 | }
115 |
116 | private static func members(of declaration: some DeclGroupSyntax) throws -> [PatternBindingSyntax] {
117 | guard
118 | let decl = declaration.as(StructDeclSyntax.self)
119 | else {
120 | throw IndexKeyRecordMacroError.invalidType
121 | }
122 |
123 | return decl.memberBlock.members
124 | .compactMap {
125 | $0.decl.as(VariableDeclSyntax.self)
126 | }
127 | .filter { varDecl in
128 | varDecl.modifiers.contains(where: { $0.name.text == "static" }) == false
129 | }
130 | .compactMap {
131 | $0.bindings.first
132 | }
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/Sources/EmpireMacros/String+hash.swift:
--------------------------------------------------------------------------------
1 | extension String {
2 | /// sdbm hash of the string contents
3 | ///
4 | /// As defined by http://www.cse.yorku.ca/~oz/hash.html.
5 | var sdbmHashValue: UInt32 {
6 | var hash: UInt32 = 0
7 |
8 | for scalar in unicodeScalars {
9 | let c = UInt32(scalar.value)
10 |
11 | // opt into overflow with ampersand-prefixed operators
12 | hash = c &+ (hash << 6) &+ (hash << 16) &- hash
13 | }
14 |
15 | return hash
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/EmpireSwiftData/DataStoreAdapter.swift:
--------------------------------------------------------------------------------
1 | #if canImport(SwiftData)
2 | import Foundation
3 | import SwiftData
4 |
5 | import Empire
6 |
7 | @IndexKeyRecord("key")
8 | struct KeyValueRecord {
9 | let key: UUID
10 | let value: Data
11 | }
12 |
13 | @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
14 | final package class DataStoreAdapter {
15 | package let configuration: Configuration
16 | private let store: Store
17 |
18 | package init(_ configuration: Configuration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
19 | self.configuration = configuration
20 | self.store = try Store(url: configuration.url)
21 | }
22 | }
23 |
24 | @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
25 | extension DataStoreAdapter {
26 | package struct Configuration: DataStoreConfiguration {
27 | package typealias Store = DataStoreAdapter
28 |
29 | package var name: String
30 | package var schema: Schema?
31 | package var url: URL
32 |
33 | package init(name: String, schema: Schema? = nil, url: URL) {
34 | self.name = name
35 | self.schema = schema
36 | self.url = url
37 | }
38 |
39 | package func validate() throws {
40 | }
41 | }
42 | }
43 |
44 | @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
45 | extension DataStoreAdapter: DataStore {
46 | package typealias Snapshot = DefaultSnapshot
47 |
48 | package func fetch(_ request: DataStoreFetchRequest) throws -> DataStoreFetchResult where T: PersistentModel {
49 | throw DataStoreError.unsupportedFeature
50 | }
51 |
52 | package func save(_ request: DataStoreSaveChangesRequest) throws -> DataStoreSaveChangesResult {
53 | for insert in request.inserted {
54 | let entityName = insert.persistentIdentifier.entityName
55 | let _ = schema.entitiesByName[entityName]
56 | }
57 |
58 | return DataStoreSaveChangesResult(for: identifier)
59 | }
60 |
61 | package var identifier: String {
62 | "hello"
63 | }
64 |
65 | package var schema: Schema {
66 | configuration.schema!
67 | }
68 | }
69 |
70 | #endif
71 |
--------------------------------------------------------------------------------
/Sources/LMDB/CLMDB+Extensions.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 |
3 | extension MDB_val {
4 | public init(buffer: UnsafeMutableRawBufferPointer) {
5 | self.init(mv_size: buffer.count, mv_data: buffer.baseAddress)
6 | }
7 |
8 | public init(buffer: UnsafeRawBufferPointer) {
9 | let unsafePtr = UnsafeMutableRawPointer(mutating: buffer.baseAddress)
10 |
11 | self.init(mv_size: buffer.count, mv_data: unsafePtr)
12 | }
13 |
14 | public var bufferPointer: UnsafeRawBufferPointer {
15 | UnsafeRawBufferPointer(start: mv_data, count: mv_size)
16 | }
17 |
18 | func truncated(to size: Int) -> MDB_val? {
19 | if mv_size < size {
20 | return nil
21 | }
22 |
23 | return MDB_val(mv_size: size, mv_data: mv_data)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/LMDB/Cursor.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 |
3 | public enum ComparisonOperator {
4 | case greater(MDB_val?)
5 | case greaterOrEqual(MDB_val?)
6 | case less(MDB_val?)
7 | case lessOrEqual(MDB_val?)
8 | case range(MDB_val)
9 | case closedRange(MDB_val)
10 |
11 | public var forward: Bool {
12 | switch self {
13 | case .greater, .greaterOrEqual, .range, .closedRange:
14 | true
15 | case .less, .lessOrEqual:
16 | false
17 | }
18 | }
19 |
20 | public var startInclusive: Bool {
21 | switch self {
22 | case .range, .closedRange, .greaterOrEqual, .lessOrEqual:
23 | true
24 | default:
25 | false
26 | }
27 | }
28 |
29 | public var endInclusive: Bool {
30 | switch self {
31 | case .closedRange, .greaterOrEqual, .lessOrEqual, .greater, .less:
32 | true
33 | default:
34 | false
35 | }
36 | }
37 |
38 | public var endKey: MDB_val? {
39 | switch self {
40 | case let .greater(value):
41 | value
42 | case let .greaterOrEqual(value):
43 | value
44 | case let .less(value):
45 | value
46 | case let .lessOrEqual(value):
47 | value
48 | case let .range(value):
49 | value
50 | case let .closedRange(value):
51 | value
52 | }
53 | }
54 | }
55 |
56 | public struct Query {
57 | public let comparison: ComparisonOperator
58 | public let key: MDB_val
59 | public let limit: Int?
60 | public let truncating: Bool
61 |
62 | public init(comparison: ComparisonOperator, key: MDB_val, limit: Int? = nil, truncating: Bool = false) {
63 | self.key = key
64 | self.comparison = comparison
65 | self.limit = limit
66 | self.truncating = truncating
67 | }
68 | }
69 |
70 | public struct Cursor: Sequence, IteratorProtocol {
71 | public typealias Element = (MDB_val, MDB_val)
72 |
73 | private enum State: Hashable {
74 | case running(Int)
75 | case completed
76 | }
77 |
78 | private let cursorPtr: OpaquePointer
79 | private let dbi: MDB_dbi
80 | private var state = State.running(0)
81 |
82 | let query: Query
83 |
84 | public init(transaction: Transaction, dbi: MDB_dbi, query: Query) throws {
85 | var ptr: OpaquePointer? = nil
86 | try MDBError.check { mdb_cursor_open(transaction.txn, dbi, &ptr) }
87 |
88 | guard let ptr else {
89 | throw MDBError.problem
90 | }
91 |
92 | self.dbi = dbi
93 | self.cursorPtr = ptr
94 | self.query = query
95 | }
96 |
97 | public func close() {
98 | mdb_cursor_close(cursorPtr)
99 | }
100 |
101 | private func get(key: MDB_val, operation: MDB_cursor_op) throws -> (MDB_val, MDB_val)? {
102 | var localKey = key
103 | var value = MDB_val()
104 |
105 | let result = mdb_cursor_get(cursorPtr, &localKey, &value, operation)
106 | switch result {
107 | case 0:
108 | break
109 | case MDB_NOTFOUND:
110 | return nil
111 | default:
112 | throw MDBError(result)
113 | }
114 |
115 | return (localKey, value)
116 | }
117 |
118 | private func get() throws -> (MDB_val, MDB_val)? {
119 | let comparisonOp = query.comparison
120 | let op = comparisonOp.forward ? MDB_NEXT : MDB_PREV
121 |
122 | return try get(key: query.key, operation: op)
123 | }
124 |
125 | private func compare(keyA: MDB_val, keyB: MDB_val) -> Int {
126 | var localKeyA = keyA
127 | var localKeyB = keyB
128 | let txn = mdb_cursor_txn(cursorPtr)
129 |
130 | return Int(mdb_cmp(txn, dbi, &localKeyA, &localKeyB))
131 | }
132 |
133 | /// Compare a key using the set comparision operator.
134 | ///
135 | /// (included, keepGoing)
136 | private func check(key: MDB_val) -> (Bool, Bool)? {
137 | guard let effectiveKey = query.truncating ? key.truncated(to: query.key.mv_size) : key else {
138 | return (false, true)
139 | }
140 |
141 | let comparison = compare(keyA: effectiveKey, keyB: query.key)
142 |
143 | let startInclusive = query.comparison.startInclusive
144 | let endInclusive = query.comparison.endInclusive
145 | let forward = query.comparison.forward
146 | if comparison < 0 && forward == true {
147 | return (false, true)
148 | }
149 |
150 | if comparison > 0 && forward == false {
151 | return (false, true)
152 | }
153 |
154 | if comparison == 0 && startInclusive == false {
155 | return (false, true)
156 | }
157 |
158 | guard let endKey = query.comparison.endKey else {
159 | return (true, true)
160 | }
161 |
162 | guard let effectiveEndKey = query.truncating ? key.truncated(to: endKey.mv_size) : key else {
163 | return nil
164 | }
165 |
166 | let endComparison = compare(keyA: effectiveEndKey, keyB: endKey)
167 |
168 | if endComparison == 0 && endInclusive == false {
169 | return (false, false)
170 | }
171 |
172 | // a < b
173 | if endComparison < 0 && forward == false {
174 | return (false, true)
175 | }
176 |
177 | // a > b
178 | if endComparison > 0 && forward == true {
179 | return (false, true)
180 | }
181 |
182 | return (true, true)
183 | }
184 |
185 | public mutating func next() -> Element? {
186 | // are we still executing?
187 | guard case let .running(count) = state else {
188 | return nil
189 | }
190 |
191 | // have we hit our limit?
192 | if let limit = query.limit, count >= limit {
193 | self.state = .completed
194 | return nil
195 | }
196 |
197 | // do we have a valid next pair?
198 | guard let pair = try? get() else {
199 | self.state = .completed
200 | return nil
201 | }
202 |
203 | // is this value in our results?
204 | guard let (included, keepGoing) = check(key: pair.0) else {
205 | self.state = .completed
206 | return nil
207 | }
208 |
209 | switch (included, keepGoing) {
210 | case (true, true):
211 | self.state = .running(count + 1)
212 |
213 | return pair
214 | case (true, false):
215 | self.state = .completed
216 |
217 | return pair
218 | case (false, true):
219 | return next()
220 | case (false, false):
221 | self.state = .completed
222 |
223 | return nil
224 | }
225 | }
226 | }
227 |
--------------------------------------------------------------------------------
/Sources/LMDB/Environment.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 |
3 | public enum MDBError: Error, Hashable {
4 | case problem
5 | case recordNotFound
6 | case permissionDenied
7 | case failure(Int, String)
8 |
9 | init(_ result: Int32) {
10 | let string = String(cString: mdb_strerror(result))
11 |
12 | self = MDBError.failure(Int(result), string)
13 | }
14 |
15 | static func check(_ operation: () throws -> Int32) throws {
16 | let result = try operation()
17 |
18 | switch result {
19 | case 0:
20 | return
21 | case MDB_NOTFOUND:
22 | throw MDBError.recordNotFound
23 | case EACCES:
24 | throw MDBError.permissionDenied
25 | default:
26 | throw MDBError(result)
27 | }
28 | }
29 | }
30 |
31 | struct SendableOpaquePointer: @unchecked Sendable {
32 | let pointer: OpaquePointer
33 | }
34 |
35 | public final class Environment: Sendable {
36 | let internalEnv: SendableOpaquePointer
37 |
38 | public init() throws {
39 | var ptr: OpaquePointer? = nil
40 |
41 | try MDBError.check { mdb_env_create(&ptr) }
42 |
43 | guard let ptr else { throw MDBError.problem }
44 |
45 | self.internalEnv = SendableOpaquePointer(pointer: ptr)
46 | }
47 |
48 | public convenience init(path: String, maxDatabases: Int? = nil, locking: Bool = true) throws {
49 | try self.init()
50 |
51 | if let max = maxDatabases {
52 | try setMaxDatabases(max)
53 | }
54 |
55 | try open(path: path, locking: locking)
56 | }
57 |
58 | deinit {
59 | mdb_env_close(internalEnv.pointer)
60 | }
61 |
62 | public func setMaxDatabases(_ value: Int) throws {
63 | try MDBError.check { mdb_env_set_maxdbs(internalEnv.pointer, UInt32(value)) }
64 | }
65 |
66 | private func open(path: String, locking: Bool) throws {
67 | let envFlags = UInt32(MDB_NOTLS) | (locking ? 0 : UInt32(MDB_NOLOCK))
68 | let envMode: mdb_mode_t = S_IRUSR | S_IWUSR
69 |
70 | try path.withCString { pathStr in
71 | try MDBError.check { mdb_env_open(internalEnv.pointer, pathStr, envFlags, envMode) }
72 | }
73 | }
74 |
75 | public var path: String {
76 | var str: UnsafePointer? = nil
77 |
78 | guard
79 | mdb_env_get_path(internalEnv.pointer, &str) == 0,
80 | let str
81 | else {
82 | return ""
83 | }
84 |
85 | return String(cString: str)
86 | }
87 |
88 | public var maximumKeySize: Int {
89 | Int(mdb_env_get_maxkeysize(internalEnv.pointer))
90 | }
91 | }
92 |
93 | #if canImport(Foundation)
94 | import Foundation
95 |
96 | extension Environment {
97 | public convenience init(url: URL, maxDatabases: Int? = nil, locking: Bool = true) throws {
98 | try self.init(path: url.path, maxDatabases: maxDatabases, locking: locking)
99 | }
100 | }
101 | #endif
102 |
103 |
--------------------------------------------------------------------------------
/Sources/LMDB/Transaction.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 |
3 | public enum KeyComparisonResult {
4 | case descending
5 | case equal
6 | case ascending
7 | }
8 |
9 | public struct Transaction {
10 | var txn: OpaquePointer?
11 | private let env: Environment
12 |
13 | public init(env: Environment, readOnly: Bool = false) throws {
14 | self.env = env
15 |
16 | let flags: UInt32 = readOnly ? UInt32(MDB_RDONLY) : 0
17 |
18 | try MDBError.check { mdb_txn_begin(env.internalEnv.pointer, nil, flags, &txn) }
19 |
20 | guard txn != nil else { throw MDBError.problem }
21 | }
22 |
23 | public static func with(
24 | env: Environment,
25 | readOnly: Bool = false,
26 | block: (inout Transaction) throws -> sending T
27 | ) throws -> sending T {
28 | var transaction = try Transaction(env: env, readOnly: readOnly)
29 |
30 | do {
31 | let value = try block(&transaction)
32 |
33 | try Task.checkCancellation()
34 | try transaction.commit()
35 |
36 | return value
37 | } catch {
38 | transaction.abort()
39 |
40 | throw error
41 | }
42 | }
43 |
44 | public func commit() throws {
45 | try MDBError.check { mdb_txn_commit(txn) }
46 | }
47 |
48 | public func abort() {
49 | mdb_txn_abort(txn)
50 | }
51 |
52 | public func open(name: String) throws -> MDB_dbi {
53 | let dbFlags = UInt32(MDB_CREATE)
54 | var dbi: MDB_dbi = 0
55 |
56 | try name.withCString { nameStr in
57 | try MDBError.check { mdb_dbi_open(txn, nameStr, dbFlags, &dbi) }
58 | }
59 |
60 | // here's where a comparator should be used...
61 | // try MDBError.check { mdb_set_compare(txn, dbi, comparator) }
62 |
63 | return dbi
64 | }
65 | }
66 |
67 | extension Transaction {
68 | public func get(dbi: MDB_dbi, key: MDB_val) throws -> MDB_val? {
69 | var localKey = key
70 | var localVal = MDB_val()
71 |
72 | let result = mdb_get(txn, dbi, &localKey, &localVal)
73 | switch result {
74 | case 0:
75 | break
76 | case MDB_NOTFOUND:
77 | return nil
78 | default:
79 | throw MDBError(result)
80 | }
81 |
82 | return localVal
83 | }
84 |
85 | public func get(dbi: MDB_dbi, key: String) throws -> MDB_val? {
86 | try key.withMDBVal { keyVal in
87 | try get(dbi: dbi, key: keyVal)
88 | }
89 | }
90 |
91 | public func getString(dbi: MDB_dbi, key: MDB_val) throws -> String? {
92 | guard let localVal = try get(dbi: dbi, key: key) else {
93 | return nil
94 | }
95 |
96 | guard let string = String(mdbVal: localVal) else {
97 | throw MDBError.problem
98 | }
99 |
100 | return string
101 | }
102 |
103 | public func getString(dbi: MDB_dbi, key: String) throws -> String? {
104 | try key.withMDBVal { keyVal in
105 | try getString(dbi: dbi, key: keyVal)
106 | }
107 | }
108 | }
109 |
110 | extension Transaction {
111 | public func set(dbi: MDB_dbi, key: MDB_val, value: MDB_val) throws {
112 | let flags = UInt32(0)
113 | var localKey = key
114 | var localValue = value
115 |
116 | try MDBError.check { mdb_put(txn, dbi, &localKey, &localValue, flags) }
117 | }
118 |
119 | public func set(dbi: MDB_dbi, keyBuffer: UnsafeRawBufferPointer, valueBuffer: UnsafeRawBufferPointer) throws {
120 | let flags = UInt32(0)
121 | var localKey = MDB_val(buffer: keyBuffer)
122 | var localValue = MDB_val(buffer: valueBuffer)
123 |
124 | try MDBError.check { mdb_put(txn, dbi, &localKey, &localValue, flags) }
125 | }
126 |
127 | public func set(dbi: MDB_dbi, key: String, value: MDB_val) throws {
128 | try key.withMDBVal { keyVal in
129 | try set(dbi: dbi, key: keyVal, value: value)
130 | }
131 | }
132 |
133 | public func set(dbi: MDB_dbi, key: String, value: String) throws {
134 | try value.withMDBVal { valueVal in
135 | try set(dbi: dbi, key: key, value: valueVal)
136 | }
137 | }
138 | }
139 |
140 | extension Transaction {
141 | public func delete(dbi: MDB_dbi, key: MDB_val) throws {
142 | var localKey = key
143 | var localVal = MDB_val()
144 |
145 | try MDBError.check { mdb_del(txn, dbi, &localKey, &localVal) }
146 | }
147 |
148 | public func delete(dbi: MDB_dbi, key: String) throws {
149 | try key.withMDBVal { keyVal in
150 | try delete(dbi: dbi, key: keyVal)
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/Sources/LMDB/Types+Values.swift:
--------------------------------------------------------------------------------
1 | import CLMDB
2 |
3 | extension String {
4 | public init?(mdbVal: MDB_val) {
5 | let ptr = mdbVal.mv_data.assumingMemoryBound(to: UInt8.self)
6 | let buffer = UnsafeBufferPointer(start: ptr, count: mdbVal.mv_size)
7 |
8 | self.init(bytes: buffer, encoding: String.Encoding.utf8)
9 | }
10 |
11 | public func withMDBVal(_ block: (MDB_val) throws -> T) rethrows -> T {
12 | try withCString { cStr in
13 | let unsafePtr = UnsafeMutableRawPointer(mutating: cStr)
14 | let value = MDB_val(mv_size: utf8.count, mv_data: unsafePtr)
15 |
16 | return try block(value)
17 | }
18 | }
19 | }
20 |
21 | #if canImport(Foundation)
22 | import Foundation
23 |
24 | extension Data {
25 | public init(mdbVal: MDB_val) {
26 | self.init(bytes: mdbVal.mv_data, count: mdbVal.mv_size)
27 | }
28 | }
29 |
30 | #endif
31 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/Array+Serialization.swift:
--------------------------------------------------------------------------------
1 | extension Array: Serializable where Element: Serializable {
2 | public var serializedSize: Int {
3 | UInt(count).serializedSize + reduce(0, { $0 + $1.serializedSize })
4 | }
5 |
6 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
7 | UInt(count).serialize(into: &buffer)
8 |
9 | for element in self {
10 | element.serialize(into: &buffer)
11 | }
12 | }
13 | }
14 |
15 | extension Array: Deserializable where Element: Deserializable {
16 | public init(buffer: inout UnsafeRawBufferPointer) throws {
17 | let count = try UInt(buffer: &buffer)
18 |
19 | var array: Self = []
20 |
21 | for _ in 0...size
4 | }
5 |
6 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
7 | withUnsafeBytes(of: self.bigEndian) { ptr in
8 | buffer.copyMemory(from: ptr)
9 | buffer = UnsafeMutableRawBufferPointer(rebasing: buffer[ptr.count...])
10 | }
11 | }
12 |
13 | public init(buffer: inout UnsafeRawBufferPointer) throws {
14 | var value: Self = 0
15 | let size = MemoryLayout.size
16 |
17 | let data = UnsafeRawBufferPointer(start: buffer.baseAddress, count: size)
18 |
19 | withUnsafeMutableBytes(of: &value) { ptr in
20 | ptr.copyMemory(from: data)
21 | }
22 |
23 | buffer = UnsafeRawBufferPointer(rebasing: buffer[size...])
24 |
25 | self.init(bigEndian: value)
26 | }
27 | }
28 |
29 | extension UInt8: Serializable {}
30 | extension UInt8: Deserializable {}
31 | extension UInt32: Serializable {}
32 | extension UInt32: Deserializable {}
33 | extension UInt: Serializable {}
34 | extension UInt: Deserializable {}
35 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/Float+Serialization.swift:
--------------------------------------------------------------------------------
1 | // doesn't work right yet
2 |
3 | //extension Float: Serializable {
4 | // public var serializedSize: Int {
5 | // bitPattern.bitWidth / 8
6 | // }
7 | //
8 | // public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
9 | // withUnsafeBytes(of: self.bitPattern) { ptr in
10 | // buffer.copyMemory(from: ptr)
11 | // buffer = UnsafeMutableRawBufferPointer(rebasing: buffer[ptr.count...])
12 | // }
13 | // }
14 | //}
15 |
16 | //extension Float: Deserializable {
17 | // public init(buffer: inout UnsafeRawBufferPointer) throws {
18 | // var value: UInt32 = 0
19 | //
20 | // let data = UnsafeRawBufferPointer(start: buffer.baseAddress, count: MemoryLayout.size)
21 | //
22 | // withUnsafeMutableBytes(of: &value) { ptr in
23 | // ptr.copyMemory(from: data)
24 | // }
25 | //
26 | // buffer = UnsafeRawBufferPointer(rebasing: buffer[8...])
27 | //
28 | // self.init(bitPattern: value.bigEndian)
29 | // }
30 | //}
31 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/Int+Serialization.swift:
--------------------------------------------------------------------------------
1 | // Shifts the signed value into the unsigned range by adding Int.max. This preserves binary comparable sort ordering.
2 | // Shifted_UInt = Signed + Signed.max + 1
3 | // Signed = Shifted_UInt - Signed.max + 1
4 |
5 | extension Int: Serializable {
6 | public var serializedSize: Int {
7 | MemoryLayout.size
8 | }
9 |
10 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
11 | let shifted: UInt
12 |
13 | if self == Self.min {
14 | UInt(0).serialize(into: &buffer)
15 | return
16 | }
17 |
18 | if self >= 0 {
19 | shifted = UInt(self) + UInt(Int.max) + 1
20 | } else {
21 | shifted = UInt(self + Int.max + 1)
22 | }
23 |
24 | shifted.serialize(into: &buffer)
25 | }
26 | }
27 |
28 | extension Int: Deserializable {
29 | public init(buffer: inout UnsafeRawBufferPointer) throws {
30 | let shifted = try UInt(buffer: &buffer)
31 |
32 | if shifted == 0 {
33 | self = Self.min
34 | return
35 | }
36 |
37 | let max = UInt(Int.max) + 1
38 |
39 | if shifted > max {
40 | self = Int(shifted - max)
41 | } else {
42 | self = Int(max - shifted) * -1
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/Int64+Serialization.swift:
--------------------------------------------------------------------------------
1 | extension Int64: Serializable {
2 | public var serializedSize: Int {
3 | MemoryLayout.size
4 | }
5 |
6 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
7 | let shifted: UInt64
8 |
9 | if self == Self.min {
10 | UInt64(0).serialize(into: &buffer)
11 | return
12 | }
13 |
14 | if self >= 0 {
15 | shifted = UInt64(self) + UInt64(Self.max) + 1
16 | } else {
17 | shifted = UInt64(self + Self.max + 1)
18 | }
19 |
20 | shifted.serialize(into: &buffer)
21 | }
22 | }
23 |
24 | extension Int64: Deserializable {
25 | public init(buffer: inout UnsafeRawBufferPointer) throws {
26 | let shifted = try UInt64(buffer: &buffer)
27 |
28 | if shifted == 0 {
29 | self = Self.min
30 | return
31 | }
32 |
33 | let max = UInt64(Self.max) + 1
34 |
35 | if shifted > max {
36 | self = Int64(shifted - max)
37 | } else {
38 | self = Int64(max - shifted) * -1
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/Optional+Serialization.swift:
--------------------------------------------------------------------------------
1 | extension Optional: Serializable where Wrapped: Serializable {
2 | public var serializedSize: Int {
3 | switch self {
4 | case .none:
5 | return false.serializedSize
6 | case let .some(value):
7 | return true.serializedSize + value.serializedSize
8 | }
9 | }
10 |
11 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
12 | switch self {
13 | case .none:
14 | return false.serialize(into: &buffer)
15 | case let .some(value):
16 | true.serialize(into: &buffer)
17 | value.serialize(into: &buffer)
18 | }
19 | }
20 | }
21 |
22 | extension Optional: Deserializable where Wrapped: Deserializable {
23 | public init(buffer: inout UnsafeRawBufferPointer) throws {
24 | guard try Bool(buffer: &buffer) else {
25 | self = .none
26 | return
27 | }
28 |
29 | let value = try Wrapped(buffer: &buffer)
30 |
31 | self = .some(value)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/RawRepresentable+Serialization.swift:
--------------------------------------------------------------------------------
1 | extension RawRepresentable where RawValue: Serializable {
2 | public var serializedSize: Int { rawValue.serializedSize }
3 |
4 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
5 | rawValue.serialize(into: &buffer)
6 | }
7 | }
8 |
9 | extension RawRepresentable where RawValue: Deserializable {
10 | public init(buffer: inout UnsafeRawBufferPointer) throws {
11 | let value = try RawValue(buffer: &buffer)
12 |
13 | guard let rawRep = Self(rawValue: value) else {
14 | throw DeserializeError.invalidValue
15 | }
16 |
17 | self = rawRep
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/Serializable.swift:
--------------------------------------------------------------------------------
1 | public protocol Serializable {
2 | var serializedSize: Int { get }
3 | func serialize(into buffer: inout UnsafeMutableRawBufferPointer)
4 | }
5 |
6 | public protocol Deserializable {
7 | init(buffer: inout UnsafeRawBufferPointer) throws
8 | }
9 |
10 | enum DeserializeError: Error {
11 | case invalidLength
12 | case invalidValue
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/String+Serialization.swift:
--------------------------------------------------------------------------------
1 | extension String: Serializable {
2 | public var serializedSize: Int {
3 | utf8.count + 1
4 | }
5 |
6 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
7 | let length = serializedSize
8 |
9 | // write the data
10 | withCString { ptr in
11 | let data = UnsafeRawBufferPointer(start: ptr, count: length)
12 | buffer.copyMemory(from: data)
13 | }
14 |
15 | buffer = UnsafeMutableRawBufferPointer(rebasing: buffer[length...])
16 | }
17 | }
18 |
19 | extension String: Deserializable {
20 | public init(buffer: inout UnsafeRawBufferPointer) throws {
21 | let cStringPtr = buffer.assumingMemoryBound(to: CChar.self).baseAddress
22 |
23 | guard let cStringPtr else {
24 | throw DeserializeError.invalidValue
25 | }
26 |
27 | self.init(cString: cStringPtr)
28 |
29 | buffer = UnsafeRawBufferPointer(rebasing: buffer[serializedSize...])
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/PackedSerialize/UUID+Serialization.swift:
--------------------------------------------------------------------------------
1 | #if canImport(Foundation)
2 | import Foundation
3 |
4 | extension UUID: Serializable {
5 | public var serializedSize: Int {
6 | MemoryLayout.size
7 | }
8 |
9 | public func serialize(into buffer: inout UnsafeMutableRawBufferPointer) {
10 | withUnsafeBytes(of: self.uuid) { ptr in
11 | buffer.copyMemory(from: ptr)
12 | buffer = UnsafeMutableRawBufferPointer(rebasing: buffer[ptr.count...])
13 | }
14 | }
15 | }
16 |
17 | extension UUID: Deserializable {
18 | public init(buffer: inout UnsafeRawBufferPointer) throws {
19 | var value: uuid_t = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
20 | let size = MemoryLayout.size
21 |
22 | let data = UnsafeRawBufferPointer(start: buffer.baseAddress, count: size)
23 |
24 | withUnsafeMutableBytes(of: &value) { ptr in
25 | ptr.copyMemory(from: data)
26 | }
27 |
28 | buffer = UnsafeRawBufferPointer(rebasing: buffer[size...])
29 |
30 | self.init(uuid: value)
31 | }
32 | }
33 | #endif
34 |
--------------------------------------------------------------------------------
/Tests/EmpireMacrosTests/CloudKitRecordMacroTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftSyntaxMacrosGenericTestSupport
2 | import SwiftSyntaxMacroExpansion
3 | import Testing
4 |
5 | #if canImport(EmpireMacros) && canImport(CloudKit)
6 | import EmpireMacros
7 |
8 | struct CloudKitRecordMacroTests {
9 | let specs: [String: MacroSpec] = [
10 | "CloudKitRecord": MacroSpec(type: CloudKitRecordMacro.self)
11 | ]
12 |
13 | @Test func testMacro() throws {
14 | assertMacroExpansion(
15 | """
16 | @CloudKitRecord
17 | struct CloudKitTestRecord: Hashable {
18 | let a: String
19 | let b: Int
20 | var c: String
21 | }
22 | """,
23 | expandedSource:
24 | """
25 | struct CloudKitTestRecord: Hashable {
26 | let a: String
27 | let b: Int
28 | var c: String
29 | }
30 |
31 | extension CloudKitTestRecord: CloudKitRecord {
32 | public init(ckRecord: CKRecord) throws {
33 | try ckRecord.validateRecordType(Self.ckRecordType)
34 |
35 | self.a = try ckRecord.getTypedValue(for: "a")
36 | self.b = try ckRecord.getTypedValue(for: "b")
37 | self.c = try ckRecord.getTypedValue(for: "c")
38 | }
39 | public func ckRecord(with recordId: CKRecord.ID) -> CKRecord {
40 | let record = CKRecord(recordType: Self.ckRecordType, recordID: recordId)
41 |
42 | record["a"] = a
43 | record["b"] = b
44 | record["c"] = c
45 |
46 | return record
47 | }
48 | }
49 | """,
50 | macroSpecs: specs,
51 | indentationWidth: .tab,
52 | failureHandler: { Issue.record($0) }
53 | )
54 | }
55 | }
56 | #endif
57 |
--------------------------------------------------------------------------------
/Tests/EmpireMacrosTests/FailureHandler.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 | import SwiftSyntaxMacrosGenericTestSupport
3 |
4 | extension SourceLocation {
5 | init(_ location: TestFailureLocation) {
6 | self.init(
7 | fileID: location.fileID,
8 | filePath: location.filePath,
9 | line: location.line,
10 | column: location.column
11 | )
12 | }
13 | }
14 |
15 | extension Issue {
16 | @discardableResult
17 | static func record(_ failureSpec: TestFailureSpec) -> Issue {
18 | Issue.record(
19 | "\(failureSpec.message)",
20 | sourceLocation: SourceLocation(failureSpec.location)
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Tests/EmpireMacrosTests/IndexKeyRecordMacroTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftSyntaxMacrosGenericTestSupport
2 | import SwiftSyntaxMacroExpansion
3 | import Testing
4 |
5 | #if canImport(EmpireMacros)
6 | import EmpireMacros
7 |
8 | struct IndexKeyRecordMacroTests {
9 | let specs: [String: MacroSpec] = [
10 | "IndexKeyRecord": MacroSpec(type: IndexKeyRecordMacro.self)
11 | ]
12 |
13 | @Test func singleFieldRecord() throws {
14 | assertMacroExpansion(
15 | """
16 | @IndexKeyRecord("key")
17 | struct KeyOnlyRecord {
18 | let key: Int
19 | }
20 | """,
21 | expandedSource:
22 | """
23 | struct KeyOnlyRecord {
24 | let key: Int
25 | }
26 |
27 | extension KeyOnlyRecord: IndexKeyRecord {
28 | public typealias IndexKey = Tuple
29 | public typealias Fields = Tuple
30 |
31 | /// Input: "KeyOnlyRecord"
32 | public static var keyPrefix: IndexKeyRecordHash {
33 | 642254044
34 | }
35 |
36 | /// Input: ""
37 | public static var fieldsVersion: IndexKeyRecordHash {
38 | 0
39 | }
40 |
41 | public var indexKey: IndexKey {
42 | Tuple(key)
43 | }
44 |
45 | public var fields: Fields {
46 | Tuple(EmptyValue())
47 | }
48 |
49 | public func serialize(into buffer: inout SerializationBuffer) {
50 | key.serialize(into: &buffer.keyBuffer)
51 | }
52 |
53 | public init(_ buffer: inout DeserializationBuffer) throws {
54 | self.key = try Int(buffer: &buffer.keyBuffer)
55 | }
56 | }
57 |
58 | extension KeyOnlyRecord {
59 | public static func select(in context: TransactionContext, limit: Int? = nil, key: ComparisonOperator) throws -> [Self] {
60 | try context.select(query: Query(last: key, limit: limit))
61 | }
62 | public static func select(in context: TransactionContext, limit: Int? = nil, key: Int) throws -> [Self] {
63 | try context.select(query: Query(last: .equals(key), limit: limit))
64 | }
65 | public static func delete(in context: TransactionContext, key: Int) throws {
66 | try context.delete(recordType: Self.self, key: Tuple(key))
67 | }
68 | }
69 | """,
70 | macroSpecs: specs,
71 | indentationWidth: .tab,
72 | failureHandler: { Issue.record($0) }
73 | )
74 | }
75 |
76 | @Test func keyAndFieldRecord() throws {
77 | assertMacroExpansion(
78 | """
79 | @IndexKeyRecord("key")
80 | struct KeyFieldRecord {
81 | let key: Int
82 | let value: Int
83 | }
84 | """,
85 | expandedSource:
86 | """
87 | struct KeyFieldRecord {
88 | let key: Int
89 | let value: Int
90 | }
91 |
92 | extension KeyFieldRecord: IndexKeyRecord {
93 | public typealias IndexKey = Tuple
94 | public typealias Fields = Tuple
95 |
96 | /// Input: "KeyFieldRecord"
97 | public static var keyPrefix: IndexKeyRecordHash {
98 | 314461292
99 | }
100 |
101 | /// Input: "Int"
102 | public static var fieldsVersion: IndexKeyRecordHash {
103 | 610305871
104 | }
105 |
106 | public var indexKey: IndexKey {
107 | Tuple(key)
108 | }
109 |
110 | public var fields: Fields {
111 | Tuple(value)
112 | }
113 |
114 | public func serialize(into buffer: inout SerializationBuffer) {
115 | key.serialize(into: &buffer.keyBuffer)
116 | value.serialize(into: &buffer.valueBuffer)
117 | }
118 |
119 | public init(_ buffer: inout DeserializationBuffer) throws {
120 | self.key = try Int(buffer: &buffer.keyBuffer)
121 | self.value = try Int(buffer: &buffer.valueBuffer)
122 | }
123 | }
124 |
125 | extension KeyFieldRecord {
126 | public static func select(in context: TransactionContext, limit: Int? = nil, key: ComparisonOperator) throws -> [Self] {
127 | try context.select(query: Query(last: key, limit: limit))
128 | }
129 | public static func select(in context: TransactionContext, limit: Int? = nil, key: Int) throws -> [Self] {
130 | try context.select(query: Query(last: .equals(key), limit: limit))
131 | }
132 | public static func delete(in context: TransactionContext, key: Int) throws {
133 | try context.delete(recordType: Self.self, key: Tuple(key))
134 | }
135 | }
136 | """,
137 | macroSpecs: specs,
138 | indentationWidth: .tab,
139 | failureHandler: { Issue.record($0) }
140 | )
141 | }
142 |
143 | @Test func keyAndFieldsRecord() throws {
144 | assertMacroExpansion(
145 | """
146 | @IndexKeyRecord("key")
147 | struct KeyFieldsRecord {
148 | let key: Int
149 | let a: Int
150 | let b: String
151 | }
152 | """,
153 | expandedSource:
154 | """
155 | struct KeyFieldsRecord {
156 | let key: Int
157 | let a: Int
158 | let b: String
159 | }
160 |
161 | extension KeyFieldsRecord: IndexKeyRecord {
162 | public typealias IndexKey = Tuple
163 | public typealias Fields = Tuple
164 |
165 | /// Input: "KeyFieldsRecord"
166 | public static var keyPrefix: IndexKeyRecordHash {
167 | 932782057
168 | }
169 |
170 | /// Input: "Int,String"
171 | public static var fieldsVersion: IndexKeyRecordHash {
172 | 3722219886
173 | }
174 |
175 | public var indexKey: IndexKey {
176 | Tuple(key)
177 | }
178 |
179 | public var fields: Fields {
180 | Tuple(a, b)
181 | }
182 |
183 | public func serialize(into buffer: inout SerializationBuffer) {
184 | key.serialize(into: &buffer.keyBuffer)
185 | a.serialize(into: &buffer.valueBuffer)
186 | b.serialize(into: &buffer.valueBuffer)
187 | }
188 |
189 | public init(_ buffer: inout DeserializationBuffer) throws {
190 | self.key = try Int(buffer: &buffer.keyBuffer)
191 | self.a = try Int(buffer: &buffer.valueBuffer)
192 | self.b = try String(buffer: &buffer.valueBuffer)
193 | }
194 | }
195 |
196 | extension KeyFieldsRecord {
197 | public static func select(in context: TransactionContext, limit: Int? = nil, key: ComparisonOperator) throws -> [Self] {
198 | try context.select(query: Query(last: key, limit: limit))
199 | }
200 | public static func select(in context: TransactionContext, limit: Int? = nil, key: Int) throws -> [Self] {
201 | try context.select(query: Query(last: .equals(key), limit: limit))
202 | }
203 | public static func delete(in context: TransactionContext, key: Int) throws {
204 | try context.delete(recordType: Self.self, key: Tuple(key))
205 | }
206 | }
207 | """,
208 | macroSpecs: specs,
209 | indentationWidth: .tab,
210 | failureHandler: { Issue.record($0) }
211 | )
212 | }
213 |
214 | @Test func staticProperties() throws {
215 | assertMacroExpansion(
216 | """
217 | @IndexKeyRecord("key")
218 | struct KeyOnlyRecord {
219 | let key: Int
220 | static let value: Int
221 | }
222 | """,
223 | expandedSource:
224 | """
225 | struct KeyOnlyRecord {
226 | let key: Int
227 | static let value: Int
228 | }
229 |
230 | extension KeyOnlyRecord: IndexKeyRecord {
231 | public typealias IndexKey = Tuple
232 | public typealias Fields = Tuple
233 |
234 | /// Input: "KeyOnlyRecord"
235 | public static var keyPrefix: IndexKeyRecordHash {
236 | 642254044
237 | }
238 |
239 | /// Input: ""
240 | public static var fieldsVersion: IndexKeyRecordHash {
241 | 0
242 | }
243 |
244 | public var indexKey: IndexKey {
245 | Tuple(key)
246 | }
247 |
248 | public var fields: Fields {
249 | Tuple(EmptyValue())
250 | }
251 |
252 | public func serialize(into buffer: inout SerializationBuffer) {
253 | key.serialize(into: &buffer.keyBuffer)
254 | }
255 |
256 | public init(_ buffer: inout DeserializationBuffer) throws {
257 | self.key = try Int(buffer: &buffer.keyBuffer)
258 | }
259 | }
260 |
261 | extension KeyOnlyRecord {
262 | public static func select(in context: TransactionContext, limit: Int? = nil, key: ComparisonOperator) throws -> [Self] {
263 | try context.select(query: Query(last: key, limit: limit))
264 | }
265 | public static func select(in context: TransactionContext, limit: Int? = nil, key: Int) throws -> [Self] {
266 | try context.select(query: Query(last: .equals(key), limit: limit))
267 | }
268 | public static func delete(in context: TransactionContext, key: Int) throws {
269 | try context.delete(recordType: Self.self, key: Tuple(key))
270 | }
271 | }
272 | """,
273 | macroSpecs: specs,
274 | indentationWidth: .tab,
275 | failureHandler: { Issue.record($0) }
276 | )
277 | }
278 |
279 | @Test func compositeKeyRecord() throws {
280 | assertMacroExpansion(
281 | """
282 | @IndexKeyRecord("a", "b", "c")
283 | struct Record {
284 | let a: Int
285 | let b: String
286 | let c: UUID
287 | }
288 | """,
289 | expandedSource:
290 | """
291 | struct Record {
292 | let a: Int
293 | let b: String
294 | let c: UUID
295 | }
296 |
297 | extension Record: IndexKeyRecord {
298 | public typealias IndexKey = Tuple
299 | public typealias Fields = Tuple
300 |
301 | /// Input: "Record"
302 | public static var keyPrefix: IndexKeyRecordHash {
303 | 464924881
304 | }
305 |
306 | /// Input: ""
307 | public static var fieldsVersion: IndexKeyRecordHash {
308 | 0
309 | }
310 |
311 | public var indexKey: IndexKey {
312 | Tuple(a, b, c)
313 | }
314 |
315 | public var fields: Fields {
316 | Tuple(EmptyValue())
317 | }
318 |
319 | public func serialize(into buffer: inout SerializationBuffer) {
320 | a.serialize(into: &buffer.keyBuffer)
321 | b.serialize(into: &buffer.keyBuffer)
322 | c.serialize(into: &buffer.keyBuffer)
323 | }
324 |
325 | public init(_ buffer: inout DeserializationBuffer) throws {
326 | self.a = try Int(buffer: &buffer.keyBuffer)
327 | self.b = try String(buffer: &buffer.keyBuffer)
328 | self.c = try UUID(buffer: &buffer.keyBuffer)
329 | }
330 | }
331 |
332 | extension Record {
333 | public static func select(in context: TransactionContext, limit: Int? = nil, a: ComparisonOperator) throws -> [Self] {
334 | try context.select(query: Query(last: a, limit: limit))
335 | }
336 | public static func select(in context: TransactionContext, limit: Int? = nil, a: Int) throws -> [Self] {
337 | try context.select(query: Query(last: .equals(a), limit: limit))
338 | }
339 | public static func select(in context: TransactionContext, limit: Int? = nil, a: Int, b: ComparisonOperator) throws -> [Self] {
340 | try context.select(query: Query(a, last: b, limit: limit))
341 | }
342 | public static func select(in context: TransactionContext, limit: Int? = nil, a: Int, b: String) throws -> [Self] {
343 | try context.select(query: Query(a, last: .equals(b), limit: limit))
344 | }
345 | public static func select(in context: TransactionContext, limit: Int? = nil, a: Int, b: String, c: ComparisonOperator) throws -> [Self] {
346 | try context.select(query: Query(a, b, last: c, limit: limit))
347 | }
348 | public static func select(in context: TransactionContext, limit: Int? = nil, a: Int, b: String, c: UUID) throws -> [Self] {
349 | try context.select(query: Query(a, b, last: .equals(c), limit: limit))
350 | }
351 | public static func delete(in context: TransactionContext, a: Int, b: String, c: UUID) throws {
352 | try context.delete(recordType: Self.self, key: Tuple(a, b, c))
353 | }
354 | }
355 | """,
356 | macroSpecs: specs,
357 | indentationWidth: .tab,
358 | failureHandler: { Issue.record($0) }
359 | )
360 | }
361 |
362 | }
363 | #endif
364 |
--------------------------------------------------------------------------------
/Tests/EmpireSwiftDataTests/DataStoreAdapterTests.swift:
--------------------------------------------------------------------------------
1 | #if canImport(SwiftData)
2 | import Foundation
3 | import SwiftData
4 | import Testing
5 |
6 | import EmpireSwiftData
7 |
8 | @Model
9 | final class Item {
10 | var name: String
11 |
12 | init(name: String) {
13 | self.name = name
14 | }
15 | }
16 |
17 | @Suite(.serialized)
18 | struct DataStoreAdapterTests {
19 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_test_store", isDirectory: true)
20 |
21 | init() throws {
22 | try? FileManager.default.removeItem(at: Self.storeURL)
23 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
24 | }
25 |
26 | @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
27 | @Test(.disabled("This isn't set up quite right yet")) func insertAndFetchValue() async throws {
28 | let schema = Schema([Item.self])
29 | let config = DataStoreAdapter.Configuration(name: "name", schema: schema, url: Self.storeURL)
30 |
31 | let container = try ModelContainer(for: schema, configurations: [config])
32 | let context = ModelContext(container)
33 |
34 | let item = Item(name: "itemA")
35 |
36 | context.insert(item)
37 |
38 | try context.save()
39 | let id = item.id
40 |
41 | let fetchDescriptor = FetchDescriptor- (
42 | predicate: #Predicate {
43 | $0.persistentModelID == id
44 | }
45 | )
46 |
47 | let fetchedItems = try context.fetch(fetchDescriptor)
48 |
49 | #expect(fetchedItems == [item])
50 | }
51 | }
52 |
53 | #endif
54 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/BackgroundTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord("key")
7 | fileprivate struct BackgroundKeyOnlyRecord: Hashable {
8 | let key: Int
9 | }
10 |
11 | @Suite(.serialized)
12 | struct BackgroundTests {
13 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_background_store", isDirectory: true)
14 |
15 | init() throws {
16 | try? FileManager.default.removeItem(at: Self.storeURL)
17 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
18 | }
19 |
20 | @Test func writeInBackground() async throws {
21 | let database = try LockingDatabase(url: Self.storeURL)
22 |
23 | let store = BackgroundStore(database: database)
24 |
25 | try await withThrowingTaskGroup(of: Void.self) { group in
26 | for i in 0..<50 {
27 | group.addTask {
28 | try await store.withTransaction { ctx in
29 | try BackgroundKeyOnlyRecord(key: i).insert(in: ctx)
30 | }
31 | }
32 | }
33 |
34 | try await group.waitForAll()
35 | }
36 |
37 | let records = try await store.withTransaction { ctx in
38 | try BackgroundKeyOnlyRecord.select(in: ctx, key: .greaterOrEqual(0))
39 | }
40 |
41 | #expect(records.count == 50)
42 | }
43 |
44 | @MainActor
45 | @Test func writeOnMainReadInBackground() async throws {
46 | let database = try LockingDatabase(url: Self.storeURL)
47 |
48 | let store = BackgroundableStore(database: database)
49 |
50 | let readTask = Task {
51 | try await withThrowingTaskGroup(of: Void.self) { group in
52 | for _ in 0..<50 {
53 | group.addTask {
54 | _ = try await store.background.withTransaction { ctx in
55 | try BackgroundKeyOnlyRecord.select(in: ctx, key: .greaterOrEqual(0))
56 | }
57 | }
58 | }
59 |
60 | try await group.waitForAll()
61 | }
62 | }
63 |
64 | try store.main.withTransaction { ctx in
65 | try BackgroundKeyOnlyRecord(key: 42).insert(in: ctx)
66 | }
67 |
68 | try await readTask.value
69 | }
70 |
71 | @Test func transactionCancellation() async throws {
72 | let database = try LockingDatabase(url: Self.storeURL)
73 |
74 | let store = BackgroundStore(database: database)
75 |
76 | let task = Task {
77 | // slow this task down a little so don't finish before cancelling
78 | try await Task.sleep(for: .milliseconds(50))
79 |
80 | try await store.withTransaction { ctx in
81 | for i in 0..<50 {
82 | try BackgroundKeyOnlyRecord(key: i).insert(in: ctx)
83 | }
84 | }
85 | }
86 |
87 | task.cancel()
88 | await #expect(throws: CancellationError.self, performing: {
89 | try await task.value
90 | })
91 |
92 | let records = try await store.withTransaction { ctx in
93 | try BackgroundKeyOnlyRecord.select(in: ctx, key: .greaterOrEqual(0))
94 | }
95 |
96 | #expect(records.count == 0)
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/CloudKitRecordTests.swift:
--------------------------------------------------------------------------------
1 | #if canImport(CloudKit)
2 | import CloudKit
3 | import Testing
4 |
5 | import Empire
6 |
7 | @CloudKitRecord
8 | struct CloudKitTestRecord: Hashable {
9 | let a: String
10 | let b: Int
11 | var c: String
12 | }
13 |
14 | /// Validates that a CloudKitRecord can be public.
15 | @CloudKitRecord
16 | public struct PublicCloudKitTestRecord: Hashable {
17 | let a: String
18 | let b: Int
19 | var c: String
20 | }
21 |
22 | @CloudKitRecord
23 | @IndexKeyRecord("a", "b")
24 | struct IndexKeyCloudKitRecord: Hashable {
25 | let a: String
26 | let b: Int
27 | var c: String
28 | }
29 |
30 | struct CloudKitRecordTests {
31 | @Test func encode() async throws {
32 | let zone = CKRecordZone.ID(zoneName: "zone", ownerName: "owner")
33 | let recordId = CKRecord.ID(recordName: "ABC", zoneID: zone)
34 |
35 | let record = CloudKitTestRecord(a: "foo", b: 1, c: "bar")
36 | let ckRecord = record.ckRecord(with: recordId)
37 |
38 | #expect(ckRecord.recordType == "CloudKitTestRecord")
39 | #expect(ckRecord["a"] == record.a)
40 | #expect(ckRecord["b"] == record.b)
41 | #expect(ckRecord["c"] == record.c)
42 |
43 | let decodedRecord = try CloudKitTestRecord(ckRecord: ckRecord)
44 |
45 | #expect(decodedRecord == record)
46 | }
47 |
48 | @Test func encodeIndexKeyRecord() async throws {
49 | let zone = CKRecordZone.ID(zoneName: "zone", ownerName: "owner")
50 |
51 | let record = IndexKeyCloudKitRecord(a: "foo", b: 1, c: "bar")
52 | let ckRecord = record.ckRecord(in: zone)
53 |
54 | #expect(ckRecord.recordType == "IndexKeyCloudKitRecord")
55 | #expect(ckRecord.recordID.recordName == "foo1")
56 | #expect(ckRecord["a"] == record.a)
57 | #expect(ckRecord["b"] == record.b)
58 | #expect(ckRecord["c"] == record.c)
59 |
60 | let decodedRecord = try IndexKeyCloudKitRecord(ckRecord: ckRecord)
61 |
62 | #expect(decodedRecord == record)
63 | }
64 | }
65 | #endif
66 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/ExampleTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | // define your records with types
7 | @IndexKeyRecord("name")
8 | struct Person {
9 | let name: String
10 | let age: Int
11 | }
12 |
13 | @Suite(.serialized)
14 | struct ExampleTests {
15 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_example_store", isDirectory: true)
16 |
17 | let store: Store
18 |
19 | init() throws {
20 | try? FileManager.default.removeItem(at: Self.storeURL)
21 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
22 |
23 | self.store = try Store(url: Self.storeURL)
24 | }
25 |
26 | @MainActor
27 | @Test func readmeHeroExample() async throws {
28 | // create a local database
29 | let store = try BackgroundableStore(url: Self.storeURL)
30 |
31 | // interact with it using transactions
32 | try store.main.withTransaction { context in
33 | try context.insert(Person(name: "Korben", age: 45))
34 | try context.insert(Person(name: "Leeloo", age: 2000))
35 | }
36 |
37 | // run queries
38 | let records = try store.main.withTransaction { context in
39 | try Person.select(in: context, limit: 1, name: .lessThan("Zorg"))
40 | }
41 |
42 | print(records.first!) // Person(name: "Leeloo", age: 2000)
43 |
44 | // move work to the background
45 | try await store.background.withTransaction { ctx in
46 | try Person.delete(in: ctx, name: "Korben")
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/IndexKeyRecordTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord("a", "b")
7 | struct TestRecord: Hashable {
8 | let a: String
9 | let b: UInt
10 | var c: String
11 | }
12 |
13 | @IndexKeyRecord(keyPrefix: 3787247394, "a", "b")
14 | struct LessThanTestRecord: Hashable {
15 | let a: String
16 | let b: UInt
17 | var c: String
18 | }
19 |
20 | @IndexKeyRecord(keyPrefix: 3787247396, "a", "b")
21 | struct GreaterThanTestRecord: Hashable {
22 | let a: String
23 | let b: UInt
24 | var c: String
25 | }
26 |
27 | @IndexKeyRecord("key")
28 | struct KeyOnlyRecord: Hashable {
29 | let key: UInt
30 | }
31 |
32 | /// Validates that a IndexKeyRecord can be public.
33 | @IndexKeyRecord("key")
34 | public struct PublicModel : Sendable {
35 | let key: Int
36 | }
37 |
38 | @Suite(.serialized)
39 | struct IndexKeyRecordTests {
40 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_test_store", isDirectory: true)
41 |
42 | init() throws {
43 | try? FileManager.default.removeItem(at: Self.storeURL)
44 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
45 | }
46 |
47 | /// If this test fails, many other tests could fail too
48 | @Test func validateTestRecords() {
49 | #expect(TestRecord.keyPrefix < GreaterThanTestRecord.keyPrefix)
50 | #expect(TestRecord.keyPrefix + 1 == GreaterThanTestRecord.keyPrefix)
51 | #expect(TestRecord.fieldsVersion == GreaterThanTestRecord.fieldsVersion)
52 |
53 | #expect(TestRecord.keyPrefix > LessThanTestRecord.keyPrefix)
54 | #expect(TestRecord.keyPrefix - 1 == LessThanTestRecord.keyPrefix)
55 | #expect(TestRecord.fieldsVersion == LessThanTestRecord.fieldsVersion)
56 | }
57 |
58 | @Test func identifiable() throws {
59 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
60 |
61 | #expect(record.id == record.indexKey)
62 | #expect(record.id == Tuple("hello", 42))
63 | }
64 |
65 | @Test func insertAndSelect() throws {
66 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
67 |
68 | let store = try Store(url: Self.storeURL)
69 |
70 | try store.withTransaction { ctx in
71 | try ctx.insert(record)
72 | }
73 |
74 | let output: TestRecord? = try store.withTransaction { ctx in
75 | try ctx.select(key: TestRecord.IndexKey("hello", 42))
76 | }
77 |
78 | #expect(output == record)
79 | }
80 |
81 | @Test func insertAndSelectSingleRecord() throws {
82 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
83 |
84 | let store = try Store(url: Self.storeURL)
85 |
86 | try store.withTransaction { ctx in
87 | try ctx.insert(record)
88 | }
89 |
90 | let records = try store.withTransaction { ctx in
91 | try TestRecord.select(in: ctx, a: "hello", b: 42)
92 | }
93 |
94 | #expect(records == [record])
95 | }
96 |
97 | @Test func insertRecordWithLargeField() throws {
98 | let record = TestRecord(a: "hello", b: 42, c: String(repeating: "z", count: 1024*10))
99 |
100 | let store = try Store(url: Self.storeURL)
101 |
102 | try store.withTransaction { ctx in
103 | try ctx.insert(record)
104 | }
105 |
106 | let output: TestRecord? = try store.withTransaction { ctx in
107 | try ctx.select(key: TestRecord.IndexKey("hello", 42))
108 | }
109 |
110 | #expect(output == record)
111 | }
112 |
113 | @Test func insertAndSelectCopy() throws {
114 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
115 |
116 | let store = try Store(url: Self.storeURL)
117 |
118 | try store.withTransaction { ctx in
119 | try ctx.insert(record)
120 | }
121 |
122 | let output: TestRecord? = try store.withTransaction { ctx in
123 | try ctx.selectCopy(key: TestRecord.IndexKey("hello", 42))
124 | }
125 |
126 | #expect(output == record)
127 | }
128 |
129 | @Test func selectGreater() throws {
130 | let store = try Store(url: Self.storeURL)
131 |
132 | try store.withTransaction { ctx in
133 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
134 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
135 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
136 |
137 | try ctx.insert(TestRecord(a: "hellp", b: 40, c: "a"))
138 | try ctx.insert(GreaterThanTestRecord(a: "hello", b: 41, c: "b"))
139 | }
140 |
141 | let records = try store.withTransaction { ctx in
142 | try TestRecord.select(in: ctx, a: "hello", b: .greaterThan(40))
143 | }
144 |
145 | let expected = [
146 | TestRecord(a: "hello", b: 41, c: "b"),
147 | TestRecord(a: "hello", b: 42, c: "c"),
148 | ]
149 |
150 | #expect(records == expected)
151 | }
152 |
153 | @Test func selectGreaterOrEqual() throws {
154 | let store = try Store(url: Self.storeURL)
155 |
156 | try store.withTransaction { ctx in
157 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
158 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
159 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
160 |
161 | try ctx.insert(TestRecord(a: "hellp", b: 40, c: "a"))
162 | try ctx.insert(GreaterThanTestRecord(a: "hello", b: 41, c: "b"))
163 | }
164 |
165 | let records = try store.withTransaction { ctx in
166 | try TestRecord.select(in: ctx, a: "hello", b: .greaterOrEqual(41))
167 | }
168 |
169 | let expected = [
170 | TestRecord(a: "hello", b: 41, c: "b"),
171 | TestRecord(a: "hello", b: 42, c: "c"),
172 | ]
173 |
174 | #expect(records == expected)
175 | }
176 |
177 | @Test func selectLessThan() throws {
178 | let store = try Store(url: Self.storeURL)
179 |
180 | try store.withTransaction { ctx in
181 | try ctx.insert(TestRecord(a: "helln", b: 40, c: "a"))
182 | try ctx.insert(LessThanTestRecord(a: "hello", b: 41, c: "b"))
183 |
184 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
185 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
186 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
187 | }
188 |
189 | let records = try store.withTransaction { ctx in
190 | try TestRecord.select(in: ctx, a: "hello", b: .lessThan(42))
191 | }
192 |
193 | let expected = [
194 | TestRecord(a: "hello", b: 41, c: "b"),
195 | TestRecord(a: "hello", b: 40, c: "a"),
196 | ]
197 |
198 | #expect(records == expected)
199 | }
200 |
201 | @Test func selectLessThanOrEqual() throws {
202 | let store = try Store(url: Self.storeURL)
203 |
204 | try store.withTransaction { ctx in
205 | try ctx.insert(TestRecord(a: "helln", b: 40, c: "a"))
206 | try ctx.insert(LessThanTestRecord(a: "hello", b: 41, c: "b"))
207 |
208 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
209 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
210 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
211 | }
212 |
213 | let records = try store.withTransaction { ctx in
214 | try TestRecord.select(in: ctx, a: "hello", b: .lessOrEqual(41))
215 | }
216 |
217 | let expected = [
218 | TestRecord(a: "hello", b: 41, c: "b"),
219 | TestRecord(a: "hello", b: 40, c: "a"),
220 | ]
221 |
222 | #expect(records == expected)
223 | }
224 |
225 | @Test func selectRange() throws {
226 | let store = try Store(url: Self.storeURL)
227 |
228 | try store.withTransaction { ctx in
229 | try ctx.insert(LessThanTestRecord(a: "hello", b: 41, c: "b"))
230 |
231 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
232 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
233 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
234 | try ctx.insert(TestRecord(a: "hello", b: 43, c: "d"))
235 |
236 | try ctx.insert(GreaterThanTestRecord(a: "hello", b: 41, c: "b"))
237 | }
238 |
239 | let records = try store.withTransaction { ctx in
240 | try TestRecord.select(in: ctx, a: "hello", b: .range(41..<43))
241 | }
242 |
243 | let expected = [
244 | TestRecord(a: "hello", b: 41, c: "b"),
245 | TestRecord(a: "hello", b: 42, c: "c"),
246 | ]
247 |
248 | #expect(records == expected)
249 | }
250 |
251 | @Test func selectClosedRange() throws {
252 | let store = try Store(url: Self.storeURL)
253 |
254 | try store.withTransaction { ctx in
255 | try ctx.insert(LessThanTestRecord(a: "hello", b: 41, c: "b"))
256 |
257 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
258 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
259 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
260 | try ctx.insert(TestRecord(a: "hello", b: 43, c: "d"))
261 |
262 | try ctx.insert(GreaterThanTestRecord(a: "hello", b: 41, c: "b"))
263 | }
264 |
265 | let records = try store.withTransaction { ctx in
266 | try TestRecord.select(in: ctx, a: "hello", b: .closedRange(41...42))
267 | }
268 |
269 | let expected = [
270 | TestRecord(a: "hello", b: 41, c: "b"),
271 | TestRecord(a: "hello", b: 42, c: "c"),
272 | ]
273 |
274 | #expect(records == expected)
275 | }
276 |
277 | @Test func selectWithin() throws {
278 | let store = try Store(url: Self.storeURL)
279 |
280 | try store.withTransaction { ctx in
281 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
282 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
283 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
284 | try ctx.insert(TestRecord(a: "hello", b: 43, c: "d"))
285 | }
286 |
287 | let records = try store.withTransaction { ctx in
288 | try TestRecord.select(in: ctx, a: "hello", b: .within([41, 43]))
289 | }
290 |
291 | let expected = [
292 | TestRecord(a: "hello", b: 41, c: "b"),
293 | TestRecord(a: "hello", b: 43, c: "d"),
294 | ]
295 |
296 | #expect(records == expected)
297 | }
298 |
299 | @Test func selectEqualsCompositeKey() throws {
300 | let store = try Store(url: Self.storeURL)
301 |
302 | try store.withTransaction { ctx in
303 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
304 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
305 | }
306 |
307 | let records: [TestRecord] = try store.withTransaction { ctx in
308 | try TestRecord.select(in: ctx, a: "hello", b: 40)
309 | }
310 |
311 | let expected = [
312 | TestRecord(a: "hello", b: 40, c: "a"),
313 | ]
314 |
315 | #expect(records == expected)
316 | }
317 | }
318 |
319 | extension IndexKeyRecordTests {
320 | @Test func selectGreaterWithLimit() throws {
321 | let store = try Store(url: Self.storeURL)
322 |
323 | try store.withTransaction { ctx in
324 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
325 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
326 | try ctx.insert(TestRecord(a: "hello", b: 42, c: "c"))
327 | try ctx.insert(GreaterThanTestRecord(a: "hello", b: 41, c: "b"))
328 | }
329 |
330 | let records: [TestRecord] = try store.withTransaction { ctx in
331 | try TestRecord.select(in: ctx, limit: 1, a: "hello", b: .greaterThan(40))
332 | }
333 |
334 | let expected = [
335 | TestRecord(a: "hello", b: 41, c: "b"),
336 | ]
337 |
338 | #expect(records == expected)
339 | }
340 |
341 | @Test func selectGreaterWithDifferentKeyComponent() throws {
342 | let store = try Store(url: Self.storeURL)
343 |
344 | try store.withTransaction { ctx in
345 | try ctx.insert(TestRecord(a: "hello", b: 40, c: "a"))
346 | try ctx.insert(TestRecord(a: "hello", b: 41, c: "b"))
347 |
348 | try ctx.insert(TestRecord(a: "hellp", b: 41, c: "c"))
349 | }
350 |
351 | let records: [TestRecord] = try store.withTransaction { ctx in
352 | try TestRecord.select(in: ctx, a: "hello", b: .greaterThan(40))
353 | }
354 |
355 | let expected = [
356 | TestRecord(a: "hello", b: 41, c: "b"),
357 | ]
358 |
359 | #expect(records == expected)
360 | }
361 | }
362 |
363 | extension IndexKeyRecordTests {
364 | @Test func insertAndSelectViaStore() throws {
365 | let store = try Store(url: Self.storeURL)
366 |
367 | let input = TestRecord(a: "hello", b: 40, c: "a")
368 |
369 | try store.insert(input)
370 | let record: TestRecord? = try store.select(key: input.indexKey)
371 |
372 | #expect(record == input)
373 | }
374 |
375 | @Test func insertAndDeleteViaStore() throws {
376 | let store = try Store(url: Self.storeURL)
377 |
378 | let input = TestRecord(a: "hello", b: 40, c: "a")
379 |
380 | try store.insert(input)
381 | #expect(try store.select(key: input.indexKey) == input)
382 |
383 | try store.delete(input)
384 | #expect(try store.select(key: input.indexKey) == Optional.none)
385 | }
386 | }
387 |
388 | extension IndexKeyRecordTests {
389 | @Test func deleteEntireRecord() throws {
390 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
391 |
392 | let store = try Store(url: Self.storeURL)
393 |
394 | try store.withTransaction { ctx in
395 | try ctx.insert(record)
396 | try ctx.delete(record)
397 | }
398 |
399 | let output = try store.withTransaction { ctx in
400 | try TestRecord.select(in: ctx, a: "hello", b: .equals(42))
401 | }
402 |
403 | #expect(output == [])
404 | }
405 |
406 | @Test func deleteViaKey() throws {
407 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
408 |
409 | let store = try Store(url: Self.storeURL)
410 |
411 | try store.withTransaction { ctx in
412 | try ctx.insert(record)
413 | try TestRecord.delete(in: ctx, a: record.a, b: record.b)
414 | }
415 |
416 | let output = try store.withTransaction { ctx in
417 | try TestRecord.select(in: ctx, a: "hello", b: .equals(42))
418 | }
419 |
420 | #expect(output == [])
421 | }
422 |
423 | @Test func deleteInstance() throws {
424 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
425 |
426 | let store = try Store(url: Self.storeURL)
427 |
428 | try store.withTransaction { ctx in
429 | try ctx.insert(record)
430 | try record.delete(in: ctx)
431 | }
432 |
433 | let output = try store.withTransaction { ctx in
434 | try TestRecord.select(in: ctx, a: "hello", b: .equals(42))
435 | }
436 |
437 | #expect(output == [])
438 | }
439 | }
440 |
441 | extension IndexKeyRecordTests {
442 | @IndexKeyRecord(validated: 3622976456, "key")
443 | struct ValidatedRecord: Sendable {
444 | let key: Int
445 | let a: Int
446 | let b: String
447 | let c: Data
448 | }
449 |
450 | @Test func validatedVersion() {
451 | #expect(ValidatedRecord.fieldsVersion == 3622976456)
452 | }
453 |
454 | @IndexKeyRecord(keyPrefix: 5, fieldsVersion: 10, "key")
455 | struct CustomVersion: Sendable {
456 | let key: Int
457 | }
458 |
459 | @Test func customVersions() {
460 | #expect(CustomVersion.keyPrefix == 5)
461 | #expect(CustomVersion.fieldsVersion == 10)
462 | }
463 | }
464 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/InsertTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord("key")
7 | fileprivate struct DateKeyRecord: Hashable {
8 | let key: Date
9 | }
10 |
11 | @IndexKeyRecord("a", "b")
12 | fileprivate struct CompoundKeyRecord: Hashable {
13 | let a: String
14 | let b: Int
15 | var c: String
16 | }
17 |
18 | @Suite(.serialized)
19 | struct InsertTests {
20 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_insert_store", isDirectory: true)
21 |
22 | let store: Store
23 |
24 | init() throws {
25 | try? FileManager.default.removeItem(at: Self.storeURL)
26 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
27 |
28 | self.store = try Store(url: Self.storeURL)
29 | }
30 |
31 | @Test func insert() throws {
32 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
33 |
34 | let output: TestRecord? = try store.withTransaction { ctx in
35 | try ctx.insert(record)
36 |
37 | return try ctx.select(key: record.indexKey)
38 | }
39 |
40 | #expect(output == record)
41 | }
42 |
43 | @Test func insertWithInstanceMethod() throws {
44 | let record = TestRecord(a: "hello", b: 42, c: "goodbye")
45 |
46 | try record.insert(in: store)
47 |
48 | let output: TestRecord? = try store.select(key: record.indexKey)
49 |
50 | #expect(output == record)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/MigrationTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord(keyPrefix: 642254044, fieldsVersion: 10, "key")
7 | struct MismatchedKeyOnlyRecord: Hashable {
8 | let key: UInt
9 | let value: String
10 | }
11 |
12 | @IndexKeyRecord(keyPrefix: 642254044, fieldsVersion: 20, "key")
13 | struct MigratableKeyOnlyRecord: Hashable {
14 | let key: UInt
15 | let value: String
16 |
17 | static let valuePlaceholder = ""
18 | }
19 |
20 | extension MigratableKeyOnlyRecord {
21 | // this is the code that actually checks for and peforms the migration
22 | init(_ buffer: inout DeserializationBuffer, version: IndexKeyRecordHash) throws {
23 | switch version {
24 | case KeyOnlyRecord.fieldsVersion:
25 | let record = try KeyOnlyRecord(&buffer)
26 | self.key = record.key
27 |
28 | self.value = Self.valuePlaceholder
29 | default:
30 | throw Self.unsupportedMigrationError(for: version)
31 | }
32 | }
33 | }
34 |
35 | @Suite(.serialized)
36 | struct MigrationTests {
37 | static let storeURL = URL(fileURLWithPath: "/tmp/migration_tests_store", isDirectory: true)
38 |
39 | init() throws {
40 | try? FileManager.default.removeItem(at: Self.storeURL)
41 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
42 | }
43 |
44 | @Test func mismatchedFieldsVersion() throws {
45 | #expect(MismatchedKeyOnlyRecord.keyPrefix == KeyOnlyRecord.keyPrefix)
46 |
47 | let mismatchedRecord = MismatchedKeyOnlyRecord(key: 5, value: "hello")
48 |
49 | let store = try Store(url: Self.storeURL)
50 |
51 | try store.withTransaction { ctx in
52 | try ctx.insert(mismatchedRecord)
53 | }
54 |
55 | let output: MismatchedKeyOnlyRecord? = try store.withTransaction { ctx in
56 | try ctx.select(key: MismatchedKeyOnlyRecord.IndexKey(5))
57 | }
58 |
59 | #expect(mismatchedRecord == output)
60 |
61 | #expect(
62 | throws: StoreError.migrationUnsupported("KeyOnlyRecord", KeyOnlyRecord.fieldsVersion, MismatchedKeyOnlyRecord.fieldsVersion)
63 | ) {
64 | let _ = try store.withTransaction { ctx in
65 | try KeyOnlyRecord.select(in: ctx, key: .equals(5))
66 | }
67 | }
68 | }
69 |
70 | @Test func migratableFieldsVersion() throws {
71 | #expect(MigratableKeyOnlyRecord.keyPrefix == KeyOnlyRecord.keyPrefix)
72 |
73 | let record = KeyOnlyRecord(key: 5)
74 |
75 | let store = try Store(url: Self.storeURL)
76 |
77 | try store.withTransaction { ctx in
78 | try ctx.insert(record)
79 | }
80 |
81 | let output: MigratableKeyOnlyRecord? = try store.withTransaction { ctx in
82 | try ctx.select(key: MigratableKeyOnlyRecord.IndexKey(5))
83 | }
84 |
85 | #expect(output?.key == 5)
86 | #expect(output?.value == MigratableKeyOnlyRecord.valuePlaceholder)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/RelationshipTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord("a", "b")
7 | fileprivate struct ParentRecord: Hashable {
8 | let a: String
9 | let b: Int
10 | var c: String
11 |
12 | func children(in context: TransactionContext) throws -> [ChildRecord] {
13 | try ChildRecord.select(in: context, parentKey: self.indexKey)
14 | }
15 | }
16 |
17 | @IndexKeyRecord("parentKey", "key")
18 | fileprivate struct ChildRecord: Hashable {
19 | let parentKey: ParentRecord.IndexKey
20 | let key: Int
21 | var value: String
22 |
23 | func parent(in context: TransactionContext) throws -> ParentRecord? {
24 | try context.select(key: parentKey)
25 | }
26 | }
27 |
28 | @Suite(.serialized)
29 | struct RelationshipTests {
30 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_relationship_store", isDirectory: true)
31 |
32 | let store: Store
33 |
34 | init() throws {
35 | try? FileManager.default.removeItem(at: Self.storeURL)
36 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
37 |
38 | self.store = try Store(url: Self.storeURL)
39 | }
40 |
41 | @Test func insert() throws {
42 | let parent = ParentRecord(a: "hello", b: 42, c: "goodbye")
43 | let child = ChildRecord(parentKey: parent.indexKey, key: 1, value: "hello")
44 |
45 | let records: [ChildRecord] = try store.withTransaction { ctx in
46 | try ctx.insert(parent)
47 | try ctx.insert(child)
48 |
49 | return try ChildRecord.select(in: ctx, parentKey: parent.indexKey)
50 | }
51 |
52 | #expect(records == [child])
53 | }
54 |
55 | @Test func selectParent() throws {
56 | let parent = ParentRecord(a: "hello", b: 42, c: "goodbye")
57 | let child = ChildRecord(parentKey: parent.indexKey, key: 1, value: "hello")
58 |
59 | try store.withTransaction { ctx in
60 | try ctx.insert(parent)
61 | try ctx.insert(child)
62 | }
63 |
64 | let record = try store.withTransaction { ctx in
65 | try child.parent(in: ctx)
66 | }
67 |
68 | #expect(record == parent)
69 | }
70 |
71 | @Test func selectChildren() throws {
72 | let parent = ParentRecord(a: "hello", b: 42, c: "goodbye")
73 | let child = ChildRecord(parentKey: parent.indexKey, key: 1, value: "hello")
74 |
75 | try store.withTransaction { ctx in
76 | try ctx.insert(parent)
77 | try ctx.insert(child)
78 | }
79 |
80 | let records = try store.withTransaction { ctx in
81 | try parent.children(in: ctx)
82 | }
83 |
84 | #expect(records == [child])
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/SelectTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import Empire
5 |
6 | @IndexKeyRecord("key")
7 | fileprivate struct DateKeyRecord: Hashable {
8 | let key: Date
9 | }
10 |
11 | @IndexKeyRecord("a", "b")
12 | fileprivate struct CompoundKeyRecord: Hashable {
13 | let a: String
14 | let b: Int
15 | var c: String
16 | }
17 |
18 | @Suite(.serialized)
19 | struct SelectTests {
20 | static let storeURL = URL(fileURLWithPath: "/tmp/empire_select_store", isDirectory: true)
21 |
22 | init() throws {
23 | try? FileManager.default.removeItem(at: Self.storeURL)
24 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
25 | }
26 |
27 | @Test func selectDateGreaterThanDistantFuture() throws {
28 | let store = try Store(url: Self.storeURL)
29 |
30 | try store.withTransaction { ctx in
31 | try ctx.insert(DateKeyRecord(key: Date(timeIntervalSince1970: 700)))
32 | try ctx.insert(DateKeyRecord(key: Date(timeIntervalSince1970: 7000)))
33 | try ctx.insert(DateKeyRecord(key: Date(timeIntervalSince1970: 70000)))
34 | }
35 |
36 | let records = try store.withTransaction { ctx in
37 | try DateKeyRecord.select(in: ctx, key: .greaterThan(.distantPast))
38 | }
39 |
40 | let expected = [
41 | DateKeyRecord(key: Date(timeIntervalSince1970: 700)),
42 | DateKeyRecord(key: Date(timeIntervalSince1970: 7000)),
43 | DateKeyRecord(key: Date(timeIntervalSince1970: 70000)),
44 | ]
45 |
46 | #expect(records == expected)
47 | }
48 |
49 | @Test func selectDateLessThanDistantFuture() throws {
50 | let store = try Store(url: Self.storeURL)
51 |
52 | try store.withTransaction { ctx in
53 | try ctx.insert(DateKeyRecord(key: Date(timeIntervalSince1970: 700)))
54 | try ctx.insert(DateKeyRecord(key: Date(timeIntervalSince1970: 7000)))
55 | try ctx.insert(DateKeyRecord(key: Date(timeIntervalSince1970: 70000)))
56 | }
57 |
58 | let records = try store.withTransaction { ctx in
59 | try DateKeyRecord.select(in: ctx, key: .lessThan(.distantFuture))
60 | }
61 |
62 | let expected = [
63 | DateKeyRecord(key: Date(timeIntervalSince1970: 70000)),
64 | DateKeyRecord(key: Date(timeIntervalSince1970: 7000)),
65 | DateKeyRecord(key: Date(timeIntervalSince1970: 700)),
66 | ]
67 |
68 | #expect(records == expected)
69 | }
70 |
71 | @Test func selectSingleCompoundKeyRecord() throws {
72 | let record = CompoundKeyRecord(a: "hello", b: 42, c: "goodbye")
73 |
74 | let store = try Store(url: Self.storeURL)
75 |
76 | let records = try store.withTransaction { ctx in
77 | try ctx.insert(record)
78 |
79 | return try CompoundKeyRecord.select(in: ctx, a: "hello", b: 42)
80 | }
81 |
82 | #expect(records == [record])
83 | }
84 |
85 | @Test func contextSelectCompoundKeyRecord() throws {
86 | let store = try Store(url: Self.storeURL)
87 |
88 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
89 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 42, c: "b"))
90 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 43, c: "c"))
91 |
92 | return try ctx.select(query: Query("hello", last: .equals(42)))
93 | }
94 |
95 | let expected = [
96 | CompoundKeyRecord(a: "hello", b: 42, c: "b"),
97 | ]
98 |
99 | #expect(records == expected)
100 | }
101 |
102 | @Test func selectPartialCompoundKeyRecord() throws {
103 | let store = try Store(url: Self.storeURL)
104 |
105 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
106 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
107 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
108 |
109 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
110 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
111 |
112 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
113 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
114 |
115 | return try CompoundKeyRecord.select(in: ctx, a: "hello")
116 | }
117 |
118 | let expected = [
119 | CompoundKeyRecord(a: "hello", b: 40, c: "a"),
120 | CompoundKeyRecord(a: "hello", b: 41, c: "b"),
121 | ]
122 |
123 | #expect(records == expected)
124 | }
125 |
126 | @Test func selectGreaterThanPartialCompoundKeyRecord() throws {
127 | let store = try Store(url: Self.storeURL)
128 |
129 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
130 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
131 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
132 |
133 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
134 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
135 |
136 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
137 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
138 |
139 | return try CompoundKeyRecord.select(in: ctx, a: "hello", b: .greaterThan(10))
140 | }
141 |
142 | let expected = [
143 | CompoundKeyRecord(a: "hello", b: 40, c: "a"),
144 | CompoundKeyRecord(a: "hello", b: 41, c: "b"),
145 | ]
146 |
147 | #expect(records == expected)
148 | }
149 |
150 | @Test func selectGreaterOrEqualPartialCompoundKeyRecord() throws {
151 | let store = try Store(url: Self.storeURL)
152 |
153 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
154 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
155 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
156 |
157 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
158 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
159 |
160 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
161 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
162 |
163 | return try CompoundKeyRecord.select(in: ctx, a: "hello", b: .greaterOrEqual(40))
164 | }
165 |
166 | let expected = [
167 | CompoundKeyRecord(a: "hello", b: 40, c: "a"),
168 | CompoundKeyRecord(a: "hello", b: 41, c: "b"),
169 | ]
170 |
171 | #expect(records == expected)
172 | }
173 |
174 | @Test func selectGreaterOrEqualFirstPartialCompoundKeyRecord() throws {
175 | let store = try Store(url: Self.storeURL)
176 |
177 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
178 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
179 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
180 |
181 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
182 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
183 |
184 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
185 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
186 |
187 | return try CompoundKeyRecord.select(in: ctx, a: .greaterThan("hello"))
188 | }
189 |
190 | let expected = [
191 | CompoundKeyRecord(a: "helloo", b: 40, c: "a"),
192 | CompoundKeyRecord(a: "hellp", b: 42, c: "c"),
193 | ]
194 |
195 | #expect(records == expected)
196 | }
197 |
198 | @Test func selectLessThanPartialCompoundKeyRecord() throws {
199 | let store = try Store(url: Self.storeURL)
200 |
201 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
202 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
203 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
204 |
205 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
206 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
207 |
208 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
209 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
210 |
211 | return try CompoundKeyRecord.select(in: ctx, a: "hello", b: .lessThan(50))
212 | }
213 |
214 | let expected = [
215 | CompoundKeyRecord(a: "hello", b: 41, c: "b"),
216 | CompoundKeyRecord(a: "hello", b: 40, c: "a"),
217 | ]
218 |
219 | #expect(records == expected)
220 | }
221 |
222 | @Test func selectLessOrEqualPartialCompoundKeyRecord() throws {
223 | let store = try Store(url: Self.storeURL)
224 |
225 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
226 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
227 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
228 |
229 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
230 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
231 |
232 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
233 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
234 |
235 | return try CompoundKeyRecord.select(in: ctx, a: "hello", b: .lessOrEqual(41))
236 | }
237 |
238 | let expected = [
239 | CompoundKeyRecord(a: "hello", b: 41, c: "b"),
240 | CompoundKeyRecord(a: "hello", b: 40, c: "a"),
241 | ]
242 |
243 | #expect(records == expected)
244 | }
245 |
246 | @Test func selectRangePartialCompoundKeyRecord() throws {
247 | let store = try Store(url: Self.storeURL)
248 |
249 | let records: [CompoundKeyRecord] = try store.withTransaction { ctx in
250 | try ctx.insert(CompoundKeyRecord(a: "helln", b: 40, c: "a")) // before
251 | try ctx.insert(CompoundKeyRecord(a: "hell", b: 40, c: "a"))
252 |
253 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 40, c: "a"))
254 | try ctx.insert(CompoundKeyRecord(a: "hello", b: 41, c: "b"))
255 |
256 | try ctx.insert(CompoundKeyRecord(a: "helloo", b: 40, c: "a")) // after
257 | try ctx.insert(CompoundKeyRecord(a: "hellp", b: 42, c: "c"))
258 |
259 | return try CompoundKeyRecord.select(in: ctx, a: "hello", b: .range(39..<42))
260 | }
261 |
262 | let expected = [
263 | CompoundKeyRecord(a: "hello", b: 40, c: "a"),
264 | CompoundKeyRecord(a: "hello", b: 41, c: "b"),
265 | ]
266 |
267 | #expect(records == expected)
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/Tests/EmpireTests/TupleTests.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 |
3 | import Empire
4 |
5 | struct TupleTests {
6 | @Test func oneValueTuple() throws {
7 | let value = Tuple(45)
8 |
9 | #expect(value.elements == 45)
10 | }
11 |
12 | @Test func zeroValueTuple() throws {
13 | let value = Tuple(EmptyValue())
14 |
15 | #expect(value.elements == EmptyValue())
16 | }
17 |
18 | @Test func emptyTuple() throws {
19 | let value = Tuple< >()
20 |
21 | #expect(value.elements == ())
22 | }
23 | }
24 |
25 | extension TupleTests {
26 | @Test
27 | func serialize() throws {
28 | let value = Tuple("Korben", 45)
29 |
30 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
31 |
32 | var output = buffer
33 |
34 | value.serialize(into: &output)
35 |
36 | var input = UnsafeRawBufferPointer(start: buffer.baseAddress, count: value.serializedSize)
37 |
38 | let result = try Tuple(buffer: &input)
39 |
40 | #expect(result.elements.0 == "Korben")
41 | #expect(result.elements.1 == 45)
42 | }
43 | }
44 |
45 | extension TupleTests {
46 | @Test func comparsionWithMultipleValues() throws {
47 | let a = Tuple("Korben", 45)
48 | let b = Tuple("Korben", 44)
49 |
50 | #expect(a > b)
51 | #expect(b < a)
52 | #expect((a > a) == false)
53 | #expect((a < a) == false)
54 | #expect(a == a)
55 | #expect(b == b)
56 | #expect(a != b)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Tests/LMDBTests/LMDBTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import LMDB
5 |
6 | extension Cursor {
7 | func getStringValues() -> [(String, String)] {
8 | compactMap {
9 | guard
10 | let key = String(mdbVal: $0.0),
11 | let value = String(mdbVal: $0.1)
12 | else {
13 | return nil
14 | }
15 |
16 | return (key, value)
17 | }
18 | }
19 | }
20 |
21 | @Suite(.serialized)
22 | struct LMDBTests {
23 | static let storeURL = URL(fileURLWithPath: "/tmp/store", isDirectory: true)
24 |
25 | init() throws {
26 | try? FileManager.default.removeItem(at: Self.storeURL)
27 | try FileManager.default.createDirectory(at: Self.storeURL, withIntermediateDirectories: false)
28 | }
29 |
30 | @Test func testWriteKey() throws {
31 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
32 |
33 | try Transaction.with(env: env) { txn in
34 | let dbi = try txn.open(name: "mydb")
35 |
36 | try txn.set(dbi: dbi, key: "hello", value: "goodbye")
37 | let value = try txn.getString(dbi: dbi, key: "hello")
38 |
39 | #expect(value == "goodbye")
40 | }
41 | }
42 |
43 | @Test func testWriteKeyCloseAndRead() throws {
44 | // Scope has to be more carefully controlled here, to ensure the database is deallocated (closed) correctly
45 | do {
46 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
47 |
48 | try Transaction.with(env: env) { txn in
49 | let dbi = try txn.open(name: "mydb")
50 |
51 | try txn.set(dbi: dbi, key: "hello", value: "goodbye")
52 | }
53 | }
54 |
55 | do {
56 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
57 |
58 | try Transaction.with(env: env) { txn in
59 | let dbi = try txn.open(name: "mydb")
60 |
61 | let value = try txn.getString(dbi: dbi, key: "hello")
62 |
63 | #expect(value == "goodbye")
64 | }
65 | }
66 | }
67 |
68 | @Test func testMissingKey() throws {
69 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
70 |
71 | try Transaction.with(env: env) { txn in
72 | let dbi = try txn.open(name: "mydb")
73 |
74 | #expect(try txn.getString(dbi: dbi, key: "hello") == nil)
75 | }
76 | }
77 |
78 | @Test func deleteKey() throws {
79 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
80 |
81 | try Transaction.with(env: env) { txn in
82 | let dbi = try txn.open(name: "mydb")
83 |
84 | try txn.set(dbi: dbi, key: "hello", value: "goodbye")
85 | let value = try txn.getString(dbi: dbi, key: "hello")
86 |
87 | #expect(value == "goodbye")
88 |
89 | try txn.delete(dbi: dbi, key: "hello")
90 |
91 | #expect(try txn.getString(dbi: dbi, key: "hello") == nil)
92 | }
93 | }
94 |
95 | @Test func deleteMissingKey() throws {
96 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
97 |
98 | try Transaction.with(env: env) { txn in
99 | let dbi = try txn.open(name: "mydb")
100 |
101 | #expect(throws: MDBError.recordNotFound) {
102 | try txn.delete(dbi: dbi, key: "hello")
103 | }
104 | }
105 | }
106 | }
107 |
108 | extension LMDBTests {
109 | @Test func greaterOrEqual() throws {
110 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
111 |
112 | try Transaction.with(env: env) { txn in
113 | let dbi = try txn.open(name: "mydb")
114 |
115 | try txn.set(dbi: dbi, key: "c", value: "3")
116 | try txn.set(dbi: dbi, key: "a", value: "1")
117 | try txn.set(dbi: dbi, key: "b", value: "2")
118 |
119 | try "a".withMDBVal { searchKey in
120 | let query = Query(comparison: .greaterOrEqual(nil), key: searchKey)
121 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
122 |
123 | let values: [(String, String)] = cursor.compactMap {
124 | guard
125 | let key = String(mdbVal: $0.0),
126 | let value = String(mdbVal: $0.1)
127 | else {
128 | return nil
129 | }
130 |
131 | return (key, value)
132 | }
133 |
134 | try #require(values.count == 3)
135 | #expect(values[0] == ("a", "1"))
136 | #expect(values[1] == ("b", "2"))
137 | #expect(values[2] == ("c", "3"))
138 | }
139 | }
140 | }
141 |
142 | @Test func greaterOrEqualWithEnding() throws {
143 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
144 |
145 | try Transaction.with(env: env) { txn in
146 | let dbi = try txn.open(name: "mydb")
147 |
148 | try txn.set(dbi: dbi, key: "c", value: "3")
149 | try txn.set(dbi: dbi, key: "a", value: "1")
150 | try txn.set(dbi: dbi, key: "b", value: "2")
151 |
152 | try "a".withMDBVal { searchKey in
153 | try "b".withMDBVal { endKey in
154 | let query = Query(comparison: .greaterOrEqual(endKey), key: searchKey)
155 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
156 |
157 | let values = cursor.getStringValues()
158 |
159 | try #require(values.count == 2)
160 | #expect(values[0] == ("a", "1"))
161 | #expect(values[1] == ("b", "2"))
162 | }
163 | }
164 | }
165 | }
166 |
167 | @Test func greater() throws {
168 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
169 |
170 | try Transaction.with(env: env) { txn in
171 | let dbi = try txn.open(name: "mydb")
172 |
173 | try txn.set(dbi: dbi, key: "c", value: "3")
174 | try txn.set(dbi: dbi, key: "b", value: "2")
175 |
176 | try "a".withMDBVal { searchKey in
177 | let query = Query(comparison: .greater(nil), key: searchKey)
178 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
179 |
180 | let values = cursor.getStringValues()
181 |
182 | try #require(values.count == 2)
183 | #expect(values[0] == ("b", "2"))
184 | #expect(values[1] == ("c", "3"))
185 | }
186 | }
187 | }
188 |
189 | @Test func greaterWithEnding() throws {
190 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
191 |
192 | try Transaction.with(env: env) { txn in
193 | let dbi = try txn.open(name: "mydb")
194 |
195 | try txn.set(dbi: dbi, key: "c", value: "3")
196 | try txn.set(dbi: dbi, key: "b", value: "2")
197 |
198 | try "a".withMDBVal { searchKey in
199 | try "b".withMDBVal { endKey in
200 | let query = Query(comparison: .greater(endKey), key: searchKey)
201 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
202 |
203 | let values = cursor.getStringValues()
204 |
205 | try #require(values.count == 1)
206 | #expect(values[0] == ("b", "2"))
207 | }
208 | }
209 | }
210 | }
211 |
212 | @Test func greaterTruncation() throws {
213 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
214 |
215 | try Transaction.with(env: env) { txn in
216 | let dbi = try txn.open(name: "mydb")
217 |
218 | try txn.set(dbi: dbi, key: "aa", value: "1")
219 | try txn.set(dbi: dbi, key: "ab", value: "2")
220 | try txn.set(dbi: dbi, key: "ba", value: "3")
221 | try txn.set(dbi: dbi, key: "bb", value: "4")
222 | try txn.set(dbi: dbi, key: "ca", value: "5")
223 |
224 | try "b".withMDBVal { searchKey in
225 | let query = Query(comparison: .greater(nil), key: searchKey, truncating: true)
226 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
227 |
228 | let values = cursor.getStringValues()
229 |
230 | try #require(values.count == 1)
231 | #expect(values[0] == ("ca", "5"))
232 | }
233 | }
234 | }
235 |
236 | @Test func greaterOrEqualWithEndingAndTruncation() throws {
237 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
238 |
239 | try Transaction.with(env: env) { txn in
240 | let dbi = try txn.open(name: "mydb")
241 |
242 | try txn.set(dbi: dbi, key: "aa", value: "1")
243 | try txn.set(dbi: dbi, key: "ab", value: "2")
244 | try txn.set(dbi: dbi, key: "ba", value: "3")
245 | try txn.set(dbi: dbi, key: "bb", value: "4")
246 | try txn.set(dbi: dbi, key: "ca", value: "5")
247 |
248 | try "b".withMDBVal { searchKey in
249 | let query = Query(comparison: .greaterOrEqual(searchKey), key: searchKey, truncating: true)
250 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
251 |
252 | let values = cursor.getStringValues()
253 |
254 | try #require(values.count == 2)
255 | #expect(values[0] == ("ba", "3"))
256 | #expect(values[1] == ("bb", "4"))
257 | }
258 | }
259 | }
260 |
261 | @Test func greaterOrEqualWithLimit() throws {
262 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
263 |
264 | try Transaction.with(env: env) { txn in
265 | let dbi = try txn.open(name: "mydb")
266 |
267 | try txn.set(dbi: dbi, key: "c", value: "3")
268 | try txn.set(dbi: dbi, key: "a", value: "1")
269 | try txn.set(dbi: dbi, key: "b", value: "2")
270 |
271 | try "a".withMDBVal { searchKey in
272 | let query = Query(comparison: .greaterOrEqual(nil), key: searchKey, limit: 2)
273 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
274 |
275 | let values: [(String, String)] = cursor.compactMap {
276 | guard
277 | let key = String(mdbVal: $0.0),
278 | let value = String(mdbVal: $0.1)
279 | else {
280 | return nil
281 | }
282 |
283 | return (key, value)
284 | }
285 |
286 | try #require(values.count == 2)
287 | #expect(values[0] == ("a", "1"))
288 | #expect(values[1] == ("b", "2"))
289 | }
290 | }
291 | }
292 |
293 | @Test func greaterWithLimit() throws {
294 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
295 |
296 | try Transaction.with(env: env) { txn in
297 | let dbi = try txn.open(name: "mydb")
298 |
299 | try txn.set(dbi: dbi, key: "c", value: "3")
300 | try txn.set(dbi: dbi, key: "a", value: "1")
301 | try txn.set(dbi: dbi, key: "b", value: "2")
302 |
303 | try "a".withMDBVal { searchKey in
304 | let query = Query(comparison: .greater(nil), key: searchKey, limit: 2)
305 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
306 |
307 | let values: [(String, String)] = cursor.compactMap {
308 | guard
309 | let key = String(mdbVal: $0.0),
310 | let value = String(mdbVal: $0.1)
311 | else {
312 | return nil
313 | }
314 |
315 | return (key, value)
316 | }
317 |
318 | try #require(values.count == 2)
319 | #expect(values[0] == ("b", "2"))
320 | #expect(values[1] == ("c", "3"))
321 | }
322 | }
323 | }
324 |
325 | @Test func less() throws {
326 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
327 |
328 | try Transaction.with(env: env) { txn in
329 | let dbi = try txn.open(name: "mydb")
330 |
331 | try txn.set(dbi: dbi, key: "c", value: "3")
332 | try txn.set(dbi: dbi, key: "b", value: "2")
333 |
334 | try "d".withMDBVal { searchKey in
335 | let query = Query(comparison: .less(nil), key: searchKey)
336 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
337 |
338 | let values = cursor.getStringValues()
339 |
340 | try #require(values.count == 2)
341 | #expect(values[0] == ("c", "3"))
342 | #expect(values[1] == ("b", "2"))
343 | }
344 | }
345 | }
346 |
347 | @Test func lessWithEnding() throws {
348 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
349 |
350 | try Transaction.with(env: env) { txn in
351 | let dbi = try txn.open(name: "mydb")
352 |
353 | try txn.set(dbi: dbi, key: "c", value: "3")
354 | try txn.set(dbi: dbi, key: "b", value: "2")
355 |
356 | try "d".withMDBVal { searchKey in
357 | try "c".withMDBVal { endKey in
358 | let query = Query(comparison: .less(endKey), key: searchKey)
359 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
360 |
361 | let values = cursor.getStringValues()
362 |
363 | try #require(values.count == 1)
364 | #expect(values[0] == ("c", "3"))
365 | }
366 | }
367 | }
368 | }
369 |
370 | @Test func lessOrEqual() throws {
371 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
372 |
373 | try Transaction.with(env: env) { txn in
374 | let dbi = try txn.open(name: "mydb")
375 |
376 | try txn.set(dbi: dbi, key: "c", value: "3")
377 | try txn.set(dbi: dbi, key: "b", value: "2")
378 |
379 | try "c".withMDBVal { searchKey in
380 | let query = Query(comparison: .lessOrEqual(nil), key: searchKey)
381 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
382 |
383 | let values = cursor.getStringValues()
384 |
385 | try #require(values.count == 2)
386 | #expect(values[0] == ("c", "3"))
387 | #expect(values[1] == ("b", "2"))
388 | }
389 | }
390 | }
391 |
392 | @Test func lessOrEqualWithEnding() throws {
393 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
394 |
395 | try Transaction.with(env: env) { txn in
396 | let dbi = try txn.open(name: "mydb")
397 |
398 | try txn.set(dbi: dbi, key: "c", value: "3")
399 | try txn.set(dbi: dbi, key: "b", value: "2")
400 |
401 | try "d".withMDBVal { searchKey in
402 | try "c".withMDBVal { endKey in
403 | let query = Query(comparison: .lessOrEqual(endKey), key: searchKey)
404 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
405 |
406 | let values = cursor.getStringValues()
407 |
408 | try #require(values.count == 1)
409 | #expect(values[0] == ("c", "3"))
410 | }
411 | }
412 | }
413 | }
414 |
415 | @Test func backwardsScanCursor() throws {
416 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
417 |
418 | try Transaction.with(env: env) { txn in
419 | let dbi = try txn.open(name: "mydb")
420 |
421 | try txn.set(dbi: dbi, key: "c", value: "3")
422 | try txn.set(dbi: dbi, key: "a", value: "1")
423 | try txn.set(dbi: dbi, key: "b", value: "2")
424 |
425 | try "b".withMDBVal { searchKey in
426 | let query = Query(comparison: .less(nil), key: searchKey)
427 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
428 |
429 | let values: [(String, String)] = cursor.compactMap {
430 | guard
431 | let key = String(mdbVal: $0.0),
432 | let value = String(mdbVal: $0.1)
433 | else {
434 | return nil
435 | }
436 |
437 | return (key, value)
438 | }
439 |
440 | try #require(values.count == 1)
441 | #expect(values[0] == ("a", "1"))
442 | }
443 |
444 | }
445 | }
446 |
447 | @Test func forwardEndingInclusiveCursor() throws {
448 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
449 |
450 | try Transaction.with(env: env) { txn in
451 | let dbi = try txn.open(name: "mydb")
452 |
453 | try txn.set(dbi: dbi, key: "c", value: "3")
454 | try txn.set(dbi: dbi, key: "a", value: "1")
455 | try txn.set(dbi: dbi, key: "b", value: "2")
456 | try txn.set(dbi: dbi, key: "d", value: "4")
457 |
458 | try "b".withMDBVal { searchKey in
459 | try "c".withMDBVal { endKey in
460 | let query = Query(comparison: .closedRange(endKey), key: searchKey)
461 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
462 |
463 | let values: [(String, String)] = cursor.compactMap {
464 | guard
465 | let key = String(mdbVal: $0.0),
466 | let value = String(mdbVal: $0.1)
467 | else {
468 | return nil
469 | }
470 |
471 | return (key, value)
472 | }
473 |
474 | try #require(values.count == 2)
475 | #expect(values[0] == ("b", "2"))
476 | #expect(values[1] == ("c", "3"))
477 | }
478 | }
479 | }
480 | }
481 |
482 | @Test func forwardEndingCursor() throws {
483 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
484 |
485 | try Transaction.with(env: env) { txn in
486 | let dbi = try txn.open(name: "mydb")
487 |
488 | try txn.set(dbi: dbi, key: "c", value: "3")
489 | try txn.set(dbi: dbi, key: "a", value: "1")
490 | try txn.set(dbi: dbi, key: "b", value: "2")
491 | try txn.set(dbi: dbi, key: "d", value: "4")
492 |
493 | try "b".withMDBVal { searchKey in
494 | try "c".withMDBVal { endKey in
495 | let query = Query(comparison: .range(endKey), key: searchKey)
496 | let cursor = try Cursor(transaction: txn, dbi: dbi, query: query)
497 |
498 | let values = cursor.getStringValues()
499 |
500 | try #require(values.count == 1)
501 | #expect(values[0] == ("b", "2"))
502 | }
503 | }
504 | }
505 | }
506 | }
507 |
508 | extension LMDBTests {
509 | @Test func readOnlyTransaction() throws {
510 | let env = try Environment(url: Self.storeURL, maxDatabases: 1)
511 |
512 | // the database must exist before trying a read-only transaction
513 | try Transaction.with(env: env, readOnly: false) { txn in
514 | let dbi = try txn.open(name: "mydb")
515 |
516 | try txn.set(dbi: dbi, key: "hello", value: "goodbye")
517 | }
518 |
519 | try Transaction.with(env: env, readOnly: true) { txn in
520 | let dbi = try txn.open(name: "mydb")
521 |
522 | let value = try txn.getString(dbi: dbi, key: "hello")
523 |
524 | #expect(value == "goodbye")
525 |
526 | #expect(throws: MDBError.permissionDenied) {
527 | try txn.set(dbi: dbi, key: "hello", value: "goodbye")
528 | }
529 | }
530 | }
531 |
532 | @Test func concurrentAccess() async throws {
533 | let env = try Environment(url: Self.storeURL, maxDatabases: 1, locking: true)
534 | let count = 100
535 |
536 | // use a transaction to first get the dbi
537 | let dbi = try Transaction.with(env: env) { txn in
538 | try txn.open(name: "mydb")
539 | }
540 |
541 | try Transaction.with(env: env) { txn in
542 | try txn.set(dbi: dbi, key: "hello", value: "goodbye")
543 | }
544 |
545 | let strings = try await withThrowingTaskGroup(of: String?.self) { group in
546 | for _ in 0..: Comparable {
4 | let buffer: UnsafeMutableRawBufferPointer
5 | let input: T
6 |
7 | init(_ value: T) {
8 | self.input = value
9 |
10 | let size = value.serializedSize
11 | self.buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: size, alignment: 8)
12 |
13 | var localBuffer = buffer
14 |
15 | value.serialize(into: &localBuffer)
16 | }
17 |
18 | static func == (lhs: ComparableData, rhs: ComparableData) -> Bool {
19 | if lhs.buffer.count != rhs.buffer.count {
20 | return false
21 | }
22 |
23 | for pair in zip(lhs.buffer, rhs.buffer) {
24 | print("==", pair.0, pair.1)
25 | if pair.0 != pair.1 {
26 | return false
27 | }
28 | }
29 |
30 | return true
31 | }
32 |
33 | static func < (lhs: ComparableData, rhs: ComparableData) -> Bool {
34 | for pair in zip(lhs.buffer, rhs.buffer) {
35 | if pair.0 == pair.1 {
36 | continue
37 | }
38 |
39 | return pair.0 < pair.1
40 | }
41 |
42 | return false
43 | }
44 |
45 | static func sort(_ array: Array) -> Array {
46 | array
47 | .map(ComparableData.init)
48 | .sorted()
49 | .map { $0.input }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Tests/PackedSerializeTests/PackedSerializeTests.swift:
--------------------------------------------------------------------------------
1 | import Testing
2 |
3 | import PackedSerialize
4 |
5 | enum HasRawRep: Int {
6 | case one
7 | case two
8 | case three
9 | }
10 |
11 | struct PackedSerializeTests {
12 | @Test func serializeUInt() throws {
13 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
14 | var inputBuffer = buffer
15 |
16 | let a = UInt.max
17 | let b = UInt(142)
18 | let c = UInt(42)
19 | let d = UInt.min
20 |
21 | a.serialize(into: &inputBuffer)
22 | b.serialize(into: &inputBuffer)
23 | c.serialize(into: &inputBuffer)
24 | d.serialize(into: &inputBuffer)
25 |
26 | var outputBuffer = UnsafeRawBufferPointer(buffer)
27 |
28 | #expect(try UInt(buffer: &outputBuffer) == a)
29 | #expect(try UInt(buffer: &outputBuffer) == b)
30 | #expect(try UInt(buffer: &outputBuffer) == c)
31 | #expect(try UInt(buffer: &outputBuffer) == d)
32 |
33 | #expect([a,b,c,d].sorted() == [d,c,b,a])
34 | #expect(ComparableData.sort([a,b,c,d]) == [d,c,b,a])
35 | }
36 |
37 | @Test func serializeInt() throws {
38 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
39 | var inputBuffer = buffer
40 |
41 | let a = Int.max
42 | let b = Int(142)
43 | let c = Int(42)
44 | let d = Int(0)
45 | let e = Int(-1)
46 | let f = Int(-42)
47 | let g = Int(Int.min + 1)
48 | let h = Int.min
49 |
50 | a.serialize(into: &inputBuffer)
51 | b.serialize(into: &inputBuffer)
52 | c.serialize(into: &inputBuffer)
53 | d.serialize(into: &inputBuffer)
54 | e.serialize(into: &inputBuffer)
55 | f.serialize(into: &inputBuffer)
56 | g.serialize(into: &inputBuffer)
57 | h.serialize(into: &inputBuffer)
58 |
59 | var outputBuffer = UnsafeRawBufferPointer(buffer)
60 |
61 | #expect(try Int(buffer: &outputBuffer) == a)
62 | #expect(try Int(buffer: &outputBuffer) == b)
63 | #expect(try Int(buffer: &outputBuffer) == c)
64 | #expect(try Int(buffer: &outputBuffer) == d)
65 | #expect(try Int(buffer: &outputBuffer) == e)
66 | #expect(try Int(buffer: &outputBuffer) == f)
67 | #expect(try Int(buffer: &outputBuffer) == g)
68 | #expect(try Int(buffer: &outputBuffer) == h)
69 |
70 | #expect([a,b,c,d,e,f,g,h].sorted() == [h,g,f,e,d,c,b,a])
71 | #expect(ComparableData.sort([a,b,c,d,e,f,g,h]) == [h,g,f,e,d,c,b,a])
72 | }
73 |
74 | @Test func serializeInt64() throws {
75 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
76 | var inputBuffer = buffer
77 |
78 | let a = Int64.max
79 | let b = Int64(142)
80 | let c = Int64(42)
81 | let d = Int64(0)
82 | let e = Int64(-1)
83 | let f = Int64(-42)
84 | let g = Int64(Int64.min + 1)
85 | let h = Int64.min
86 |
87 | a.serialize(into: &inputBuffer)
88 | b.serialize(into: &inputBuffer)
89 | c.serialize(into: &inputBuffer)
90 | d.serialize(into: &inputBuffer)
91 | e.serialize(into: &inputBuffer)
92 | f.serialize(into: &inputBuffer)
93 | g.serialize(into: &inputBuffer)
94 | h.serialize(into: &inputBuffer)
95 |
96 | var outputBuffer = UnsafeRawBufferPointer(buffer)
97 |
98 | #expect(try Int64(buffer: &outputBuffer) == a)
99 | #expect(try Int64(buffer: &outputBuffer) == b)
100 | #expect(try Int64(buffer: &outputBuffer) == c)
101 | #expect(try Int64(buffer: &outputBuffer) == d)
102 | #expect(try Int64(buffer: &outputBuffer) == e)
103 | #expect(try Int64(buffer: &outputBuffer) == f)
104 | #expect(try Int64(buffer: &outputBuffer) == g)
105 | #expect(try Int64(buffer: &outputBuffer) == h)
106 |
107 | #expect([a,b,c,d,e,f,g,h].sorted() == [h,g,f,e,d,c,b,a])
108 | #expect(ComparableData.sort([a,b,c,d,e,f,g,h]) == [h,g,f,e,d,c,b,a])
109 | }
110 |
111 | @Test func serializeUInt8() throws {
112 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
113 | var inputBuffer = buffer
114 |
115 | UInt8(42).serialize(into: &inputBuffer)
116 | UInt8(142).serialize(into: &inputBuffer)
117 | UInt8(0).serialize(into: &inputBuffer)
118 |
119 | var outputBuffer = UnsafeRawBufferPointer(buffer)
120 |
121 | #expect(try UInt8(buffer: &outputBuffer) == 42)
122 | #expect(try UInt8(buffer: &outputBuffer) == 142)
123 | #expect(try UInt8(buffer: &outputBuffer) == 0)
124 | }
125 |
126 | @Test func serializeString() throws {
127 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
128 | var inputBuffer = buffer
129 |
130 | let a = "ddd"
131 | let b = "cccc"
132 | let c = "ccc"
133 | let d = "cca"
134 | let e = "bcc"
135 | let f = "acc"
136 | let g = "aaa"
137 | let h = "aa"
138 | let i = ""
139 |
140 | a.serialize(into: &inputBuffer)
141 | b.serialize(into: &inputBuffer)
142 | c.serialize(into: &inputBuffer)
143 | d.serialize(into: &inputBuffer)
144 | e.serialize(into: &inputBuffer)
145 | f.serialize(into: &inputBuffer)
146 | g.serialize(into: &inputBuffer)
147 | h.serialize(into: &inputBuffer)
148 | i.serialize(into: &inputBuffer)
149 |
150 | var outputBuffer = UnsafeRawBufferPointer(buffer)
151 |
152 | #expect(try String(buffer: &outputBuffer) == a)
153 | #expect(try String(buffer: &outputBuffer) == b)
154 | #expect(try String(buffer: &outputBuffer) == c)
155 | #expect(try String(buffer: &outputBuffer) == d)
156 | #expect(try String(buffer: &outputBuffer) == e)
157 | #expect(try String(buffer: &outputBuffer) == f)
158 | #expect(try String(buffer: &outputBuffer) == g)
159 | #expect(try String(buffer: &outputBuffer) == h)
160 | #expect(try String(buffer: &outputBuffer) == i)
161 |
162 | #expect([a,b,c,d,e,f,g,h,i].sorted() == [i,h,g,f,e,d,c,b,a])
163 | #expect(ComparableData.sort([a,b,c,d,e,f,g,h,i]) == [i,h,g,f,e,d,c,b,a])
164 | }
165 |
166 | @Test func serializeBoolString() throws {
167 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
168 | var inputBuffer = buffer
169 |
170 | true.serialize(into: &inputBuffer)
171 | false.serialize(into: &inputBuffer)
172 |
173 | var outputBuffer = UnsafeRawBufferPointer(buffer)
174 |
175 | #expect(try Bool(buffer: &outputBuffer) == true)
176 | #expect(try Bool(buffer: &outputBuffer) == false)
177 | }
178 |
179 | @Test func deserializeBoolWithInvalidValue() throws {
180 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
181 | var inputBuffer = buffer
182 |
183 | UInt8(4).serialize(into: &inputBuffer)
184 |
185 | var outputBuffer = UnsafeRawBufferPointer(buffer)
186 |
187 | #expect(throws: (any Error).self, performing: {
188 | try Bool(buffer: &outputBuffer)
189 | })
190 | }
191 |
192 | @Test func serializeOptionalString() throws {
193 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
194 | var inputBuffer = buffer
195 |
196 | Optional.some("hello").serialize(into: &inputBuffer)
197 | Optional.some("goodbye").serialize(into: &inputBuffer)
198 |
199 | var outputBuffer = UnsafeRawBufferPointer(buffer)
200 |
201 | #expect(try String?(buffer: &outputBuffer) == "hello")
202 | #expect(try String?(buffer: &outputBuffer) == "goodbye")
203 | }
204 |
205 | @Test func serializeStringArray() throws {
206 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
207 | var inputBuffer = buffer
208 |
209 | ["1", "2", "3"].serialize(into: &inputBuffer)
210 |
211 | var outputBuffer = UnsafeRawBufferPointer(buffer)
212 |
213 | #expect(try [String](buffer: &outputBuffer) == ["1", "2", "3"])
214 | }
215 |
216 | @Test func rawRepresentable() throws {
217 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
218 | var inputBuffer = buffer
219 |
220 | HasRawRep.two.serialize(into: &inputBuffer)
221 | HasRawRep.one.serialize(into: &inputBuffer)
222 |
223 | var outputBuffer = UnsafeRawBufferPointer(buffer)
224 |
225 | #expect(try HasRawRep(buffer: &outputBuffer) == HasRawRep.two)
226 | #expect(try HasRawRep(buffer: &outputBuffer) == HasRawRep.one)
227 | }
228 | }
229 |
230 | #if canImport(Foundation)
231 | import Foundation
232 |
233 | extension PackedSerializeTests {
234 | @Test func serializeUUID() throws {
235 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
236 | var inputBuffer = buffer
237 |
238 | let uuidA = UUID()
239 | let uuidB = UUID()
240 |
241 | uuidA.serialize(into: &inputBuffer)
242 | uuidB.serialize(into: &inputBuffer)
243 |
244 | var outputBuffer = UnsafeRawBufferPointer(buffer)
245 |
246 | #expect(try UUID(buffer: &outputBuffer) == uuidA)
247 | #expect(try UUID(buffer: &outputBuffer) == uuidB)
248 | }
249 |
250 | @Test func serializeData() throws {
251 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
252 | var inputBuffer = buffer
253 |
254 | let dataA = Data([1,2,3,4])
255 | let dataB = Data([0xfa, 0xdb, 0xcc, 0xbd])
256 |
257 | dataA.serialize(into: &inputBuffer)
258 | dataB.serialize(into: &inputBuffer)
259 |
260 | var outputBuffer = UnsafeRawBufferPointer(buffer)
261 |
262 | #expect(try Data(buffer: &outputBuffer) == dataA)
263 | #expect(try Data(buffer: &outputBuffer) == dataB)
264 | }
265 |
266 | @Test func serializeDate() throws {
267 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
268 | var inputBuffer = buffer
269 |
270 | // rounding is important because this serialization only has precision to the millisecond
271 | let a = Date(timeIntervalSince1970:Date.now.timeIntervalSince1970.rounded(.down))
272 | let b = Date(timeIntervalSince1970: 0.0)
273 | let c = Date(timeIntervalSince1970: -1.0)
274 | let d = Date.distantPast
275 |
276 | a.serialize(into: &inputBuffer)
277 | b.serialize(into: &inputBuffer)
278 | c.serialize(into: &inputBuffer)
279 | d.serialize(into: &inputBuffer)
280 |
281 | var outputBuffer = UnsafeRawBufferPointer(buffer)
282 |
283 | #expect(try Date(buffer: &outputBuffer) == a)
284 | #expect(try Date(buffer: &outputBuffer) == b)
285 | #expect(try Date(buffer: &outputBuffer) == c)
286 | #expect(try Date(buffer: &outputBuffer) == d)
287 |
288 | // check the encoding for ordering
289 | #expect([a,b,c,d].sorted() == [d,c,b,a])
290 | #expect(ComparableData.sort([a,b,c,d]) == [d,c,b,a])
291 | }
292 |
293 | @Test func serializeEmptyValue() throws {
294 | let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 128, alignment: 8)
295 | var inputBuffer = buffer
296 |
297 | let value = EmptyValue()
298 |
299 | #expect(value.serializedSize == 0)
300 |
301 | value.serialize(into: &inputBuffer)
302 |
303 | #expect(inputBuffer.baseAddress == buffer.baseAddress)
304 | }
305 | }
306 |
307 | #endif
308 |
--------------------------------------------------------------------------------