diff --git a/Sources/CodableDatastore/Datastore/Datastore.swift b/Sources/CodableDatastore/Datastore/Datastore.swift index 1cb9678..bab78f0 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 format: Format 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 indexRepresentations: [AnyIndexRepresentation : GeneratedIndexRepresentation] var updatedDescriptor: DatastoreDescriptor? @@ -31,28 +41,34 @@ 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, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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] = [], + 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)], configuration: Configuration = .init() ) where AccessMode == ReadWrite { self.persistence = persistence + 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 } @@ -62,45 +78,53 @@ public actor Datastore< public init( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.self, - identifierType: IdentifierType.Type, - decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, + decoders: [Version: (_ data: Data) async throws -> (id: IdentifierType, instance: InstanceType)], configuration: Configuration = .init() ) where AccessMode == ReadOnly { self.persistence = persistence + 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.") + } } } // MARK: - Helper Methods extension Datastore { - func updatedDescriptor(for instance: CodedType) throws -> DatastoreDescriptor { + func generateUpdatedDescriptor() 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 } - 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 +173,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. @@ -168,7 +192,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 = [] @@ -181,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. @@ -213,8 +237,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. @@ -228,8 +252,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) } @@ -267,90 +291,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 + ) + } + } } } @@ -366,15 +362,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: [] @@ -383,10 +379,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.secondaryIndexes[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 } @@ -402,10 +401,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.secondaryIndexes[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 { @@ -419,7 +421,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. } @@ -444,7 +446,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() @@ -459,7 +461,10 @@ extension Datastore { } } - public func load(_ identifier: IdentifierType) async throws -> CodedType? { + /// 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() return try await persistence._withTransaction( @@ -485,11 +490,17 @@ extension Datastore { } } - nonisolated func load( - _ range: some IndexRangeExpression, + /// **Internal:** Load a range of instances from a datastore based on the identifier range passed in as an async sequence. + /// - Parameters: + /// - 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( + _ identifierRange: 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() @@ -500,7 +511,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) @@ -514,35 +525,67 @@ extension Datastore { } } - nonisolated public func load( - _ range: some IndexRangeExpression, + /// 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: + /// - 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( + _ identifierRange: some IndexRangeExpression, order: RangeOrder = .ascending - ) -> some TypedAsyncSequence { - load(range, order: order, awaitWarmup: true) + ) -> some TypedAsyncSequence where IdentifierType: RangedIndexable { + _load(identifierRange, 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: + /// - 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 { - load(range, order: order) + ) -> some TypedAsyncSequence where IdentifierType: RangedIndexable { + 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: + /// - 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) + ) -> some TypedAsyncSequence { + _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( @@ -550,12 +593,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) @@ -564,10 +606,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) @@ -586,41 +628,115 @@ 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> - ) -> some TypedAsyncSequence { - load(range, order: order, from: keypath) + from index: KeyPath + ) -> some TypedAsyncSequence { + _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> - ) -> some TypedAsyncSequence { - load(IndexRange(), order: order, from: keypath) + from index: KeyPath + ) -> some TypedAsyncSequence { + _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. + /// + /// - 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: + /// - 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>( + _ unboundedRange: Swift.UnboundedRange, order: RangeOrder = .ascending, - from keypath: IndexPath> - ) -> some TypedAsyncSequence { - load(value...value, order: order, from: keypath) + from index: KeyPath + ) -> some TypedAsyncSequence { + _load(IndexRange.unbounded, order: order, from: index) } } // 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,10 +770,10 @@ 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) + let updatedDescriptor = try self.generateUpdatedDescriptor() let versionData = try Data(self.version) let instanceData = try await self.encoder(instance) @@ -668,7 +784,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) @@ -735,140 +851,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 @@ -883,19 +948,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( @@ -936,73 +1001,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 + 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.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 - 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 @@ -1011,41 +1050,50 @@ 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 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) } } @@ -1055,43 +1103,33 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I extension Datastore where AccessMode == ReadWrite { public static func JSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], configuration: Configuration = .init() ) -> Self { self.init( 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) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } public static func propertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], configuration: Configuration = .init() ) -> Self { let encoder = PropertyListEncoder() @@ -1103,14 +1141,10 @@ 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) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } @@ -1119,40 +1153,30 @@ extension Datastore where AccessMode == ReadWrite { extension Datastore where AccessMode == ReadOnly { public static func readOnlyJSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> (id: IdentifierType, instance: InstanceType)], configuration: Configuration = .init() ) -> Self { self.init( persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: identifierType, decoders: migrations.mapValues { migration in { data in try await migration(data, decoder) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } public static func readOnlyPropertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.self, - identifierType: IdentifierType.Type, - migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> (id: IdentifierType, instance: CodedType)], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + 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)], configuration: Configuration = .init() ) -> Self { let decoder = PropertyListDecoder() @@ -1161,38 +1185,30 @@ 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) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } } -// 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] = [], + 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], configuration: Configuration = .init() ) { self.init( persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, encoder: encoder, decoders: decoders.mapValues { decoder in { data in @@ -1200,30 +1216,24 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } public static func JSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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 -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], configuration: Configuration = .init() ) -> Self { self.JSONStore( persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, encoder: encoder, decoder: decoder, migrations: migrations.mapValues { migration in @@ -1232,29 +1242,23 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } public static func propertyListStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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 -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], configuration: Configuration = .init() ) -> Self { self.propertyListStore( persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, outputFormat: outputFormat, migrations: migrations.mapValues { migration in { data, decoder in @@ -1262,59 +1266,47 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } } -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] = [], + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, + decoders: [Version: (_ data: Data) async throws -> InstanceType], configuration: Configuration = .init() ) { self.init( 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) return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } public static func readOnlyJSONStore( persistence: some Persistence, - key: DatastoreKey, - version: Version, - codedType: CodedType.Type = CodedType.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 -> CodedType], - directIndexes: [IndexPath] = [], - computedIndexes: [IndexPath] = [], + migrations: [Version: (_ data: Data, _ decoder: JSONDecoder) async throws -> InstanceType], configuration: Configuration = .init() ) -> Self { self.readOnlyJSONStore( persistence: persistence, key: key, version: version, - codedType: codedType, - identifierType: codedType.ID.self, decoder: decoder, migrations: migrations.mapValues { migration in { data, decoder in @@ -1322,36 +1314,28 @@ extension Datastore where CodedType: Identifiable, IdentifierType == CodedType.I return (id: instance.id, instance: instance) } }, - directIndexes: directIndexes, - computedIndexes: computedIndexes, configuration: configuration ) } public static func readOnlyPropertyListStore( 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] = [], + format: Format.Type = Format.self, + key: DatastoreKey = Format.defaultKey, + version: Version = Format.currentVersion, + migrations: [Version: (_ data: Data, _ decoder: PropertyListDecoder) async throws -> InstanceType], configuration: Configuration = .init() ) -> Self { self.readOnlyPropertyListStore( 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) 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 c9614e8..8edd69e 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,48 @@ 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 - } + for (_, generatedRepresentation) in format.generateIndexRepresentations() { + let indexName = generatedRepresentation.indexName + guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" + else { continue } - secondaryIndexes.insert(indexDescriptor) - } - - for indexPath in directIndexPaths { - 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 - } - - /// Make sure the secondary indexes don't contain any of the direct indexes - secondaryIndexes.remove(indexDescriptor) - directIndexes.insert(indexDescriptor) - } - - Mirror.indexedChildren(from: sampleInstance) { indexName, value in - let indexName = IndexName(indexName) let indexDescriptor = IndexDescriptor( version: versionData, name: indexName, - type: value.projectedValue.indexedType + type: generatedRepresentation.index.indexType ) - if !directIndexes.contains(indexDescriptor) { - secondaryIndexes.insert(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( 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/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 new file mode 100644 index 0000000..185a167 --- /dev/null +++ b/Sources/CodableDatastore/Datastore/DatastoreFormat.swift @@ -0,0 +1,186 @@ +// +// 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 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. +/// +/// 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. + /// + /// 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 & DiscreteIndexable + + /// 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 } + + /// 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 + + /// Generate index representations for the datastore. + /// + /// 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. + /// + /// - 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 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) + 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 } + + /// 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. + /// - 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.") + } + return nil + } + + return GeneratedIndexRepresentation( + indexName: indexName, + index: index, + storage: storage + ) + } +} + +//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/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/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 new file mode 100644 index 0000000..5e4ae4c --- /dev/null +++ b/Sources/CodableDatastore/Indexes/IndexRepresentation.swift @@ -0,0 +1,251 @@ +// +// 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)? + + /// 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 & 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. +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 { + /// The sequence of values represented in the index. + 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 + } + + 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. +/// +/// 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 + } + + 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. +/// +/// 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 + } + + 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. +/// +/// 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 + } + + 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. +/// +/// - 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 a ``Direct`` index with an initial ``IndexRepresentation`` 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 + } +} + +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/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 +} diff --git a/Sources/CodableDatastore/Indexes/Indexable.swift b/Sources/CodableDatastore/Indexes/Indexable.swift new file mode 100644 index 0000000..e47f773 --- /dev/null +++ b/Sources/CodableDatastore/Indexes/Indexable.swift @@ -0,0 +1,84 @@ +// +// 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 & 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 + } +} + +#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 { + 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 {} +} +#endif + +/// 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 & Hashable & 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: Hashable & Codable {} + +// MARK: - Swift Standard Library Conformances + +extension Bool: DiscreteIndexable {} +extension Double: RangedIndexable {} +@available(macOS 13.0, iOS 16, tvOS 16, watchOS 9, *) +extension Duration: RangedIndexable {} +extension Float: RangedIndexable {} +@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 {} +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 deleted file mode 100644 index ae97991..0000000 --- a/Sources/CodableDatastore/Indexes/Indexed.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// Indexed.swift -// CodableDatastore -// -// Created by Dimitri Bouniol on 2023-05-31. -// Copyright © 2023 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 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/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/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/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..9a551ff 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() @@ -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..038767e 100644 --- a/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift +++ b/Tests/CodableDatastoreTests/DatastoreDescriptorTests.swift @@ -34,53 +34,8 @@ 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 +44,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 +180,171 @@ 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"), + "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.referenceIndexes, [:]) + } + + func testTypeDuplicatePaths() throws { + enum SharedVersion: String, CaseIterable { + case a, b, c + } - 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") - ] + 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(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"), + 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)"), ]) - XCTAssertEqual(descE.secondaryIndexes, [:]) } } 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 ecc65a5..2d8ead5 100644 --- a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift +++ b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift @@ -21,44 +21,53 @@ 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } do { @@ -66,11 +75,10 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -81,21 +89,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,58 +121,220 @@ final class DiskPersistenceDatastoreTests: XCTestCase { } func testWritingEntryWithIndex() async throws { - enum Version: Int, CaseIterable { - case zero + 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 = Index(\.value) } - struct TestStruct: Codable, Identifiable { - var id: String - @Indexed var value: String + 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) let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, 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) - 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 testObservingEntries() async throws { - enum Version: Int, CaseIterable { - case zero + 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) } - struct TestStruct: Codable, Identifiable { - var id: String - var value: Int + 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 { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + 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( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -187,12 +357,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 +371,28 @@ 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -228,7 +402,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,13 +464,18 @@ 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) @@ -304,11 +483,10 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -325,7 +503,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,13 +511,18 @@ 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) @@ -347,11 +530,10 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -369,7 +551,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,13 +560,18 @@ 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) @@ -392,11 +579,10 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -415,7 +601,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,13 +610,18 @@ 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 + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) @@ -438,11 +629,10 @@ final class DiskPersistenceDatastoreTests: XCTestCase { let datastore = Datastore.JSONStore( persistence: persistence, - key: "test", - version: Version.zero, + format: TestFormat.self, migrations: [ .zero: { data, decoder in - try decoder.decode(TestStruct.self, from: data) + try decoder.decode(TestFormat.Instance.self, from: data) } ] ) @@ -457,7 +647,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 +656,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..30fa1b4 100644 --- a/Tests/CodableDatastoreTests/DiskTransactionTests.swift +++ b/Tests/CodableDatastoreTests/DiskTransactionTests.swift @@ -23,30 +23,32 @@ 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 + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero } - struct TestStruct: Codable {} let datastore = Datastore( persistence: persistence, - key: "test", - version: Version.zero, - codedType: TestStruct.self, - identifierType: UUID.self, - decoders: [.zero: { _ in (id: UUID(), instance: TestStruct()) }], - directIndexes: [], - computedIndexes: [], + format: TestFormat.self, + decoders: [.zero: { _ in (id: UUID(), instance: TestFormat.Instance()) }], configuration: .init() ) let descriptor = DatastoreDescriptor( version: Data([0x00]), - codedType: "TestStruct", + instanceType: "TestStruct", identifierType: "UUID", directIndexes: [:], - secondaryIndexes: [:], + referenceIndexes: [:], size: 0 ) 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")) - } -}