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