From 151132f7ba1ec0e1227a311af78b4e25b7cc98fe Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 7 Apr 2024 03:03:29 -0700 Subject: [PATCH 01/10] Refactored Datastore to use a dedicated Format type --- .../Datastore/Datastore.swift | 206 ++++++++------- .../Datastore/DatastoreFormat.swift | 39 +++ .../DatastoreInterfaceProtocol.swift | 2 +- .../Disk Persistence/DiskPersistence.swift | 25 +- .../Transaction/Transaction.swift | 4 +- .../DiskPersistenceDatastoreTests.swift | 244 ++++++++++-------- .../DiskTransactionTests.swift | 17 +- 7 files changed, 303 insertions(+), 234 deletions(-) create mode 100644 Sources/CodableDatastore/Datastore/DatastoreFormat.swift diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index 1cb9678..6559978 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -9,19 +9,29 @@ import Foundation /// A store for a homogenous collection of instances. -public actor Datastore< - Version: RawRepresentable & Hashable & CaseIterable, - CodedType: Codable, - IdentifierType: Indexable, - AccessMode: _AccessMode -> where Version.RawValue: Indexable & Comparable { +public actor Datastore { + /// A type representing the version of the datastore within the persistence. + /// + /// - SeeAlso: ``DatastoreFormat/Version`` + public typealias Version = Format.Version + + /// The instance type to use when persisting and loading values from the datastore. + /// + /// - SeeAlso: ``DatastoreFormat/Instance`` + public typealias InstanceType = Format.Instance + + /// The identifier to be used when de-duplicating instances saved in the persistence. + /// + /// - SeeAlso: ``DatastoreFormat/Identifier`` + public typealias IdentifierType = Format.Identifier + let persistence: any Persistence let key: DatastoreKey let version: Version - let encoder: (_ instance: CodedType) async throws -> Data - let decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: CodedType)] - let directIndexes: [IndexPath] - let computedIndexes: [IndexPath] + let encoder: (_ instance: InstanceType) async throws -> Data + let decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)] + let directIndexes: [IndexPath] + let computedIndexes: [IndexPath] var updatedDescriptor: DatastoreDescriptor? @@ -31,19 +41,19 @@ public actor Datastore< fileprivate var storeMigrationStatus: TaskStatus = .waiting fileprivate var storeMigrationProgressHandlers: [ProgressHandler] = [] - fileprivate var indexMigrationStatus: [IndexPath : TaskStatus] = [:] - fileprivate var indexMigrationProgressHandlers: [IndexPath : ProgressHandler] = [:] + fileprivate var indexMigrationStatus: [IndexPath : TaskStatus] = [:] + fileprivate var indexMigrationProgressHandlers: [IndexPath : ProgressHandler] = [:] public init( persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, identifierType: IdentifierType.Type, - encoder: @escaping (_ instance: CodedType) async throws -> Data, - decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + encoder: @escaping (_ instance: InstanceType) async throws -> Data, + decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) where AccessMode == ReadWrite { self.persistence = persistence @@ -64,11 +74,11 @@ public actor Datastore< persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, identifierType: IdentifierType.Type, - decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) where AccessMode == ReadOnly { self.persistence = persistence @@ -84,7 +94,7 @@ public actor Datastore< // MARK: - Helper Methods extension Datastore { - func updatedDescriptor(for instance: CodedType) throws -> DatastoreDescriptor { + func updatedDescriptor(for instance: InstanceType) throws -> DatastoreDescriptor { if let updatedDescriptor { return updatedDescriptor } @@ -100,7 +110,7 @@ extension Datastore { return descriptor } - func decoder(for version: Version) throws -> (_ data: Data) async throws -> (id: IdentifierType, instance: CodedType) { + func decoder(for version: Version) throws -> (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType) { guard let decoder = decoders[version] else { throw DatastoreError.missingDecoder(version: String(describing: version)) } @@ -149,7 +159,7 @@ extension Datastore { let persistedDescriptor = try await transaction.register(datastore: self) /// Only operate on read-write datastores beyond this point. - guard let self = self as? Datastore + guard let self = self as? Datastore else { return } /// Make sure we have a descriptor, and that there is at least one entry, otherwise stop here. @@ -374,7 +384,7 @@ extension Datastore where AccessMode == ReadWrite { /// - index: The index to migrate. /// - minimumVersion: The minimum valid version for an index to not be migrated. /// - progressHandler: A closure that will be regularly called with progress during the migration. If no migration needs to occur, it won't be called, so setup and tear down any UI within the handler. - public func migrate(index: IndexPath, ifLessThan minimumVersion: Version, progressHandler: ProgressHandler? = nil) async throws { + public func migrate(index: IndexPath, ifLessThan minimumVersion: Version, progressHandler: ProgressHandler? = nil) async throws { try await persistence._withTransaction( actionName: "Migrate Entries", options: [] @@ -419,7 +429,7 @@ extension Datastore where AccessMode == ReadWrite { } } - func migrate(index: IndexPath, progressHandler: ProgressHandler? = nil) async throws { + func migrate(index: IndexPath, progressHandler: ProgressHandler? = nil) async throws { // TODO: Migrate just that index, use indexMigrationStatus and indexMigrationProgressHandlers to record progress. } @@ -459,7 +469,7 @@ extension Datastore { } } - public func load(_ identifier: IdentifierType) async throws -> CodedType? { + public func load(_ identifier: IdentifierType) async throws -> InstanceType? { try await warmupIfNeeded() return try await persistence._withTransaction( @@ -489,7 +499,7 @@ extension Datastore { _ range: some IndexRangeExpression, order: RangeOrder, awaitWarmup: Bool - ) -> some TypedAsyncSequence<(id: IdentifierType, instance: CodedType)> { + ) -> some TypedAsyncSequence<(id: IdentifierType, instance: InstanceType)> { AsyncThrowingBackpressureStream { provider in if awaitWarmup { try await self.warmupIfNeeded() @@ -517,7 +527,7 @@ extension Datastore { nonisolated public func load( _ range: some IndexRangeExpression, order: RangeOrder = .ascending - ) -> some TypedAsyncSequence { + ) -> some TypedAsyncSequence { load(range, order: order, awaitWarmup: true) .map { $0.instance } } @@ -526,22 +536,22 @@ extension Datastore { public nonisolated func load( _ range: IndexRange, order: RangeOrder = .ascending - ) -> some TypedAsyncSequence { + ) -> some TypedAsyncSequence { load(range, order: order) } public nonisolated func load( _ range: Swift.UnboundedRange, order: RangeOrder = .ascending - ) -> some TypedAsyncSequence { + ) -> some TypedAsyncSequence { load(IndexRange(), order: order) } public nonisolated func load( _ range: some IndexRangeExpression, order: RangeOrder = .ascending, - from indexPath: IndexPath> - ) -> some TypedAsyncSequence { + from indexPath: IndexPath> + ) -> some TypedAsyncSequence { AsyncThrowingBackpressureStream { provider in try await self.warmupIfNeeded() @@ -590,24 +600,24 @@ extension Datastore { public nonisolated func load( _ range: IndexRange, order: RangeOrder = .ascending, - from keypath: IndexPath> - ) -> some TypedAsyncSequence { + from keypath: IndexPath> + ) -> some TypedAsyncSequence { load(range, order: order, from: keypath) } public nonisolated func load( _ range: Swift.UnboundedRange, order: RangeOrder = .ascending, - from keypath: IndexPath> - ) -> some TypedAsyncSequence { + from keypath: IndexPath> + ) -> some TypedAsyncSequence { load(IndexRange(), order: order, from: keypath) } public nonisolated func load( _ value: IndexedValue, order: RangeOrder = .ascending, - from keypath: IndexPath> - ) -> some TypedAsyncSequence { + from keypath: IndexPath> + ) -> some TypedAsyncSequence { load(value...value, order: order, from: keypath) } } @@ -615,12 +625,12 @@ extension Datastore { // MARK: - Observation extension Datastore { - public func observe(_ idenfifier: IdentifierType) async throws -> some TypedAsyncSequence> { + public func observe(_ idenfifier: IdentifierType) async throws -> some TypedAsyncSequence> { try await self.observe() .filter { $0.id == idenfifier } } - public func observe() async throws -> some TypedAsyncSequence> { + public func observe() async throws -> some TypedAsyncSequence> { try await warmupIfNeeded() return try await persistence._withTransaction( @@ -654,7 +664,7 @@ extension Datastore where AccessMode == ReadWrite { /// - instance: The instance to persist. /// - idenfifier: The unique identifier to use to reference the item being persisted. @discardableResult - public func persist(_ instance: CodedType, to idenfifier: IdentifierType) async throws -> CodedType? { + public func persist(_ instance: InstanceType, to idenfifier: IdentifierType) async throws -> InstanceType? { try await warmupIfNeeded() let updatedDescriptor = try self.updatedDescriptor(for: instance) @@ -668,7 +678,7 @@ extension Datastore where AccessMode == ReadWrite { /// Create any missing indexes or prime the datastore for writing. try await transaction.apply(descriptor: updatedDescriptor, for: self.key) - let existingEntry: (cursor: any InstanceCursorProtocol, instance: CodedType, versionData: Data, instanceData: Data)? = try await { + let existingEntry: (cursor: any InstanceCursorProtocol, instance: InstanceType, versionData: Data, instanceData: Data)? = try await { do { let existingEntry = try await transaction.primaryIndexCursor(for: idenfifier, datastoreKey: self.key) @@ -883,19 +893,19 @@ extension Datastore where AccessMode == ReadWrite { /// - instance: The instance to persist. /// - keypath: The keypath the identifier is located at. @discardableResult - public func persist(_ instance: CodedType, id keypath: KeyPath) async throws -> CodedType? { + public func persist(_ instance: InstanceType, id keypath: KeyPath) async throws -> InstanceType? { try await persist(instance, to: instance[keyPath: keypath]) } @discardableResult - public func delete(_ idenfifier: IdentifierType) async throws -> CodedType { + public func delete(_ idenfifier: IdentifierType) async throws -> InstanceType { guard let deletedInstance = try await deleteIfPresent(idenfifier) else { throw DatastoreInterfaceError.instanceNotFound } return deletedInstance } @discardableResult - public func deleteIfPresent(_ idenfifier: IdentifierType) async throws -> CodedType? { + public func deleteIfPresent(_ idenfifier: IdentifierType) async throws -> InstanceType? { try await warmupIfNeeded() return try await persistence._withTransaction( @@ -1011,41 +1021,41 @@ extension Datastore where AccessMode == ReadWrite { /// A read-only view into the data store. // TODO: Make a proper copy here - public var readOnly: Datastore { self as Any as! Datastore } + public var readOnly: Datastore { self as Any as! Datastore } } -// MARK: Identifiable CodedType +// MARK: Identifiable InstanceType -extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.ID { +extension Datastore where InstanceType: Identifiable, IdentifierType == InstanceType.ID { /// Persist an instance to the data store. /// /// If an instance does not already exist for the specified identifier, it will be created. If an instance already exists, it will be updated. /// - Parameter instance: The instance to persist. @_disfavoredOverload @discardableResult - public func persist(_ instance: CodedType) async throws -> CodedType? where AccessMode == ReadWrite { + public func persist(_ instance: InstanceType) async throws -> InstanceType? where AccessMode == ReadWrite { try await self.persist(instance, to: instance.id) } @_disfavoredOverload @discardableResult - public func delete(_ instance: CodedType) async throws -> CodedType where AccessMode == ReadWrite { + public func delete(_ instance: InstanceType) async throws -> InstanceType where AccessMode == ReadWrite { try await self.delete(instance.id) } @_disfavoredOverload @discardableResult - public func deleteIfPresent(_ instance: CodedType) async throws -> CodedType? where AccessMode == ReadWrite { + public func deleteIfPresent(_ instance: InstanceType) async throws -> InstanceType? where AccessMode == ReadWrite { try await self.deleteIfPresent(instance.id) } @_disfavoredOverload - public func load(_ instance: CodedType) async throws -> CodedType? { + public func load(_ instance: InstanceType) async throws -> InstanceType? { try await self.load(instance.id) } @_disfavoredOverload - public func observe(_ instance: CodedType) async throws -> some TypedAsyncSequence> { + public func observe(_ instance: InstanceType) async throws -> some TypedAsyncSequence> { try await observe(instance.id) } } @@ -1057,13 +1067,13 @@ extension Datastore where AccessMode == ReadWrite { persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, identifierType: IdentifierType.Type, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), - migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.init( @@ -1086,12 +1096,12 @@ extension Datastore where AccessMode == ReadWrite { persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, identifierType: IdentifierType.Type, outputFormat: PropertyListSerialization.PropertyListFormat = .binary, - migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { let encoder = PropertyListEncoder() @@ -1121,12 +1131,12 @@ extension Datastore where AccessMode == ReadOnly { persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, identifierType: IdentifierType.Type, decoder: JSONDecoder = JSONDecoder(), - migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.init( @@ -1148,11 +1158,11 @@ extension Datastore where AccessMode == ReadOnly { persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, identifierType: IdentifierType.Type, - migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { let decoder = PropertyListDecoder() @@ -1173,18 +1183,18 @@ extension Datastore where AccessMode == ReadOnly { } } -// MARK: - Identifiable CodedType Initializers +// MARK: - Identifiable InstanceType Initializers -extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.ID, AccessMode == ReadWrite { +extension Datastore where InstanceType: Identifiable, IdentifierType == InstanceType.ID, AccessMode == ReadWrite { public init( persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, - encoder: @escaping (_ object: CodedType) async throws -> Data, - decoders: [Version: (_ data: Data) async throws -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + codedType: InstanceType.Type = InstanceType.self, + encoder: @escaping (_ object: InstanceType) async throws -> Data, + decoders: [Version: (_ data: Data) async throws -> InstanceType], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) { self.init( @@ -1210,12 +1220,12 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), - migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.JSONStore( @@ -1242,11 +1252,11 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, outputFormat: PropertyListSerialization.PropertyListFormat = .binary, - migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.propertyListStore( @@ -1269,15 +1279,15 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I } } -extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.ID, AccessMode == ReadOnly { +extension Datastore where InstanceType: Identifiable, IdentifierType == InstanceType.ID, AccessMode == ReadOnly { public init( persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, - decoders: [Version: (_ data: Data) async throws -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + codedType: InstanceType.Type = InstanceType.self, + decoders: [Version: (_ data: Data) async throws -> InstanceType], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) { self.init( @@ -1302,11 +1312,11 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, + codedType: InstanceType.Type = InstanceType.self, decoder: JSONDecoder = JSONDecoder(), - migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.readOnlyJSONStore( @@ -1332,10 +1342,10 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I persistence: some Persistence, key: DatastoreKey, version: Version, - codedType: CodedType.Type = CodedType.self, - migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + codedType: InstanceType.Type = InstanceType.self, + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], + directIndexes: [IndexPath] = [], + computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.readOnlyPropertyListStore( diff --git a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift new file mode 100644 index 0000000..444737e --- /dev/null +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -0,0 +1,39 @@ +// +// DatastoreFormat.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2024-04-07. +// Copyright © 2023-24 Mochi Development, Inc. All rights reserved. +// + +import Foundation + +/// A representation of the underlying format of a ``Datastore``. +/// +/// A ``DatastoreFormat`` will be instanciated and owned by the datastore associated with it to provide both type and index information to the store. It is expected to represent the ideal types for the latest version of the code that is instantiating the datastore. +/// +/// Conformers can create subtypes for their versioned models either in the body of their struct or in legacy extentions. Additionally, you are encouraged to make **static** properties available for things like the current version, or a configured ``Datastore`` — this allows easy access to them without mucking around declaring them in far-away places in your code base. +/// +/// - Important: We discourage declaring non-static stored and computed properties on your conforming type, as that will polute the key-path namespace of the format which is used for generating getters on the datastore. +public protocol DatastoreFormat { + /// A type representing the version of the datastore on disk. + /// + /// Best represented as an enum, this represents the every single version of the datastore you wish to be able to decode from disk. Assign a new version any time the codable representation or the representation of indexes is no longer backwards compatible. + /// + /// The various ``Datastore`` initializers take a disctionary that maps between these versions and the most up-to-date Instance type, and will provide an opportunity to use legacy representations to decode the data to the expected type. + associatedtype Version: RawRepresentable & Hashable & CaseIterable where Version.RawValue: Indexable & Comparable + + /// The most up-to-date representation you use in your codebase. + associatedtype Instance: Codable + + /// The identifier to be used when de-duplicating instances saved in the persistence. + /// + /// Although ``Instance`` does _not_ need to be ``Identifiable``, a consistent identifier must still be provided for every instance to retrive and persist them. This identifier can be different from `Instance.ID` if truly necessary, though most conformers can simply set it to `Instance.ID` + associatedtype Identifier: Indexable + + init() +} + +extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable { + typealias Identifier = Instance.ID +} diff --git a/Sources/CodableDatastore/Persistence/DatastoreInterfaceProtocol.swift b/Sources/CodableDatastore/Persistence/DatastoreInterfaceProtocol.swift index 9db18c6..90fc4e4 100644 --- a/Sources/CodableDatastore/Persistence/DatastoreInterfaceProtocol.swift +++ b/Sources/CodableDatastore/Persistence/DatastoreInterfaceProtocol.swift @@ -19,7 +19,7 @@ public protocol DatastoreInterfaceProtocol { /// A datastore should only be registered once to a single persistence. /// - Parameter datastore: The datastore to register. /// - Returns: A descriptor of the datastore as the persistence knows it. - func register(datastore: Datastore) async throws -> DatastoreDescriptor? + func register(datastore: Datastore) async throws -> DatastoreDescriptor? // MARK: Descriptors diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift index bbc0eec..4c06c59 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift @@ -391,8 +391,8 @@ extension DiskPersistence where AccessMode == ReadWrite { // MARK: - Datastore Registration extension DiskPersistence { - func register( - datastore newDatastore: CodableDatastore.Datastore + func register( + datastore newDatastore: CodableDatastore.Datastore ) throws { guard let datastorePersistence = newDatastore.persistence as? DiskPersistence, @@ -555,34 +555,33 @@ class WeakDatastore { var isAlive: Bool { return true } - func contains(datastore: Datastore) -> Bool { + func contains( + datastore: Datastore + ) -> Bool { return false } } -class WeakSpecificDatastore< - Version: RawRepresentable & Hashable & CaseIterable, - CodedType: Codable, - IdentifierType: Indexable, - AccessMode: _AccessMode ->: WeakDatastore where Version.RawValue: Indexable & Comparable { - weak var datastore: Datastore? +class WeakSpecificDatastore: WeakDatastore { + weak var datastore: Datastore? override var isAlive: Bool { return datastore != nil } - init(datastore: Datastore) { + init(datastore: Datastore) { self.datastore = datastore super.init() self.canWrite = false } - init(datastore: Datastore) where AccessMode == ReadWrite { + init(datastore: Datastore) where AccessMode == ReadWrite { self.datastore = datastore super.init() self.canWrite = true } - override func contains(datastore: Datastore) -> Bool { + override func contains( + datastore: Datastore + ) -> Bool { return datastore === self.datastore } } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift index 7f1a441..f880bb4 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift @@ -304,8 +304,8 @@ extension DiskPersistence { // MARK: - Datastore Interface extension DiskPersistence.Transaction: DatastoreInterfaceProtocol { - func register( - datastore: Datastore + func register( + datastore: Datastore ) async throws -> DatastoreDescriptor? { try checkIsActive() diff --git a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift index ecc65a5..40c1cd8 100644 --- a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift +++ b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift @@ -21,56 +21,60 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testWritingEntry() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: String - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) - try await datastore.persist(TestStruct(id: "3", value: "My name is Dimitri")) - try await datastore.persist(TestStruct(id: "1", value: "Hello, World!")) - try await datastore.persist(TestStruct(id: "2", value: "Twenty Three is Number One")) + try await datastore.persist(.init(id: "3", value: "My name is Dimitri")) + try await datastore.persist(.init(id: "1", value: "Hello, World!")) + try await datastore.persist(.init(id: "2", value: "Twenty Three is Number One")) let count = try await datastore.count XCTAssertEqual(count, 3) } func testLoadingEntriesFromDisk() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: String - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + var value: String + } } do { let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -81,21 +85,21 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let entry0 = try await datastore.load("0") XCTAssertNil(entry0) - try await datastore.persist(TestStruct(id: "3", value: "My name is Dimitri")) - try await datastore.persist(TestStruct(id: "1", value: "Hello, World!")) - try await datastore.persist(TestStruct(id: "2", value: "Twenty Three is Number One")) + try await datastore.persist(.init(id: "3", value: "My name is Dimitri")) + try await datastore.persist(.init(id: "1", value: "Hello, World!")) + try await datastore.persist(.init(id: "2", value: "Twenty Three is Number One")) } catch { throw error } /// Create a brand new persistence and load the entries we saved let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -113,31 +117,33 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testWritingEntryWithIndex() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: String - @Indexed var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + @Indexed var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) - try await datastore.persist(TestStruct(id: "3", value: "My name is Dimitri")) - try await datastore.persist(TestStruct(id: "1", value: "Hello, World!")) - try await datastore.persist(TestStruct(id: "2", value: "Twenty Three is Number One")) + try await datastore.persist(.init(id: "3", value: "My name is Dimitri")) + try await datastore.persist(.init(id: "1", value: "Hello, World!")) + try await datastore.persist(.init(id: "2", value: "Twenty Three is Number One")) let count = try await datastore.count XCTAssertEqual(count, 3) @@ -147,24 +153,26 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testObservingEntries() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: String - var value: Int + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + var value: Int + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -187,12 +195,12 @@ final class DiskPersistenceDatastoreTests: XCTestCase { return total } - try await datastore.persist(TestStruct(id: "3", value: 3)) - try await datastore.persist(TestStruct(id: "1", value: 1)) - try await datastore.persist(TestStruct(id: "2", value: 2)) - try await datastore.persist(TestStruct(id: "1", value: 5)) + try await datastore.persist(.init(id: "3", value: 3)) + try await datastore.persist(.init(id: "1", value: 1)) + try await datastore.persist(.init(id: "2", value: 2)) + try await datastore.persist(.init(id: "1", value: 5)) try await datastore.delete("2") - try await datastore.persist(TestStruct(id: "1", value: 3)) + try await datastore.persist(.init(id: "1", value: 3)) let count = try await datastore.count XCTAssertEqual(count, 2) @@ -201,24 +209,26 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testRangeReads() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: Int - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: Int + var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -228,7 +238,7 @@ final class DiskPersistenceDatastoreTests: XCTestCase { XCTAssertEqual(values, []) for n in 0..<200 { - try await datastore.persist(TestStruct(id: n*2, value: "\(n*2)")) + try await datastore.persist(.init(id: n*2, value: "\(n*2)")) } let count = try await datastore.count @@ -290,25 +300,27 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testWritingManyEntries() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: UUID = UUID() - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: UUID = UUID() + var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -325,7 +337,7 @@ final class DiskPersistenceDatastoreTests: XCTestCase { for n in 1...100 { let time = ProcessInfo.processInfo.systemUptime for _ in 0..<100 { - try await datastore.persist(TestStruct(value: valueBank.randomElement()!)) + try await datastore.persist(.init(value: valueBank.randomElement()!)) } let now = ProcessInfo.processInfo.systemUptime print("\(n*100): \((100*(now - time)).rounded()/100)s - total: \((10*(now - start)).rounded()/10)s") @@ -333,25 +345,27 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testWritingManyEntriesInTransactions() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: UUID = UUID() - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: UUID = UUID() + var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -369,7 +383,7 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let time = ProcessInfo.processInfo.systemUptime try await persistence.perform { for _ in 0..<5000 { - try await datastore.persist(TestStruct(value: valueBank.randomElement()!)) + try await datastore.persist(.init(value: valueBank.randomElement()!)) } } let now = ProcessInfo.processInfo.systemUptime @@ -378,25 +392,27 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testWritingManyConsecutiveEntriesInTransactions() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: Int - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: Int + var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -415,7 +431,7 @@ final class DiskPersistenceDatastoreTests: XCTestCase { try await persistence.perform { for m in 0..<5000 { let id = (n-1)*5000 + m - try await datastore.persist(TestStruct(id: id, value: valueBank.randomElement()!)) + try await datastore.persist(.init(id: id, value: valueBank.randomElement()!)) } } let now = ProcessInfo.processInfo.systemUptime @@ -424,25 +440,27 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testReplacingEntriesInTransactions() async throws { - enum Version: Int, CaseIterable { - case zero - } - - struct TestStruct: Codable, Identifiable { - var id: Int - var value: String + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: Int + var value: String + } } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, key: "test", - version: Version.zero, + version: .zero, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -457,7 +475,7 @@ final class DiskPersistenceDatastoreTests: XCTestCase { try await persistence.perform { for id in 0..<5000 { - try await datastore.persist(TestStruct(id: id, value: valueBank.randomElement()!)) + try await datastore.persist(.init(id: id, value: valueBank.randomElement()!)) } } @@ -466,7 +484,7 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let time = ProcessInfo.processInfo.systemUptime try await persistence.perform { for _ in 0..<100 { - try await datastore.persist(TestStruct(id: Int.random(in: 0..<5000), value: valueBank.randomElement()!)) + try await datastore.persist(.init(id: Int.random(in: 0..<5000), value: valueBank.randomElement()!)) } } let now = ProcessInfo.processInfo.systemUptime diff --git a/Tests/CodableDatastoreTests/DiskTransactionTests.swift b/Tests/CodableDatastoreTests/DiskTransactionTests.swift index 3392558..95f2589 100644 --- a/Tests/CodableDatastoreTests/DiskTransactionTests.swift +++ b/Tests/CodableDatastoreTests/DiskTransactionTests.swift @@ -23,19 +23,22 @@ final class DiskTransactionTests: XCTestCase { func testApplyDescriptor() async throws { let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - enum Version: Int, CaseIterable { - case zero + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable {} + typealias Identifier = UUID } - struct TestStruct: Codable {} - let datastore = Datastore( + let datastore = Datastore( persistence: persistence, key: "test", - version: Version.zero, - codedType: TestStruct.self, + version: .zero, identifierType: UUID.self, - decoders: [.zero: { _ in (id: UUID(), instance: TestStruct()) }], + decoders: [.zero: { _ in (id: UUID(), instance: TestFormat.Instance()) }], directIndexes: [], computedIndexes: [], configuration: .init() From ae98d34c68d6d384b9a086ca1232b5e4932087dd Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 7 Apr 2024 03:22:01 -0700 Subject: [PATCH 02/10] Refactored key and current version definitions into the datastore format --- .../Datastore/Datastore.swift | 98 +++++++------------ .../Datastore/DatastoreFormat.swift | 7 ++ .../DiskPersistenceDatastoreTests.swift | 72 +++++++++----- .../DiskTransactionTests.swift | 9 +- 4 files changed, 93 insertions(+), 93 deletions(-) diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index 6559978..e17fc81 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -46,10 +46,9 @@ public actor Datastore { public init( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, - identifierType: IdentifierType.Type, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, encoder: @escaping (_ instance: InstanceType) async throws -> Data, decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], directIndexes: [IndexPath] = [], @@ -72,10 +71,9 @@ public actor Datastore { public init( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, - identifierType: IdentifierType.Type, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], directIndexes: [IndexPath] = [], computedIndexes: [IndexPath] = [], @@ -1065,10 +1063,9 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance extension Datastore where AccessMode == ReadWrite { public static func JSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, - identifierType: IdentifierType.Type, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], @@ -1080,8 +1077,6 @@ extension Datastore where AccessMode == ReadWrite { persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: identifierType, encoder: { try encoder.encode($0) }, decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } @@ -1094,10 +1089,9 @@ extension Datastore where AccessMode == ReadWrite { public static func propertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, - identifierType: IdentifierType.Type, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, outputFormat: PropertyListSerialization.PropertyListFormat = .binary, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], directIndexes: [IndexPath] = [], @@ -1113,8 +1107,6 @@ extension Datastore where AccessMode == ReadWrite { persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: identifierType, encoder: { try encoder.encode($0) }, decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } @@ -1129,10 +1121,9 @@ extension Datastore where AccessMode == ReadWrite { extension Datastore where AccessMode == ReadOnly { public static func readOnlyJSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, - identifierType: IdentifierType.Type, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], directIndexes: [IndexPath] = [], @@ -1143,8 +1134,6 @@ extension Datastore where AccessMode == ReadOnly { persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: identifierType, decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, @@ -1156,10 +1145,9 @@ extension Datastore where AccessMode == ReadOnly { public static func readOnlyPropertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, - identifierType: IdentifierType.Type, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], directIndexes: [IndexPath] = [], computedIndexes: [IndexPath] = [], @@ -1171,8 +1159,6 @@ extension Datastore where AccessMode == ReadOnly { persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: identifierType, decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, @@ -1188,9 +1174,9 @@ extension Datastore where AccessMode == ReadOnly { extension Datastore where InstanceType: Identifiable, IdentifierType == InstanceType.ID, AccessMode == ReadWrite { public init( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, encoder: @escaping (_ object: InstanceType) async throws -> Data, decoders: [Version: (_ data: Data) async throws -> InstanceType], directIndexes: [IndexPath] = [], @@ -1201,8 +1187,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, encoder: encoder, decoders: decoders.mapValues { decoder in { data in @@ -1218,9 +1202,9 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance public static func JSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], @@ -1232,8 +1216,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, encoder: encoder, decoder: decoder, migrations: migrations.mapValues { migration in @@ -1250,9 +1232,9 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance public static func propertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, outputFormat: PropertyListSerialization.PropertyListFormat = .binary, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], directIndexes: [IndexPath] = [], @@ -1263,8 +1245,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, outputFormat: outputFormat, migrations: migrations.mapValues { migration in { data, decoder in @@ -1282,9 +1262,9 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance extension Datastore where InstanceType: Identifiable, IdentifierType == InstanceType.ID, AccessMode == ReadOnly { public init( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, decoders: [Version: (_ data: Data) async throws -> InstanceType], directIndexes: [IndexPath] = [], computedIndexes: [IndexPath] = [], @@ -1294,8 +1274,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, decoders: decoders.mapValues { decoder in { data in let instance = try await decoder(data) @@ -1310,9 +1288,9 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance public static func readOnlyJSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], directIndexes: [IndexPath] = [], @@ -1323,8 +1301,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, decoder: decoder, migrations: migrations.mapValues { migration in { data, decoder in @@ -1340,9 +1316,9 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance public static func readOnlyPropertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: InstanceType.Type = InstanceType.self, + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], directIndexes: [IndexPath] = [], computedIndexes: [IndexPath] = [], @@ -1352,8 +1328,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, migrations: migrations.mapValues { migration in { data, decoder in let instance = try await migration(data, decoder) diff --git a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift index 444737e..0191200 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -31,7 +31,14 @@ public protocol DatastoreFormat { /// Although ``Instance`` does _not_ need to be ``Identifiable``, a consistent identifier must still be provided for every instance to retrive and persist them. This identifier can be different from `Instance.ID` if truly necessary, though most conformers can simply set it to `Instance.ID` associatedtype Identifier: Indexable + /// A default initializer creating a format instance the datastore can use for evaluation. init() + + /// The default key to use when accessing the datastore for this type. + static var defaultKey: DatastoreKey { get } + + /// The current version to normalize the persisted datastore to. + static var currentVersion: Version { get } } extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable { diff --git a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift index 40c1cd8..50431d6 100644 --- a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift +++ b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift @@ -30,14 +30,16 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: String var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -63,15 +65,17 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: String var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } do { let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -126,14 +130,16 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: String @Indexed var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -162,14 +168,16 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: String var value: Int } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -218,14 +226,16 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: Int var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -309,15 +319,17 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: UUID = UUID() var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -354,15 +366,17 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: UUID = UUID() var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -401,15 +415,17 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: Int var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) @@ -449,15 +465,17 @@ final class DiskPersistenceDatastoreTests: XCTestCase { var id: Int var value: String } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) try await persistence.createPersistenceIfNecessary() - let datastore = Datastore.JSONStore( + let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: .zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in try decoder.decode(TestFormat.Instance.self, from: data) diff --git a/Tests/CodableDatastoreTests/DiskTransactionTests.swift b/Tests/CodableDatastoreTests/DiskTransactionTests.swift index 95f2589..749928e 100644 --- a/Tests/CodableDatastoreTests/DiskTransactionTests.swift +++ b/Tests/CodableDatastoreTests/DiskTransactionTests.swift @@ -30,14 +30,15 @@ final class DiskTransactionTests: XCTestCase { struct Instance: Codable {} typealias Identifier = UUID + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } - let datastore = Datastore( + let datastore = Datastore( persistence: persistence, - key: "test", - version: .zero, - identifierType: UUID.self, + format: TestFormat.self, decoders: [.zero: { _ in (id: UUID(), instance: TestFormat.Instance()) }], directIndexes: [], computedIndexes: [], From 7084d334f480dd527f5d50a8ac97ea92e8ef8667 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 8 Apr 2024 02:28:17 -0700 Subject: [PATCH 03/10] Added new index types --- .../Indexes/IndexRepresentation.swift | 187 ++++++++++++++++++ .../CodableDatastore/Indexes/Indexable.swift | 59 ++++++ .../CodableDatastore/Indexes/Indexed.swift | 3 - 3 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 Sources/CodableDatastore/Indexes/IndexRepresentation.swift create mode 100644 Sources/CodableDatastore/Indexes/Indexable.swift diff --git a/Sources/CodableDatastore/Indexes/IndexRepresentation.swift b/Sources/CodableDatastore/Indexes/IndexRepresentation.swift new file mode 100644 index 0000000..2746a44 --- /dev/null +++ b/Sources/CodableDatastore/Indexes/IndexRepresentation.swift @@ -0,0 +1,187 @@ +// +// IndexRepresentation.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2024-04-07. +// Copyright © 2023-24 Mochi Development, Inc. All rights reserved. +// + +import Foundation + +/// A representation of an index for a given instance, with value information erased. +/// +/// - Note: Although conforming to this type will construct an index, you don't be able to access this index using any of the usuall accessors. Instead, consider confirming to ``RetrievableIndexRepresentation`` or ``SingleInstanceIndexRepresentation`` as appropriate. +public protocol IndexRepresentation: Hashable { + /// The instance the index belongs to. + associatedtype Instance + + /// The index type seriealized to the datastore to detect changes to index structure. + var indexType: IndexType { get } + + /// Conditionally cast the index to one that matched the instance type T. + /// - Parameter instance: The instance type that we would like to verify. + /// - Returns: The casted index. + func matches(_ instance: T.Type) -> (any IndexRepresentation)? +} + +/// A representation of an index for a given instance, preserving value information. +public protocol RetrievableIndexRepresentation: IndexRepresentation { + /// The value represented within the index. + associatedtype Value: Indexable +} + +/// A representation of an index for a given instance, where a single instance matches every provided value. +public protocol SingleInstanceIndexRepresentation< + Instance, + Value +>: RetrievableIndexRepresentation where Value: DiscreteIndexable {} + +/// A representation of an index for a given instance, where multiple index values could point to one or more instances. +public protocol MultipleInputIndexRepresentation< + Instance, + Sequence, + Value +>: RetrievableIndexRepresentation { + associatedtype Sequence: Swift.Sequence +} + +/// An index where every value matches at most a single instance. +/// +/// This type of index is typically used for most unique identifiers, and may be useful if there is an alternative unique identifier a instance may be referenced under. +public struct OneToOneIndexRepresentation< + Instance, + Value: Indexable & DiscreteIndexable +>: SingleInstanceIndexRepresentation { + let keypath: KeyPath + + /// Initialize a One-value to One-instance index. + public init(_ keypath: KeyPath) { + self.keypath = keypath + } + + public var indexType: IndexType { + IndexType("OneToOneIndex(\(String(describing: Value.self)))") + } + + public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { + guard let copy = self as? OneToOneIndexRepresentation + else { return nil } + return copy + } +} + +/// An index where every value can match any number of instances, but every instance is represented by a single value. +/// +/// This type of index is the most common, where multiple instances can share the same single value that is passed in. +public struct OneToManyIndexRepresentation< + Instance, + Value: Indexable +>: RetrievableIndexRepresentation { + let keypath: KeyPath + + /// Initialize a One-value to Many-instance index. + public init(_ keypath: KeyPath) { + self.keypath = keypath + } + + public var indexType: IndexType { + IndexType("OneToManyIndex(\(String(describing: Value.self)))") + } + + public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { + guard let copy = self as? OneToManyIndexRepresentation + else { return nil } + return copy + } +} + +/// An index where every value matches at most a single instance., but every instance can be represented by more than a single value. +/// +/// This type of index can be used if several alternative identifiers can reference an instance, and they all reside in a single property. +public struct ManyToOneIndexRepresentation< + Instance, + Sequence: Swift.Sequence, + Value: Indexable & DiscreteIndexable +>: SingleInstanceIndexRepresentation & MultipleInputIndexRepresentation { + let keypath: KeyPath + + /// Initialize a Many-value to One-instance index. + public init(_ keypath: KeyPath) { + self.keypath = keypath + } + + public var indexType: IndexType { + IndexType("ManyToOneIndex(\(String(describing: Value.self)))") + } + + public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { + guard let copy = self as? ManyToOneIndexRepresentation + else { return nil } + return copy + } +} + +/// An index where every value can match any number of instances, and every instance can be represented by more than a single value. +/// +/// This type of index is common when building relationships between different instances, where one instance may be related to several others in some way. +public struct ManyToManyIndexRepresentation< + Instance, + Sequence: Swift.Sequence, + Value: Indexable +>: MultipleInputIndexRepresentation { + let keypath: KeyPath + + /// Initialize a Many-value to Many-instance index. + public init(_ keypath: KeyPath) { + self.keypath = keypath + } + + public var indexType: IndexType { + IndexType("ManyToManyIndex(\(String(describing: Value.self)))") + } + + public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { + guard let copy = self as? ManyToManyIndexRepresentation + else { return nil } + return copy + } +} + +/// A property wrapper for marking which indexes should store instances in their entirety without needing to do a secondary lookup. +/// +/// - Note: Direct indexes are best used when reads are a #1 priority and disk space is not a concern, as each direct index duplicates the etirety of the data stored in the datastore to prioritize faster reads. +/// +/// - Important: Do not include an index for `id` if your type is Identifiable — one is created automatically on your behalf. +@propertyWrapper +public struct Direct { + /// The underlying value that the index will be based off of. + /// + /// This is ordinarily handled transparently when used as a property wrapper. + public let wrappedValue: Index + + /// Initialize an ``Indexed`` value with an initial value. + /// + /// This is ordinarily handled transparently when used as a property wrapper. + public init(wrappedValue: Index) { + self.wrappedValue = wrappedValue + } +} + +/// An internal helper protocol for detecting direct indexes when reflecting the format for compatible properties. +protocol DirectIndexRepresentation { + associatedtype Instance + + /// The underlying index being wrapped, conditionally casted if the instance types match. + /// - Parameter instance: The instance type to cast to. + /// - Returns: The casted index + func index(matching instance: T.Type) -> (any IndexRepresentation)? +} + +extension Direct: DirectIndexRepresentation { + typealias Instance = Index.Instance + + func index(matching instance: T.Type) -> (any IndexRepresentation)? { + guard let index = wrappedValue.matches(instance) else { return nil } + return index + } +} diff --git a/Sources/CodableDatastore/Indexes/Indexable.swift b/Sources/CodableDatastore/Indexes/Indexable.swift new file mode 100644 index 0000000..c1d8d8a --- /dev/null +++ b/Sources/CodableDatastore/Indexes/Indexable.swift @@ -0,0 +1,59 @@ +// +// Indexable.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2024-04-07. +// Copyright © 2023-24 Mochi Development, Inc. All rights reserved. +// + +import Foundation + +/// An alias representing the requirements for a property to be indexable, namely that they conform to both ``/Swift/Codable`` and ``/Swift/Comparable``. +public typealias Indexable = Comparable & Codable + +/// A marker protocol for types that can be used as a ranged index. +/// +/// Ranged indexes are usually used for continuous values, where it is more desirable to retrieve intances who's indexed values lie between two other values. +/// +/// - Note: If an existing type is not marked as ``RangedIndexable``, but it is advantageous for your use case for it to be marked as such and satisfies the main requirements (such as retriving a range of ordered UUIDs), simply conform that type as needed: +/// ```swift +/// extension UUID: RangedIndexable {} +/// ``` +public protocol RangedIndexable: Comparable & Codable {} + +/// A marker protocol for types that can be used as a discrete index. +/// +/// Discrete indexes are usually used for specific values, where it is more desirable to retrieve intances who's indexed values match another value exactly. +/// +/// - Note: If an existing type is not marked as ``DiscreteIndexable``, but it is advantageous for your use case for it to be marked as such and satisfies the main requirements (such as retriving a specific float value), simply conform that type as needed: +/// ```swift +/// extension Double: DiscreteIndexable {} +/// ``` +public protocol DiscreteIndexable: Equatable & Codable {} + +// MARK: - Swift Standard Library Conformances + +extension Bool: DiscreteIndexable {} +extension Double: RangedIndexable {} +@available(macOS 13.0, *) +extension Duration: RangedIndexable {} +extension Float: RangedIndexable {} +@available(macOS 11.0, *) +extension Float16: RangedIndexable {} +extension Int: DiscreteIndexable, RangedIndexable {} +extension Int8: DiscreteIndexable, RangedIndexable {} +extension Int16: DiscreteIndexable, RangedIndexable {} +extension Int32: DiscreteIndexable, RangedIndexable {} +extension Int64: DiscreteIndexable, RangedIndexable {} +extension String: DiscreteIndexable, RangedIndexable {} +extension UInt: DiscreteIndexable, RangedIndexable {} +extension UInt8: DiscreteIndexable, RangedIndexable {} +extension UInt16: DiscreteIndexable, RangedIndexable {} +extension UInt32: DiscreteIndexable, RangedIndexable {} +extension UInt64: DiscreteIndexable, RangedIndexable {} + +// MARK: - Foundation Conformances + +extension Date: RangedIndexable {} +extension Decimal: RangedIndexable {} +extension UUID: DiscreteIndexable {} diff --git a/Sources/CodableDatastore/Indexes/Indexed.swift b/Sources/CodableDatastore/Indexes/Indexed.swift index ae97991..d8058cc 100644 --- a/Sources/CodableDatastore/Indexes/Indexed.swift +++ b/Sources/CodableDatastore/Indexes/Indexed.swift @@ -8,9 +8,6 @@ import Foundation -/// An alias representing the requirements for a property to be indexable, namely that they conform to both ``/Swift/Codable`` and ``/Swift/Comparable``. -public typealias Indexable = Comparable & Codable - /// A property wrapper to mark a property as one that is indexable by a data store. /// /// Indexable properties must be ``/Swift/Codable`` so that their values can be encoded and decoded, From f3d55840d9bf94a190208d887030b858f30894d1 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 8 Apr 2024 02:29:30 -0700 Subject: [PATCH 04/10] Added reflection by default to the new DatastoreFormat protocol to introspect valid keypaths --- .../Datastore/DatastoreFormat.swift | 78 ++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift index 0191200..6193d93 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -29,7 +29,7 @@ public protocol DatastoreFormat { /// The identifier to be used when de-duplicating instances saved in the persistence. /// /// Although ``Instance`` does _not_ need to be ``Identifiable``, a consistent identifier must still be provided for every instance to retrive and persist them. This identifier can be different from `Instance.ID` if truly necessary, though most conformers can simply set it to `Instance.ID` - associatedtype Identifier: Indexable + associatedtype Identifier: Indexable & DiscreteIndexable /// A default initializer creating a format instance the datastore can use for evaluation. init() @@ -39,8 +39,82 @@ public protocol DatastoreFormat { /// The current version to normalize the persisted datastore to. static var currentVersion: Version { get } + + /// A One-value to Many-instance index. + /// + /// This type of index is the most common, where multiple instances can share the same single value that is passed in. + typealias Index = OneToManyIndexRepresentation + + /// A One-value to One-instance index. + /// + /// This type of index is typically used for most unique identifiers, and may be useful if there is an alternative unique identifier a instance may be referenced under. + typealias OneToOneIndex = OneToOneIndexRepresentation + + /// A Many-value to One-instance index. + /// + /// This type of index can be used if several alternative identifiers can reference an instance, and they all reside in a single property. + typealias ManyToManyIndex, Value: Indexable> = ManyToManyIndexRepresentation + + /// A Many-value to Many-instance index. + /// + /// This type of index is common when building relationships between different instances, where one instance may be related to several others in some way. + typealias ManyToOneIndex, Value: Indexable & DiscreteIndexable> = ManyToOneIndexRepresentation + + /// Map through the declared direct indexes, processing them as necessary. + /// + /// Although a default implementation is provided, this method can also be implemented manually by calling transform once for every index that should be registered. + /// - Parameter transform: A transformation that will be called for every direct index. + func mapDirectIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows + + /// Map through the declared reference or secondary indexes, processing them as necessary. + /// + /// Although a default implementation is provided, this method can also be implemented manually by calling transform once for every index that should be registered. + /// - Parameter transform: A transformation that will be called for every reference index. + func mapReferenceIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows +} + +extension DatastoreFormat { + func mapDirectIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows { + let mirror = Mirror(reflecting: self) + + for child in mirror.children { + guard let label = child.label else { continue } + guard + let erasedIndexRepresentation = child.value as? any DirectIndexRepresentation, + let index = erasedIndexRepresentation.index(matching: Instance.self) + else { continue } + + let indexName = if label.prefix(1) == "_" { + IndexName("\(label.dropFirst())") + } else { + IndexName(label) + } + + try transform(indexName, index) + } + } + + func mapReferenceIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows { + let mirror = Mirror(reflecting: self) + + for child in mirror.children { + guard let label = child.label else { continue } + guard + let erasedIndexRepresentation = child.value as? any IndexRepresentation, + let index = erasedIndexRepresentation.matches(Instance.self) + else { continue } + + let indexName = if label.prefix(1) == "_" { + IndexName("\(label.dropFirst())") + } else { + IndexName(label) + } + + try transform(indexName, index) + } + } } -extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable { +extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable & DiscreteIndexable { typealias Identifier = Instance.ID } From 4aa0cc1bb356ac92f47295614970051146e66cd1 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 9 Apr 2024 02:06:24 -0700 Subject: [PATCH 05/10] Refactored DatastoreDescriptor to read from the format rather than an instance --- .../Datastore/Datastore.swift | 23 +- .../Datastore/DatastoreDescriptor.swift | 157 ++++---- .../Datastore/DatastoreFormat.swift | 35 ++ .../Datastore/DatastoreRoot.swift | 10 +- .../Transaction/Transaction.swift | 2 +- .../DatastoreDescriptorTests.swift | 373 ++++++++---------- .../DiskTransactionTests.swift | 4 +- 7 files changed, 294 insertions(+), 310 deletions(-) diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index e17fc81..d7134e0 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -26,6 +26,7 @@ public actor Datastore { public typealias IdentifierType = Format.Identifier let persistence: any Persistence + let format: Format let key: DatastoreKey let version: Version let encoder: (_ instance: InstanceType) async throws -> Data @@ -56,6 +57,7 @@ public actor Datastore { configuration: Configuration = .init() ) where AccessMode == ReadWrite { self.persistence = persistence + self.format = Format() self.key = key self.version = version self.encoder = encoder @@ -80,6 +82,7 @@ public actor Datastore { configuration: Configuration = .init() ) where AccessMode == ReadOnly { self.persistence = persistence + self.format = Format() self.key = key self.version = version self.encoder = { _ in preconditionFailure("Encode called on read-only instance.") } @@ -92,17 +95,15 @@ public actor Datastore { // MARK: - Helper Methods extension Datastore { + // TODO: Remove instance requirement from this method. func updatedDescriptor(for instance: InstanceType) throws -> DatastoreDescriptor { if let updatedDescriptor { return updatedDescriptor } let descriptor = try DatastoreDescriptor( - version: version, - sampleInstance: instance, - identifierType: IdentifierType.self, - directIndexes: directIndexes, - computedIndexes: computedIndexes + format: format, + version: version ) updatedDescriptor = descriptor return descriptor @@ -221,8 +222,8 @@ extension Datastore { } /// Check existing secondary indexes for compatibility - for (_, persistedIndex) in persistedDescriptor.secondaryIndexes { - if let updatedIndex = updatedDescriptor.secondaryIndexes[persistedIndex.name] { + for (_, persistedIndex) in persistedDescriptor.referenceIndexes { + if let updatedIndex = updatedDescriptor.referenceIndexes[persistedIndex.name] { /// If the index still exists, make sure it is compatible if persistedIndex.type != updatedIndex.type { /// They were not compatible, so delete the bad index, and queue it to be re-built. @@ -236,8 +237,8 @@ extension Datastore { } /// Check for new secondary indexes to build - for (_, updatedIndex) in updatedDescriptor.secondaryIndexes { - guard persistedDescriptor.secondaryIndexes[updatedIndex.name] == nil else { continue } + for (_, updatedIndex) in updatedDescriptor.referenceIndexes { + guard persistedDescriptor.referenceIndexes[updatedIndex.name] == nil else { continue } /// The index does not yet exist, so queue it to be built. secondaryIndexesToBuild.insert(updatedIndex.name) } @@ -392,7 +393,7 @@ extension Datastore where AccessMode == ReadWrite { let descriptor = try await transaction.datastoreDescriptor(for: self.key), descriptor.size > 0, /// If we don't have an index stored, there is nothing to do here. This means we can skip checking it on the type. - let matchingIndex = descriptor.directIndexes[index.path] ?? descriptor.secondaryIndexes[index.path], + let matchingIndex = descriptor.directIndexes[index.path] ?? descriptor.referenceIndexes[index.path], /// We don't care in this method of the version is incompatible — the index will be discarded. let version = try? Version(matchingIndex.version), /// Make sure the stored version is smaller than the one we require, otherwise stop early. @@ -411,7 +412,7 @@ extension Datastore where AccessMode == ReadWrite { let descriptor = try await transaction.datastoreDescriptor(for: self.key), descriptor.size > 0, /// If we don't have an index stored, there is nothing to do here. This means we can skip checking it on the type. - let matchingIndex = descriptor.directIndexes[index.path] ?? descriptor.secondaryIndexes[index.path], + let matchingIndex = descriptor.directIndexes[index.path] ?? descriptor.referenceIndexes[index.path], /// We don't care in this method of the version is incompatible — the index will be discarded. let version = try? Version(matchingIndex.version), /// Make sure the stored version is smaller than the one we require, otherwise stop early. diff --git a/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift b/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift index c9614e8..10ce0a6 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift @@ -11,7 +11,7 @@ import Foundation /// A description of a ``Datastore``'s requirements of a persistence. /// /// A persistence is expected to save a description and retrieve it when a connected ``Datastore`` requests it. The ``Datastore`` then uses it to compute if indexes need to be invalidated or re-built. -public struct DatastoreDescriptor: Codable, Equatable, Hashable { +public struct DatastoreDescriptor: Equatable, Hashable { /// The version that was current at time of serialization. /// /// If a ``Datastore`` cannot decode this version, the datastore is presumed inaccessible, and any reads or writes will fail. @@ -20,7 +20,7 @@ public struct DatastoreDescriptor: Codable, Equatable, Hashable { /// The main type the ``Datastore`` serves. /// /// This type information is strictly informational — it can freely change between runs so long as the codable representations are compatible. - public var codedType: String + public var instanceType: String /// The type used to identify instances in the ``Datastore``. /// @@ -39,12 +39,59 @@ public struct DatastoreDescriptor: Codable, Equatable, Hashable { /// Secondary indexes store just the value being indexed, and point to the object in the primary datastore. /// /// If the index produces the same value, the identifier of the instance is implicitly used as a secondary sort parameter. - public var secondaryIndexes: [String : IndexDescriptor] + public var referenceIndexes: [String : IndexDescriptor] /// The number of instances the ``Datastore`` manages. public var size: Int } +extension DatastoreDescriptor { + @available(*, deprecated, renamed: "instanceType", message: "Deprecated in favor of instanceType.") + public var codedType: String { + get { instanceType } + set { instanceType = newValue } + } + + @available(*, deprecated, renamed: "referenceIndexes", message: "Deprecated in favor of referenceIndexes.") + public var secondaryIndexes: [String : IndexDescriptor] { + get { referenceIndexes } + set { referenceIndexes = newValue } + } +} + +extension DatastoreDescriptor: Codable { + enum CodingKeys: CodingKey { + case version + case instanceType + case codedType // Deprecated + case identifierType + case directIndexes + case referenceIndexes + case secondaryIndexes // Deprecated + case size + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.version = try container.decode(Data.self, forKey: .version) + self.instanceType = try container.decodeIfPresent(String.self, forKey: .instanceType) ?? container.decode(String.self, forKey: .codedType) + self.identifierType = try container.decode(String.self, forKey: .identifierType) + self.directIndexes = try container.decode([String : DatastoreDescriptor.IndexDescriptor].self, forKey: .directIndexes) + self.referenceIndexes = try container.decodeIfPresent([String : DatastoreDescriptor.IndexDescriptor].self, forKey: .referenceIndexes) ?? container.decode([String : DatastoreDescriptor.IndexDescriptor].self, forKey: .secondaryIndexes) + self.size = try container.decode(Int.self, forKey: .size) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.version, forKey: .version) + try container.encode(self.instanceType, forKey: .instanceType) + try container.encode(self.identifierType, forKey: .identifierType) + try container.encode(self.directIndexes, forKey: .directIndexes) + try container.encode(self.referenceIndexes, forKey: .referenceIndexes) + try container.encode(self.size, forKey: .size) + } +} + extension DatastoreDescriptor { /// A description of an Index used by a ``Datastore``. /// @@ -74,105 +121,55 @@ extension DatastoreDescriptor { extension DatastoreDescriptor { /// Initialize a descriptor from types a ``Datastore`` deals in directly. /// - /// This will use Swift reflection to infer the indexable properties from those that use the @``Indexed`` property wrapper. - /// + /// This will use Swift reflection to infer the indexes from the conforming ``DatastoreFormat`` instance. + /// /// - Parameters: + /// - format:The format of the datastore as described by the caller. /// - version: The current version being used by a data store. - /// - sampleInstance: A sample instance to use reflection on. - /// - identifierType: The identifier type the data store was created with. - /// - directIndexPaths: A list of direct indexes to describe from the sample instance. - /// - computedIndexPaths: Additional secondary indexes to describe from the same instance. - init< - Version: RawRepresentable & Hashable & CaseIterable, - CodedType: Codable, - IdentifierType: Indexable - >( - version: Version, - sampleInstance: CodedType, - identifierType: IdentifierType.Type, - directIndexes directIndexPaths: [IndexPath], - computedIndexes computedIndexPaths: [IndexPath] - ) throws where Version.RawValue: Indexable { + init( + format: Format, + version: Format.Version + ) throws { let versionData = try Data(version) - var directIndexes: Set = [] - var secondaryIndexes: Set = [] + var directIndexes: [String : IndexDescriptor] = [:] + var referenceIndexes: [String : IndexDescriptor] = [:] - for indexPath in computedIndexPaths { - let indexDescriptor = IndexDescriptor( - version: versionData, - sampleInstance: sampleInstance, - indexPath: indexPath - ) - - /// If the type is identifiable, skip the `id` index as we always make one based on `id` - if indexDescriptor.name == "$id" && sampleInstance is any Identifiable { - continue - } + format.mapReferenceIndexes { indexName, index in + guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" + else { return } - secondaryIndexes.insert(indexDescriptor) - } - - for indexPath in directIndexPaths { let indexDescriptor = IndexDescriptor( version: versionData, - sampleInstance: sampleInstance, - indexPath: indexPath + name: indexName, + type: index.indexType ) - /// If the type is identifiable, skip the `id` index as we always make one based on `id` - if indexDescriptor.name == "$id" && sampleInstance is any Identifiable { - continue - } - - /// Make sure the secondary indexes don't contain any of the direct indexes - secondaryIndexes.remove(indexDescriptor) - directIndexes.insert(indexDescriptor) + referenceIndexes[indexName.rawValue] = indexDescriptor } - Mirror.indexedChildren(from: sampleInstance) { indexName, value in - let indexName = IndexName(indexName) + format.mapDirectIndexes { indexName, index in + guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" + else { return } + let indexDescriptor = IndexDescriptor( version: versionData, name: indexName, - type: value.projectedValue.indexedType + type: index.indexType ) - if !directIndexes.contains(indexDescriptor) { - secondaryIndexes.insert(indexDescriptor) - } + /// Make sure the reference indexes don't contain any of the direct indexes + referenceIndexes.removeValue(forKey: indexName.rawValue) + directIndexes[indexName.rawValue] = indexDescriptor } self.init( version: versionData, - codedType: String(describing: type(of: sampleInstance)), - identifierType: String(describing: identifierType), - directIndexes: Dictionary(uniqueKeysWithValues: directIndexes.map({ ($0.name.rawValue, $0) })), - secondaryIndexes: Dictionary(uniqueKeysWithValues: secondaryIndexes.map({ ($0.name.rawValue, $0) })), + instanceType: String(describing: Format.Instance.self), + identifierType: String(describing: Format.Identifier.self), + directIndexes: directIndexes, + referenceIndexes: referenceIndexes, size: 0 ) } } - -extension DatastoreDescriptor.IndexDescriptor { - /// Initialize a descriptor from a key path. - /// - /// - Parameters: - /// - version: The current version being used by a data store. - /// - sampleInstance: A sample instance to probe for type information. - /// - indexPath: The ``IndexPath`` to the indexed property. - init( - version: Data, - sampleInstance: CodedType, - indexPath: IndexPath - ) { - let sampleIndexValue = sampleInstance[keyPath: indexPath] - let indexType = sampleIndexValue.indexedType - - self.init( - version: version, - name: indexPath.path, - type: indexType - ) - } -} diff --git a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift index 6193d93..5f44848 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -12,8 +12,43 @@ import Foundation /// /// A ``DatastoreFormat`` will be instanciated and owned by the datastore associated with it to provide both type and index information to the store. It is expected to represent the ideal types for the latest version of the code that is instantiating the datastore. /// +/// This type also exists so implementers can conform a `struct` to it that declares a number of key paths as stored properties. +/// /// Conformers can create subtypes for their versioned models either in the body of their struct or in legacy extentions. Additionally, you are encouraged to make **static** properties available for things like the current version, or a configured ``Datastore`` — this allows easy access to them without mucking around declaring them in far-away places in your code base. /// +/// ```swift +/// struct BooksFormat { +/// static let defaultKey: DatastoreKey = "BooksStore" +/// static let currentVersion: Version = .one +/// +/// enum Version: String { +/// case zero = "2024-04-01" +/// case one = "2024-04-09" +/// } +/// +/// typealias Instance = Book +/// +/// struct BookV1: Codable, Identifiable { +/// var id: UUID +/// var title: String +/// var author: String +/// } +/// +/// struct Book: Codable, Identifiable { +/// var id: UUID +/// var title: SortableTitle +/// var authors: [AuthorID] +/// var isbn: ISBN +/// } +/// +/// let title = Index(\.title) +/// let author = ManyToMany(\.author) +/// let isbn = OneToOne(\.isbn) +/// } +/// ``` +/// +/// - Note: If your ``Instance`` type is ``/Swift/Identifiable``, you should _not_ declare an index for `id` — special accessors are created on your behalf that can be used instead. +/// /// - Important: We discourage declaring non-static stored and computed properties on your conforming type, as that will polute the key-path namespace of the format which is used for generating getters on the datastore. public protocol DatastoreFormat { /// A type representing the version of the datastore on disk. diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift index dc7a53d..2a665cb 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift @@ -156,7 +156,7 @@ extension DiskPersistence.Datastore.RootObject { var manifest = originalManifest manifest.descriptor.version = descriptor.version - manifest.descriptor.codedType = descriptor.codedType + manifest.descriptor.instanceType = descriptor.instanceType manifest.descriptor.identifierType = descriptor.identifierType var createdIndexes: Set = [] @@ -202,12 +202,12 @@ extension DiskPersistence.Datastore.RootObject { ) } - for (_, indexDescriptor) in descriptor.secondaryIndexes { + for (_, indexDescriptor) in descriptor.referenceIndexes { let indexName = indexDescriptor.name let indexType = indexDescriptor.type var version = indexDescriptor.version - if let originalVersion = originalManifest.descriptor.secondaryIndexes[indexName]?.version { + if let originalVersion = originalManifest.descriptor.referenceIndexes[indexName]?.version { version = originalVersion } else { let indexInfo = DatastoreRootManifest.IndexInfo( @@ -229,7 +229,7 @@ extension DiskPersistence.Datastore.RootObject { manifest.secondaryIndexManifests.append(indexInfo) } - manifest.descriptor.secondaryIndexes[indexName] = DatastoreDescriptor.IndexDescriptor( + manifest.descriptor.referenceIndexes[indexName] = DatastoreDescriptor.IndexDescriptor( version: version, name: indexName, type: indexType @@ -339,7 +339,7 @@ extension DiskPersistence.Datastore.RootObject { if let entryIndex = updatedManifest.secondaryIndexManifests.firstIndex(where: { $0.id == indexID }) { let indexName = updatedManifest.secondaryIndexManifests[entryIndex].name updatedManifest.secondaryIndexManifests.remove(at: entryIndex) - updatedManifest.descriptor.secondaryIndexes.removeValue(forKey: indexName) + updatedManifest.descriptor.referenceIndexes.removeValue(forKey: indexName) } } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift index f880bb4..9a551ff 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift @@ -365,7 +365,7 @@ extension DiskPersistence.Transaction: DatastoreInterfaceProtocol { ) } - let secondaryIndexManifests = descriptor.secondaryIndexes.map { (_, index) in + let secondaryIndexManifests = descriptor.referenceIndexes.map { (_, index) in DatastoreRootManifest.IndexInfo( name: index.name, id: DatastoreIndexIdentifier(name: index.name), diff --git a/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift b/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift index 81ddbe0..078e630 100644 --- a/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift +++ b/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift @@ -34,53 +34,9 @@ final class DatastoreDescriptorTests: XCTestCase { XCTAssertFalse(desc2 < desc1) } - func testIndexDescriptorReflection() throws { - enum Enum: Codable, Comparable { - case a, b, c - } - - struct Nested: Codable { - @Indexed - var a: Enum - } - - struct SampleType: Codable { - @Indexed - var a: String - - @Indexed - var b: Int - - var c: Nested - - var d: _AnyIndexed { Indexed(wrappedValue: "\(a).\(b)").projectedValue } - } - - let sample = SampleType(a: "A", b: 1, c: Nested(a: .b)) - - let descA = DatastoreDescriptor.IndexDescriptor(version: Data([0]), sampleInstance: sample, indexPath: IndexPath(uncheckedKeyPath: \.$a, path: "$a")) - XCTAssertEqual(descA.version, Data([0])) - XCTAssertEqual(descA.name, "$a") - XCTAssertEqual(descA.type, "String") - - let descB = DatastoreDescriptor.IndexDescriptor(version: Data([0]), sampleInstance: sample, indexPath: IndexPath(uncheckedKeyPath: \.$b, path: "$b")) - XCTAssertEqual(descB.version, Data([0])) - XCTAssertEqual(descB.name, "$b") - XCTAssertEqual(descB.type, "Int") - - let descC = DatastoreDescriptor.IndexDescriptor(version: Data([0]), sampleInstance: sample, indexPath: IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a")) - XCTAssertEqual(descC.version, Data([0])) - XCTAssertEqual(descC.name, "c.$a") - XCTAssertEqual(descC.type, "Enum") - - let descD = DatastoreDescriptor.IndexDescriptor(version: Data([0]), sampleInstance: sample, indexPath: IndexPath(uncheckedKeyPath: \.d, path: "d")) - XCTAssertEqual(descD.version, Data([0])) - XCTAssertEqual(descD.name, "d") - XCTAssertEqual(descD.type, "String") - } func testTypeReflection() throws { - enum Version: String, CaseIterable { + enum SharedVersion: String, CaseIterable { case a, b, c } @@ -89,132 +45,134 @@ final class DatastoreDescriptorTests: XCTestCase { } struct Nested: Codable { - @Indexed var a: Enum } struct SampleType: Codable { - @Indexed var id: UUID - - @Indexed var a: String - - @Indexed var b: Int - var c: Nested - - var d: _AnyIndexed { Indexed(wrappedValue: "\(a).\(b)").projectedValue } + var d: String { "\(a).\(b)" } } - let sample = SampleType(id: UUID(), a: "A", b: 1, c: Nested(a: .b)) + struct SampleFormatA: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + typealias Identifier = UUID + + let id = OneToOneIndex(\.id) + let a = Index(\.a) + let b = Index(\.b) + } let descA = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [], - computedIndexes: [] + format: SampleFormatA(), + version: .a ) XCTAssertEqual(descA.version, Data([34, 97, 34])) - XCTAssertEqual(descA.codedType, "SampleType") + XCTAssertEqual(descA.instanceType, "SampleType") XCTAssertEqual(descA.identifierType, "UUID") XCTAssertEqual(descA.directIndexes, [:]) - XCTAssertEqual(descA.secondaryIndexes, [ - "$id" : .init(version: Data([34, 97, 34]), name: "$id", type: "UUID"), - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), + XCTAssertEqual(descA.referenceIndexes, [ + "id" : .init(version: Data([34, 97, 34]), name: "id", type: "OneToOneIndex(UUID)"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), ]) + struct SampleFormatB: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + typealias Identifier = UUID + + let id = OneToOneIndex(\.id) + @Direct var a = Index(\.a) + let b = Index(\.b) + } + let descB = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [IndexPath(uncheckedKeyPath: \.$a, path: "$a")], - computedIndexes: [] + format: SampleFormatB(), + version: .a ) XCTAssertEqual(descB.version, Data([34, 97, 34])) - XCTAssertEqual(descB.codedType, "SampleType") + XCTAssertEqual(descB.instanceType, "SampleType") XCTAssertEqual(descB.identifierType, "UUID") XCTAssertEqual(descB.directIndexes, [ - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), ]) - XCTAssertEqual(descB.secondaryIndexes, [ - "$id" : .init(version: Data([34, 97, 34]), name: "$id", type: "UUID"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), + XCTAssertEqual(descB.referenceIndexes, [ + "id" : .init(version: Data([34, 97, 34]), name: "id", type: "OneToOneIndex(UUID)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), ]) + struct SampleFormatC: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + typealias Identifier = UUID + + let id = OneToOneIndex(\.id) + let a = Index(\.a) + let b = Index(\.b) + @Direct var c = Index(\.c.a) + } + let descC = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a")], - computedIndexes: [] + format: SampleFormatC(), + version: .a ) XCTAssertEqual(descC.version, Data([34, 97, 34])) - XCTAssertEqual(descC.codedType, "SampleType") + XCTAssertEqual(descC.instanceType, "SampleType") XCTAssertEqual(descC.identifierType, "UUID") XCTAssertEqual(descC.directIndexes, [ - "c.$a" : .init(version: Data([34, 97, 34]), name: "c.$a", type: "Enum"), + "c" : .init(version: Data([34, 97, 34]), name: "c", type: "OneToManyIndex(Enum)"), ]) - XCTAssertEqual(descC.secondaryIndexes, [ - "$id" : .init(version: Data([34, 97, 34]), name: "$id", type: "UUID"), - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), + XCTAssertEqual(descC.referenceIndexes, [ + "id" : .init(version: Data([34, 97, 34]), name: "id", type: "OneToOneIndex(UUID)"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), ]) + struct SampleFormatD: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + typealias Identifier = UUID + + @Direct var id = OneToOneIndex(\.id) + @Direct var a = Index(\.a) + @Direct var b = Index(\.b) + @Direct var c = Index(\.c.a) + } + let descD = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a")], - computedIndexes: [ - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a"), - IndexPath(uncheckedKeyPath: \.$b, path: "$b") - ] + format: SampleFormatD(), + version: .a ) XCTAssertEqual(descD.version, Data([34, 97, 34])) - XCTAssertEqual(descD.codedType, "SampleType") + XCTAssertEqual(descD.instanceType, "SampleType") XCTAssertEqual(descD.identifierType, "UUID") XCTAssertEqual(descD.directIndexes, [ - "c.$a" : .init(version: Data([34, 97, 34]), name: "c.$a", type: "Enum"), + "id" : .init(version: Data([34, 97, 34]), name: "id", type: "OneToOneIndex(UUID)"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), + "c" : .init(version: Data([34, 97, 34]), name: "c", type: "OneToManyIndex(Enum)"), ]) - XCTAssertEqual(descD.secondaryIndexes, [ - "$id" : .init(version: Data([34, 97, 34]), name: "$id", type: "UUID"), - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), - ]) - - let descE = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [ - IndexPath(uncheckedKeyPath: \.$id, path: "$id"), - IndexPath(uncheckedKeyPath: \.$a, path: "$a"), - IndexPath(uncheckedKeyPath: \.$b, path: "$b"), - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a") - ], - computedIndexes: [ - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a"), - IndexPath(uncheckedKeyPath: \.$b, path: "$b") - ] - ) - XCTAssertEqual(descE.version, Data([34, 97, 34])) - XCTAssertEqual(descE.codedType, "SampleType") - XCTAssertEqual(descE.identifierType, "UUID") - XCTAssertEqual(descE.directIndexes, [ - "$id" : .init(version: Data([34, 97, 34]), name: "$id", type: "UUID"), - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), - "c.$a" : .init(version: Data([34, 97, 34]), name: "c.$a", type: "Enum"), - ]) - XCTAssertEqual(descE.secondaryIndexes, [:]) + XCTAssertEqual(descD.referenceIndexes, [:]) } func testTypeIdentifiableReflection() throws { - enum Version: String, CaseIterable { + enum SharedVersion: String, CaseIterable { case a, b, c } @@ -223,128 +181,121 @@ final class DatastoreDescriptorTests: XCTestCase { } struct Nested: Codable { - @Indexed var a: Enum } struct SampleType: Codable, Identifiable { - @Indexed var id: UUID - - @Indexed var a: String - - @Indexed var b: Int - var c: Nested - - var d: _AnyIndexed { Indexed(wrappedValue: "\(a).\(b)").projectedValue } + var d: String { "\(a).\(b)" } } - let sample = SampleType(id: UUID(), a: "A", b: 1, c: Nested(a: .b)) + struct SampleFormatA: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + + let id = OneToOneIndex(\.id) + let a = Index(\.a) + let b = Index(\.b) + } let descA = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [], - computedIndexes: [] + format: SampleFormatA(), + version: .a ) XCTAssertEqual(descA.version, Data([34, 97, 34])) - XCTAssertEqual(descA.codedType, "SampleType") + XCTAssertEqual(descA.instanceType, "SampleType") XCTAssertEqual(descA.identifierType, "UUID") XCTAssertEqual(descA.directIndexes, [:]) - XCTAssertEqual(descA.secondaryIndexes, [ - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), + XCTAssertEqual(descA.referenceIndexes, [ + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), ]) + struct SampleFormatB: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + + let id = OneToOneIndex(\.id) + @Direct var a = Index(\.a) + let b = Index(\.b) + } + let descB = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [ - IndexPath(uncheckedKeyPath: \.$a, path: "$a") - ], - computedIndexes: [] + format: SampleFormatB(), + version: .a ) XCTAssertEqual(descB.version, Data([34, 97, 34])) - XCTAssertEqual(descB.codedType, "SampleType") + XCTAssertEqual(descB.instanceType, "SampleType") XCTAssertEqual(descB.identifierType, "UUID") XCTAssertEqual(descB.directIndexes, [ - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), ]) - XCTAssertEqual(descB.secondaryIndexes, [ - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), + XCTAssertEqual(descB.referenceIndexes, [ + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), ]) + struct SampleFormatC: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + + let id = OneToOneIndex(\.id) + let a = Index(\.a) + let b = Index(\.b) + @Direct var c = Index(\.c.a) + } + let descC = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [ - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a") - ], - computedIndexes: [] + format: SampleFormatC(), + version: .a ) XCTAssertEqual(descC.version, Data([34, 97, 34])) - XCTAssertEqual(descC.codedType, "SampleType") + XCTAssertEqual(descC.instanceType, "SampleType") XCTAssertEqual(descC.identifierType, "UUID") XCTAssertEqual(descC.directIndexes, [ - "c.$a" : .init(version: Data([34, 97, 34]), name: "c.$a", type: "Enum"), + "c" : .init(version: Data([34, 97, 34]), name: "c", type: "OneToManyIndex(Enum)"), ]) - XCTAssertEqual(descC.secondaryIndexes, [ - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), + XCTAssertEqual(descC.referenceIndexes, [ + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), ]) + struct SampleFormatD: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + + @Direct var id = OneToOneIndex(\.id) + @Direct var a = Index(\.a) + @Direct var b = Index(\.b) + @Direct var c = Index(\.c.a) + } + let descD = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [ - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a") - ], - computedIndexes: [ - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a"), - IndexPath(uncheckedKeyPath: \.$b, path: "$b") - ] + format: SampleFormatD(), + version: .a ) XCTAssertEqual(descD.version, Data([34, 97, 34])) - XCTAssertEqual(descD.codedType, "SampleType") + XCTAssertEqual(descD.instanceType, "SampleType") XCTAssertEqual(descD.identifierType, "UUID") XCTAssertEqual(descD.directIndexes, [ - "c.$a" : .init(version: Data([34, 97, 34]), name: "c.$a", type: "Enum"), - ]) - XCTAssertEqual(descD.secondaryIndexes, [ - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), - ]) - - let descE = try DatastoreDescriptor( - version: Version.a, - sampleInstance: sample, - identifierType: UUID.self, - directIndexes: [ - IndexPath(uncheckedKeyPath: \.$id, path: "$id"), - IndexPath(uncheckedKeyPath: \.$a, path: "$a"), - IndexPath(uncheckedKeyPath: \.$b, path: "$b"), - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a") - ], - computedIndexes: [ - IndexPath(uncheckedKeyPath: \.c.$a, path: "c.$a"), - IndexPath(uncheckedKeyPath: \.$b, path: "$b") - ] - ) - XCTAssertEqual(descE.version, Data([34, 97, 34])) - XCTAssertEqual(descE.codedType, "SampleType") - XCTAssertEqual(descE.identifierType, "UUID") - XCTAssertEqual(descE.directIndexes, [ - "$a" : .init(version: Data([34, 97, 34]), name: "$a", type: "String"), - "$b" : .init(version: Data([34, 97, 34]), name: "$b", type: "Int"), - "c.$a" : .init(version: Data([34, 97, 34]), name: "c.$a", type: "Enum"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), + "c" : .init(version: Data([34, 97, 34]), name: "c", type: "OneToManyIndex(Enum)"), ]) - XCTAssertEqual(descE.secondaryIndexes, [:]) + XCTAssertEqual(descD.referenceIndexes, [:]) } } diff --git a/Tests/CodableDatastoreTests/DiskTransactionTests.swift b/Tests/CodableDatastoreTests/DiskTransactionTests.swift index 749928e..2e49875 100644 --- a/Tests/CodableDatastoreTests/DiskTransactionTests.swift +++ b/Tests/CodableDatastoreTests/DiskTransactionTests.swift @@ -47,10 +47,10 @@ final class DiskTransactionTests: XCTestCase { let descriptor = DatastoreDescriptor( version: Data([0x00]), - codedType: "TestStruct", + instanceType: "TestStruct", identifierType: "UUID", directIndexes: [:], - secondaryIndexes: [:], + referenceIndexes: [:], size: 0 ) From 1495535e262c07c2d0c47e578ec6c3d2c26375fa Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Wed, 10 Apr 2024 02:18:44 -0700 Subject: [PATCH 06/10] Refactored index mapping to be done in a single pass --- .../Datastore/DatastoreDescriptor.swift | 29 +++--- .../Datastore/DatastoreFormat.swift | 92 +++++++++++++------ .../GeneratedIndexRepresentation.swift | 26 ++++++ .../Indexes/IndexStorage.swift | 16 ++++ 4 files changed, 116 insertions(+), 47 deletions(-) create mode 100644 Sources/CodableDatastore/Indexes/GeneratedIndexRepresentation.swift create mode 100644 Sources/CodableDatastore/Indexes/IndexStorage.swift diff --git a/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift b/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift index 10ce0a6..1d653ab 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift @@ -135,32 +135,25 @@ extension DatastoreDescriptor { var directIndexes: [String : IndexDescriptor] = [:] var referenceIndexes: [String : IndexDescriptor] = [:] - format.mapReferenceIndexes { indexName, index in + format.mapIndexRepresentations { generatedRepresentation in + let indexName = generatedRepresentation.indexName guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" else { return } let indexDescriptor = IndexDescriptor( version: versionData, name: indexName, - type: index.indexType + type: generatedRepresentation.index.indexType ) - referenceIndexes[indexName.rawValue] = indexDescriptor - } - - format.mapDirectIndexes { indexName, index in - guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" - else { return } - - let indexDescriptor = IndexDescriptor( - version: versionData, - name: indexName, - type: index.indexType - ) - - /// Make sure the reference indexes don't contain any of the direct indexes - referenceIndexes.removeValue(forKey: indexName.rawValue) - directIndexes[indexName.rawValue] = indexDescriptor + switch generatedRepresentation.storage { + case .direct: + /// Make sure the reference indexes don't contain any of the direct indexes + referenceIndexes.removeValue(forKey: indexName.rawValue) + directIndexes[indexName.rawValue] = indexDescriptor + case .reference: + referenceIndexes[indexName.rawValue] = indexDescriptor + } } self.init( diff --git a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift index 5f44848..241a66d 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -95,58 +95,92 @@ public protocol DatastoreFormat { /// This type of index is common when building relationships between different instances, where one instance may be related to several others in some way. typealias ManyToOneIndex, Value: Indexable & DiscreteIndexable> = ManyToOneIndexRepresentation - /// Map through the declared direct indexes, processing them as necessary. + /// Map through the declared indexes, processing them as necessary. /// /// Although a default implementation is provided, this method can also be implemented manually by calling transform once for every index that should be registered. - /// - Parameter transform: A transformation that will be called for every direct index. - func mapDirectIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows + /// - Parameter transform: A transformation that will be called for every index. + func mapIndexRepresentations(assertIdentifiable: Bool, transform: (GeneratedIndexRepresentation) throws -> ()) rethrows - /// Map through the declared reference or secondary indexes, processing them as necessary. + /// Map through the declared indexes asynchronously, processing them as necessary. /// /// Although a default implementation is provided, this method can also be implemented manually by calling transform once for every index that should be registered. - /// - Parameter transform: A transformation that will be called for every reference index. - func mapReferenceIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows + /// - Parameter transform: A transformation that will be called for every index. + func mapIndexRepresentations(assertIdentifiable: Bool, transform: (GeneratedIndexRepresentation) async throws -> ()) async rethrows } extension DatastoreFormat { - func mapDirectIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows { + public func mapIndexRepresentations( + assertIdentifiable: Bool = false, + transform: (GeneratedIndexRepresentation) throws -> () + ) rethrows { let mirror = Mirror(reflecting: self) for child in mirror.children { - guard let label = child.label else { continue } - guard - let erasedIndexRepresentation = child.value as? any DirectIndexRepresentation, - let index = erasedIndexRepresentation.index(matching: Instance.self) + guard let generatedIndex = generateIndexRepresentation(child: child, assertIdentifiable: assertIdentifiable) else { continue } - - let indexName = if label.prefix(1) == "_" { - IndexName("\(label.dropFirst())") - } else { - IndexName(label) - } - try transform(indexName, index) + try transform(generatedIndex) } } - func mapReferenceIndexes(transform: (_ indexName: IndexName, _ index: any IndexRepresentation) throws -> ()) rethrows { + public func mapIndexRepresentations( + assertIdentifiable: Bool = false, + transform: (GeneratedIndexRepresentation) async throws -> () + ) async rethrows { let mirror = Mirror(reflecting: self) for child in mirror.children { - guard let label = child.label else { continue } - guard - let erasedIndexRepresentation = child.value as? any IndexRepresentation, - let index = erasedIndexRepresentation.matches(Instance.self) + guard let generatedIndex = generateIndexRepresentation(child: child, assertIdentifiable: assertIdentifiable) else { continue } - let indexName = if label.prefix(1) == "_" { - IndexName("\(label.dropFirst())") - } else { - IndexName(label) + try await transform(generatedIndex) + } + } + + /// Generate an index representation for a given mirror's child, or return nil if no valid index was found. + /// - Parameters: + /// - child: The child to introspect. + /// - assertIdentifiable: A flag to throw an assert if an `id` field was found when it would otherwise be a mistake. + /// - Returns: The generated index representation, or nil if one could not be found. + public func generateIndexRepresentation( + child: Mirror.Child, + assertIdentifiable: Bool = false + ) -> GeneratedIndexRepresentation? { + guard let label = child.label else { return nil } + + let storage: IndexStorage + let index: any IndexRepresentation + if let erasedIndexRepresentation = child.value as? any DirectIndexRepresentation, + let matchingIndex = erasedIndexRepresentation.index(matching: Instance.self) { + index = matchingIndex + storage = .direct + } else if let erasedIndexRepresentation = child.value as? any IndexRepresentation, + let matchingIndex = erasedIndexRepresentation.matches(Instance.self) { + index = matchingIndex + storage = .reference + } else { + return nil + } + + let indexName = if label.prefix(1) == "_" { + IndexName("\(label.dropFirst())") + } else { + IndexName(label) + } + + /// If the type is identifiable, skip the `id` index as we always make one based on `id` + if indexName == "id", Instance.self as? any Identifiable.Type != nil { + if assertIdentifiable { + assertionFailure("\(String(describing: Self.self)) declared `id` as an index, when the conformance is automatic since \(String(describing: Instance.self)) is Identifiable and \(String(describing: Self.self)).ID matches \(String(describing: Identifier.self)). Please remove the `id` member from the format.") } - - try transform(indexName, index) + return nil } + + return GeneratedIndexRepresentation( + indexName: indexName, + index: index, + storage: storage + ) } } diff --git a/Sources/CodableDatastore/Indexes/GeneratedIndexRepresentation.swift b/Sources/CodableDatastore/Indexes/GeneratedIndexRepresentation.swift new file mode 100644 index 0000000..0ceb560 --- /dev/null +++ b/Sources/CodableDatastore/Indexes/GeneratedIndexRepresentation.swift @@ -0,0 +1,26 @@ +// +// GeneratedIndexRepresentation.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2024-04-10. +// Copyright © 2023-24 Mochi Development, Inc. All rights reserved. +// + +/// A helper type for passing around metadata about an index. +public struct GeneratedIndexRepresentation { + /// The name the index should be serialized under. + public var indexName: IndexName + + /// The index itself, which can be queried accordingly. + public var index: any IndexRepresentation + + /// If the index is direct or referential in nature. + public var storage: IndexStorage + + /// Initialize a new generated index representation. + public init(indexName: IndexName, index: any IndexRepresentation, storage: IndexStorage) { + self.indexName = indexName + self.index = index + self.storage = storage + } +} diff --git a/Sources/CodableDatastore/Indexes/IndexStorage.swift b/Sources/CodableDatastore/Indexes/IndexStorage.swift new file mode 100644 index 0000000..6aee8b5 --- /dev/null +++ b/Sources/CodableDatastore/Indexes/IndexStorage.swift @@ -0,0 +1,16 @@ +// +// IndexStorage.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2024-04-10. +// Copyright © 2023-24 Mochi Development, Inc. All rights reserved. +// + +/// Indicates how instances are stored in the presistence. +public enum IndexStorage { + /// Instances are stored in the index directly, requiring no further reads to access them. + case direct + + /// Instances are only references in the index, and must be fetched in the principle index to complete a read. + case reference +} From b2bcbe2ea4e92adee054b101fd7d514e89640329 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 12 Apr 2024 01:28:23 -0700 Subject: [PATCH 07/10] Updated indexes to be completely defined by the format Fixes #165 #169 #178 #185 --- .../Datastore/Datastore.swift | 738 +++++++++--------- .../Datastore/DatastoreDescriptor.swift | 4 +- .../Datastore/DatastoreError.swift | 4 + .../Datastore/DatastoreFormat.swift | 55 +- .../CodableDatastore/Indexes/IndexPath.swift | 57 -- .../Indexes/IndexRangeExpression.swift | 4 + .../Indexes/IndexRepresentation.swift | 68 +- .../CodableDatastore/Indexes/Indexable.swift | 33 +- .../CodableDatastore/Indexes/Indexed.swift | 135 ---- .../Indexes/Mirror+Indexed.swift | 73 -- .../DatastoreDescriptorTests.swift | 51 +- .../DatastoreFormatTests.swift | 47 ++ .../DiskPersistenceDatastoreTests.swift | 158 +++- .../DiskTransactionTests.swift | 2 - .../CodableDatastoreTests/IndexedTests.swift | 146 ---- 15 files changed, 751 insertions(+), 824 deletions(-) delete mode 100644 Sources/CodableDatastore/Indexes/IndexPath.swift delete mode 100644 Sources/CodableDatastore/Indexes/Indexed.swift delete mode 100644 Sources/CodableDatastore/Indexes/Mirror+Indexed.swift create mode 100644 Tests/CodableDatastoreTests/DatastoreFormatTests.swift delete mode 100644 Tests/CodableDatastoreTests/IndexedTests.swift diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index d7134e0..df5843c 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -31,8 +31,7 @@ public actor Datastore { let version: Version let encoder: (_ instance: InstanceType) async throws -> Data let decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)] - let directIndexes: [IndexPath] - let computedIndexes: [IndexPath] + let indexRepresentations: [AnyIndexRepresentation : GeneratedIndexRepresentation] var updatedDescriptor: DatastoreDescriptor? @@ -42,8 +41,8 @@ public actor Datastore { fileprivate var storeMigrationStatus: TaskStatus = .waiting fileprivate var storeMigrationProgressHandlers: [ProgressHandler] = [] - fileprivate var indexMigrationStatus: [IndexPath : TaskStatus] = [:] - fileprivate var indexMigrationProgressHandlers: [IndexPath : ProgressHandler] = [:] + fileprivate var indexMigrationStatus: [AnyIndexRepresentation : TaskStatus] = [:] + fileprivate var indexMigrationProgressHandlers: [AnyIndexRepresentation : ProgressHandler] = [:] public init( persistence: some Persistence, @@ -52,18 +51,24 @@ public actor Datastore { version: Version = Format.currentVersion, encoder: @escaping (_ instance: InstanceType) async throws -> Data, decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) where AccessMode == ReadWrite { self.persistence = persistence - self.format = Format() + let format = Format() + self.format = format + self.indexRepresentations = format.generateIndexRepresentations(assertIdentifiable: true) self.key = key self.version = version self.encoder = encoder self.decoders = decoders - self.directIndexes = directIndexes - self.computedIndexes = computedIndexes + + var usedIndexNames: Set = [] + for (indexKey, indexRepresentation) in indexRepresentations { + assert(!usedIndexNames.contains(indexRepresentation.indexName), "Index \"\(indexRepresentation.indexName.rawValue)\" (\(indexRepresentation.index.indexType.rawValue)) was used more than once, which will lead to undefined behavior on every run. Please make sure \(String(describing: Format.self)) only declares a single index for \"\(indexRepresentation.indexName.rawValue)\".") + usedIndexNames.insert(indexRepresentation.indexName) + + assert(indexKey == AnyIndexRepresentation(indexRepresentation: indexRepresentation.index), "The key returned for index \"\(indexRepresentation.indexName.rawValue)\" does not match the generated representation. Please double check to make sure that these values are aligned!") + } for decoderVersion in Version.allCases { guard decoders[decoderVersion] == nil else { continue } @@ -77,18 +82,29 @@ public actor Datastore { key: DatastoreKey = Format.defaultKey, version: Version = Format.currentVersion, decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) where AccessMode == ReadOnly { self.persistence = persistence - self.format = Format() + let format = Format() + self.format = format + self.indexRepresentations = format.generateIndexRepresentations(assertIdentifiable: true) self.key = key self.version = version self.encoder = { _ in preconditionFailure("Encode called on read-only instance.") } self.decoders = decoders - self.directIndexes = directIndexes - self.computedIndexes = computedIndexes + + var usedIndexNames: Set = [] + for (indexKey, indexRepresentation) in indexRepresentations { + assert(!usedIndexNames.contains(indexRepresentation.indexName), "Index \"\(indexRepresentation.indexName.rawValue)\" (\(indexRepresentation.index.indexType.rawValue)) was used more than once, which will lead to undefined behavior on every run. Please make sure \(String(describing: Format.self)) only declares a single index for \"\(indexRepresentation.indexName.rawValue)\".") + usedIndexNames.insert(indexRepresentation.indexName) + + assert(indexKey == AnyIndexRepresentation(indexRepresentation: indexRepresentation.index), "The key returned for index \"\(indexRepresentation.indexName.rawValue)\" does not match the generated representation. Please double check to make sure that these values are aligned!") + } + + for decoderVersion in Version.allCases { + guard decoders[decoderVersion] == nil else { continue } + assertionFailure("Decoders missing case for \(decoderVersion). Please make sure you have a decoder configured for this version or you may encounter errors at runtime.") + } } } @@ -177,7 +193,7 @@ extension Datastore { var newDescriptor: DatastoreDescriptor? - let primaryIndex = load(IndexRange(), order: .ascending, awaitWarmup: false) + let primaryIndex = _load(IndexRange(), order: .ascending, awaitWarmup: false) var rebuildPrimaryIndex = false var directIndexesToBuild: Set = [] @@ -276,90 +292,62 @@ extension Datastore { var queriedIndexes: Set = [] - /// Persist the direct indexes with full copies - for indexPath in directIndexes { - let indexName = indexPath.path - guard - directIndexesToBuild.contains(indexName), - !queriedIndexes.contains(indexName) - else { continue } - queriedIndexes.insert(indexName) - - let updatedValue = instance[keyPath: indexPath] - - /// Grab a cursor to insert the new value in the index. - let updatedValueCursor = try await transaction.directIndexCursor( - inserting: updatedValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: key - ) - - /// Insert it. - try await transaction.persistDirectIndexEntry( - versionData: versionData, - indexValue: updatedValue.indexed, - identifierValue: idenfifier, - instanceData: instanceData, - cursor: updatedValueCursor, - indexName: indexName, - datastoreKey: key - ) - } - - /// Next, go through any remaining computed indexes as secondary indexes. - for indexPath in computedIndexes { - let indexName = indexPath.path - guard - secondaryIndexesToBuild.contains(indexName), - !queriedIndexes.contains(indexName) - else { continue } - queriedIndexes.insert(indexName) - - let updatedValue = instance[keyPath: indexPath] - - /// Grab a cursor to insert the new value in the index. - let updatedValueCursor = try await transaction.secondaryIndexCursor( - inserting: updatedValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Insert it. - try await transaction.persistSecondaryIndexEntry( - indexValue: updatedValue.indexed, - identifierValue: idenfifier, - cursor: updatedValueCursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Re-insert any remaining indexed values into the new index. - try await Mirror.indexedChildren(from: instance, assertIdentifiable: true) { indexName, value in - let indexName = IndexName(indexName) - guard - secondaryIndexesToBuild.contains(indexName), - !queriedIndexes.contains(indexName) - else { return } - - /// Grab a cursor to insert the new value in the index. - let updatedValueCursor = try await transaction.secondaryIndexCursor( - inserting: value, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Insert it. - try await transaction.persistSecondaryIndexEntry( - indexValue: value, - identifierValue: idenfifier, - cursor: updatedValueCursor, - indexName: indexName, - datastoreKey: self.key - ) + for (_, generatedRepresentation) in indexRepresentations { + let indexName = generatedRepresentation.indexName + switch generatedRepresentation.storage { + case .direct: + guard + directIndexesToBuild.contains(indexName), + !queriedIndexes.contains(indexName) + else { return } + queriedIndexes.insert(indexName) + + for updatedValue in instance[index: generatedRepresentation.index] { + /// Grab a cursor to insert the new value in the index. + let updatedValueCursor = try await transaction.directIndexCursor( + inserting: updatedValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: key + ) + + /// Insert it. + try await transaction.persistDirectIndexEntry( + versionData: versionData, + indexValue: updatedValue.indexed, + identifierValue: idenfifier, + instanceData: instanceData, + cursor: updatedValueCursor, + indexName: indexName, + datastoreKey: key + ) + } + case .reference: + guard + secondaryIndexesToBuild.contains(indexName), + !queriedIndexes.contains(indexName) + else { return } + queriedIndexes.insert(indexName) + + for updatedValue in instance[index: generatedRepresentation.index] { + /// Grab a cursor to insert the new value in the index. + let updatedValueCursor = try await transaction.secondaryIndexCursor( + inserting: updatedValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Insert it. + try await transaction.persistSecondaryIndexEntry( + indexValue: updatedValue.indexed, + identifierValue: idenfifier, + cursor: updatedValueCursor, + indexName: indexName, + datastoreKey: self.key + ) + } + } } } @@ -375,15 +363,15 @@ extension Datastore { // MARK: - Migrations extension Datastore where AccessMode == ReadWrite { - /// Manually migrate an index if the version persisted is less than a given minimum version. - /// + /// Force a full migration of an index if the version persisted is less than the specified minimum version. + /// /// Only use this if you must force an index to be re-calculated, which is sometimes necessary when the implementation of the compare method changes between releases. /// /// - Parameters: /// - index: The index to migrate. /// - minimumVersion: The minimum valid version for an index to not be migrated. /// - progressHandler: A closure that will be regularly called with progress during the migration. If no migration needs to occur, it won't be called, so setup and tear down any UI within the handler. - public func migrate(index: IndexPath, ifLessThan minimumVersion: Version, progressHandler: ProgressHandler? = nil) async throws { + public func migrate>(index: KeyPath, ifLessThan minimumVersion: Version, progressHandler: ProgressHandler? = nil) async throws { try await persistence._withTransaction( actionName: "Migrate Entries", options: [] @@ -392,10 +380,13 @@ extension Datastore where AccessMode == ReadWrite { /// If we have no descriptor, then no data exists to be migrated. let descriptor = try await transaction.datastoreDescriptor(for: self.key), descriptor.size > 0, + /// If we didn't declare the index, we can't do anything. This is likely an error only encountered to self-implementers of ``DatastoreFormat``'s ``DatastoreFormat/generateIndexRepresentations``. + let declaredIndex = self.indexRepresentations[AnyIndexRepresentation(indexRepresentation: self.format[keyPath: index])], /// If we don't have an index stored, there is nothing to do here. This means we can skip checking it on the type. - let matchingIndex = descriptor.directIndexes[index.path] ?? descriptor.referenceIndexes[index.path], + let matchingDescriptor = + descriptor.directIndexes[declaredIndex.indexName.rawValue] ?? descriptor.referenceIndexes[declaredIndex.indexName.rawValue], /// We don't care in this method of the version is incompatible — the index will be discarded. - let version = try? Version(matchingIndex.version), + let version = try? Version(matchingDescriptor.version), /// Make sure the stored version is smaller than the one we require, otherwise stop early. version.rawValue < minimumVersion.rawValue else { return } @@ -411,10 +402,13 @@ extension Datastore where AccessMode == ReadWrite { /// If we have no descriptor, then no data exists to be migrated. let descriptor = try await transaction.datastoreDescriptor(for: self.key), descriptor.size > 0, + /// If we didn't declare the index, we can't do anything. This is likely an error only encountered to self-implementers of ``DatastoreFormat``'s ``DatastoreFormat/generateIndexRepresentations``. + let declaredIndex = self.indexRepresentations[AnyIndexRepresentation(indexRepresentation: self.format[keyPath: index])], /// If we don't have an index stored, there is nothing to do here. This means we can skip checking it on the type. - let matchingIndex = descriptor.directIndexes[index.path] ?? descriptor.referenceIndexes[index.path], + let matchingDescriptor = + descriptor.directIndexes[declaredIndex.indexName.rawValue] ?? descriptor.referenceIndexes[declaredIndex.indexName.rawValue], /// We don't care in this method of the version is incompatible — the index will be discarded. - let version = try? Version(matchingIndex.version), + let version = try? Version(matchingDescriptor.version), /// Make sure the stored version is smaller than the one we require, otherwise stop early. version.rawValue < minimumVersion.rawValue else { @@ -428,7 +422,7 @@ extension Datastore where AccessMode == ReadWrite { } } - func migrate(index: IndexPath, progressHandler: ProgressHandler? = nil) async throws { + func migrate>(index: KeyPath, progressHandler: ProgressHandler? = nil) async throws { // TODO: Migrate just that index, use indexMigrationStatus and indexMigrationProgressHandlers to record progress. } @@ -453,7 +447,7 @@ extension Datastore where AccessMode == ReadWrite { extension Datastore { /// The number of objects in the datastore. /// - /// - Note: This count may not reflect an up to dat value while data is being written concurrently, but will be acurate after such a transaction finishes. + /// - Note: This count may not reflect an up to date value while instances are being written concurrently, but will be acurate after such a transaction finishes. public var count: Int { get async throws { try await warmupIfNeeded() @@ -468,6 +462,9 @@ extension Datastore { } } + /// Load an instance with a given identifier, or return nil if one is not found. + /// - Parameter identifier: The identifier of the instance to load. + /// - Returns: The instance keyed to the identifier, or nil if none are found. public func load(_ identifier: IdentifierType) async throws -> InstanceType? { try await warmupIfNeeded() @@ -494,7 +491,13 @@ extension Datastore { } } - nonisolated func load( + /// **Internal:** Load a range of instances from a datastore based on the identifier range passed in as an async sequence. + /// - Parameters: + /// - range: The range to load. + /// - order: The order to process instances in. + /// - awaitWarmup: Whether the sequence should await warmup or jump right into loading. + /// - Returns: An asynchronous sequence containing the instances matching the range of values in that sequence. + nonisolated func _load( _ range: some IndexRangeExpression, order: RangeOrder, awaitWarmup: Bool @@ -523,35 +526,67 @@ extension Datastore { } } - nonisolated public func load( + /// Load a range of instances from a datastore based on the identifier range passed in as an async sequence. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - range: The range to load. + /// - order: The order to process instances in. + /// - Returns: An asynchronous sequence containing the instances matching the range of identifiers. + public nonisolated func load( _ range: some IndexRangeExpression, order: RangeOrder = .ascending - ) -> some TypedAsyncSequence { - load(range, order: order, awaitWarmup: true) + ) -> some TypedAsyncSequence where IdentifierType: RangedIndexable { + _load(range, order: order, awaitWarmup: true) .map { $0.instance } } + /// Load a range of instances from a datastore based on the identifier range passed in as an async sequence. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - range: The range to load. + /// - order: The order to process instances in. + /// - Returns: An asynchronous sequence containing the instances matching the range of identifiers. @_disfavoredOverload public nonisolated func load( _ range: IndexRange, order: RangeOrder = .ascending - ) -> some TypedAsyncSequence { + ) -> some TypedAsyncSequence where IdentifierType: RangedIndexable { load(range, order: order) } + /// Load all instances in a datastore as an async sequence. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - range: The range to load. Specify `...` to load every instance. + /// - order: The order to process instances in. + /// - Returns: An asynchronous sequence containing all the instances. public nonisolated func load( _ range: Swift.UnboundedRange, order: RangeOrder = .ascending ) -> some TypedAsyncSequence { - load(IndexRange(), order: order) + _load(IndexRange(), order: order, awaitWarmup: true) + .map { $0.instance } } - public nonisolated func load( - _ range: some IndexRangeExpression, + /// **Internal:** Load a range of instances from a given index as an async sequence. + /// - Parameters: + /// - range: The range to load. + /// - order: The order to process instances in. + /// - index: The index to load from. + /// - Returns: An asynchronous sequence containing the instances matching the range of values in that sequence. + @usableFromInline + nonisolated func _load, Range: IndexRangeExpression>( + _ range: Range, order: RangeOrder = .ascending, - from indexPath: IndexPath> - ) -> some TypedAsyncSequence { + from index: KeyPath + ) -> some TypedAsyncSequence where Range.Bound: Indexable { AsyncThrowingBackpressureStream { provider in + guard let declaredIndex = self.indexRepresentations[AnyIndexRepresentation(indexRepresentation: self.format[keyPath: index])] + else { throw DatastoreError.missingIndex } + try await self.warmupIfNeeded() try await self.persistence._withTransaction( @@ -559,12 +594,11 @@ extension Datastore { options: [.readOnly] ) { transaction, _ in do { - let isDirectIndex = self.directIndexes.contains { $0.path == indexPath.path } - - if isDirectIndex { + switch declaredIndex.storage { + case .direct: try await transaction.directIndexScan( range: range.applying(order), - indexName: indexPath.path, + indexName: declaredIndex.indexName, datastoreKey: self.key ) { versionData, instanceData in let entryVersion = try Version(versionData) @@ -573,10 +607,10 @@ extension Datastore { try await provider.yield(instance) } - } else { + case .reference: try await transaction.secondaryIndexScan( range: range.applying(order), - indexName: indexPath.path, + indexName: declaredIndex.indexName, datastoreKey: self.key ) { (identifier: IdentifierType) in let persistedEntry = try await transaction.primaryIndexCursor(for: identifier, datastoreKey: self.key) @@ -595,29 +629,101 @@ extension Datastore { } } - @_disfavoredOverload - public nonisolated func load( - _ range: IndexRange, + /// Load all instances with the matching indexed value as an async sequence. + /// + /// This is conceptually similar to loading all instances and filtering only those who's indexed key path matches the specified value, but is much more efficient as an index is already maintained for that value. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - value: The value to match against. + /// - order: The order to process instances in. + /// - index: The index to load from. + /// - Returns: An asynchronous sequence containing the instances matching the specified indexed value. + public nonisolated func load< + Value: DiscreteIndexable, + Index: RetrievableIndexRepresentation + >( + _ value: Index.Value, order: RangeOrder = .ascending, - from keypath: IndexPath> + from index: KeyPath ) -> some TypedAsyncSequence { - load(range, order: order, from: keypath) + _load(IndexRange(only: value), order: order, from: index) } - public nonisolated func load( - _ range: Swift.UnboundedRange, + /// Load an instance with the matching indexed value, or return nil if one is not found. + /// + /// This requires either a ``DatastoreFormat/OneToOneIndex`` or ``DatastoreFormat/ManyToOneIndex`` to be declared as the index, and a guarantee on the caller's part that at most only a single instance will match the specified value. If multiple instancess match, the one with the identifier that sorts first will be returned. + /// - Parameters: + /// - value: The value to match against. + /// - index: The index to load from. + /// - Returns: The instance keyed to the specified indexed value, or nil if none are found. + public nonisolated func load< + Value, + Index: SingleInstanceIndexRepresentation + >( + _ value: Index.Value, + from index: KeyPath + ) async throws -> InstanceType? { + try await _load(IndexRange(only: value), from: index).first(where: { _ in true }) + } + + /// Load a range of instances from a given index as an async sequence. + /// + /// This is conceptually similar to loading all instances and filtering only those who's indexed key path matches the specified range, but is much more efficient as an index is already maintained for that range of values. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - range: The range to load. + /// - order: The order to process instances in. + /// - index: The index to load from. + /// - Returns: An asynchronous sequence containing the instances matching the range of values in that sequence. + public nonisolated func load< + Value: RangedIndexable, + Index: RetrievableIndexRepresentation + >( + _ range: some IndexRangeExpression, + order: RangeOrder = .ascending, + from index: KeyPath + ) -> some TypedAsyncSequence { + _load(range, order: order, from: index) + } + + /// Load a range of instances from a given index as an async sequence. + /// + /// This is conceptually similar to loading all instances and filtering only those who's indexed key path matches the specified range, but is much more efficient as an index is already maintained for that range of values. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - range: The range to load. + /// - order: The order to process instances in. + /// - index: The index to load from. + /// - Returns: An asynchronous sequence containing the instances matching the range of values in that sequence. + @_disfavoredOverload + public nonisolated func load< + Value: RangedIndexable, + Index: RetrievableIndexRepresentation + >( + _ range: IndexRange, order: RangeOrder = .ascending, - from keypath: IndexPath> + from index: KeyPath ) -> some TypedAsyncSequence { - load(IndexRange(), order: order, from: keypath) + _load(range, order: order, from: index) } - public nonisolated func load( - _ value: IndexedValue, + /// Load all instances in a datastore in index order as an async sequence. + /// + /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// - Parameters: + /// - range: The range to load. Specify `...` to load every instance. + /// - order: The order to process instances in. + /// - index: The index to load from. + /// - Returns: An asynchronous sequence containing all the instances, ordered by the specified index. + public nonisolated func load>( + _ range: Swift.UnboundedRange, order: RangeOrder = .ascending, - from keypath: IndexPath> + from index: KeyPath ) -> some TypedAsyncSequence { - load(value...value, order: order, from: keypath) + _load(IndexRange.unbounded, order: order, from: index) } } @@ -744,140 +850,89 @@ extension Datastore where AccessMode == ReadWrite { var queriedIndexes: Set = [] - /// Persist the direct indexes with full copies - for indexPath in self.directIndexes { - let indexName = indexPath.path + for (_, generatedRepresentation) in self.indexRepresentations { + let indexName = generatedRepresentation.indexName guard !queriedIndexes.contains(indexName) else { continue } queriedIndexes.insert(indexName) - let existingValue = existingInstance?[keyPath: indexPath] - let updatedValue = instance[keyPath: indexPath] -// let indexType = updatedValue.indexedType - - if let existingValue { - /// Grab a cursor to the old value in the index. - let existingValueCursor = try await transaction.directIndexCursor( - for: existingValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) + switch generatedRepresentation.storage { + case .direct: + /// Persist the direct indexes with full copies + for existingValue in existingInstance?[index: generatedRepresentation.index] ?? [] { + /// Grab a cursor to the old value in the index. + let existingValueCursor = try await transaction.directIndexCursor( + for: existingValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Delete it. + try await transaction.deleteDirectIndexEntry( + cursor: existingValueCursor.cursor, + indexName: indexName, + datastoreKey: self.key + ) + } - /// Delete it. - try await transaction.deleteDirectIndexEntry( - cursor: existingValueCursor.cursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Grab a cursor to insert the new value in the index. - let updatedValueCursor = try await transaction.directIndexCursor( - inserting: updatedValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Insert it. - try await transaction.persistDirectIndexEntry( - versionData: versionData, - indexValue: updatedValue.indexed, - identifierValue: idenfifier, - instanceData: instanceData, - cursor: updatedValueCursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Next, go through any remaining computed indexes as secondary indexes. - for indexPath in self.computedIndexes { - let indexName = indexPath.path - guard !queriedIndexes.contains(indexName) else { continue } - queriedIndexes.insert(indexName) - - let existingValue = existingInstance?[keyPath: indexPath] - let updatedValue = instance[keyPath: indexPath] -// let indexType = updatedValue.indexedType - - if let existingValue { - /// Grab a cursor to the old value in the index. - let existingValueCursor = try await transaction.secondaryIndexCursor( - for: existingValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) + for updatedValue in instance[index: generatedRepresentation.index] { + /// Grab a cursor to insert the new value in the index. + let updatedValueCursor = try await transaction.directIndexCursor( + inserting: updatedValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Insert it. + try await transaction.persistDirectIndexEntry( + versionData: versionData, + indexValue: updatedValue.indexed, + identifierValue: idenfifier, + instanceData: instanceData, + cursor: updatedValueCursor, + indexName: indexName, + datastoreKey: self.key + ) + } + case .reference: + /// Persist the reference indexes with identifiers only + for existingValue in existingInstance?[index: generatedRepresentation.index] ?? [] { + /// Grab a cursor to the old value in the index. + let existingValueCursor = try await transaction.secondaryIndexCursor( + for: existingValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Delete it. + try await transaction.deleteSecondaryIndexEntry( + cursor: existingValueCursor, + indexName: indexName, + datastoreKey: self.key + ) + } - /// Delete it. - try await transaction.deleteSecondaryIndexEntry( - cursor: existingValueCursor, - indexName: indexName, - datastoreKey: self.key - ) + for updatedValue in instance[index: generatedRepresentation.index] { + /// Grab a cursor to insert the new value in the index. + let updatedValueCursor = try await transaction.secondaryIndexCursor( + inserting: updatedValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Insert it. + try await transaction.persistSecondaryIndexEntry( + indexValue: updatedValue.indexed, + identifierValue: idenfifier, + cursor: updatedValueCursor, + indexName: indexName, + datastoreKey: self.key + ) + } } - - /// Grab a cursor to insert the new value in the index. - let updatedValueCursor = try await transaction.secondaryIndexCursor( - inserting: updatedValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Insert it. - try await transaction.persistSecondaryIndexEntry( - indexValue: updatedValue.indexed, - identifierValue: idenfifier, - cursor: updatedValueCursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Remove any remaining indexed values from the old instance. - try await Mirror.indexedChildren(from: existingInstance) { indexName, value in - let indexName = IndexName(indexName) - guard !queriedIndexes.contains(indexName) else { return } - - /// Grab a cursor to the old value in the index. - let existingValueCursor = try await transaction.secondaryIndexCursor( - for: value, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Delete it. - try await transaction.deleteSecondaryIndexEntry( - cursor: existingValueCursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Re-insert those indexes into the new index. - try await Mirror.indexedChildren(from: instance, assertIdentifiable: true) { indexName, value in - let indexName = IndexName(indexName) - guard !queriedIndexes.contains(indexName) else { return } - - /// Grab a cursor to insert the new value in the index. - let updatedValueCursor = try await transaction.secondaryIndexCursor( - inserting: value, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Insert it. - try await transaction.persistSecondaryIndexEntry( - indexValue: value, - identifierValue: idenfifier, - cursor: updatedValueCursor, - indexName: indexName, - datastoreKey: self.key - ) } return existingInstance @@ -945,73 +1000,47 @@ extension Datastore where AccessMode == ReadWrite { var queriedIndexes: Set = [] - /// Persist the direct indexes with full copies - for indexPath in self.directIndexes { - let indexName = indexPath.path - guard !queriedIndexes.contains(indexName) else { continue } - queriedIndexes.insert(indexName) - - let existingValue = existingInstance[keyPath: indexPath] - - /// Grab a cursor to the old value in the index. - let existingValueCursor = try await transaction.directIndexCursor( - for: existingValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Delete it. - try await transaction.deleteDirectIndexEntry( - cursor: existingValueCursor.cursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Next, go through any remaining computed indexes as secondary indexes. - for indexPath in self.computedIndexes { - let indexName = indexPath.path + for (_, generatedRepresentation) in self.indexRepresentations { + let indexName = generatedRepresentation.indexName guard !queriedIndexes.contains(indexName) else { continue } queriedIndexes.insert(indexName) - let existingValue = existingInstance[keyPath: indexPath] - - /// Grab a cursor to the old value in the index. - let existingValueCursor = try await transaction.secondaryIndexCursor( - for: existingValue.indexed, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Delete it. - try await transaction.deleteSecondaryIndexEntry( - cursor: existingValueCursor, - indexName: indexName, - datastoreKey: self.key - ) - } - - /// Remove any remaining indexed values from the old instance. - try await Mirror.indexedChildren(from: existingInstance) { indexName, value in - let indexName = IndexName(indexName) - guard !queriedIndexes.contains(indexName) else { return } - - /// Grab a cursor to the old value in the index. - let existingValueCursor = try await transaction.secondaryIndexCursor( - for: value, - identifier: idenfifier, - indexName: indexName, - datastoreKey: self.key - ) - - /// Delete it. - try await transaction.deleteSecondaryIndexEntry( - cursor: existingValueCursor, - indexName: indexName, - datastoreKey: self.key - ) + switch generatedRepresentation.storage { + case .direct: + for existingValue in existingInstance[index: generatedRepresentation.index] { + /// Grab a cursor to the old value in the index. + let existingValueCursor = try await transaction.directIndexCursor( + for: existingValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Delete it. + try await transaction.deleteDirectIndexEntry( + cursor: existingValueCursor.cursor, + indexName: indexName, + datastoreKey: self.key + ) + } + case .reference: + for existingValue in existingInstance[index: generatedRepresentation.index] { + /// Grab a cursor to the old value in the index. + let existingValueCursor = try await transaction.secondaryIndexCursor( + for: existingValue.indexed, + identifier: idenfifier, + indexName: indexName, + datastoreKey: self.key + ) + + /// Delete it. + try await transaction.deleteSecondaryIndexEntry( + cursor: existingValueCursor, + indexName: indexName, + datastoreKey: self.key + ) + } + } } return existingInstance @@ -1020,7 +1049,16 @@ extension Datastore where AccessMode == ReadWrite { /// A read-only view into the data store. // TODO: Make a proper copy here - public var readOnly: Datastore { self as Any as! Datastore } + public var readOnly: Datastore { + Datastore( + persistence: persistence, + format: Format.self, + key: key, + version: version, + decoders: decoders +// configuration: configuration // TODO: Copy configuration here + ) + } } // MARK: Identifiable InstanceType @@ -1070,8 +1108,6 @@ extension Datastore where AccessMode == ReadWrite { encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.init( @@ -1082,8 +1118,6 @@ extension Datastore where AccessMode == ReadWrite { decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1095,8 +1129,6 @@ extension Datastore where AccessMode == ReadWrite { version: Version = Format.currentVersion, outputFormat: PropertyListSerialization.PropertyListFormat = .binary, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { let encoder = PropertyListEncoder() @@ -1112,8 +1144,6 @@ extension Datastore where AccessMode == ReadWrite { decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1127,8 +1157,6 @@ extension Datastore where AccessMode == ReadOnly { version: Version = Format.currentVersion, decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.init( @@ -1138,8 +1166,6 @@ extension Datastore where AccessMode == ReadOnly { decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1150,8 +1176,6 @@ extension Datastore where AccessMode == ReadOnly { key: DatastoreKey = Format.defaultKey, version: Version = Format.currentVersion, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { let decoder = PropertyListDecoder() @@ -1163,8 +1187,6 @@ extension Datastore where AccessMode == ReadOnly { decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1180,8 +1202,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance version: Version = Format.currentVersion, encoder: @escaping (_ object: InstanceType) async throws -> Data, decoders: [Version: (_ data: Data) async throws -> InstanceType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) { self.init( @@ -1195,8 +1215,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1209,8 +1227,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.JSONStore( @@ -1225,8 +1241,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1238,8 +1252,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance version: Version = Format.currentVersion, outputFormat: PropertyListSerialization.PropertyListFormat = .binary, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.propertyListStore( @@ -1253,8 +1265,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1267,8 +1277,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance key: DatastoreKey = Format.defaultKey, version: Version = Format.currentVersion, decoders: [Version: (_ data: Data) async throws -> InstanceType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) { self.init( @@ -1281,8 +1289,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1294,8 +1300,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance version: Version = Format.currentVersion, decoder: JSONDecoder = JSONDecoder(), migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.readOnlyJSONStore( @@ -1309,8 +1313,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1321,8 +1323,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance key: DatastoreKey = Format.defaultKey, version: Version = Format.currentVersion, migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], configuration: Configuration = .init() ) -> Self { self.readOnlyPropertyListStore( @@ -1335,8 +1335,6 @@ extension Datastore where InstanceType: Identifiable, IdentifierType == Instance return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } diff --git a/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift b/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift index 1d653ab..8edd69e 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift @@ -135,10 +135,10 @@ extension DatastoreDescriptor { var directIndexes: [String : IndexDescriptor] = [:] var referenceIndexes: [String : IndexDescriptor] = [:] - format.mapIndexRepresentations { generatedRepresentation in + for (_, generatedRepresentation) in format.generateIndexRepresentations() { let indexName = generatedRepresentation.indexName guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" - else { return } + else { continue } let indexDescriptor = IndexDescriptor( version: versionData, diff --git a/Sources/CodableDatastore/Datastore/DatastoreError.swift b/Sources/CodableDatastore/Datastore/DatastoreError.swift index e9a8514..16c281c 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreError.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreError.swift @@ -10,6 +10,8 @@ import Foundation /// A ``Datastore``-specific error. public enum DatastoreError: LocalizedError { + case missingIndex + /// A decoder was missing for the specified version. case missingDecoder(version: String) @@ -18,6 +20,8 @@ public enum DatastoreError: LocalizedError { public var errorDescription: String? { switch self { + case .missingIndex: + return "The specified index was not properly declared on this datastore. Please double check your implementation of `DatastoreFormat.generateIndexRepresentations()`." case .missingDecoder(let version): return "The decoder for version \(version) is missing." case .incompatibleVersion(.some(let version)): diff --git a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift index 241a66d..185a167 100644 --- a/Sources/CodableDatastore/Datastore/DatastoreFormat.swift +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -10,7 +10,7 @@ import Foundation /// A representation of the underlying format of a ``Datastore``. /// -/// A ``DatastoreFormat`` will be instanciated and owned by the datastore associated with it to provide both type and index information to the store. It is expected to represent the ideal types for the latest version of the code that is instantiating the datastore. +/// A ``DatastoreFormat`` will be instantiated and owned by the datastore associated with it to provide both type and index information to the store. It is expected to represent the ideal types for the latest version of the code that is instantiating the datastore. /// /// This type also exists so implementers can conform a `struct` to it that declares a number of key paths as stored properties. /// @@ -95,46 +95,38 @@ public protocol DatastoreFormat { /// This type of index is common when building relationships between different instances, where one instance may be related to several others in some way. typealias ManyToOneIndex, Value: Indexable & DiscreteIndexable> = ManyToOneIndexRepresentation - /// Map through the declared indexes, processing them as necessary. + /// Generate index representations for the datastore. /// - /// Although a default implementation is provided, this method can also be implemented manually by calling transform once for every index that should be registered. - /// - Parameter transform: A transformation that will be called for every index. - func mapIndexRepresentations(assertIdentifiable: Bool, transform: (GeneratedIndexRepresentation) throws -> ()) rethrows - - /// Map through the declared indexes asynchronously, processing them as necessary. + /// The default implementation will create an entry for each member of the conforming type that is an ``IndexRepresentation`` type. If two members represent the _same_ type, only the one with the name that sorts _earliest_ will be used. Only stored members will be evaluated — computed members will be skipped. + /// + /// It is recommended that these results should be cached rather than re-generated every time. + /// + /// - Important: It is up to the implementer to ensure that no two _indexes_ refer to the same index name. Doing so is a mistake and will result in undefined behavior not guaranteed by the library, likely indexes being invalidated on different runs of your app. /// - /// Although a default implementation is provided, this method can also be implemented manually by calling transform once for every index that should be registered. - /// - Parameter transform: A transformation that will be called for every index. - func mapIndexRepresentations(assertIdentifiable: Bool, transform: (GeneratedIndexRepresentation) async throws -> ()) async rethrows + /// - Parameter assertIdentifiable: A flag to throw an assert if an `id` field was found when it would otherwise be a mistake. + /// - Returns: A mapping between unique indexes and their usable metadata. + func generateIndexRepresentations(assertIdentifiable: Bool) -> [AnyIndexRepresentation : GeneratedIndexRepresentation] } extension DatastoreFormat { - public func mapIndexRepresentations( - assertIdentifiable: Bool = false, - transform: (GeneratedIndexRepresentation) throws -> () - ) rethrows { + public func generateIndexRepresentations(assertIdentifiable: Bool = false) -> [AnyIndexRepresentation : GeneratedIndexRepresentation] { let mirror = Mirror(reflecting: self) + var results: [AnyIndexRepresentation : GeneratedIndexRepresentation] = [:] for child in mirror.children { - guard let generatedIndex = generateIndexRepresentation(child: child, assertIdentifiable: assertIdentifiable) + guard + let generatedIndex = generateIndexRepresentation(child: child, assertIdentifiable: assertIdentifiable) else { continue } - try transform(generatedIndex) - } - } - - public func mapIndexRepresentations( - assertIdentifiable: Bool = false, - transform: (GeneratedIndexRepresentation) async throws -> () - ) async rethrows { - let mirror = Mirror(reflecting: self) - - for child in mirror.children { - guard let generatedIndex = generateIndexRepresentation(child: child, assertIdentifiable: assertIdentifiable) - else { continue } + let key = AnyIndexRepresentation(indexRepresentation: generatedIndex.index) + /// If two indexes share a name, use the one that sorts earlier. + if let oldIndex = results[key], oldIndex.indexName < generatedIndex.indexName { continue } - try await transform(generatedIndex) + /// Otherwise replace it with the current index. + results[key] = generatedIndex } + + return results } /// Generate an index representation for a given mirror's child, or return nil if no valid index was found. @@ -184,6 +176,11 @@ extension DatastoreFormat { } } +//extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable & DiscreteIndexable, Self.Identifier == Instance.ID { +// @available(*, unavailable, message: "id is reserved on Identifiable Instance types.") +// var id: Never { preconditionFailure("id is reserved on Identifiable Instance types.") } +//} + extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable & DiscreteIndexable { typealias Identifier = Instance.ID } diff --git a/Sources/CodableDatastore/Indexes/IndexPath.swift b/Sources/CodableDatastore/Indexes/IndexPath.swift deleted file mode 100644 index 9bd70e1..0000000 --- a/Sources/CodableDatastore/Indexes/IndexPath.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// IndexPath.swift -// CodableDatastore -// -// Created by Dimitri Bouniol on 2023-06-13. -// Copyright © 2023 Mochi Development, Inc. All rights reserved. -// - -/// A keypath with an associated name. -public struct IndexPath: Equatable, Hashable { - /// The ``/Swift/KeyPath`` associated with the index path. - public let keyPath: KeyPath - - /// The path as a string. - public let path: IndexName - - /// Initialize a new ``IndexPath``. - /// - /// - Note: It is preferable to use the #indexPath macro instead, as it will infer the path automatically. - /// - Parameters: - /// - uncheckedKeyPath: The keypath to bind to. - /// - path: The name of the path as a string, which should match the keypath itself. - public init(uncheckedKeyPath: KeyPath, path: IndexName) { - self.keyPath = uncheckedKeyPath - self.path = path - } - - /// Initialize a new ``IndexPath``, erasing its type in the process. - /// - /// - Note: It is preferable to use the #indexPath macro instead, as it will infer the path automatically. - /// - Parameters: - /// - uncheckedKeyPath: The keypath to bind to. - /// - path: The name of the path as a string, which should match the keypath itself. - public init( - uncheckedKeyPath: KeyPath>, - path: IndexName - ) where Value == _AnyIndexed { - self.keyPath = uncheckedKeyPath.appending(path: \.anyIndexed) - self.path = path - } -} - -extension IndexPath: Comparable { - public static func < (lhs: Self, rhs: Self) -> Bool { - lhs.path < rhs.path - } -} - -extension Encodable { - subscript(keyPath indexPath: IndexPath) -> _AnyIndexed { - return self[keyPath: indexPath.keyPath] - } - - subscript(keyPath indexPath: IndexPath>) -> _SomeIndexed { - return self[keyPath: indexPath.keyPath] - } -} diff --git a/Sources/CodableDatastore/Indexes/IndexRangeExpression.swift b/Sources/CodableDatastore/Indexes/IndexRangeExpression.swift index 6ecf213..cf10739 100644 --- a/Sources/CodableDatastore/Indexes/IndexRangeExpression.swift +++ b/Sources/CodableDatastore/Indexes/IndexRangeExpression.swift @@ -189,6 +189,10 @@ public struct IndexRange: IndexRangeExpression { } } +extension IndexRange where Bound == Never { + static let unbounded = IndexRange() +} + infix operator ..> postfix operator ..> diff --git a/Sources/CodableDatastore/Indexes/IndexRepresentation.swift b/Sources/CodableDatastore/Indexes/IndexRepresentation.swift index 2746a44..5e4ae4c 100644 --- a/Sources/CodableDatastore/Indexes/IndexRepresentation.swift +++ b/Sources/CodableDatastore/Indexes/IndexRepresentation.swift @@ -22,12 +22,34 @@ public protocol IndexRepresentation: Hashable { /// - Parameter instance: The instance type that we would like to verify. /// - Returns: The casted index. func matches(_ instance: T.Type) -> (any IndexRepresentation)? + + /// The type erased values the index matches against for a given index. + func valuesToIndex(for instance: Instance) -> [AnyIndexable] +} + +extension IndexRepresentation { + /// The index representation in a form suitable for keying in a dictionary. + public var key: AnyIndexRepresentation { AnyIndexRepresentation(indexRepresentation: self) } + + /// Check if two ``IndexRepresentation``s are equal. + func isEqual(rhs: some IndexRepresentation) -> Bool { + return self == rhs as? Self + } } /// A representation of an index for a given instance, preserving value information. public protocol RetrievableIndexRepresentation: IndexRepresentation { /// The value represented within the index. - associatedtype Value: Indexable + associatedtype Value: Indexable & Hashable + + /// The concrete values the index matches against for a given index. + func valuesToIndex(for instance: Instance) -> Set +} + +extension RetrievableIndexRepresentation { + public func valuesToIndex(for instance: Instance) -> [AnyIndexable] { + valuesToIndex(for: instance).map { AnyIndexable($0)} + } } /// A representation of an index for a given instance, where a single instance matches every provided value. @@ -42,6 +64,7 @@ public protocol MultipleInputIndexRepresentation< Sequence, Value >: RetrievableIndexRepresentation { + /// The sequence of values represented in the index. associatedtype Sequence: Swift.Sequence } @@ -68,6 +91,10 @@ public struct OneToOneIndexRepresentation< else { return nil } return copy } + + public func valuesToIndex(for instance: Instance) -> Set { + [instance[keyPath: keypath]] + } } /// An index where every value can match any number of instances, but every instance is represented by a single value. @@ -93,6 +120,10 @@ public struct OneToManyIndexRepresentation< else { return nil } return copy } + + public func valuesToIndex(for instance: Instance) -> Set { + [instance[keyPath: keypath]] + } } /// An index where every value matches at most a single instance., but every instance can be represented by more than a single value. @@ -119,6 +150,10 @@ public struct ManyToOneIndexRepresentation< else { return nil } return copy } + + public func valuesToIndex(for instance: Instance) -> Set { + Set(instance[keyPath: keypath]) + } } /// An index where every value can match any number of instances, and every instance can be represented by more than a single value. @@ -145,6 +180,10 @@ public struct ManyToManyIndexRepresentation< else { return nil } return copy } + + public func valuesToIndex(for instance: Instance) -> Set { + Set(instance[keyPath: keypath]) + } } /// A property wrapper for marking which indexes should store instances in their entirety without needing to do a secondary lookup. @@ -159,7 +198,7 @@ public struct Direct { /// This is ordinarily handled transparently when used as a property wrapper. public let wrappedValue: Index - /// Initialize an ``Indexed`` value with an initial value. + /// Initialize a ``Direct`` index with an initial ``IndexRepresentation`` value. /// /// This is ordinarily handled transparently when used as a property wrapper. public init(wrappedValue: Index) { @@ -185,3 +224,28 @@ extension Direct: DirectIndexRepresentation { return index } } + +extension Encodable { + /// Retrieve the type erased values for a given index. + subscript>(index indexRepresentation: Index) -> [AnyIndexable] { + return indexRepresentation.valuesToIndex(for: self) + } + + /// Retrieve the concrete values for a given index. + subscript, Value>(index indexRepresentation: Index) -> Set { + return indexRepresentation.valuesToIndex(for: self) + } +} + +/// A type erased index representation to be used for keying indexes in a dictionary. +public struct AnyIndexRepresentation: Hashable { + var indexRepresentation: any IndexRepresentation + + public static func == (lhs: AnyIndexRepresentation, rhs: AnyIndexRepresentation) -> Bool { + return lhs.indexRepresentation.isEqual(rhs: rhs.indexRepresentation) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(indexRepresentation) + } +} diff --git a/Sources/CodableDatastore/Indexes/Indexable.swift b/Sources/CodableDatastore/Indexes/Indexable.swift index c1d8d8a..2dd8483 100644 --- a/Sources/CodableDatastore/Indexes/Indexable.swift +++ b/Sources/CodableDatastore/Indexes/Indexable.swift @@ -9,7 +9,29 @@ import Foundation /// An alias representing the requirements for a property to be indexable, namely that they conform to both ``/Swift/Codable`` and ``/Swift/Comparable``. -public typealias Indexable = Comparable & Codable +public typealias Indexable = Comparable & Hashable & Codable + +/// A type-erased container for Indexable values +public struct AnyIndexable { + /// The original indexable value. + public var indexed: any Indexable + + /// Initialize a type-erased indexable value. Access it again with ``indexed``. + public init(_ indexable: some Indexable) { + indexed = indexable + } +} + +/// Matching implementation from https://github.com/apple/swift/pull/64899/files +extension Never: Codable { + public init(from decoder: any Decoder) throws { + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unable to decode an instance of Never.") + throw DecodingError.typeMismatch(Never.self, context) + } + public func encode(to encoder: any Encoder) throws {} +} /// A marker protocol for types that can be used as a ranged index. /// @@ -19,7 +41,7 @@ public typealias Indexable = Comparable & Codable /// ```swift /// extension UUID: RangedIndexable {} /// ``` -public protocol RangedIndexable: Comparable & Codable {} +public protocol RangedIndexable: Comparable & Hashable & Codable {} /// A marker protocol for types that can be used as a discrete index. /// @@ -29,22 +51,23 @@ public protocol RangedIndexable: Comparable & Codable {} /// ```swift /// extension Double: DiscreteIndexable {} /// ``` -public protocol DiscreteIndexable: Equatable & Codable {} +public protocol DiscreteIndexable: Hashable & Codable {} // MARK: - Swift Standard Library Conformances extension Bool: DiscreteIndexable {} extension Double: RangedIndexable {} -@available(macOS 13.0, *) +@available(macOS 13.0, iOS 16, tvOS 16, watchOS 9, *) extension Duration: RangedIndexable {} extension Float: RangedIndexable {} -@available(macOS 11.0, *) +@available(macOS 11.0, iOS 14, tvOS 14, watchOS 7, *) extension Float16: RangedIndexable {} extension Int: DiscreteIndexable, RangedIndexable {} extension Int8: DiscreteIndexable, RangedIndexable {} extension Int16: DiscreteIndexable, RangedIndexable {} extension Int32: DiscreteIndexable, RangedIndexable {} extension Int64: DiscreteIndexable, RangedIndexable {} +extension Never: DiscreteIndexable, RangedIndexable {} extension String: DiscreteIndexable, RangedIndexable {} extension UInt: DiscreteIndexable, RangedIndexable {} extension UInt8: DiscreteIndexable, RangedIndexable {} diff --git a/Sources/CodableDatastore/Indexes/Indexed.swift b/Sources/CodableDatastore/Indexes/Indexed.swift deleted file mode 100644 index d8058cc..0000000 --- a/Sources/CodableDatastore/Indexes/Indexed.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// Indexed.swift -// CodableDatastore -// -// Created by Dimitri Bouniol on 2023-05-31. -// Copyright © 2023 Mochi Development, Inc. All rights reserved. -// - -import Foundation - -/// A property wrapper to mark a property as one that is indexable by a data store. -/// -/// Indexable properties must be ``/Swift/Codable`` so that their values can be encoded and decoded, -/// and be ``/Swift/Comparable`` so that a stable order may be formed when saving to a data store. -/// -/// To mark a property as one that an index should be built against, mark it as such: -/// -/// struct MyStruct { -/// var id: UUID -/// -/// @Indexed -/// var name: String -/// -/// @Indexed -/// var age: Int = 1 -/// -/// var other: [Int] = [] -/// -/// //@Indexed -/// //var nonCodable = NonCodable() // Not allowed! -/// -/// //@Indexed -/// //var nonComparable = NonComparable() // Not allowed! -/// } -/// -/// - Note: The `id` field from ``/Foundation/Identifiable`` does not need to be indexed, as it is indexed by default for instance uniqueness in a data store. -/// -/// - Warning: Although changing which properties are indexed, including their names and types, is fully supported, -/// changing the ``/Swift/Comparable`` implementation of a type between builds can lead to problems. If ``/Swift/Comparable`` -/// conformance changes, you should declare a new version along side it so you can force an index to be migrated at the same time. -/// -/// > Attention: -/// > Only use this type as a property wrapper. Marking a computed property as returning an ``Indexed`` field is not supported, and will fail at runtime. -/// > -/// > This is because the index won't be properly detected when warming up a datastore and won't properly -/// migrate indices as a result of that failed detection: -/// > -/// >``` -/// >struct MyStruct { -/// > var id: UUID -/// > -/// > @Indexed -/// > var name: String -/// > -/// > @Indexed -/// > var age: Int = 1 -/// > -/// > /// Don't do this: -/// > var composed: Indexed { Indexed(wrappedValue: "\(name) \(age)") } -/// >} -/// >``` -/// -@propertyWrapper -public struct Indexed where T: Indexable { - /// The underlying value that the index will be based off of. - /// - /// This is ordinarily handled transparently when used as a property wrapper. - public var wrappedValue: T - - /// Initialize an ``Indexed`` value with an initial value. - /// - /// This is ordinarily handled transparently when used as a property wrapper. - public init(wrappedValue: T) { - self.wrappedValue = wrappedValue - } - - /// The projected value of the indexed property, which is a type-erased version of ourself. - /// - /// This allows the indexed property to be used in the data store using `.$property` syntax. - public var projectedValue: _SomeIndexed { _SomeIndexed(indexed: self) } -} - -/// A type-erased wrapper for indexed types. -/// -/// You should not reach for this directly, and instead use the @``Indexed`` property wrapper. -public class _AnyIndexed { - var indexed: any _IndexedProtocol - var indexedType: IndexType - - init(indexed: Indexed) { - self.indexed = indexed - indexedType = IndexType(T.self) - } - - var anyIndexed: _AnyIndexed { - self - } -} - -public class _SomeIndexed: _AnyIndexed { - init(indexed: Indexed) { - super.init(indexed: indexed) - } -} - -/// An internal protocol to use when evaluating types for indexed properties. -protocol _IndexedProtocol: Indexable { - associatedtype T: Indexable - associatedtype ProjectedType: _AnyIndexed - - init(wrappedValue: T) - - var wrappedValue: T { get } - var projectedValue: ProjectedType { get } -} - -extension _IndexedProtocol { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let wrappedValue = try container.decode(T.self) - - self.init(wrappedValue: wrappedValue) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(wrappedValue) - } - - public static func < (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue < rhs.wrappedValue } - public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } -} - -extension Indexed: _IndexedProtocol { -} diff --git a/Sources/CodableDatastore/Indexes/Mirror+Indexed.swift b/Sources/CodableDatastore/Indexes/Mirror+Indexed.swift deleted file mode 100644 index 6d4f32f..0000000 --- a/Sources/CodableDatastore/Indexes/Mirror+Indexed.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Mirror+Indexed.swift -// CodableDatastore -// -// Created by Dimitri Bouniol on 2023-06-18. -// Copyright © 2023 Mochi Development, Inc. All rights reserved. -// - -import Foundation - -extension Mirror { - static func indexedChildren( - from instance: T?, - assertIdentifiable: Bool = false, - transform: (_ indexName: String, _ value: any _IndexedProtocol) throws -> () - ) rethrows { - guard let instance else { return } - - let mirror = Mirror(reflecting: instance) - - for child in mirror.children { - guard let label = child.label else { continue } - guard let childValue = child.value as? any _IndexedProtocol else { continue } - - let indexName: String - if label.prefix(1) == "_" { - indexName = "$\(label.dropFirst())" - } else { - indexName = label - } - - /// If the type is identifiable, skip the `id` index as we always make one based on `id` - if indexName == "$id" && instance is any Identifiable { - if assertIdentifiable { - assertionFailure("\(type(of: instance)) declared `id` to be @Indexed, when the conformance is automatic. Please remove @Indexed from the `id` field.") - } - continue - } - - try transform(indexName, childValue) - } - } - - static func indexedChildren( - from instance: T?, - assertIdentifiable: Bool = false, - transform: (_ indexName: String, _ value: any _IndexedProtocol) async throws -> () - ) async rethrows { - guard let instance else { return } - - let mirror = Mirror(reflecting: instance) - - for child in mirror.children { - guard let label = child.label else { continue } - guard let childValue = child.value as? any _IndexedProtocol else { continue } - - let indexName: String - if label.prefix(1) == "_" { - indexName = "$\(label.dropFirst())" - } else { - indexName = label - } - - /// If the type is identifiable, skip the `id` index as we always make one based on `id` - if indexName == "$id" && instance is any Identifiable { - assertionFailure("\(type(of: instance)) declared `id` to be @Indexed, when the conformance is automatic. Please remove @Indexed from the `id` field.") - continue - } - - try await transform(indexName, childValue) - } - } -} diff --git a/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift b/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift index 078e630..038767e 100644 --- a/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift +++ b/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift @@ -34,7 +34,6 @@ final class DatastoreDescriptorTests: XCTestCase { XCTAssertFalse(desc2 < desc1) } - func testTypeReflection() throws { enum SharedVersion: String, CaseIterable { case a, b, c @@ -298,4 +297,54 @@ final class DatastoreDescriptorTests: XCTestCase { ]) XCTAssertEqual(descD.referenceIndexes, [:]) } + + func testTypeDuplicatePaths() throws { + enum SharedVersion: String, CaseIterable { + case a, b, c + } + + enum Enum: Codable, Comparable { + case a, b, c + } + + struct Nested: Codable { + var a: Enum + } + + struct SampleType: Codable { + var id: UUID + var a: String + var b: Int + var c: Nested + var d: String { "\(a).\(b)" } + } + + struct SampleFormatA: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = SharedVersion.a + + typealias Version = SharedVersion + typealias Instance = SampleType + typealias Identifier = UUID + + let id = OneToOneIndex(\.id) + let a = Index(\.a) + let b = Index(\.b) + @Direct var otherB = Index(\.b) + } + + let descA = try DatastoreDescriptor( + format: SampleFormatA(), + version: .a + ) + XCTAssertEqual(descA.version, Data([34, 97, 34])) + XCTAssertEqual(descA.instanceType, "SampleType") + XCTAssertEqual(descA.identifierType, "UUID") + XCTAssertEqual(descA.directIndexes, [:]) + XCTAssertEqual(descA.referenceIndexes, [ + "id" : .init(version: Data([34, 97, 34]), name: "id", type: "OneToOneIndex(UUID)"), + "a" : .init(version: Data([34, 97, 34]), name: "a", type: "OneToManyIndex(String)"), + "b" : .init(version: Data([34, 97, 34]), name: "b", type: "OneToManyIndex(Int)"), + ]) + } } diff --git a/Tests/CodableDatastoreTests/DatastoreFormatTests.swift b/Tests/CodableDatastoreTests/DatastoreFormatTests.swift new file mode 100644 index 0000000..a503d8a --- /dev/null +++ b/Tests/CodableDatastoreTests/DatastoreFormatTests.swift @@ -0,0 +1,47 @@ +// +// DatastoreFormatTests.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2024-04-12. +// Copyright © 2023 Mochi Development, Inc. All rights reserved. +// + +import XCTest +@testable import CodableDatastore + +final class DatastoreFormatTests: XCTestCase { + func testDatastoreFormatAccessors() throws { + struct NonCodable {} + + struct TestFormat: DatastoreFormat { + static var defaultKey = DatastoreKey("sample") + static var currentVersion = Version.a + + enum Version: String, CaseIterable { + case a, b, c + } + + struct Instance: Codable, Identifiable { + let id: UUID + var name: String + var age: Int + var other: [Int] +// var nonCodable: NonCodable // Not allowed: Type 'TestFormat.Instance' does not conform to protocol 'Codable' + var composed: String { "\(name) \(age)"} + } + + let name = Index(\.name) + let age = Index(\.age) +// let other = Index(\.other) // Not allowed: Generic struct 'OneToManyIndexRepresentation' requires that '[Int]' conform to 'Comparable' + let other = ManyToManyIndex(\.other) + let composed = Index(\.composed) + } + + let myValue = TestFormat.Instance(id: UUID(), name: "Hello!", age: 1, other: [2, 6]) + + XCTAssertEqual(myValue[index: TestFormat().age], [1]) + XCTAssertEqual(myValue[index: TestFormat().name], ["Hello!"]) + XCTAssertEqual(myValue[index: TestFormat().other], [2, 6]) + XCTAssertEqual(myValue[index: TestFormat().composed], ["Hello! 1"]) + } +} diff --git a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift index 50431d6..2d8ead5 100644 --- a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift +++ b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift @@ -128,11 +128,53 @@ final class DiskPersistenceDatastoreTests: XCTestCase { struct Instance: Codable, Identifiable { var id: String - @Indexed var value: String + var value: String + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero + + let value = Index(\.value) + } + + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + try await datastore.persist(.init(id: "3", value: "My name is Dimitri")) + try await datastore.persist(.init(id: "1", value: "Hello, World!")) + try await datastore.persist(.init(id: "2", value: "Twenty Three is Number One")) + + let count = try await datastore.count + XCTAssertEqual(count, 3) + + let values = try await datastore.load("A"..."Z", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(values, ["2", "3", "1"]) + } + + func testWritingEntryWithOneToOneIndex() async throws { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + var value: String } static let defaultKey: DatastoreKey = "test" static let currentVersion = Version.zero + + let value = OneToOneIndex(\.value) } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) @@ -154,8 +196,120 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let count = try await datastore.count XCTAssertEqual(count, 3) - let values = try await datastore.load("A"..."Z", order: .descending, from: IndexPath(uncheckedKeyPath: \.$value, path: "$value")).map { $0.id }.reduce(into: []) { $0.append($1) } + let values = try await datastore.load("A"..."Z", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } XCTAssertEqual(values, ["2", "3", "1"]) + let value3 = try await datastore.load("My name is Dimitri", from: \.value).map { $0.id } + XCTAssertEqual(value3, "3") + let value1 = try await datastore.load("Hello, World!", from: \.value).map { $0.id } + XCTAssertEqual(value1, "1") + let value2 = try await datastore.load("Twenty Three is Number One", from: \.value).map { $0.id } + XCTAssertEqual(value2, "2") + let valueNil = try await datastore.load("D", from: \.value).map { $0.id } + XCTAssertNil(valueNil) + } + + func testWritingEntryWithManyToManyIndex() async throws { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + var value: [String] + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero + + let value = ManyToManyIndex(\.value) + } + + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + try await datastore.persist(.init(id: "3", value: ["My name is Dimitri", "A", "B"])) + try await datastore.persist(.init(id: "1", value: ["Hello, World!", "B", "B", "C"])) + try await datastore.persist(.init(id: "2", value: ["Twenty Three is Number One", "C"])) + + let count = try await datastore.count + XCTAssertEqual(count, 3) + + let values = try await datastore.load("A"..."Z", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(values, ["2", "3", "1", "2", "1", "3", "1", "3"]) + let valuesA = try await datastore.load("A", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesA, ["3"]) + let valuesB = try await datastore.load("B", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesB, ["3", "1"]) + let valuesC = try await datastore.load("C", order: .ascending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesC, ["1", "2"]) + let valuesD = try await datastore.load("D", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesD, []) + } + + func testWritingEntryWithManyToOneIndex() async throws { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: String + var value: [String] + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero + + let value = ManyToOneIndex(\.value) + } + + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + try await datastore.persist(.init(id: "3", value: ["My name is Dimitri", "A", "B"])) + try await datastore.persist(.init(id: "1", value: ["Hello, World!", "B", "B", "C"])) + try await datastore.persist(.init(id: "2", value: ["Twenty Three is Number One", "C"])) + + let count = try await datastore.count + XCTAssertEqual(count, 3) + + let values = try await datastore.load("A"..."Z", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(values, ["2", "3", "1", "2", "1", "3", "1", "3"]) + let valuesA = try await datastore.load("A", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesA, ["3"]) + let valueA = try await datastore.load("A", from: \.value).map { $0.id } + XCTAssertEqual(valueA, "3") + let valuesB = try await datastore.load("B", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesB, ["3", "1"]) + let valueB = try await datastore.load("B", from: \.value).map { $0.id } + XCTAssertEqual(valueB, "1") + let valuesC = try await datastore.load("C", order: .ascending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesC, ["1", "2"]) + let valueC = try await datastore.load("C", from: \.value).map { $0.id } + XCTAssertEqual(valueC, "1") + let valuesD = try await datastore.load("D", order: .descending, from: \.value).map { $0.id }.reduce(into: []) { $0.append($1) } + XCTAssertEqual(valuesD, []) + let valueNil = try await datastore.load("D", from: \.value).map { $0.id } + XCTAssertNil(valueNil) } func testObservingEntries() async throws { diff --git a/Tests/CodableDatastoreTests/DiskTransactionTests.swift b/Tests/CodableDatastoreTests/DiskTransactionTests.swift index 2e49875..30fa1b4 100644 --- a/Tests/CodableDatastoreTests/DiskTransactionTests.swift +++ b/Tests/CodableDatastoreTests/DiskTransactionTests.swift @@ -40,8 +40,6 @@ final class DiskTransactionTests: XCTestCase { persistence: persistence, format: TestFormat.self, decoders: [.zero: { _ in (id: UUID(), instance: TestFormat.Instance()) }], - directIndexes: [], - computedIndexes: [], configuration: .init() ) diff --git a/Tests/CodableDatastoreTests/IndexedTests.swift b/Tests/CodableDatastoreTests/IndexedTests.swift deleted file mode 100644 index ae86a21..0000000 --- a/Tests/CodableDatastoreTests/IndexedTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// IndexedTests.swift -// CodableDatastore -// -// Created by Dimitri Bouniol on 2023-05-31. -// Copyright © 2023 Mochi Development, Inc. All rights reserved. -// - -import XCTest -@testable import CodableDatastore - -final class IndexedTests: XCTestCase { - func testIndexed() throws { - struct NonCodable {} - - struct TestStruct: Identifiable, Codable { - var id: UUID - - @Indexed - var name: String = "" - - @Indexed - var age: Int = 1 - - var other: [Int] = [] - -// @Indexed -// var nonCodable = NonCodable() // Not allowed! - - // Technically possible, but heavily discouraged: - var composed: _AnyIndexed { Indexed(wrappedValue: "\(name) \(age)").projectedValue } - } - - let myValue = TestStruct(id: UUID(), name: "Hello!") - - XCTAssertEqual("\(myValue[keyPath: \.age])", "1") -// XCTAssertEqual("\(myValue[keyPath: \.$age])", "Indexed(wrappedValue: 1)") - XCTAssertEqual("\(myValue[keyPath: \.composed])", #"CodableDatastore._SomeIndexed"#) - - // This did not work unfortunately: -// withUnsafeTemporaryAllocation(of: TestStruct.self, capacity: 1) { pointer in -//// print(Mirror(reflecting: pointer).children) -// let value = pointer.first! -// -// let mirror = Mirror(reflecting: value) -// var indexedProperties: [String] = [] -// for child in mirror.children { -// guard let label = child.label else { continue } -// let childType = type(of: child.value) -// guard childType is _IndexedProtocol.Type else { continue } -// print("Child: \(label), type: \(childType)") -// indexedProperties.append(label) -// } -// print("Indexable Children from type: \(indexedProperties)") -// } - - -// let mirror = Mirror(reflecting: TestStruct.self) // Doesn't work :( - let mirror = Mirror(reflecting: myValue) - var indexedProperties: [String] = [] - for child in mirror.children { - guard let label = child.label else { continue } - let childType = type(of: child.value) - guard childType is any _IndexedProtocol.Type else { continue } - indexedProperties.append(label) - } - XCTAssertEqual(indexedProperties, ["_name", "_age"]) - - struct TestAccessor { - func load(from keypath: KeyPath) -> [T] { - XCTAssertEqual(keypath, \TestStruct.$age) - return [] - } - } - - let accessor: TestAccessor = TestAccessor() -// let values = accessor.load(from: \.other) // not allowed! -// let values = accessor.load(from: \.age) // not allowed! - let values = accessor.load(from: \.$age) - XCTAssertEqual("\(values)", "[]") - XCTAssertEqual("\(type(of: values))", "Array") - } - - func testCodable() throws { - struct TestStruct: Identifiable, Codable, Equatable { - var id: UUID - - @Indexed - var name: String - - @Indexed - var age: Int = 1 - - var other: [Int] = [] - - // Technically possible, but heavily discouraged: - var composed: Indexed { Indexed(wrappedValue: "\(name) \(age)") } - } - - let originalValue = TestStruct(id: UUID(uuidString: "58167FAA-18C2-43E7-8E31-66E28141C9FE")!, name: "Hello!") - - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(originalValue) - let newValue = try JSONDecoder().decode(TestStruct.self, from: data) - - XCTAssertEqual(originalValue, newValue) - - let jsonString = String(data: data, encoding: .utf8)! - print(jsonString) - XCTAssertEqual(jsonString, #"{"age":1,"id":"58167FAA-18C2-43E7-8E31-66E28141C9FE","name":"Hello!","other":[]}"#) - } - - func testCodableIndexedString() throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(Indexed(wrappedValue: "A string")) - let jsonString = String(data: data, encoding: .utf8)! - let decodedValue = try JSONDecoder().decode(String.self, from: data) - - XCTAssertEqual(jsonString, #""A string""#) - XCTAssertEqual(decodedValue, "A string") - } - - func testCodableIndexedInt() throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(Indexed(wrappedValue: 1234)) - let jsonString = String(data: data, encoding: .utf8)! - let decodedValue = try JSONDecoder().decode(Int.self, from: data) - - XCTAssertEqual(jsonString, #"1234"#) - XCTAssertEqual(decodedValue, 1234) - } - - func testCodableIndexedUUID() throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(Indexed(wrappedValue: UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")!)) - let jsonString = String(data: data, encoding: .utf8)! - let decodedValue = try JSONDecoder().decode(UUID.self, from: data) - - XCTAssertEqual(jsonString, #""00112233-4455-6677-8899-AABBCCDDEEFF""#) - XCTAssertEqual(decodedValue, UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")) - } -} From e7e08be810f0eee68c097de005142abac59c637d Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 12 Apr 2024 01:33:48 -0700 Subject: [PATCH 08/10] Renamed identifier ranges for code completions Fixed #164 --- .../Datastore/Datastore.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index df5843c..33e0a25 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -493,12 +493,12 @@ extension Datastore { /// **Internal:** Load a range of instances from a datastore based on the identifier range passed in as an async sequence. /// - Parameters: - /// - range: The range to load. + /// - identifierRange: The range to load. /// - order: The order to process instances in. /// - awaitWarmup: Whether the sequence should await warmup or jump right into loading. /// - Returns: An asynchronous sequence containing the instances matching the range of values in that sequence. nonisolated func _load( - _ range: some IndexRangeExpression, + _ identifierRange: some IndexRangeExpression, order: RangeOrder, awaitWarmup: Bool ) -> some TypedAsyncSequence<(id: IdentifierType, instance: InstanceType)> { @@ -512,7 +512,7 @@ extension Datastore { options: [.readOnly] ) { transaction, _ in do { - try await transaction.primaryIndexScan(range: range.applying(order), datastoreKey: self.key) { versionData, instanceData in + try await transaction.primaryIndexScan(range: identifierRange.applying(order), datastoreKey: self.key) { versionData, instanceData in let entryVersion = try Version(versionData) let decoder = try await self.decoder(for: entryVersion) let decodedValue = try await decoder(instanceData) @@ -530,14 +530,14 @@ extension Datastore { /// /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. /// - Parameters: - /// - range: The range to load. + /// - identifierRange: The range to load. /// - order: The order to process instances in. /// - Returns: An asynchronous sequence containing the instances matching the range of identifiers. public nonisolated func load( - _ range: some IndexRangeExpression, + _ identifierRange: some IndexRangeExpression, order: RangeOrder = .ascending ) -> some TypedAsyncSequence where IdentifierType: RangedIndexable { - _load(range, order: order, awaitWarmup: true) + _load(identifierRange, order: order, awaitWarmup: true) .map { $0.instance } } @@ -545,26 +545,26 @@ extension Datastore { /// /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. /// - Parameters: - /// - range: The range to load. + /// - identifierRange: The range to load. /// - order: The order to process instances in. /// - Returns: An asynchronous sequence containing the instances matching the range of identifiers. @_disfavoredOverload public nonisolated func load( - _ range: IndexRange, + _ identifierRange: IndexRange, order: RangeOrder = .ascending ) -> some TypedAsyncSequence where IdentifierType: RangedIndexable { - load(range, order: order) + load(identifierRange, order: order) } /// Load all instances in a datastore as an async sequence. /// /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. /// - Parameters: - /// - range: The range to load. Specify `...` to load every instance. + /// - unboundedRange: The range to load. Specify `...` to load every instance. /// - order: The order to process instances in. /// - Returns: An asynchronous sequence containing all the instances. public nonisolated func load( - _ range: Swift.UnboundedRange, + _ unboundedRange: Swift.UnboundedRange, order: RangeOrder = .ascending ) -> some TypedAsyncSequence { _load(IndexRange(), order: order, awaitWarmup: true) @@ -713,13 +713,15 @@ extension Datastore { /// Load all instances in a datastore in index order as an async sequence. /// /// The sequence should be consumed a single time, ideally within the same transaction it was created in as it holds a reference to that transaction and thus snapshot of the datastore for data consistency. + /// + /// - Note: If the index is a Mant-to-Any type of index, a smaller or larger number of results may be returned here, as some instances may not be respresented in the index, while others are other-represented and may show up multiple times. /// - Parameters: - /// - range: The range to load. Specify `...` to load every instance. + /// - unboundedRange: The range to load. Specify `...` to load every instance. /// - order: The order to process instances in. /// - index: The index to load from. /// - Returns: An asynchronous sequence containing all the instances, ordered by the specified index. public nonisolated func load>( - _ range: Swift.UnboundedRange, + _ unboundedRange: Swift.UnboundedRange, order: RangeOrder = .ascending, from index: KeyPath ) -> some TypedAsyncSequence { From fe8049993d0945cbc356360261647ac3acbe3f93 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 12 Apr 2024 01:58:13 -0700 Subject: [PATCH 09/10] Removed instance from descriptor generation --- Sources/CodableDatastore/Datastore/Datastore.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index 33e0a25..bab78f0 100644 --- a/Sources/CodableDatastore/Datastore/Datastore.swift +++ b/Sources/CodableDatastore/Datastore/Datastore.swift @@ -111,8 +111,7 @@ public actor Datastore { // MARK: - Helper Methods extension Datastore { - // TODO: Remove instance requirement from this method. - func updatedDescriptor(for instance: InstanceType) throws -> DatastoreDescriptor { + func generateUpdatedDescriptor() throws -> DatastoreDescriptor { if let updatedDescriptor { return updatedDescriptor } @@ -206,7 +205,7 @@ extension Datastore { defer { index += 1 } /// Use the first index to grab an up-to-date descriptor if newDescriptor == nil { - let updatedDescriptor = try updatedDescriptor(for: instance) + let updatedDescriptor = try generateUpdatedDescriptor() newDescriptor = updatedDescriptor /// Check the primary index for compatibility. @@ -774,7 +773,7 @@ extension Datastore where AccessMode == ReadWrite { public func persist(_ instance: InstanceType, to idenfifier: IdentifierType) async throws -> InstanceType? { try await warmupIfNeeded() - let updatedDescriptor = try self.updatedDescriptor(for: instance) + let updatedDescriptor = try self.generateUpdatedDescriptor() let versionData = try Data(self.version) let instanceData = try await self.encoder(instance) From 40d643683541a24a61f8f41f274f95ac7548d842 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 12 Apr 2024 02:07:34 -0700 Subject: [PATCH 10/10] Fixed an issue compiling on Linux --- Sources/CodableDatastore/Indexes/Indexable.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CodableDatastore/Indexes/Indexable.swift b/Sources/CodableDatastore/Indexes/Indexable.swift index 2dd8483..e47f773 100644 --- a/Sources/CodableDatastore/Indexes/Indexable.swift +++ b/Sources/CodableDatastore/Indexes/Indexable.swift @@ -22,6 +22,7 @@ public struct AnyIndexable { } } +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) /// Matching implementation from https://github.com/apple/swift/pull/64899/files extension Never: Codable { public init(from decoder: any Decoder) throws { @@ -32,6 +33,7 @@ extension Never: Codable { } public func encode(to encoder: any Encoder) throws {} } +#endif /// A marker protocol for types that can be used as a ranged index. ///