├── .spi.yml ├── .gitignore ├── Sources ├── SwiftletModelMacros │ ├── EntityModelMacro │ │ ├── Attributes │ │ │ ├── PropertyAttributes.swift │ │ │ ├── Extensions │ │ │ │ └── String.swift │ │ │ ├── AccessAttributes.swift │ │ │ ├── PropertyWrapperAttributes.swift │ │ │ ├── IndexAttributes.swift │ │ │ ├── SearchIndexAttributes.swift │ │ │ ├── RelationshipAttributes.swift │ │ │ └── UniqueAttributes.swift │ │ └── SwiftSyntaxExtensions │ │ │ └── LabeledExprSyntaxExtension.swift │ └── Plugins.swift └── SwiftletModel │ ├── Model │ ├── Metadata.swift │ ├── DeletedEntity.swift │ ├── MergeStrategy.swift │ └── EntityModelProtocol.swift │ ├── Query │ ├── ContextQuery.swift │ ├── QuerySchema.swift │ ├── QueryGroup.swift │ ├── QueryAllNested.swift │ ├── QueryLogicalFilters.swift │ ├── Nested.swift │ ├── Query.swift │ ├── QueryList.swift │ ├── QueryRelated.swift │ └── QueryListNested.swift │ ├── Extensions │ ├── KeyPathExtensions.swift │ ├── ArrayExtensions.swift │ └── EncodableExtensions.swift │ ├── Index │ ├── FullTextIndex.swift │ ├── String+FuzzyMatch.swift │ ├── Comparable+SortOrder.swift │ ├── Entity+FullTextIndex.swift │ ├── String+SearchTokens.swift │ ├── Index.swift │ ├── CollisionResolver.swift │ ├── String+IndexName.swift │ ├── Index+Hashable.swift │ ├── UniqueIndex.swift │ ├── Entity+Metadata.swift │ ├── UniqueIndex+Comparable.swift │ ├── UniqueIndex+Hashable.swift │ ├── Tuples.swift │ ├── Entity+IndexHashable.swift │ ├── Entity+IndexComparable.swift │ ├── Entity+UniqueHashable.swift │ ├── Entity+UniqueComparable.swift │ └── Entity+UniqueHashable&Comparable.swift │ ├── Relationship │ ├── Relation+UpdateOption.swift │ ├── EncodingStrategy.swift │ ├── DecodingStrategy.swift │ ├── RelationSave.swift │ ├── Relation+Conveniece.swift │ ├── Relationship.swift │ ├── RelationTypes.swift │ └── Relation.swift │ ├── Macros.swift │ └── Context │ ├── Context.swift │ └── EntitiesRepository.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── SwiftletModel.xcscheme ├── Tests └── SwiftletModelTests │ ├── Models │ ├── Attachment.swift │ ├── Chat.swift │ ├── Message.swift │ ├── User.swift │ └── Schema.swift │ ├── ModelsForTesting.swift │ ├── CodableTests │ └── __Snapshots__ │ │ └── RelationsEncodingDecodingTests │ │ ├── test_WhenDefaultCoding_EqualExpectedJSON.1.json │ │ ├── test_WhenDefaultCoding_EqualExpectedJSON.2.json │ │ ├── test_WhenDefaultEncodingSlice_EqualExpectedJSON.1.json │ │ ├── test_WhenDefaultEncodingSlice_EqualExpectedJSON.2.json │ │ ├── test_WhenExactCoding_EqualExpectedJSON.1.json │ │ ├── test_WhenExactCoding_EqualExpectedJSON.2.json │ │ ├── test_WhenExplicitCoding_EqualExpectedJSON.1.json │ │ ├── test_WhenExplicitCoding_EqualExpectedJSON.2.json │ │ ├── test_WhenExactEncodingSlice_EqualExpectedJSON.1.json │ │ ├── test_WhenExactEncodingSlice_EqualExpectedJSON.2.json │ │ ├── test_WhenExplicitEncodingSlice_EqualExpectedJSON.1.json │ │ └── test_WhenExplicitEncodingSlice_EqualExpectedJSON.2.json │ ├── QueriesTests │ ├── __Snapshots__ │ │ ├── AllNestedModelsQueryTest │ │ │ ├── test_WhenQuerySchemaLatestRange_IncludesEntitiesUpdatedWithinRange.1.json │ │ │ ├── test_WhenQueryWithNestedIds_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQuerySchemaOlderRange_IncludesEntitiesUpdatedWithinRange.1.json │ │ │ └── test_WhenQueryWithNestedEntities_EqualExpectedJSON.1.json │ │ └── NestedModelsQueryTest │ │ │ ├── test_WhenQueryWithNestedModelId_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQueryWithNestedModelIds_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQueryWithNestedIdsSlice_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQueryWithNestedModelsAndFilter_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQueryWithNestedModel_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQueryWithNestedModels_EqualExpectedJSON.1.json │ │ │ ├── test_WhenQueryWithNestedModelsAndSort_EqualExpectedJSON.1.json │ │ │ └── test_WhenQueryWithNestedModelsSlice_EqualExpectedJSON.1.json │ ├── FilterMetadataQueryTests.swift │ └── NestedModelsQueryTest.swift │ ├── RelationsTests │ ├── ManyToManyTests.swift │ └── ToOneTests.swift │ ├── IndexTests │ ├── SortIndexPerformanceTests.swift │ ├── FullTextIndexPerformanceTests.swift │ └── FilterIndexPerformanceTests.swift │ └── EntitiesTests │ └── DeleteTests.swift ├── .github └── workflows │ └── tests.yml ├── LICENSE └── Package.swift /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [SwiftletModel] 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | Package.resolved 5 | /*.xcodeproj 6 | xcuserdata/ 7 | DerivedData/ 8 | .swiftpm/config/registries.json 9 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 10 | .netrc -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/PropertyAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 10/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PropertyAttributes { 11 | let propertyName: String 12 | let isMutable: Bool 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Model/Metadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataIndex.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 21/04/2025. 6 | // 7 | 8 | public enum Metadata: String { 9 | case updatedAt 10 | 11 | var indexName: String { 12 | "\(Metadata.self).\(rawValue)" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/ContextQuery.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContextQuery.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 12/04/2025. 6 | // 7 | 8 | public struct ContextQuery { 9 | let key: (Context) -> Key? 10 | let result: (Context, Key?) -> Result 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 06/11/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func cleanedKeyPath() -> String { 12 | replacingOccurrences(of: "\\", with: "") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/Plugins.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 03/08/2024. 6 | // 7 | 8 | import SwiftCompilerPlugin 9 | import SwiftSyntaxMacros 10 | 11 | @main 12 | struct MacrosPlugin: CompilerPlugin { 13 | let providingMacros: [Macro.Type] = [ 14 | EntityModelMacro.self, EntityRefModelMacro.self 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Extensions/KeyPathExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyPathExtensions.swift 3 | // SwiftletModel 4 | // 5 | // Created by Sergey Kazakov on 15/06/2025. 6 | // 7 | 8 | extension KeyPath { 9 | var valueType: Value.Type { 10 | Value.self 11 | } 12 | } 13 | 14 | extension KeyPath where Root: EntityModelProtocol { 15 | var name: String { 16 | Root.indexedKeyPathName(self) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/AccessAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 11/09/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | enum AccessAttribute: String { 11 | case `private` 12 | case `fileprivate` 13 | case `internal` 14 | case `public` 15 | case `open` 16 | case missingAttribute = "" 17 | 18 | var name: String { 19 | rawValue 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/FullTextIndex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 08/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | @propertyWrapper 12 | public struct FullTextIndex: Sendable, Codable { 13 | 14 | public var wrappedValue: FullTextIndex { 15 | self 16 | } 17 | 18 | public init(_ keypaths: KeyPath...) { 19 | 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/PropertyWrapperAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WrapperType.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 07/04/2025. 6 | // 7 | 8 | enum PropertyWrapperAttributes: String, CaseIterable { 9 | case fullTextIndex = "FullTextIndex" 10 | case unique = "Unique" 11 | case index = "Index" 12 | case relationship = "Relationship" 13 | 14 | var title: String { 15 | rawValue 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/Relation+UpdateOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Sergey Kazakov on 15/06/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Relation { 11 | func updateOption() -> Link.UpdateOption { 12 | isSlice ? .append : .replace 13 | } 14 | 15 | static func inverseUpdateOption() -> Link.UpdateOption { 16 | Cardinality.isToMany ? .append : .replace 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QuerySchema.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuerySchema.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 22/04/2025. 6 | // 7 | 8 | public extension ContextQuery where Result == Entity?, Key == Entity.ID { 9 | static func schemaQuery() -> Query { 10 | Query.none 11 | } 12 | } 13 | 14 | public extension ContextQuery where Result == [Query], Key == Void { 15 | static func schemaQuery() -> QueryList { 16 | Entity.query().sorted(by: .updatedAt) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/String+FuzzyMatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 05/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func matches(fuzzy pattern: String) -> Bool { 12 | let patternTokens = Set(pattern.makeTokens()) 13 | return patternTokens.first { token in self.contains(token) } != nil 14 | } 15 | 16 | func matches(tokens: [String]) -> Bool { 17 | tokens.first { token in self.contains(token) } != nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Comparable+SortOrder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 30/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Descending: Comparable, Sendable where T: Sendable { 11 | public let value: T 12 | 13 | public static func < (lhs: Descending, rhs: Descending) -> Bool { 14 | return lhs.value > rhs.value 15 | } 16 | } 17 | 18 | public extension Comparable where Self: Sendable { 19 | var desc: Descending { Descending(value: self) } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Macros.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 03/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | @attached(extension, conformances: EntityModelProtocol, names: arbitrary) 11 | public macro EntityModel() = 12 | #externalMacro( 13 | module: "SwiftletModelMacros", type: "EntityModelMacro" 14 | ) 15 | 16 | @attached(extension, conformances: EntityModelProtocol, names: arbitrary) 17 | internal macro EntityRefModel() = 18 | #externalMacro( 19 | module: "SwiftletModelMacros", type: "EntityRefModelMacro" 20 | ) 21 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/Models/Attachment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 03/03/2024. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | 11 | extension Schema.V1 { 12 | 13 | @EntityModel 14 | struct Attachment: Codable, Sendable { 15 | let id: String 16 | var kind: Kind 17 | 18 | @Relationship(.required, inverse: \.attachment) 19 | var message: Message? 20 | 21 | enum Kind: Codable { 22 | case image(url: URL) 23 | case video(url: URL) 24 | case file(url: URL) 25 | } 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: maxim-lobanov/setup-xcode@v1 19 | with: 20 | xcode-version: latest-stable 21 | - uses: actions/checkout@v4 22 | - name: Build 23 | run: swift build -v 24 | - name: Run tests 25 | run: swift test -v 26 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Extensions/ArrayExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 03/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array { 11 | func removingDuplicates(by key: (Element) -> Key) -> [Element] { 12 | var existingDict = [Key: Bool]() 13 | 14 | return filter { 15 | existingDict.updateValue(true, forKey: key($0)) == nil 16 | } 17 | } 18 | } 19 | 20 | extension Array { 21 | func limit(_ limit: Int, offset: Int) -> Array { 22 | guard limit > 0, offset >= 0 23 | else { 24 | return [] 25 | } 26 | return Array(dropFirst(offset).prefix(limit)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/Models/Chat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 03/03/2024. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | 11 | extension Schema.V1 { 12 | @EntityModel 13 | struct Chat: Codable, Sendable { 14 | let id: String 15 | 16 | @Relationship(inverse: \.chats) 17 | var users: [User]? 18 | 19 | @Relationship(deleteRule: .cascade, inverse: \.chat) 20 | var messages: [Message]? 21 | 22 | @Relationship(inverse: \.adminOf) 23 | var admins: [User]? 24 | 25 | func willDelete(from context: inout Context) throws { 26 | try delete(\.$messages, inverse: \.$chat, from: &context) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Extensions/EncodableExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 24/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Encodable { 11 | func prettyDescription(with encoder: JSONEncoder) -> String? { 12 | guard let data = try? encoder.encode(self) else { 13 | return nil 14 | } 15 | return String(data: data, encoding: .utf8) 16 | } 17 | } 18 | 19 | public extension JSONEncoder { 20 | static var prettyPrinting: JSONEncoder { 21 | let encoder = JSONEncoder() 22 | if #available(iOS 13.0, *) { 23 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 24 | } else { 25 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 26 | } 27 | 28 | return encoder 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QueryGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueriesGroup.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 13/04/2025. 6 | // 7 | 8 | public typealias QueryGroup = ContextQuery]], Void> 9 | 10 | public extension ContextQuery where Result == [[Query]], Key == Void { 11 | func resolve(in context: Context) -> [[Entity]] { 12 | resolveQueries(context).compactMap { $0.resolve(in: context) } 13 | } 14 | } 15 | 16 | extension ContextQuery where Result == [[Query]], Key == Void { 17 | init(queriesResolver: @escaping (Context) -> [[Query]]) { 18 | self.key = { _ in Void() } 19 | self.result = { context, _ in queriesResolver(context) } 20 | } 21 | 22 | func resolveQueries(_ context: Context) -> [[Query]] { 23 | result(context, key(context)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Model/DeletedEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 21/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | @EntityModel 11 | public struct Deleted { 12 | public var id: Entity.ID { entity.id } 13 | public let entity: Entity 14 | 15 | init(_ entity: Entity) { 16 | self.entity = entity 17 | } 18 | } 19 | 20 | extension Deleted: Codable where Entity: Codable { } 21 | 22 | public extension Deleted { 23 | func asDeleted(in context: Context) -> Deleted? { nil } 24 | 25 | mutating func willSave(to context: inout Context) throws { 26 | try Entity.delete(id: id, from: &context) 27 | } 28 | 29 | func restore(in context: inout Context) throws { 30 | try entity.save(to: &context, options: Entity.defaultMergeStrategy) 31 | try delete(from: &context) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QueryAllNested.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryAllNested.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 02/04/2025. 6 | // 7 | 8 | // MARK: - All Nested Entities Query 9 | 10 | public extension ContextQuery where Result == Entity?, Key == Entity.ID { 11 | func with(_ nested: Nested...) -> Query { 12 | with(nested) 13 | } 14 | 15 | func with(_ nested: [Nested]) -> Query { 16 | Entity.nestedQueryModifier(self, nested: nested) 17 | } 18 | } 19 | 20 | // MARK: - All Nested Entities Collection Query 21 | 22 | public extension ContextQuery where Result == [Query], Key == Void { 23 | func with(_ nested: Nested...) -> QueryList { 24 | with(nested) 25 | } 26 | 27 | func with(_ nested: [Nested]) -> QueryList { 28 | then { context, queries in 29 | queries.map { $0.with(nested) } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+FullTextIndex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 16/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EntityModelProtocol { 11 | func updateFullTextIndex( 12 | _ keyPaths: KeyPath..., 13 | in context: inout Context) throws 14 | where 15 | T: Hashable & Sendable { 16 | 17 | try FullTextIndex.HashableValue.updateIndex( 18 | indexName: .indexName(keyPaths), 19 | self, 20 | value: keyPaths.map { self[keyPath: $0] }, 21 | in: &context 22 | ) 23 | } 24 | } 25 | 26 | public extension EntityModelProtocol { 27 | func removeFromFullTextIndex( 28 | _ keyPaths: KeyPath..., 29 | in context: inout Context) throws 30 | where 31 | T: Hashable & Sendable { 32 | 33 | try FullTextIndex.HashableValue<[T]>.removeFromIndex(indexName: .indexName(keyPaths), self, in: &context) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/ModelsForTesting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 13/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | extension User { 11 | static let bob = User(id: "1", name: "Bob", username: "@bob", email: "bob@gmail.com") 12 | static let alice = User(id: "2", name: "Alice", username: "@alice", email: "alice@gmail.com") 13 | static let john = User(id: "3", name: "John", username: "@john", email: "john@gmail.com") 14 | static let michael = User(id: "4", name: "Michael", username: "@michael", email: "michael@gmail.com") 15 | static let tom = User(id: "5", name: "Tom", username: "@tom", email: "tom@gmail.com") 16 | } 17 | 18 | extension Attachment { 19 | static let imageOne = Attachment(id: "1", kind: .file(url: URL(string: "http://google.com/image-1.jpg")!)) 20 | static let imageTwo = Attachment(id: "2", kind: .file(url: URL(string: "http://google.com/image-2.jpg")!)) 21 | } 22 | 23 | extension Chat { 24 | static let one = Chat(id: "1") 25 | static let two = Chat(id: "2") 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/SwiftSyntaxExtensions/LabeledExprSyntaxExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 10/08/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | #if !canImport(SwiftSyntax600) 15 | import SwiftSyntaxMacroExpansion 16 | #endif 17 | 18 | extension LabeledExprSyntax { 19 | var labelString: String? { 20 | label.map { "\($0)" } 21 | } 22 | 23 | var expressionString: String { 24 | "\(expression)" 25 | } 26 | 27 | var isKeyPath: Bool { 28 | expressionString.isKeyPath 29 | } 30 | } 31 | 32 | fileprivate extension String { 33 | static var keyPathRegEx: String { 34 | #"^\\.*\..*$"# 35 | } 36 | 37 | var isKeyPath: Bool { 38 | range(of: String.keyPathRegEx, options: .regularExpression) != nil 39 | } 40 | } 41 | 42 | extension DeclModifierListSyntax { 43 | var isStaticProperty: Bool { 44 | contains(where: { $0.name.text == "static" }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Serge Kazakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/EncodingStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 24/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum RelationEncodingStrategy: Sendable { 11 | case plain 12 | case keyedContainer 13 | case explicitKeyedContainer 14 | } 15 | 16 | public extension RelationEncodingStrategy { 17 | static let `default`: Self = .plain 18 | } 19 | 20 | public extension RelationEncodingStrategy { 21 | static let userInfoKey = CodingUserInfoKey(rawValue: "RelationEncodingStrategy.userInfoKey")! 22 | } 23 | 24 | public extension Encoder { 25 | var relationEncodingStrategy: RelationEncodingStrategy { 26 | (userInfo[RelationEncodingStrategy.userInfoKey] as? RelationEncodingStrategy) ?? .default 27 | } 28 | } 29 | 30 | public extension JSONEncoder { 31 | var relationEncodingStrategy: RelationEncodingStrategy { 32 | get { 33 | (userInfo[RelationEncodingStrategy.userInfoKey] as? RelationEncodingStrategy) ?? .default 34 | } 35 | set { 36 | userInfo[RelationEncodingStrategy.userInfoKey] = newValue 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/String+SearchTokens.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 05/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func makeTokens() -> [String] { 12 | self.nGrams(of: 3) 13 | } 14 | } 15 | 16 | extension String { 17 | func nGrams(of length: Int) -> [String] { 18 | guard length > 0 else { return [] } 19 | return self 20 | .lowercased() 21 | .components(separatedBy: CharacterSet.alphanumerics.inverted) 22 | .flatMap { $0.windows(ofLength: length) } 23 | .map { String($0) } 24 | } 25 | 26 | func windows(ofLength length: Int) -> [SubSequence] { 27 | guard length > 0 else { 28 | return [] 29 | } 30 | 31 | guard self.count >= length else { 32 | return [] 33 | } 34 | 35 | return (0...(self.count - length)) 36 | .map { start in 37 | let end = start + length 38 | return self[index(startIndex, offsetBy: start)..: Sendable, Codable { 13 | public var wrappedValue: Index.Type { 14 | Self.self 15 | } 16 | 17 | public init(_ kp0: KeyPath) 18 | where 19 | T0: Comparable { 20 | 21 | } 22 | 23 | public init( 24 | _ kp0: KeyPath, 25 | _ kp1: KeyPath) 26 | where 27 | T0: Comparable, 28 | T1: Comparable { 29 | 30 | } 31 | 32 | public init( 33 | _ kp0: KeyPath, 34 | _ kp1: KeyPath, 35 | _ kp2: KeyPath) 36 | 37 | where 38 | T0: Comparable, 39 | T1: Comparable, 40 | T2: Comparable { 41 | 42 | } 43 | 44 | public init( 45 | _ kp0: KeyPath, 46 | _ kp1: KeyPath, 47 | _ kp2: KeyPath, 48 | _ kp3: KeyPath) 49 | 50 | where 51 | T0: Comparable, 52 | T1: Comparable, 53 | T2: Comparable, 54 | T3: Comparable { 55 | 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QueryLogicalFilters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryAndFilters.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 06/04/2025. 6 | // 7 | 8 | public extension ContextQuery where Result == [Query], Key == Void { 9 | func and( 10 | _ predicate: Predicate) -> QueryList 11 | where 12 | T: Comparable { 13 | filter(predicate) 14 | } 15 | 16 | func and( 17 | _ predicate: EqualityPredicate) -> QueryList 18 | where 19 | T: Hashable { 20 | filter(predicate) 21 | } 22 | 23 | func and( 24 | _ predicate: EqualityPredicate) -> QueryList 25 | where 26 | T: Equatable { 27 | filter(predicate) 28 | } 29 | 30 | func and( 31 | _ predicate: Predicate) -> QueryList 32 | where 33 | T: Hashable & Comparable { 34 | filter(predicate) 35 | } 36 | 37 | func or(_ queryList: @escaping @autoclosure () -> QueryList) -> QueryList { 38 | then { context, queries in 39 | [queries, queryList().queries(context)] 40 | .flatMap { $0 } 41 | .removingDuplicates(by: { $0.id(context) }) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/Nested.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 08/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Nested { 11 | case ids 12 | case entities(MetadataPredicate?, schemaQuery: Bool) 13 | case fragments(MetadataPredicate?, schemaQuery: Bool) 14 | } 15 | 16 | public extension Nested { 17 | static var entities: Nested { 18 | .entities(nil, schemaQuery: false) 19 | } 20 | 21 | static var fragments: Nested { 22 | .fragments(nil, schemaQuery: false) 23 | } 24 | 25 | static func entities(filter: MetadataPredicate) -> Nested { 26 | .entities(filter, schemaQuery: false) 27 | } 28 | 29 | static func fragments(filter: MetadataPredicate) -> Nested { 30 | .entities(filter, schemaQuery: false) 31 | } 32 | 33 | static func schemaEntities(filter: MetadataPredicate) -> Nested { 34 | .entities(filter, schemaQuery: true) 35 | } 36 | 37 | static func schemaFragments(filter: MetadataPredicate) -> Nested { 38 | .entities(filter, schemaQuery: true) 39 | } 40 | 41 | static var schemaEntities: Nested { 42 | .entities(nil, schemaQuery: true) 43 | } 44 | 45 | static var schemaFragments: Nested { 46 | .fragments(nil, schemaQuery: true) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/Models/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 03/03/2024. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | 11 | extension Schema.V1 { 12 | 13 | @EntityModel 14 | struct Message: Codable, Sendable { 15 | 16 | @Index(\Message.timestamp) private static var timestampIndex 17 | @FullTextIndex(\Message.text) private static var textSearchIndex1 18 | 19 | public let id: String 20 | let text: String 21 | var timestamp: Date = .distantPast 22 | 23 | @Relationship(.required) 24 | var author: User? 25 | 26 | @Relationship(inverse: \.messages) 27 | var chat: Chat? 28 | 29 | @Relationship(deleteRule: .cascade, inverse: \.message) 30 | var attachment: Attachment? 31 | 32 | @Relationship(inverse: \.replyTo) 33 | var replies: [Message]? 34 | 35 | @Relationship(inverse: \.replies) 36 | var replyTo: Message? 37 | 38 | @Relationship 39 | var viewedBy: [User]? = nil 40 | 41 | public func willDelete(from context: inout Context) throws { 42 | try delete(\.$attachment, inverse: \.$message, from: &context) 43 | } 44 | } 45 | } 46 | 47 | extension Query { 48 | func isMyMessage(in context: Context) -> Bool? { 49 | related(\.$author) 50 | .resolve(in: context)?.isCurrent == true 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/DecodingStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 19/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct RelationDecodingStrategy: OptionSet, Sendable { 11 | public let rawValue: UInt 12 | 13 | public init(rawValue: UInt) { 14 | self.rawValue = rawValue 15 | } 16 | } 17 | 18 | public extension RelationDecodingStrategy { 19 | static let `default`: Self = [.plain] 20 | 21 | static let plain = RelationDecodingStrategy(rawValue: 1 << 0) 22 | static let keyedContainer = RelationDecodingStrategy(rawValue: 1 << 1) 23 | static let explicitKeyedContainer = RelationDecodingStrategy(rawValue: 1 << 2) 24 | } 25 | 26 | public extension RelationDecodingStrategy { 27 | static let userInfoKey = CodingUserInfoKey(rawValue: "RelationDecodingStrategy.userInfoKey")! 28 | } 29 | 30 | public extension Decoder { 31 | var relationDecodingStrategy: RelationDecodingStrategy { 32 | (userInfo[RelationDecodingStrategy.userInfoKey] as? RelationDecodingStrategy) ?? .default 33 | } 34 | } 35 | 36 | public extension JSONDecoder { 37 | var relationDecodingStrategy: RelationDecodingStrategy { 38 | get { 39 | (userInfo[RelationDecodingStrategy.userInfoKey] as? RelationDecodingStrategy) ?? .default 40 | } 41 | set { 42 | userInfo[RelationDecodingStrategy.userInfoKey] = newValue 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenDefaultCoding_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : [ 4 | { 5 | "admins" : [ 6 | { 7 | "id" : "1" 8 | } 9 | ], 10 | "id" : "1", 11 | "messages" : [ 12 | { 13 | "attachment" : { 14 | "id" : "1", 15 | "kind" : { 16 | "file" : { 17 | "url" : "http://google.com/image-1.jpg" 18 | } 19 | }, 20 | "message" : { 21 | "id" : "1" 22 | } 23 | }, 24 | "author" : { 25 | "id" : "2" 26 | }, 27 | "chat" : { 28 | "id" : "1" 29 | }, 30 | "id" : "1", 31 | "replies" : null, 32 | "replyTo" : null, 33 | "text" : "hello", 34 | "timestamp" : -63114076800, 35 | "viewedBy" : null 36 | } 37 | ], 38 | "users" : [ 39 | { 40 | "id" : "1" 41 | }, 42 | { 43 | "id" : "2" 44 | }, 45 | { 46 | "id" : "5" 47 | }, 48 | { 49 | "id" : "3" 50 | }, 51 | { 52 | "id" : "4" 53 | } 54 | ] 55 | } 56 | ], 57 | "email" : "bob@gmail.com", 58 | "id" : "1", 59 | "isCurrent" : false, 60 | "name" : "Bob", 61 | "username" : "@bob" 62 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenDefaultCoding_EqualExpectedJSON.2.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : [ 4 | { 5 | "admins" : [ 6 | { 7 | "id" : "1" 8 | } 9 | ], 10 | "id" : "1", 11 | "messages" : [ 12 | { 13 | "attachment" : { 14 | "id" : "1", 15 | "kind" : { 16 | "file" : { 17 | "url" : "http://google.com/image-1.jpg" 18 | } 19 | }, 20 | "message" : { 21 | "id" : "1" 22 | } 23 | }, 24 | "author" : { 25 | "id" : "2" 26 | }, 27 | "chat" : { 28 | "id" : "1" 29 | }, 30 | "id" : "1", 31 | "replies" : null, 32 | "replyTo" : null, 33 | "text" : "hello", 34 | "timestamp" : -63114076800, 35 | "viewedBy" : null 36 | } 37 | ], 38 | "users" : [ 39 | { 40 | "id" : "1" 41 | }, 42 | { 43 | "id" : "2" 44 | }, 45 | { 46 | "id" : "5" 47 | }, 48 | { 49 | "id" : "3" 50 | }, 51 | { 52 | "id" : "4" 53 | } 54 | ] 55 | } 56 | ], 57 | "email" : "bob@gmail.com", 58 | "id" : "1", 59 | "isCurrent" : false, 60 | "name" : "Bob", 61 | "username" : "@bob" 62 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenDefaultEncodingSlice_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : [ 4 | { 5 | "admins" : [ 6 | { 7 | "id" : "1" 8 | } 9 | ], 10 | "id" : "1", 11 | "messages" : [ 12 | { 13 | "attachment" : { 14 | "id" : "1", 15 | "kind" : { 16 | "file" : { 17 | "url" : "http://google.com/image-1.jpg" 18 | } 19 | }, 20 | "message" : { 21 | "id" : "1" 22 | } 23 | }, 24 | "author" : { 25 | "id" : "2" 26 | }, 27 | "chat" : { 28 | "id" : "1" 29 | }, 30 | "id" : "1", 31 | "replies" : null, 32 | "replyTo" : null, 33 | "text" : "hello", 34 | "timestamp" : -63114076800, 35 | "viewedBy" : null 36 | } 37 | ], 38 | "users" : [ 39 | { 40 | "id" : "1" 41 | }, 42 | { 43 | "id" : "2" 44 | }, 45 | { 46 | "id" : "5" 47 | }, 48 | { 49 | "id" : "3" 50 | }, 51 | { 52 | "id" : "4" 53 | } 54 | ] 55 | } 56 | ], 57 | "email" : "bob@gmail.com", 58 | "id" : "1", 59 | "isCurrent" : false, 60 | "name" : "Bob", 61 | "username" : "@bob" 62 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenDefaultEncodingSlice_EqualExpectedJSON.2.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : [ 4 | { 5 | "admins" : [ 6 | { 7 | "id" : "1" 8 | } 9 | ], 10 | "id" : "1", 11 | "messages" : [ 12 | { 13 | "attachment" : { 14 | "id" : "1", 15 | "kind" : { 16 | "file" : { 17 | "url" : "http://google.com/image-1.jpg" 18 | } 19 | }, 20 | "message" : { 21 | "id" : "1" 22 | } 23 | }, 24 | "author" : { 25 | "id" : "2" 26 | }, 27 | "chat" : { 28 | "id" : "1" 29 | }, 30 | "id" : "1", 31 | "replies" : null, 32 | "replyTo" : null, 33 | "text" : "hello", 34 | "timestamp" : -63114076800, 35 | "viewedBy" : null 36 | } 37 | ], 38 | "users" : [ 39 | { 40 | "id" : "1" 41 | }, 42 | { 43 | "id" : "2" 44 | }, 45 | { 46 | "id" : "5" 47 | }, 48 | { 49 | "id" : "3" 50 | }, 51 | { 52 | "id" : "4" 53 | } 54 | ] 55 | } 56 | ], 57 | "email" : "bob@gmail.com", 58 | "id" : "1", 59 | "isCurrent" : false, 60 | "name" : "Bob", 61 | "username" : "@bob" 62 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/AllNestedModelsQueryTest/test_WhenQuerySchemaLatestRange_IncludesEntitiesUpdatedWithinRange.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "v1" : { 4 | "attachments" : [ 5 | 6 | ], 7 | "chats" : [ 8 | { 9 | "admins" : [ 10 | { 11 | "id" : "1" 12 | } 13 | ], 14 | "id" : "1", 15 | "messages" : [ 16 | { 17 | "id" : "0" 18 | }, 19 | { 20 | "id" : "1" 21 | }, 22 | { 23 | "id" : "2" 24 | }, 25 | { 26 | "id" : "3" 27 | }, 28 | { 29 | "id" : "4" 30 | } 31 | ], 32 | "users" : [ 33 | { 34 | "id" : "1" 35 | }, 36 | { 37 | "id" : "2" 38 | }, 39 | { 40 | "id" : "5" 41 | }, 42 | { 43 | "id" : "3" 44 | }, 45 | { 46 | "id" : "4" 47 | } 48 | ] 49 | } 50 | ], 51 | "deletedAttachments" : [ 52 | 53 | ], 54 | "deletedChats" : [ 55 | 56 | ], 57 | "deletedMessages" : [ 58 | 59 | ], 60 | "deletedUsers" : [ 61 | 62 | ], 63 | "messages" : [ 64 | 65 | ], 66 | "users" : [ 67 | 68 | ] 69 | } 70 | } 71 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/CollisionResolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 10/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct CollisionResolver { 11 | let resolveCollisionHandler: (Entity.ID, Entity.ID, String, inout Context) throws -> Void 12 | 13 | func resolveCollision(existing: Entity.ID, new: Entity.ID, indexName: String, in context: inout Context) throws { 14 | try resolveCollisionHandler(existing, new, indexName, &context) 15 | } 16 | 17 | public init(resolveCollisionHandler: @escaping (Entity.ID, Entity.ID, String, inout Context) throws -> Void) { 18 | self.resolveCollisionHandler = resolveCollisionHandler 19 | } 20 | } 21 | 22 | public extension CollisionResolver { 23 | static var `throw`: Self { 24 | CollisionResolver { existing, new, indexName, _ in 25 | throw Errors.uniqueValueIndexViolation(existing: existing, new: new, indexName: indexName) 26 | } 27 | } 28 | 29 | static var upsert: Self { 30 | CollisionResolver { existing, _, _, context in 31 | try Query(id: existing) 32 | .resolve(in: context)? 33 | .delete(from: &context) 34 | } 35 | } 36 | } 37 | 38 | public extension CollisionResolver { 39 | enum Errors: Error { 40 | case uniqueValueIndexViolation(existing: Entity.ID, new: Entity.ID, indexName: String) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModelId_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : { 5 | "id" : "4" 6 | }, 7 | "chat" : null, 8 | "id" : "0", 9 | "replies" : null, 10 | "replyTo" : null, 11 | "text" : "hello, ya'll", 12 | "timestamp" : -63114076800, 13 | "viewedBy" : null 14 | }, 15 | { 16 | "attachment" : null, 17 | "author" : { 18 | "id" : "2" 19 | }, 20 | "chat" : null, 21 | "id" : "1", 22 | "replies" : null, 23 | "replyTo" : null, 24 | "text" : "hello", 25 | "timestamp" : -63114076800, 26 | "viewedBy" : null 27 | }, 28 | { 29 | "attachment" : null, 30 | "author" : { 31 | "id" : "1" 32 | }, 33 | "chat" : null, 34 | "id" : "2", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "howdy", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | }, 41 | { 42 | "attachment" : null, 43 | "author" : { 44 | "id" : "5" 45 | }, 46 | "chat" : null, 47 | "id" : "3", 48 | "replies" : null, 49 | "replyTo" : null, 50 | "text" : "yo!", 51 | "timestamp" : -63114076800, 52 | "viewedBy" : null 53 | }, 54 | { 55 | "attachment" : null, 56 | "author" : { 57 | "id" : "3" 58 | }, 59 | "chat" : null, 60 | "id" : "4", 61 | "replies" : null, 62 | "replyTo" : null, 63 | "text" : "wassap!", 64 | "timestamp" : -63114076800, 65 | "viewedBy" : null 66 | } 67 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/String+IndexName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 09/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | static func indexName( 12 | _ keyPath: KeyPath) -> String where Entity: EntityModelProtocol { 13 | keyPath.name 14 | } 15 | 16 | static func indexName( 17 | _ keyPaths: [KeyPath]) -> String where Entity: EntityModelProtocol { 18 | keyPaths 19 | .map { $0.name } 20 | .joined(separator: "-") 21 | } 22 | 23 | static func indexName( 24 | _ kp0: KeyPath, 25 | _ kp1: KeyPath) -> String where Entity: EntityModelProtocol { 26 | 27 | [kp0.name, kp1.name] 28 | .joined(separator: "-") 29 | } 30 | 31 | static func indexName( 32 | _ kp0: KeyPath, 33 | _ kp1: KeyPath, 34 | _ kp2: KeyPath) -> String where Entity: EntityModelProtocol { 35 | 36 | [kp0.name, kp1.name, kp2.name] 37 | .joined(separator: "-") 38 | } 39 | 40 | static func indexName( 41 | _ kp0: KeyPath, 42 | _ kp1: KeyPath, 43 | _ kp2: KeyPath, 44 | _ kp3: KeyPath) -> String where Entity: EntityModelProtocol { 45 | 46 | [kp0.name, kp1.name, kp2.name, kp3.name] 47 | .joined(separator: "-") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExactCoding_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "objects" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExactCoding_EqualExpectedJSON.2.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "objects" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExplicitCoding_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "objects" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExplicitCoding_EqualExpectedJSON.2.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "objects" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExactEncodingSlice_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "slice" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "slice_ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExactEncodingSlice_EqualExpectedJSON.2.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "slice" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "slice_ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExplicitEncodingSlice_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "objects" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/CodableTests/__Snapshots__/RelationsEncodingDecodingTests/test_WhenExplicitEncodingSlice_EqualExpectedJSON.2.json: -------------------------------------------------------------------------------- 1 | { 2 | "adminOf" : null, 3 | "chats" : { 4 | "objects" : [ 5 | { 6 | "admins" : { 7 | "ids" : [ 8 | "1" 9 | ] 10 | }, 11 | "id" : "1", 12 | "messages" : { 13 | "objects" : [ 14 | { 15 | "attachment" : { 16 | "object" : { 17 | "id" : "1", 18 | "kind" : { 19 | "file" : { 20 | "url" : "http://google.com/image-1.jpg" 21 | } 22 | }, 23 | "message" : { 24 | "id" : "1" 25 | } 26 | } 27 | }, 28 | "author" : { 29 | "id" : "2" 30 | }, 31 | "chat" : { 32 | "id" : "1" 33 | }, 34 | "id" : "1", 35 | "replies" : null, 36 | "replyTo" : null, 37 | "text" : "hello", 38 | "timestamp" : -63114076800, 39 | "viewedBy" : null 40 | } 41 | ] 42 | }, 43 | "users" : { 44 | "ids" : [ 45 | "1", 46 | "2", 47 | "5", 48 | "3", 49 | "4" 50 | ] 51 | } 52 | } 53 | ] 54 | }, 55 | "email" : "bob@gmail.com", 56 | "id" : "1", 57 | "isCurrent" : false, 58 | "name" : "Bob", 59 | "username" : "@bob" 60 | } -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModelIds_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : null, 5 | "chat" : null, 6 | "id" : "0", 7 | "replies" : [ 8 | { 9 | "id" : "1" 10 | }, 11 | { 12 | "id" : "2" 13 | }, 14 | { 15 | "id" : "3" 16 | }, 17 | { 18 | "id" : "4" 19 | } 20 | ], 21 | "replyTo" : null, 22 | "text" : "hello, ya'll", 23 | "timestamp" : -63114076800, 24 | "viewedBy" : null 25 | }, 26 | { 27 | "attachment" : null, 28 | "author" : null, 29 | "chat" : null, 30 | "id" : "1", 31 | "replies" : [ 32 | 33 | ], 34 | "replyTo" : null, 35 | "text" : "hello", 36 | "timestamp" : -63114076800, 37 | "viewedBy" : null 38 | }, 39 | { 40 | "attachment" : null, 41 | "author" : null, 42 | "chat" : null, 43 | "id" : "2", 44 | "replies" : [ 45 | 46 | ], 47 | "replyTo" : null, 48 | "text" : "howdy", 49 | "timestamp" : -63114076800, 50 | "viewedBy" : null 51 | }, 52 | { 53 | "attachment" : null, 54 | "author" : null, 55 | "chat" : null, 56 | "id" : "3", 57 | "replies" : [ 58 | 59 | ], 60 | "replyTo" : null, 61 | "text" : "yo!", 62 | "timestamp" : -63114076800, 63 | "viewedBy" : null 64 | }, 65 | { 66 | "attachment" : null, 67 | "author" : null, 68 | "chat" : null, 69 | "id" : "4", 70 | "replies" : [ 71 | 72 | ], 73 | "replyTo" : null, 74 | "text" : "wassap!", 75 | "timestamp" : -63114076800, 76 | "viewedBy" : null 77 | } 78 | ] -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedIdsSlice_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : null, 5 | "chat" : null, 6 | "id" : "0", 7 | "replies" : { 8 | "slice_ids" : [ 9 | "1", 10 | "2", 11 | "3", 12 | "4" 13 | ] 14 | }, 15 | "replyTo" : null, 16 | "text" : "hello, ya'll", 17 | "timestamp" : -63114076800, 18 | "viewedBy" : null 19 | }, 20 | { 21 | "attachment" : null, 22 | "author" : null, 23 | "chat" : null, 24 | "id" : "1", 25 | "replies" : { 26 | "slice_ids" : [ 27 | 28 | ] 29 | }, 30 | "replyTo" : null, 31 | "text" : "hello", 32 | "timestamp" : -63114076800, 33 | "viewedBy" : null 34 | }, 35 | { 36 | "attachment" : null, 37 | "author" : null, 38 | "chat" : null, 39 | "id" : "2", 40 | "replies" : { 41 | "slice_ids" : [ 42 | 43 | ] 44 | }, 45 | "replyTo" : null, 46 | "text" : "howdy", 47 | "timestamp" : -63114076800, 48 | "viewedBy" : null 49 | }, 50 | { 51 | "attachment" : null, 52 | "author" : null, 53 | "chat" : null, 54 | "id" : "3", 55 | "replies" : { 56 | "slice_ids" : [ 57 | 58 | ] 59 | }, 60 | "replyTo" : null, 61 | "text" : "yo!", 62 | "timestamp" : -63114076800, 63 | "viewedBy" : null 64 | }, 65 | { 66 | "attachment" : null, 67 | "author" : null, 68 | "chat" : null, 69 | "id" : "4", 70 | "replies" : { 71 | "slice_ids" : [ 72 | 73 | ] 74 | }, 75 | "replyTo" : null, 76 | "text" : "wassap!", 77 | "timestamp" : -63114076800, 78 | "viewedBy" : null 79 | } 80 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/IndexAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 09/03/2025. 6 | // 7 | 8 | import Foundation 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | #if !canImport(SwiftSyntax600) 15 | import SwiftSyntaxMacroExpansion 16 | #endif 17 | 18 | struct IndexAttributes { 19 | let relationWrapperType: PropertyWrapperAttributes 20 | let propertyName: String 21 | let keyPathAttributes: KeyPathAttributes 22 | } 23 | 24 | extension IndexAttributes { 25 | enum KeyPathAttributes { 26 | case labeledExpressionList(String) 27 | case propertyIdentifier(String) 28 | 29 | init(propertyIdentifier: String, 30 | labeledExprListSyntax: LabeledExprListSyntax) { 31 | 32 | let keyPathAttributes = labeledExprListSyntax 33 | .filter(\.isKeyPath) 34 | .map { $0.expressionString } 35 | 36 | guard !keyPathAttributes.isEmpty else { 37 | self = .init(propertyIdentifier: propertyIdentifier) 38 | return 39 | } 40 | 41 | let attributes = [keyPathAttributes] 42 | .flatMap { $0 } 43 | .joined(separator: ",") 44 | 45 | self = .labeledExpressionList(attributes) 46 | } 47 | 48 | init(propertyIdentifier: String) { 49 | self = .propertyIdentifier("\\.$\(propertyIdentifier)") 50 | } 51 | 52 | var attribute: String { 53 | switch self { 54 | case .labeledExpressionList(let value), .propertyIdentifier(let value): 55 | return value 56 | } 57 | } 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 03/03/2024. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | 11 | extension Schema.V1 { 12 | 13 | @EntityModel 14 | struct User: Codable, Sendable { 15 | @Unique(\.username, collisions: .upsert) static var uniqueUsername 16 | @Unique(\.email, collisions: .throw) static var uniqueEmail 17 | @Unique(\.isCurrent, collisions: .updateCurrentUser) static var currentUserIndex 18 | 19 | let id: String 20 | private(set) var name: String? 21 | private(set) var avatar: Avatar? 22 | private(set) var profile: Profile? 23 | private(set) var username: String 24 | private(set) var email: String 25 | 26 | var isCurrent: Bool = false 27 | 28 | var fullname: String? { name } 29 | 30 | @Relationship(inverse: \.users) 31 | var chats: [Chat]? 32 | 33 | @Relationship(inverse: \.admins) 34 | var adminOf: [Chat]? 35 | } 36 | 37 | struct Profile: Codable { 38 | let bio: String? 39 | let url: String? 40 | } 41 | 42 | struct Avatar: Codable { 43 | let small: URL? 44 | let medium: URL? 45 | let large: URL? 46 | } 47 | } 48 | 49 | extension CollisionResolver where Entity == User { 50 | static var updateCurrentUser: Self { 51 | CollisionResolver { existingId, _, _, context in 52 | guard var user = Entity.query(existingId).resolve(in: context), 53 | user.isCurrent 54 | else { 55 | return 56 | } 57 | 58 | user.isCurrent = false 59 | try user.save(to: &context) 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/SearchIndexAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 09/03/2025. 6 | // 7 | 8 | import Foundation 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | #if !canImport(SwiftSyntax600) 15 | import SwiftSyntaxMacroExpansion 16 | #endif 17 | 18 | struct FullTextIndexAttributes { 19 | let propertyWrapper: PropertyWrapperAttributes 20 | let propertyName: String 21 | let keyPathAttributes: KeyPathAttributes 22 | } 23 | 24 | extension FullTextIndexAttributes { 25 | enum KeyPathAttributes { 26 | case labeledExpressionList(String) 27 | case propertyIdentifier(String) 28 | 29 | init(propertyIdentifier: String, 30 | labeledExprListSyntax: LabeledExprListSyntax) { 31 | 32 | let keyPathAttributes = labeledExprListSyntax 33 | .filter(\.isKeyPath) 34 | .map { $0.expressionString } 35 | 36 | guard !keyPathAttributes.isEmpty else { 37 | self = .init(propertyIdentifier: propertyIdentifier) 38 | return 39 | } 40 | 41 | let attributes = [keyPathAttributes] 42 | .flatMap { $0 } 43 | .joined(separator: ",") 44 | 45 | self = .labeledExpressionList(attributes) 46 | } 47 | 48 | init(propertyIdentifier: String) { 49 | self = .propertyIdentifier("\\.$\(propertyIdentifier)") 50 | } 51 | 52 | var attribute: String { 53 | switch self { 54 | case .labeledExpressionList(let value), .propertyIdentifier(let value): 55 | return value 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModelsAndFilter_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : null, 5 | "chat" : null, 6 | "id" : "0", 7 | "replies" : [ 8 | { 9 | "attachment" : null, 10 | "author" : null, 11 | "chat" : null, 12 | "id" : "4", 13 | "replies" : null, 14 | "replyTo" : { 15 | "id" : "0" 16 | }, 17 | "text" : "wassap!", 18 | "timestamp" : -63114076800, 19 | "viewedBy" : null 20 | } 21 | ], 22 | "replyTo" : null, 23 | "text" : "hello, ya'll", 24 | "timestamp" : -63114076800, 25 | "viewedBy" : null 26 | }, 27 | { 28 | "attachment" : null, 29 | "author" : null, 30 | "chat" : null, 31 | "id" : "1", 32 | "replies" : [ 33 | 34 | ], 35 | "replyTo" : null, 36 | "text" : "hello", 37 | "timestamp" : -63114076800, 38 | "viewedBy" : null 39 | }, 40 | { 41 | "attachment" : null, 42 | "author" : null, 43 | "chat" : null, 44 | "id" : "2", 45 | "replies" : [ 46 | 47 | ], 48 | "replyTo" : null, 49 | "text" : "howdy", 50 | "timestamp" : -63114076800, 51 | "viewedBy" : null 52 | }, 53 | { 54 | "attachment" : null, 55 | "author" : null, 56 | "chat" : null, 57 | "id" : "3", 58 | "replies" : [ 59 | 60 | ], 61 | "replyTo" : null, 62 | "text" : "yo!", 63 | "timestamp" : -63114076800, 64 | "viewedBy" : null 65 | }, 66 | { 67 | "attachment" : null, 68 | "author" : null, 69 | "chat" : null, 70 | "id" : "4", 71 | "replies" : [ 72 | 73 | ], 74 | "replyTo" : null, 75 | "text" : "wassap!", 76 | "timestamp" : -63114076800, 77 | "viewedBy" : null 78 | } 79 | ] -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/Models/Schema.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 18/04/2025. 6 | // 7 | 8 | import Foundation 9 | import SwiftletModel 10 | 11 | @EntityModel 12 | struct Schema: Codable { 13 | enum Version: String { case v1 } 14 | 15 | var id: String { "\(Schema.self)"} 16 | 17 | @Relationship 18 | var v1: V1? = .relation(V1()) 19 | } 20 | 21 | typealias User = Schema.V1.User 22 | typealias Chat = Schema.V1.Chat 23 | typealias Message = Schema.V1.Message 24 | typealias Attachment = Schema.V1.Attachment 25 | 26 | extension Schema { 27 | 28 | @EntityModel 29 | struct V1: Codable { 30 | var version: Version { .v1 } 31 | 32 | var id: String { version.rawValue } 33 | 34 | @Relationship var attachments: [Attachment]? = .none 35 | @Relationship var chats: [Chat]? = .none 36 | @Relationship var messages: [Message]? = .none 37 | @Relationship var users: [User]? = .none 38 | 39 | @Relationship var deletedAttachments: [Deleted]? = .none 40 | @Relationship var deletedChats: [Deleted]? = .none 41 | @Relationship var deletedMessages: [Deleted]? = .none 42 | @Relationship var deletedUsers: [Deleted]? = .none 43 | } 44 | } 45 | 46 | extension Schema { 47 | static func fullSchemaQuery(updated range: ClosedRange) -> QueryList { 48 | Schema.queryAll( 49 | with: .entities, .schemaEntities(filter: .updated(within: range)), .ids 50 | ) 51 | } 52 | 53 | static func fullSchemaQuery() -> QueryList { 54 | Schema.queryAll( 55 | with: .entities, .schemaEntities, .ids 56 | ) 57 | } 58 | 59 | static func fullSchemaQueryFragments() -> QueryList { 60 | Schema.queryAll( 61 | with: .entities, .schemaFragments, .ids 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | import CompilerPluginSupport 6 | 7 | let package = Package( 8 | name: "SwiftletModel", 9 | platforms: [ 10 | .iOS(.v12), 11 | .macOS(.v13), 12 | .tvOS(.v12), 13 | .watchOS(.v7) 14 | ], 15 | products: [ 16 | .library( 17 | name: "SwiftletModel", 18 | targets: ["SwiftletModel"]) 19 | ], 20 | dependencies: [ 21 | .package( 22 | url: "https://github.com/apple/swift-collections.git", 23 | .upToNextMajor(from: "1.1.0") 24 | ), 25 | 26 | .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0"), 27 | .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"), 28 | .package(url: "https://github.com/attaswift/BTree", from: "4.0.0") 29 | ], 30 | targets: [ 31 | .macro( 32 | name: "SwiftletModelMacros", 33 | dependencies: [ 34 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 35 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax") 36 | ] 37 | ), 38 | .target( 39 | name: "SwiftletModel", 40 | 41 | dependencies: [ 42 | "SwiftletModelMacros", 43 | .product(name: "Collections", package: "swift-collections"), 44 | .product(name: "BTree", package: "BTree") 45 | ] 46 | ), 47 | 48 | .testTarget( 49 | name: "SwiftletModelTests", 50 | dependencies: [ 51 | "SwiftletModel", 52 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), 53 | .product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing") 54 | ] 55 | ) 56 | ] 57 | ) 58 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/Query.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias Query = ContextQuery 11 | 12 | // MARK: - Resolve Query 13 | 14 | public extension ContextQuery where Result == Entity?, Key == Entity.ID { 15 | func resolve(in context: Context) -> Entity? { 16 | result(context, id(context)) 17 | } 18 | } 19 | 20 | public extension Collection { 21 | func resolve(in context: Context) -> [Entity] where Element == Query { 22 | compactMap { $0.resolve(in: context) } 23 | } 24 | } 25 | 26 | extension ContextQuery where Result == Entity?, Key == Entity.ID { 27 | func id(_ context: Context) -> Entity.ID? { key(context) } 28 | 29 | init(id: Entity.ID) { 30 | self.key = { _ in id } 31 | self.result = { context, id in id.flatMap { context.find($0) }} 32 | } 33 | 34 | init(id: @escaping (Context) -> Entity.ID?) { 35 | self.key = id 36 | self.result = { context, id in id.flatMap { context.find($0) } } 37 | } 38 | 39 | init(id: @escaping (Context) -> Entity.ID?, entity: @escaping (Context) -> Entity?) { 40 | self.key = id 41 | self.result = { context, _ in entity(context) } 42 | } 43 | 44 | func then(perform: @escaping (Context, Entity) -> Entity?) -> Query { 45 | Query(id: id) { context in 46 | guard let entity = resolve(in: context) else { 47 | return nil 48 | } 49 | 50 | return perform(context, entity) 51 | } 52 | } 53 | 54 | static var none: Self { 55 | Self(id: { _ in nil }) 56 | } 57 | } 58 | 59 | // MARK: - Entities Collection Extension 60 | 61 | extension Collection { 62 | func query() -> [Query] 63 | where 64 | Element == Entity, 65 | Entity: EntityModelProtocol { 66 | 67 | map { $0.query() } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/RelationsTests/ManyToManyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 13/07/2024. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import SwiftletModel 11 | 12 | final class ManyToManyTests: XCTestCase { 13 | var context = Context() 14 | 15 | func test_WhenDirectAdded_InverseIsAdded() { 16 | var chatOne = Chat.one 17 | chatOne.$users = .relation([.bob]) 18 | try! chatOne.save(to: &context) 19 | 20 | var chatTwo = Chat.two 21 | chatTwo.$users = .relation([.bob]) 22 | try! chatTwo.save(to: &context) 23 | 24 | let bobChats = User 25 | .query(User.bob.id) 26 | .related(\.$chats) 27 | .resolve(in: context) 28 | 29 | XCTAssertEqual(bobChats.compactMap { $0.id }, [Chat.one.id, Chat.two.id]) 30 | } 31 | 32 | func test_WhenRelationUpdatedWithInsert_NewRelationsInserted() { 33 | var chat = Chat.one 34 | chat.$users = .relation([.bob, .alice, .tom]) 35 | try! chat.save(to: &context) 36 | 37 | chat.$users = .appending(relation: [.john, .michael]) 38 | try! chat.save(to: &context) 39 | 40 | let chatUsers = Chat 41 | .query(Chat.one.id) 42 | .related(\.$users) 43 | .resolve(in: context) 44 | 45 | let expectedChatUsers = [User.bob.id, User.alice.id, User.tom.id, User.john.id, User.michael.id] 46 | 47 | XCTAssertEqual(chatUsers.compactMap { $0.id }, 48 | expectedChatUsers) 49 | } 50 | 51 | func test_WhenDirectReplaced_InverseIsUpdated() { 52 | var chat = Chat.one 53 | chat.$users = .relation([.bob, .alice, .tom]) 54 | try! chat.save(to: &context) 55 | 56 | chat.$users = .relation([.john, .michael]) 57 | try! chat.save(to: &context) 58 | 59 | let bobsChats = User 60 | .query(User.bob.id) 61 | .related(\.$chats) 62 | .resolve(in: context) 63 | 64 | XCTAssertTrue(bobsChats.isEmpty) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/RelationsTests/ToOneTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 13/07/2024. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import SwiftletModel 11 | 12 | final class ToOneTests: XCTestCase { 13 | var context = Context() 14 | let initialMessage: Message = Message( 15 | id: "1", text: "hello", 16 | attachment: .relation(Attachment.imageOne) 17 | ) 18 | 19 | override func setUp() async throws { 20 | try! initialMessage.save(to: &context) 21 | } 22 | 23 | func test_WhenDirectAdded_InverseIsAdded() { 24 | let messageForAttachment = Attachment 25 | .query(Attachment.imageOne.id) 26 | .related(\.$message) 27 | .resolve(in: context) 28 | 29 | XCTAssertEqual(messageForAttachment?.id, Attachment.imageOne.id) 30 | } 31 | 32 | func test_WhenDirectReplaced_InverseIsUpdated() { 33 | var message = initialMessage 34 | message.$attachment = .relation(Attachment.imageTwo) 35 | try! message.save(to: &context) 36 | 37 | let messageForAttachment = Attachment 38 | .query(Attachment.imageOne.id) 39 | .related(\.$message) 40 | .resolve(in: context) 41 | 42 | XCTAssertNil(messageForAttachment) 43 | } 44 | 45 | func test_WhenNullify_InverseIsRemoved() { 46 | var message = initialMessage 47 | message.$attachment = .null 48 | try! message.save(to: &context) 49 | 50 | let messageForAttachment = Attachment 51 | .query(Attachment.imageOne.id) 52 | .related(\.$message) 53 | .resolve(in: context) 54 | 55 | XCTAssertNil(messageForAttachment) 56 | } 57 | 58 | func test_WhenNullify_RelationIsRemoved() { 59 | var message = initialMessage 60 | message.$attachment = .null 61 | try! message.save(to: &context) 62 | 63 | let attachment = message 64 | .query() 65 | .related(\.$attachment) 66 | .resolve(in: context) 67 | 68 | XCTAssertNil(attachment) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/AllNestedModelsQueryTest/test_WhenQueryWithNestedIds_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : { 5 | "id" : "4" 6 | }, 7 | "chat" : { 8 | "id" : "1" 9 | }, 10 | "id" : "0", 11 | "replies" : [ 12 | { 13 | "id" : "1" 14 | }, 15 | { 16 | "id" : "2" 17 | }, 18 | { 19 | "id" : "3" 20 | }, 21 | { 22 | "id" : "4" 23 | } 24 | ], 25 | "replyTo" : null, 26 | "text" : "hello, ya'll", 27 | "timestamp" : -63114076800, 28 | "viewedBy" : [ 29 | 30 | ] 31 | }, 32 | { 33 | "attachment" : null, 34 | "author" : { 35 | "id" : "2" 36 | }, 37 | "chat" : { 38 | "id" : "1" 39 | }, 40 | "id" : "1", 41 | "replies" : [ 42 | 43 | ], 44 | "replyTo" : { 45 | "id" : "0" 46 | }, 47 | "text" : "hello", 48 | "timestamp" : -63114076800, 49 | "viewedBy" : [ 50 | 51 | ] 52 | }, 53 | { 54 | "attachment" : null, 55 | "author" : { 56 | "id" : "1" 57 | }, 58 | "chat" : { 59 | "id" : "1" 60 | }, 61 | "id" : "2", 62 | "replies" : [ 63 | 64 | ], 65 | "replyTo" : { 66 | "id" : "0" 67 | }, 68 | "text" : "howdy", 69 | "timestamp" : -63114076800, 70 | "viewedBy" : [ 71 | 72 | ] 73 | }, 74 | { 75 | "attachment" : null, 76 | "author" : { 77 | "id" : "5" 78 | }, 79 | "chat" : { 80 | "id" : "1" 81 | }, 82 | "id" : "3", 83 | "replies" : [ 84 | 85 | ], 86 | "replyTo" : { 87 | "id" : "0" 88 | }, 89 | "text" : "yo!", 90 | "timestamp" : -63114076800, 91 | "viewedBy" : [ 92 | 93 | ] 94 | }, 95 | { 96 | "attachment" : null, 97 | "author" : { 98 | "id" : "3" 99 | }, 100 | "chat" : { 101 | "id" : "1" 102 | }, 103 | "id" : "4", 104 | "replies" : [ 105 | 106 | ], 107 | "replyTo" : { 108 | "id" : "0" 109 | }, 110 | "text" : "wassap!", 111 | "timestamp" : -63114076800, 112 | "viewedBy" : [ 113 | 114 | ] 115 | } 116 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QueryList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 07/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias QueryList = ContextQuery], Void> 11 | 12 | public extension ContextQuery where Result == [Query], Key == Void { 13 | func resolve(in context: Context) -> [Entity] { 14 | queries(context).resolve(in: context) 15 | } 16 | } 17 | 18 | extension ContextQuery where Result == [Query], Key == Void, Entity: EntityModelProtocol { 19 | func then(perform: @escaping (Context, [Query]) -> [Query]) -> QueryList { 20 | QueryList { context in 21 | let queries = queries(context) 22 | return perform(context, queries) 23 | } 24 | } 25 | 26 | func then(perform: @escaping (Context, [Query]) -> [[Query]]) -> QueryGroup { 27 | QueryGroup { context in 28 | let queries = queries(context) 29 | return perform(context, queries) 30 | } 31 | } 32 | 33 | init(queries: @escaping (Context) -> [Query]) { 34 | self.key = { _ in Void() } 35 | self.result = { context, _ in queries(context) } 36 | } 37 | 38 | init(ids: [Entity.ID]) { 39 | self.key = { _ in Void() } 40 | self.result = { context, _ in ids.map { context.query($0) } } 41 | } 42 | 43 | init() { 44 | self = QueryList { context in 45 | context.ids(Entity.self).map { context.query($0)} 46 | } 47 | } 48 | 49 | func queries(_ context: Context) -> [Query] { 50 | result(context, key(context)) 51 | } 52 | } 53 | 54 | public extension ContextQuery where Result == [Query], Key == Void { 55 | func first() -> Query { 56 | Query { context in 57 | queries(context).first?.id(context) 58 | } 59 | } 60 | 61 | func last() -> Query { 62 | Query { context in 63 | queries(context).last?.id(context) 64 | } 65 | } 66 | 67 | func limit(_ limit: Int, offset: Int = 0) -> QueryList { 68 | then { context, queries in 69 | queries.limit(limit, offset: offset) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModel_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : { 5 | "adminOf" : null, 6 | "chats" : null, 7 | "email" : "michael@gmail.com", 8 | "id" : "4", 9 | "isCurrent" : false, 10 | "name" : "Michael", 11 | "username" : "@michael" 12 | }, 13 | "chat" : null, 14 | "id" : "0", 15 | "replies" : null, 16 | "replyTo" : null, 17 | "text" : "hello, ya'll", 18 | "timestamp" : -63114076800, 19 | "viewedBy" : null 20 | }, 21 | { 22 | "attachment" : null, 23 | "author" : { 24 | "adminOf" : null, 25 | "chats" : null, 26 | "email" : "alice@gmail.com", 27 | "id" : "2", 28 | "isCurrent" : false, 29 | "name" : "Alice", 30 | "username" : "@alice" 31 | }, 32 | "chat" : null, 33 | "id" : "1", 34 | "replies" : null, 35 | "replyTo" : null, 36 | "text" : "hello", 37 | "timestamp" : -63114076800, 38 | "viewedBy" : null 39 | }, 40 | { 41 | "attachment" : null, 42 | "author" : { 43 | "adminOf" : null, 44 | "chats" : null, 45 | "email" : "bob@gmail.com", 46 | "id" : "1", 47 | "isCurrent" : false, 48 | "name" : "Bob", 49 | "username" : "@bob" 50 | }, 51 | "chat" : null, 52 | "id" : "2", 53 | "replies" : null, 54 | "replyTo" : null, 55 | "text" : "howdy", 56 | "timestamp" : -63114076800, 57 | "viewedBy" : null 58 | }, 59 | { 60 | "attachment" : null, 61 | "author" : { 62 | "adminOf" : null, 63 | "chats" : null, 64 | "email" : "tom@gmail.com", 65 | "id" : "5", 66 | "isCurrent" : false, 67 | "name" : "Tom", 68 | "username" : "@tom" 69 | }, 70 | "chat" : null, 71 | "id" : "3", 72 | "replies" : null, 73 | "replyTo" : null, 74 | "text" : "yo!", 75 | "timestamp" : -63114076800, 76 | "viewedBy" : null 77 | }, 78 | { 79 | "attachment" : null, 80 | "author" : { 81 | "adminOf" : null, 82 | "chats" : null, 83 | "email" : "john@gmail.com", 84 | "id" : "3", 85 | "isCurrent" : false, 86 | "name" : "John", 87 | "username" : "@john" 88 | }, 89 | "chat" : null, 90 | "id" : "4", 91 | "replies" : null, 92 | "replyTo" : null, 93 | "text" : "wassap!", 94 | "timestamp" : -63114076800, 95 | "viewedBy" : null 96 | } 97 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QueryRelated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Related Entities Query 11 | 12 | public extension ContextQuery where Result == Entity?, Key == Entity.ID { 13 | func related( 14 | _ keyPath: KeyPath> 15 | ) -> QueryList { 16 | 17 | QueryList { context in 18 | queryRelated(in: context, keyPath) 19 | } 20 | } 21 | } 22 | 23 | public extension ContextQuery where Result == Entity?, Key == Entity.ID { 24 | 25 | func related( 26 | _ keyPath: KeyPath> 27 | 28 | ) -> Query { 29 | 30 | Query { context in 31 | guard let id = id(context) else { 32 | return nil 33 | } 34 | 35 | return Link.findChildrenOf( 36 | id, with: keyPath, 37 | in: context 38 | ) 39 | .first 40 | } 41 | } 42 | } 43 | 44 | extension ContextQuery where Result == Entity?, Key == Entity.ID { 45 | func queryRelated( 46 | in context: Context, 47 | _ keyPath: KeyPath> 48 | 49 | ) -> [Query] { 50 | 51 | guard let id = id(context) else { 52 | return [] 53 | } 54 | 55 | return Link.findChildrenOf( 56 | id, with: keyPath, 57 | in: context 58 | ) 59 | .map { Query(id: $0) } 60 | } 61 | } 62 | 63 | // MARK: - Related Entities Collection Query 64 | 65 | public extension ContextQuery where Result == [Query], Key == Void { 66 | 67 | func related( 68 | _ keyPath: KeyPath>) -> QueryGroup { 69 | 70 | then { context, queries in 71 | queries.map { $0.queryRelated(in: context, keyPath) } 72 | } 73 | } 74 | 75 | func related( 76 | _ keyPath: KeyPath>) -> QueryList { 77 | 78 | then { context, queries in 79 | queries.map { $0.related(keyPath) } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModels_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : null, 5 | "chat" : null, 6 | "id" : "0", 7 | "replies" : [ 8 | { 9 | "attachment" : null, 10 | "author" : null, 11 | "chat" : null, 12 | "id" : "1", 13 | "replies" : null, 14 | "replyTo" : { 15 | "id" : "0" 16 | }, 17 | "text" : "hello", 18 | "timestamp" : -63114076800, 19 | "viewedBy" : null 20 | }, 21 | { 22 | "attachment" : null, 23 | "author" : null, 24 | "chat" : null, 25 | "id" : "2", 26 | "replies" : null, 27 | "replyTo" : { 28 | "id" : "0" 29 | }, 30 | "text" : "howdy", 31 | "timestamp" : -63114076800, 32 | "viewedBy" : null 33 | }, 34 | { 35 | "attachment" : null, 36 | "author" : null, 37 | "chat" : null, 38 | "id" : "4", 39 | "replies" : null, 40 | "replyTo" : { 41 | "id" : "0" 42 | }, 43 | "text" : "wassap!", 44 | "timestamp" : -63114076800, 45 | "viewedBy" : null 46 | } 47 | ], 48 | "replyTo" : null, 49 | "text" : "hello, ya'll", 50 | "timestamp" : -63114076800, 51 | "viewedBy" : null 52 | }, 53 | { 54 | "attachment" : null, 55 | "author" : null, 56 | "chat" : null, 57 | "id" : "1", 58 | "replies" : [ 59 | 60 | ], 61 | "replyTo" : null, 62 | "text" : "hello", 63 | "timestamp" : -63114076800, 64 | "viewedBy" : null 65 | }, 66 | { 67 | "attachment" : null, 68 | "author" : null, 69 | "chat" : null, 70 | "id" : "2", 71 | "replies" : [ 72 | 73 | ], 74 | "replyTo" : null, 75 | "text" : "howdy", 76 | "timestamp" : -63114076800, 77 | "viewedBy" : null 78 | }, 79 | { 80 | "attachment" : null, 81 | "author" : null, 82 | "chat" : null, 83 | "id" : "3", 84 | "replies" : [ 85 | 86 | ], 87 | "replyTo" : null, 88 | "text" : "yo!", 89 | "timestamp" : -63114076800, 90 | "viewedBy" : null 91 | }, 92 | { 93 | "attachment" : null, 94 | "author" : null, 95 | "chat" : null, 96 | "id" : "4", 97 | "replies" : [ 98 | 99 | ], 100 | "replyTo" : null, 101 | "text" : "wassap!", 102 | "timestamp" : -63114076800, 103 | "viewedBy" : null 104 | } 105 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Index+Hashable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Unique.ComparableValueIndex 2.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 12/03/2025. 6 | // 7 | import Foundation 8 | 9 | extension Index { 10 | @EntityModel 11 | struct HashableValue { 12 | var id: String { name } 13 | 14 | let name: String 15 | 16 | private var index: [Value: Set] = [:] 17 | private var indexedValues: [Entity.ID: Value] = [:] 18 | 19 | init(name: String) { 20 | self.name = name 21 | } 22 | 23 | func asDeleted(in context: Context) -> Deleted? { nil } 24 | 25 | func saveMetadata(to context: inout Context) throws { } 26 | 27 | func deleteMetadata(from context: inout Context) throws { } 28 | } 29 | } 30 | 31 | extension Index.HashableValue { 32 | static func updateIndex(indexName: String, 33 | _ entity: Entity, 34 | value: Value, 35 | in context: inout Context) throws { 36 | 37 | var index = Query(id: indexName).resolve(in: context) ?? Self(name: indexName) 38 | index.update(entity, value: value) 39 | try index.save(to: &context) 40 | } 41 | 42 | static func removeFromIndex(indexName: String, 43 | _ entity: Entity, 44 | in context: inout Context) throws { 45 | 46 | guard var index = Query(id: indexName).resolve(in: context) else { 47 | return 48 | } 49 | 50 | index.remove(entity) 51 | try index.save(to: &context) 52 | } 53 | 54 | func find(_ value: Value) -> Set { 55 | index[value] ?? [] 56 | } 57 | } 58 | 59 | private extension Index.HashableValue { 60 | 61 | mutating func update(_ entity: Entity, 62 | value: Value) { 63 | let existingValue = indexedValues[entity.id] 64 | 65 | guard existingValue != value else { 66 | return 67 | } 68 | 69 | if let existingValue, index[existingValue] != nil { 70 | remove(entity) 71 | } 72 | 73 | var entities = index[value] ?? [] 74 | entities.insert(entity.id) 75 | index[value] = entities 76 | indexedValues[entity.id] = value 77 | } 78 | 79 | mutating func remove(_ entity: Entity) { 80 | guard let value = indexedValues[entity.id], 81 | index[value] != nil 82 | else { 83 | return 84 | } 85 | 86 | indexedValues[entity.id] = nil 87 | index[value] = nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Context/Context.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | import Collections 10 | 11 | public struct Context: Sendable { 12 | private var entitiesRepository = EntitiesRepository() 13 | 14 | public init() { } 15 | } 16 | 17 | public extension Context { 18 | func ids(_ entityType: T.Type) -> [T.ID] { 19 | entitiesRepository.ids(T.self) 20 | } 21 | 22 | func all() -> [T] { 23 | entitiesRepository.all() 24 | } 25 | 26 | func find(_ id: T.ID) -> T? { 27 | entitiesRepository.find(id) 28 | } 29 | 30 | func findAll(_ ids: [T.ID]) -> [T?] { 31 | entitiesRepository.findAll(ids) 32 | } 33 | 34 | func findAllExisting(_ ids: [T.ID]) -> [T] { 35 | entitiesRepository.findAllExisting(ids) 36 | } 37 | } 38 | 39 | public extension Context { 40 | 41 | mutating func remove(_ entityType: T.Type, id: T.ID) { 42 | entitiesRepository.remove(T.self, id: id) 43 | } 44 | 45 | mutating func removeAll(_ entityType: T.Type, ids: [T.ID]) { 46 | entitiesRepository.removeAll(T.self, ids: ids) 47 | } 48 | 49 | mutating func insert(_ entity: T, 50 | options: MergeStrategy = .replace) { 51 | 52 | entitiesRepository.insert(entity, options: options) 53 | } 54 | 55 | mutating func insert(_ entity: T?, 56 | options: MergeStrategy = .replace) { 57 | 58 | entitiesRepository.insert(entity, options: options) 59 | } 60 | 61 | mutating func insert(_ entities: [T], 62 | options: MergeStrategy = .replace) { 63 | 64 | entitiesRepository.insert(entities, options: options) 65 | } 66 | } 67 | 68 | extension Context { 69 | func query(_ id: Entity.ID) -> Query { 70 | Query(id: id) 71 | } 72 | 73 | func query(_ ids: [Entity.ID]) -> QueryList { 74 | QueryList { context in 75 | ids.map { context.query($0) } 76 | } 77 | } 78 | 79 | func queryAll() -> QueryList { 80 | QueryList { context in 81 | context 82 | .ids(Entity.self) 83 | .map { context.query($0) } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Context/EntitiesRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct EntitiesRepository { 11 | typealias EntityID = String 12 | typealias EntityName = String 13 | 14 | private var storages: [EntityName: [EntityID: any EntityModelProtocol]] = [:] 15 | } 16 | 17 | extension EntitiesRepository { 18 | func ids(_ entityType: T.Type) -> [T.ID] { 19 | let entityName = String(reflecting: T.self) 20 | return storages[entityName]?.keys.compactMap { T.ID($0) } ?? [] 21 | } 22 | 23 | func all() -> [T] { 24 | let entityName = String(reflecting: T.self) 25 | return storages[entityName]?.compactMap { $0.value as? T } ?? [] 26 | } 27 | 28 | func find(_ id: T.ID) -> T? { 29 | let entityName = EntityName(reflecting: T.self) 30 | let storage = storages[entityName] ?? [:] 31 | return storage[id.description] as? T 32 | } 33 | 34 | func findAll(_ ids: [T.ID]) -> [T?] { 35 | ids.map { find($0) } 36 | } 37 | 38 | func findAllExisting(_ ids: [T.ID]) -> [T] { 39 | findAll(ids).compactMap { $0 } 40 | } 41 | } 42 | 43 | extension EntitiesRepository { 44 | mutating func remove(_ entityType: T.Type, id: T.ID) { 45 | let key = EntityName(reflecting: T.self) 46 | var storage = storages[key] ?? [:] 47 | storage.removeValue(forKey: id.description) 48 | storages[key] = storage 49 | } 50 | 51 | mutating func removeAll(_ entityType: T.Type, ids: [T.ID]) { 52 | ids.forEach { remove(T.self, id: $0) } 53 | } 54 | 55 | mutating func insert(_ entity: T, options: MergeStrategy) { 56 | let key = String(reflecting: T.self) 57 | var storage = storages[key] ?? [:] 58 | 59 | guard let existing: T = find(entity.id) else { 60 | storage[entity.id.description] = entity 61 | storages[key] = storage 62 | return 63 | } 64 | 65 | let merged = options.merge(existing, entity) 66 | 67 | storage[entity.id.description] = merged 68 | storages[key] = storage 69 | } 70 | 71 | mutating func insert(_ entity: T?, options: MergeStrategy) { 72 | guard let entity else { 73 | return 74 | } 75 | 76 | insert(entity, options: options) 77 | } 78 | 79 | mutating func insert(_ entities: [T], options: MergeStrategy) { 80 | entities.forEach { insert($0, options: options) } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/UniqueIndex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 05/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | @propertyWrapper 12 | public struct Unique: Sendable, Codable { 13 | 14 | public var wrappedValue: Unique { 15 | self 16 | } 17 | 18 | public init( 19 | _ kp0: KeyPath, 20 | collisions: CollisionResolver = .upsert) 21 | where 22 | T0: Comparable { 23 | 24 | } 25 | 26 | public init( 27 | _ kp0: KeyPath, 28 | _ kp1: KeyPath, 29 | collisions: CollisionResolver = .upsert) 30 | where 31 | T0: Comparable, 32 | T1: Comparable { 33 | 34 | } 35 | 36 | public init( 37 | _ kp0: KeyPath, 38 | _ kp1: KeyPath, 39 | _ kp2: KeyPath, 40 | collisions: CollisionResolver = .upsert) 41 | 42 | where 43 | T0: Comparable, 44 | T1: Comparable, 45 | T2: Comparable { 46 | 47 | } 48 | 49 | public init( 50 | _ kp0: KeyPath, 51 | _ kp1: KeyPath, 52 | _ kp2: KeyPath, 53 | _ kp3: KeyPath, 54 | collisions: CollisionResolver = .upsert) 55 | 56 | where 57 | T0: Comparable, 58 | T1: Comparable, 59 | T2: Comparable, 60 | T3: Comparable { 61 | 62 | } 63 | 64 | public init( 65 | _ kp0: KeyPath, 66 | collisions: CollisionResolver = .upsert) 67 | where 68 | T0: Equatable { 69 | 70 | } 71 | 72 | public init( 73 | _ kp0: KeyPath, 74 | _ kp1: KeyPath, 75 | collisions: CollisionResolver = .upsert) 76 | where 77 | T0: Equatable, 78 | T1: Equatable { 79 | 80 | } 81 | 82 | public init( 83 | _ kp0: KeyPath, 84 | _ kp1: KeyPath, 85 | _ kp2: KeyPath, 86 | collisions: CollisionResolver = .upsert) 87 | 88 | where 89 | T0: Equatable, 90 | T1: Equatable, 91 | T2: Equatable { 92 | 93 | } 94 | 95 | public init( 96 | _ kp0: KeyPath, 97 | _ kp1: KeyPath, 98 | _ kp2: KeyPath, 99 | _ kp3: KeyPath, 100 | collisions: CollisionResolver = .upsert) 101 | 102 | where 103 | T0: Equatable, 104 | T1: Equatable, 105 | T2: Equatable, 106 | T3: Equatable { 107 | 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/IndexTests/SortIndexPerformanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 30/03/2025. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | import XCTest 11 | 12 | final class SortIndexPerformanceTests: XCTestCase { 13 | var count: Int { 2000 } 14 | var context = Context() 15 | 16 | lazy var notIndexedModels = { 17 | TestingModels.NotIndexed.shuffled(count) 18 | }() 19 | 20 | lazy var indexedModels = { 21 | TestingModels.SingleValueIndexed.shuffled(count) 22 | }() 23 | 24 | lazy var evalPropertyIndexedModels = { 25 | TestingModels.EvaluatedPropertyDescIndexed.shuffled(count) 26 | }() 27 | 28 | override func setUp() async throws { 29 | context = Context() 30 | try notIndexedModels 31 | .forEach { try $0.save(to: &context) } 32 | 33 | try indexedModels 34 | .forEach { try $0.save(to: &context) } 35 | 36 | try evalPropertyIndexedModels 37 | .forEach { try $0.save(to: &context) } 38 | } 39 | 40 | func test_NoIndex_SortPerformance() throws { 41 | let queries: QueryList = TestingModels.NotIndexed.query() 42 | measure { 43 | _ = queries 44 | .sorted(by: \.numOf1) 45 | .resolve(in: context) 46 | } 47 | } 48 | 49 | func test_Indexed_SortPerformance() throws { 50 | let queries: QueryList = TestingModels.SingleValueIndexed.query() 51 | measure { 52 | _ = queries 53 | .sorted(by: \.numOf1) 54 | .resolve(in: context) 55 | } 56 | } 57 | 58 | func test_EvalProperyIndexed_SortPerformance() throws { 59 | let queries: QueryList = TestingModels.EvaluatedPropertyDescIndexed.query() 60 | measure { 61 | _ = queries 62 | .sorted(by: \.numOf1.desc) 63 | .resolve(in: context) 64 | } 65 | } 66 | 67 | func test_NoIndex_SavePerformance() throws { 68 | let models = notIndexedModels 69 | 70 | measure { 71 | var context = Context() 72 | try! models.forEach { try $0.save(to: &context) } 73 | } 74 | } 75 | 76 | func test_Indexed_SavePerformance() throws { 77 | let models = indexedModels 78 | 79 | measure { 80 | var context = Context() 81 | try! models.forEach { try $0.save(to: &context) } 82 | } 83 | } 84 | 85 | func test_EvaluatedPropertyIndexed_SavePerformance() throws { 86 | let models = evalPropertyIndexedModels 87 | 88 | measure { 89 | var context = Context() 90 | try! models.forEach { try $0.save(to: &context) } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/RelationshipAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 10/08/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | #if !canImport(SwiftSyntax600) 15 | import SwiftSyntaxMacroExpansion 16 | #endif 17 | 18 | struct RelationshipAttributes { 19 | let propertyWrapperType: PropertyWrapperAttributes 20 | let propertyName: String 21 | let keyPathAttributes: KeyPathAttributes 22 | let deleteRule: DeleteRuleAttribute 23 | } 24 | 25 | extension RelationshipAttributes { 26 | enum DeleteRuleAttribute: String, CaseIterable { 27 | static let deleteRule = "deleteRule" 28 | 29 | case cascade 30 | case nullify 31 | 32 | init?(_ expressionString: String) { 33 | let value = Self.allCases.first { expressionString.contains($0.rawValue) } 34 | guard let value else { 35 | return nil 36 | } 37 | 38 | self = value 39 | } 40 | 41 | init(labeledExprListSyntax: LabeledExprListSyntax) { 42 | self = labeledExprListSyntax 43 | .filter { $0.labelString?.contains(DeleteRuleAttribute.deleteRule) ?? false } 44 | .compactMap { DeleteRuleAttribute($0.expressionString) } 45 | .first ?? .nullify 46 | } 47 | } 48 | 49 | enum KeyPathAttributes { 50 | case labeledExpressionList(String) 51 | case propertyIdentifier(String) 52 | 53 | init(propertyIdentifier: String, 54 | labeledExprListSyntax: LabeledExprListSyntax) { 55 | 56 | let keyPathAttributes = labeledExprListSyntax 57 | .filter(\.isKeyPath) 58 | .map { 59 | 60 | let label = $0.labelString.map { "\($0): "} ?? "" 61 | let expression = $0.expressionString.replacingOccurrences(of: ".", with: ".$") 62 | return "\(label)\(expression)" 63 | } 64 | 65 | guard !keyPathAttributes.isEmpty else { 66 | self = .init(propertyIdentifier: propertyIdentifier) 67 | return 68 | } 69 | 70 | let attributes = [["\\.$\(propertyIdentifier)"], keyPathAttributes] 71 | .flatMap { $0 } 72 | .joined(separator: ",") 73 | 74 | self = .labeledExpressionList(attributes) 75 | } 76 | 77 | init(propertyIdentifier: String) { 78 | self = .propertyIdentifier("\\.$\(propertyIdentifier)") 79 | } 80 | 81 | var attribute: String { 82 | switch self { 83 | case .labeledExpressionList(let value), .propertyIdentifier(let value): 84 | return value 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModelsAndSort_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : null, 5 | "chat" : null, 6 | "id" : "0", 7 | "replies" : [ 8 | { 9 | "attachment" : null, 10 | "author" : null, 11 | "chat" : null, 12 | "id" : "3", 13 | "replies" : null, 14 | "replyTo" : { 15 | "id" : "0" 16 | }, 17 | "text" : "yo!", 18 | "timestamp" : -63114076800, 19 | "viewedBy" : null 20 | }, 21 | { 22 | "attachment" : null, 23 | "author" : null, 24 | "chat" : null, 25 | "id" : "1", 26 | "replies" : null, 27 | "replyTo" : { 28 | "id" : "0" 29 | }, 30 | "text" : "hello", 31 | "timestamp" : -63114076800, 32 | "viewedBy" : null 33 | }, 34 | { 35 | "attachment" : null, 36 | "author" : null, 37 | "chat" : null, 38 | "id" : "2", 39 | "replies" : null, 40 | "replyTo" : { 41 | "id" : "0" 42 | }, 43 | "text" : "howdy", 44 | "timestamp" : -63114076800, 45 | "viewedBy" : null 46 | }, 47 | { 48 | "attachment" : null, 49 | "author" : null, 50 | "chat" : null, 51 | "id" : "4", 52 | "replies" : null, 53 | "replyTo" : { 54 | "id" : "0" 55 | }, 56 | "text" : "wassap!", 57 | "timestamp" : -63114076800, 58 | "viewedBy" : null 59 | } 60 | ], 61 | "replyTo" : null, 62 | "text" : "hello, ya'll", 63 | "timestamp" : -63114076800, 64 | "viewedBy" : null 65 | }, 66 | { 67 | "attachment" : null, 68 | "author" : null, 69 | "chat" : null, 70 | "id" : "1", 71 | "replies" : [ 72 | 73 | ], 74 | "replyTo" : null, 75 | "text" : "hello", 76 | "timestamp" : -63114076800, 77 | "viewedBy" : null 78 | }, 79 | { 80 | "attachment" : null, 81 | "author" : null, 82 | "chat" : null, 83 | "id" : "2", 84 | "replies" : [ 85 | 86 | ], 87 | "replyTo" : null, 88 | "text" : "howdy", 89 | "timestamp" : -63114076800, 90 | "viewedBy" : null 91 | }, 92 | { 93 | "attachment" : null, 94 | "author" : null, 95 | "chat" : null, 96 | "id" : "3", 97 | "replies" : [ 98 | 99 | ], 100 | "replyTo" : null, 101 | "text" : "yo!", 102 | "timestamp" : -63114076800, 103 | "viewedBy" : null 104 | }, 105 | { 106 | "attachment" : null, 107 | "author" : null, 108 | "chat" : null, 109 | "id" : "4", 110 | "replies" : [ 111 | 112 | ], 113 | "replyTo" : null, 114 | "text" : "wassap!", 115 | "timestamp" : -63114076800, 116 | "viewedBy" : null 117 | } 118 | ] -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftletModel.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 56 | 62 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/NestedModelsQueryTest/test_WhenQueryWithNestedModelsSlice_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : null, 5 | "chat" : null, 6 | "id" : "0", 7 | "replies" : { 8 | "slice" : [ 9 | { 10 | "attachment" : null, 11 | "author" : null, 12 | "chat" : null, 13 | "id" : "1", 14 | "replies" : null, 15 | "replyTo" : { 16 | "id" : "0" 17 | }, 18 | "text" : "hello", 19 | "timestamp" : -63114076800, 20 | "viewedBy" : null 21 | }, 22 | { 23 | "attachment" : null, 24 | "author" : null, 25 | "chat" : null, 26 | "id" : "2", 27 | "replies" : null, 28 | "replyTo" : { 29 | "id" : "0" 30 | }, 31 | "text" : "howdy", 32 | "timestamp" : -63114076800, 33 | "viewedBy" : null 34 | }, 35 | { 36 | "attachment" : null, 37 | "author" : null, 38 | "chat" : null, 39 | "id" : "3", 40 | "replies" : null, 41 | "replyTo" : { 42 | "id" : "0" 43 | }, 44 | "text" : "yo!", 45 | "timestamp" : -63114076800, 46 | "viewedBy" : null 47 | }, 48 | { 49 | "attachment" : null, 50 | "author" : null, 51 | "chat" : null, 52 | "id" : "4", 53 | "replies" : null, 54 | "replyTo" : { 55 | "id" : "0" 56 | }, 57 | "text" : "wassap!", 58 | "timestamp" : -63114076800, 59 | "viewedBy" : null 60 | } 61 | ] 62 | }, 63 | "replyTo" : null, 64 | "text" : "hello, ya'll", 65 | "timestamp" : -63114076800, 66 | "viewedBy" : null 67 | }, 68 | { 69 | "attachment" : null, 70 | "author" : null, 71 | "chat" : null, 72 | "id" : "1", 73 | "replies" : { 74 | "slice" : [ 75 | 76 | ] 77 | }, 78 | "replyTo" : null, 79 | "text" : "hello", 80 | "timestamp" : -63114076800, 81 | "viewedBy" : null 82 | }, 83 | { 84 | "attachment" : null, 85 | "author" : null, 86 | "chat" : null, 87 | "id" : "2", 88 | "replies" : { 89 | "slice" : [ 90 | 91 | ] 92 | }, 93 | "replyTo" : null, 94 | "text" : "howdy", 95 | "timestamp" : -63114076800, 96 | "viewedBy" : null 97 | }, 98 | { 99 | "attachment" : null, 100 | "author" : null, 101 | "chat" : null, 102 | "id" : "3", 103 | "replies" : { 104 | "slice" : [ 105 | 106 | ] 107 | }, 108 | "replyTo" : null, 109 | "text" : "yo!", 110 | "timestamp" : -63114076800, 111 | "viewedBy" : null 112 | }, 113 | { 114 | "attachment" : null, 115 | "author" : null, 116 | "chat" : null, 117 | "id" : "4", 118 | "replies" : { 119 | "slice" : [ 120 | 121 | ] 122 | }, 123 | "replyTo" : null, 124 | "text" : "wassap!", 125 | "timestamp" : -63114076800, 126 | "viewedBy" : null 127 | } 128 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+Metadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 21/04/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EntityModelProtocol { 11 | func updateMetadata( 12 | _ metadata: Metadata, 13 | value: Value, 14 | in context: inout Context) throws 15 | where 16 | Value: Comparable & Sendable { 17 | 18 | try Index.ComparableValue.updateIndex( 19 | indexName: metadata.indexName, 20 | self, 21 | value: value, 22 | in: &context 23 | ) 24 | } 25 | } 26 | 27 | public extension EntityModelProtocol { 28 | func removeFromMetadata( 29 | _ metadata: Metadata, 30 | valueType: Value.Type, 31 | in context: inout Context) throws 32 | where 33 | Value: Comparable & Sendable { 34 | 35 | try Index.ComparableValue.removeFromIndex( 36 | indexName: metadata.indexName, 37 | self, in: &context) 38 | } 39 | } 40 | 41 | public extension EntityModelProtocol { 42 | func updateMetadata( 43 | _ metadata: Metadata, 44 | value: Value, 45 | in context: inout Context) throws 46 | where 47 | Value: Hashable & Sendable { 48 | 49 | try Index.HashableValue.updateIndex( 50 | indexName: metadata.indexName, 51 | self, 52 | value: value, 53 | in: &context 54 | ) 55 | } 56 | } 57 | 58 | public extension EntityModelProtocol { 59 | func removeFromMetadata( 60 | _ metadata: Metadata, 61 | valueType: Value.Type, 62 | in context: inout Context) throws 63 | where 64 | Value: Hashable & Sendable { 65 | 66 | try Index.HashableValue.removeFromIndex( 67 | indexName: metadata.indexName, 68 | self, in: &context) 69 | } 70 | } 71 | 72 | public extension EntityModelProtocol { 73 | func updateMetadata( 74 | _ metadata: Metadata, 75 | value: Value, 76 | in context: inout Context) throws 77 | where 78 | Value: Hashable & Comparable & Sendable { 79 | 80 | try Index.HashableValue.updateIndex( 81 | indexName: metadata.indexName, 82 | self, 83 | value: value, 84 | in: &context 85 | ) 86 | 87 | try Index.ComparableValue.updateIndex( 88 | indexName: metadata.indexName, 89 | self, 90 | value: value, 91 | in: &context 92 | ) 93 | } 94 | } 95 | 96 | public extension EntityModelProtocol { 97 | func removeFromMetadata( 98 | _ metadata: Metadata, 99 | valueType: Value.Type, 100 | in context: inout Context) throws 101 | where 102 | Value: Hashable & Comparable & Sendable { 103 | 104 | try Index.HashableValue.removeFromIndex( 105 | indexName: metadata.indexName, 106 | self, in: &context) 107 | 108 | try Index.ComparableValue.removeFromIndex( 109 | indexName: metadata.indexName, 110 | self, in: &context) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/IndexTests/FullTextIndexPerformanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 02/04/2025. 6 | // 7 | 8 | @testable import SwiftletModel 9 | import Foundation 10 | import XCTest 11 | 12 | final class FullTextIndexPerformanceTests: XCTestCase { 13 | var context = Context() 14 | 15 | lazy var indexedModels = { 16 | TestingModels.StringFullTextIndexed.shuffled() 17 | }() 18 | 19 | lazy var notIndexeddModels = { 20 | TestingModels.StringNotIndexed.shuffled() 21 | }() 22 | 23 | override func setUp() async throws { 24 | context = Context() 25 | 26 | try indexedModels 27 | .forEach { try $0.save(to: &context) } 28 | 29 | try notIndexeddModels 30 | .forEach { try $0.save(to: &context) } 31 | } 32 | 33 | func test_RawContainTextFilter_FilterPerformance() throws { 34 | measure { 35 | _ = TestingModels.StringNotIndexed 36 | .query() 37 | .resolve(in: context) 38 | .filter { 39 | $0.text.contains("banan", caseSensitive: false) 40 | } 41 | } 42 | } 43 | 44 | func test_IndexedContainsTextFilter_FilterPerformance() throws { 45 | measure { 46 | _ = TestingModels.StringFullTextIndexed 47 | .filter(.string(\.text, contains: "banan")) 48 | .resolve(in: context) 49 | } 50 | } 51 | 52 | func test_NotIndexedContainsTextFilter_FilterPerformance() throws { 53 | measure { 54 | _ = TestingModels.StringNotIndexed 55 | .filter(.string(\.text, contains: "banan")) 56 | .resolve(in: context) 57 | } 58 | } 59 | 60 | func test_IndexedMatchTextFilter_FilterPerformance() throws { 61 | measure { 62 | _ = TestingModels.StringFullTextIndexed 63 | .filter(.string(\.text, matches: "banan")) 64 | .resolve(in: context) 65 | } 66 | } 67 | 68 | func test_NotIndexedMatchTextFilter_FilterPerformance() throws { 69 | measure { 70 | _ = TestingModels.StringNotIndexed 71 | .filter(.string(\.text, matches: "banan")) 72 | .resolve(in: context) 73 | } 74 | } 75 | 76 | func test_RawMatchTextFilter_FilterPerformance() throws { 77 | measure { 78 | let tokens = "banan".makeTokens() 79 | _ = TestingModels.StringNotIndexed 80 | .query() 81 | .resolve(in: context) 82 | .filter { 83 | $0.text.matches(tokens: tokens) 84 | } 85 | } 86 | } 87 | 88 | func test_NoIndex_SavePerformance() throws { 89 | let models = notIndexeddModels 90 | 91 | measure { 92 | var context = Context() 93 | try! models.forEach { try $0.save(to: &context) } 94 | } 95 | } 96 | 97 | func test_Indexed_SavePerformance() throws { 98 | let models = indexedModels 99 | 100 | measure { 101 | var context = Context() 102 | try! models.forEach { try $0.save(to: &context) } 103 | } 104 | } 105 | } 106 | 107 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/UniqueIndex+Comparable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 10/03/2025. 6 | // 7 | 8 | import Foundation 9 | import BTree 10 | 11 | extension Unique { 12 | @EntityModel 13 | struct ComparableValue { 14 | var id: String { name } 15 | 16 | let name: String 17 | 18 | private var index: Map = [:] 19 | private var indexedValues: [Entity.ID: Value] = [:] 20 | 21 | init(name: String) { 22 | self.name = name 23 | } 24 | 25 | func asDeleted(in context: Context) -> Deleted? { nil } 26 | 27 | func saveMetadata(to context: inout Context) throws { } 28 | 29 | func deleteMetadata(from context: inout Context) throws { } 30 | } 31 | } 32 | 33 | extension Unique.ComparableValue { 34 | enum Errors: Error { 35 | case uniqueValueViolation(Entity.ID, Value) 36 | } 37 | } 38 | 39 | extension Unique.ComparableValue { 40 | static func updateIndex(indexName: String, 41 | _ entity: Entity, 42 | value: Value, 43 | in context: inout Context, 44 | resolveCollisions resolver: CollisionResolver) throws { 45 | 46 | var index = Query(id: indexName).resolve(in: context) ?? Self(name: indexName) 47 | try index.checkForCollisions(entity, value: value, in: &context, resolveCollisions: resolver) 48 | index = index.query().resolve(in: context) ?? index 49 | index.update(entity, value: value) 50 | try index.save(to: &context) 51 | } 52 | 53 | static func removeFromIndex(indexName: String, 54 | _ entity: Entity, 55 | in context: inout Context) throws { 56 | 57 | var index = Query(id: indexName).resolve(in: context) 58 | index?.remove(entity) 59 | try index?.save(to: &context) 60 | } 61 | } 62 | 63 | private extension Unique.ComparableValue { 64 | func checkForCollisions(_ entity: Entity, 65 | value: Value, 66 | in context: inout Context, 67 | resolveCollisions resolver: CollisionResolver) throws { 68 | guard let existingId = index[value], existingId != entity.id else { 69 | return 70 | } 71 | 72 | try resolver.resolveCollision(existing: existingId, new: entity.id, indexName: name, in: &context) 73 | } 74 | 75 | mutating func update(_ entity: Entity, 76 | value: Value) { 77 | let existingValue = indexedValues[entity.id] 78 | 79 | guard existingValue != value else { 80 | return 81 | } 82 | 83 | if let existingValue, index[existingValue] != nil { 84 | index[existingValue] = nil 85 | } 86 | 87 | index[value] = entity.id 88 | indexedValues[entity.id] = value 89 | } 90 | 91 | mutating func remove(_ entity: Entity) { 92 | guard let value = indexedValues[entity.id], 93 | index[value] != nil 94 | else { 95 | return 96 | } 97 | 98 | indexedValues[entity.id] = nil 99 | index[value] = nil 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/UniqueIndex+Hashable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Unique.ComparableValueIndex 2.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 12/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Unique { 11 | @EntityModel 12 | struct HashableValue { 13 | var id: String { name } 14 | 15 | let name: String 16 | 17 | private var index: [Value: Entity.ID] = [:] 18 | private var indexedValues: [Entity.ID: Value] = [:] 19 | 20 | init(name: String) { 21 | self.name = name 22 | } 23 | 24 | func asDeleted(in context: Context) -> Deleted? { nil } 25 | 26 | func saveMetadata(to context: inout Context) throws { } 27 | 28 | func deleteMetadata(from context: inout Context) throws { } 29 | } 30 | } 31 | 32 | extension Unique.HashableValue { 33 | enum Errors: Error { 34 | case uniqueValueViolation(Entity.ID, Value) 35 | } 36 | } 37 | 38 | extension Unique.HashableValue { 39 | static func updateIndex(indexName: String, 40 | _ entity: Entity, 41 | value: Value, 42 | in context: inout Context, 43 | resolveCollisions resolver: CollisionResolver) throws { 44 | 45 | var index = Query(id: indexName).resolve(in: context) ?? Self(name: indexName) 46 | try index.checkForCollisions(entity, value: value, in: &context, resolveCollisions: resolver) 47 | index = index.query().resolve(in: context) ?? index 48 | index.update(entity, value: value) 49 | try index.save(to: &context) 50 | } 51 | 52 | static func removeFromIndex(indexName: String, 53 | _ entity: Entity, 54 | in context: inout Context) throws { 55 | 56 | guard var index = Query(id: indexName).resolve(in: context) else { 57 | return 58 | } 59 | 60 | index.remove(entity) 61 | try index.save(to: &context) 62 | } 63 | } 64 | 65 | private extension Unique.HashableValue { 66 | func checkForCollisions(_ entity: Entity, 67 | value: Value, 68 | in context: inout Context, 69 | resolveCollisions resolver: CollisionResolver) throws { 70 | guard let existingId = index[value], existingId != entity.id else { 71 | return 72 | } 73 | 74 | try resolver.resolveCollision(existing: existingId, new: entity.id, indexName: name, in: &context) 75 | } 76 | 77 | mutating func update(_ entity: Entity, 78 | value: Value) { 79 | let existingValue = indexedValues[entity.id] 80 | 81 | guard existingValue != value else { 82 | return 83 | } 84 | 85 | if let existingValue, index[existingValue] != nil { 86 | index[existingValue] = nil 87 | } 88 | 89 | index[value] = entity.id 90 | indexedValues[entity.id] = value 91 | } 92 | 93 | mutating func remove(_ entity: Entity) { 94 | guard let value = indexedValues[entity.id], 95 | index[value] != nil 96 | else { 97 | return 98 | } 99 | 100 | indexedValues[entity.id] = nil 101 | index[value] = nil 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/SwiftletModelMacros/EntityModelMacro/Attributes/UniqueAttributes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 09/03/2025. 6 | // 7 | 8 | import Foundation 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | #if !canImport(SwiftSyntax600) 15 | import SwiftSyntaxMacroExpansion 16 | #endif 17 | 18 | struct UniqueAttributes { 19 | let propertyWrapper: PropertyWrapperAttributes 20 | let propertyName: String 21 | let keyPathAttributes: KeyPathAttributes 22 | let collisions: CollisionsResolverAttributes 23 | } 24 | 25 | extension UniqueAttributes { 26 | enum KeyPathAttributes { 27 | case labeledExpressionList(String) 28 | case propertyIdentifier(String) 29 | 30 | init(propertyIdentifier: String, 31 | labeledExprListSyntax: LabeledExprListSyntax) { 32 | 33 | let keyPathAttributes = labeledExprListSyntax 34 | .filter(\.isKeyPath) 35 | .map { $0.expressionString } 36 | 37 | guard !keyPathAttributes.isEmpty else { 38 | self = .init(propertyIdentifier: propertyIdentifier) 39 | return 40 | } 41 | 42 | let attributes = [keyPathAttributes] 43 | .flatMap { $0 } 44 | .joined(separator: ",") 45 | 46 | self = .labeledExpressionList(attributes) 47 | } 48 | 49 | init(propertyIdentifier: String) { 50 | self = .propertyIdentifier("\\.$\(propertyIdentifier)") 51 | } 52 | 53 | var attribute: String { 54 | switch self { 55 | case .labeledExpressionList(let value), .propertyIdentifier(let value): 56 | return value 57 | } 58 | } 59 | } 60 | 61 | struct CollisionsResolverAttributes { 62 | static let collisions = "collisions" 63 | 64 | let attributes: String 65 | static let upsert: CollisionsResolverAttributes = { 66 | CollisionsResolverAttributes(attributes: ".upsert") 67 | }() 68 | 69 | init(attributes: String) { 70 | self.attributes = attributes 71 | } 72 | 73 | init?(_ expressionString: String) { 74 | attributes = expressionString 75 | } 76 | 77 | init(labeledExprListSyntax: LabeledExprListSyntax) { 78 | self = labeledExprListSyntax 79 | .filter { $0.labelString?.contains(CollisionsResolverAttributes.collisions) ?? false } 80 | .compactMap { CollisionsResolverAttributes($0.expressionString) } 81 | .first ?? .upsert 82 | } 83 | 84 | // // 85 | // init?(_ expressionString: String) { 86 | // let value = Self.allCases.first { expressionString.contains($0.rawValue) } 87 | // guard let value else { 88 | // return nil 89 | // } 90 | // 91 | // self = value 92 | // } 93 | // 94 | // init(labeledExprListSyntax: LabeledExprListSyntax) { 95 | // self = labeledExprListSyntax 96 | // .filter { $0.labelString?.contains(CollisionsResolverAttributes.collisions) ?? false } 97 | // .compactMap { CollisionsResolverAttributes($0.expressionString) } 98 | // .first ?? .upsert 99 | // } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Model/MergeStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct MergeStrategy: Sendable { 11 | let merge: @Sendable (_ old: T, _ new: T) -> T 12 | 13 | public init(merge: @Sendable @escaping (T, T) -> T) { 14 | self.merge = merge 15 | } 16 | } 17 | 18 | public extension MergeStrategy { 19 | static var replace: MergeStrategy { 20 | MergeStrategy(merge: { _, new in new }) 21 | } 22 | } 23 | 24 | public extension MergeStrategy { 25 | init(_ strategies: MergeStrategy...) { 26 | merge = { old, new in 27 | strategies.reduce(new) { result, strategy in 28 | strategy.merge(old, result) 29 | } 30 | } 31 | } 32 | 33 | static func lastWriteWins( 34 | _ strategies: MergeStrategy..., 35 | comparedBy keyPath: @Sendable @escaping @autoclosure () -> KeyPath 36 | ) -> MergeStrategy { 37 | 38 | lastWriteWins(strategies, comparedBy: keyPath()) 39 | } 40 | 41 | static func lastWriteWins( 42 | _ strategies: MergeStrategy...) -> MergeStrategy 43 | where T: Comparable { 44 | 45 | lastWriteWins(strategies, comparedBy: \.self) 46 | } 47 | 48 | static func lastWriteWins( 49 | _ strategies: [MergeStrategy], 50 | comparedBy keyPath: @Sendable @escaping @autoclosure () -> KeyPath 51 | ) -> MergeStrategy { 52 | 53 | MergeStrategy { old, new in 54 | old[keyPath: keyPath()] < new[keyPath: keyPath()] 55 | ? strategies.reduce(new) { result, strategy in strategy.merge(old, result) } 56 | : strategies.reduce(old) { result, strategy in strategy.merge(new, result) } 57 | 58 | } 59 | } 60 | } 61 | 62 | public extension MergeStrategy { 63 | 64 | static func patch( 65 | _ keyPath: @Sendable @escaping @autoclosure () -> WritableKeyPath 66 | ) -> MergeStrategy { 67 | 68 | MergeStrategy { old, new in 69 | var new = new 70 | new[keyPath: keyPath()] = new[keyPath: keyPath()] ?? old[keyPath: keyPath()] 71 | return new 72 | } 73 | } 74 | 75 | static func patch() -> MergeStrategy where T == Value? { 76 | MergeStrategy { old, new in 77 | new ?? old 78 | } 79 | } 80 | } 81 | 82 | public extension MergeStrategy { 83 | static func append( 84 | _ keyPath: @Sendable @escaping @autoclosure () -> WritableKeyPath 85 | ) -> MergeStrategy { 86 | 87 | MergeStrategy { old, new in 88 | var new = new 89 | new[keyPath: keyPath()] = [old[keyPath: keyPath()], new[keyPath: keyPath()]].flatMap { $0 } 90 | return new 91 | } 92 | } 93 | 94 | static func append( 95 | _ keyPath: @Sendable @escaping @autoclosure () -> WritableKeyPath 96 | ) -> MergeStrategy { 97 | 98 | MergeStrategy { old, new in 99 | var new = new 100 | let result = [old[keyPath: keyPath()], new[keyPath: keyPath()]] 101 | .compactMap { $0 } 102 | .flatMap { $0 } 103 | new[keyPath: keyPath()] = result.isEmpty ? nil : result 104 | return new 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/FilterMetadataQueryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 02/04/2025. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | import XCTest 11 | 12 | final class FilterMetadataQueryTests: XCTestCase { 13 | let count = 100 14 | var context = Context() 15 | 16 | lazy var indexedModels = { 17 | TestingModels.ExtensivelyIndexed.shuffled(count) 18 | }() 19 | 20 | override func setUpWithError() throws { 21 | context = Context() 22 | 23 | try indexedModels 24 | .forEach { try $0.save(to: &context) } 25 | 26 | // Wait to ensure initial saves have an older timestamp 27 | Thread.sleep(forTimeInterval: 1.0) 28 | } 29 | 30 | func test_WhenFilterIndexed_ThenEqualPlainFiltering() throws { 31 | let expected = indexedModels 32 | .filter { $0.numOf1 == 1 } 33 | 34 | let filterResult = TestingModels.ExtensivelyIndexed 35 | .filter(\.numOf1 == 1) 36 | .resolve(in: context) 37 | 38 | XCTAssertEqual(Set(filterResult.map { $0.id }), 39 | Set(expected.map { $0.id })) 40 | } 41 | 42 | func test_WhenFilterByUpdatedAtRange_ThenReturnsRecentlyUpdatedModels() throws { 43 | // Given 44 | let modelsToUpdate = Array(indexedModels.prefix(5)) 45 | try modelsToUpdate.forEach { try $0.save(to: &context) } 46 | 47 | let now = Date() 48 | let pastDate = now.addingTimeInterval(-0.5) // 0.5 seconds ago 49 | let range = pastDate...now 50 | 51 | // When 52 | let filterResult = TestingModels.ExtensivelyIndexed 53 | .filter(MetadataPredicate.updated(within: range)) 54 | .resolve(in: context) 55 | 56 | // Then 57 | XCTAssertEqual(Set(filterResult.map { $0.id }), 58 | Set(modelsToUpdate.map { $0.id })) 59 | 60 | // Verify that non-updated models are not included 61 | let nonUpdatedIds = Set(indexedModels.dropFirst(5).map { $0.id }) 62 | let resultIds = Set(filterResult.map { $0.id }) 63 | XCTAssertTrue(resultIds.isDisjoint(with: nonUpdatedIds)) 64 | } 65 | 66 | func test_WhenFilterByUpdatedAtRange_WithPastRange_ThenReturnsNoModels() throws { 67 | // Given 68 | let twoDaysAgo = Date().addingTimeInterval(-172800) // 48 hours ago 69 | let oneDayAgo = Date().addingTimeInterval(-86400) // 24 hours ago 70 | let pastRange = twoDaysAgo...oneDayAgo 71 | 72 | // When 73 | let filterResult = TestingModels.ExtensivelyIndexed 74 | .filter(.updated(within: pastRange)) 75 | .resolve(in: context) 76 | 77 | // Then 78 | XCTAssertTrue(filterResult.isEmpty) 79 | } 80 | 81 | func test_WhenLastThenFilterByUpdatedAtRange_ThenFiltersLastModel() throws { 82 | // Given 83 | let modelsToUpdate = Array(indexedModels.prefix(5)) 84 | try modelsToUpdate.forEach { try $0.save(to: &context) } 85 | 86 | let now = Date() 87 | let pastDate = now.addingTimeInterval(-0.5) // 0.5 seconds ago 88 | let range = pastDate...now 89 | 90 | // When 91 | let filterResult = TestingModels.ExtensivelyIndexed 92 | .query() 93 | .sorted(by: .updatedAt) 94 | .last() 95 | .filter(MetadataPredicate.updated(within: range)) 96 | .resolve(in: context) 97 | 98 | // Then 99 | XCTAssertNotNil(filterResult) 100 | XCTAssertEqual(filterResult?.id, modelsToUpdate.last?.id) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Tuples.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 08/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | // swiftlint:disable identifier_name 11 | struct Pair { 12 | let t0: T0 13 | let t1: T1 14 | } 15 | 16 | extension Pair: Hashable where T0: Hashable, 17 | T1: Hashable { 18 | 19 | } 20 | 21 | extension Pair: Equatable where T0: Equatable, 22 | T1: Equatable { 23 | 24 | } 25 | 26 | extension Pair: Sendable where T0: Sendable, 27 | T1: Sendable { 28 | 29 | } 30 | 31 | extension Pair: Comparable where T0: Comparable, 32 | T1: Comparable { 33 | 34 | static func < (lhs: Pair, rhs: Pair) -> Bool { 35 | (lhs.t0, lhs.t1) < (rhs.t0, rhs.t1) 36 | } 37 | } 38 | 39 | struct Triplet { 40 | let t0: T0 41 | let t1: T1 42 | let t2: T2 43 | } 44 | 45 | extension Triplet: Equatable where T0: Equatable, 46 | T1: Equatable, 47 | T2: Equatable { 48 | 49 | } 50 | 51 | extension Triplet: Sendable where T0: Sendable, 52 | T1: Sendable, 53 | T2: Sendable { 54 | 55 | } 56 | 57 | extension Triplet: Comparable where T0: Comparable, 58 | T1: Comparable, 59 | T2: Comparable { 60 | 61 | static func < (lhs: Triplet, rhs: Triplet) -> Bool { 62 | (lhs.t0, lhs.t1, lhs.t2) < (rhs.t0, rhs.t1, rhs.t2) 63 | } 64 | } 65 | 66 | extension Triplet: Hashable where T0: Hashable, 67 | T1: Hashable, 68 | T2: Hashable { 69 | } 70 | 71 | struct Quadruple { 72 | let t0: T0 73 | let t1: T1 74 | let t2: T2 75 | let t3: T3 76 | } 77 | 78 | extension Quadruple: Equatable where T0: Equatable, 79 | T1: Equatable, 80 | T2: Equatable, 81 | T3: Equatable { 82 | 83 | } 84 | 85 | extension Quadruple: Comparable where T0: Comparable, 86 | T1: Comparable, 87 | T2: Comparable, 88 | T3: Comparable { 89 | 90 | static func < (lhs: Quadruple, rhs: Quadruple) -> Bool { 91 | (lhs.t0, lhs.t1, lhs.t2, lhs.t3) < (rhs.t0, rhs.t1, rhs.t2, rhs.t3) 92 | } 93 | } 94 | 95 | extension Quadruple: Hashable where T0: Hashable, 96 | T1: Hashable, 97 | T2: Hashable, 98 | T3: Hashable { 99 | } 100 | 101 | extension Quadruple: Sendable where T0: Sendable, 102 | T1: Sendable, 103 | T2: Sendable, 104 | T3: Sendable { 105 | } 106 | // swiftlint:enable identifier_name 107 | 108 | func indexValue(_ tuple: (T0, T1)) -> Pair { 109 | Pair(t0: tuple.0, t1: tuple.1) 110 | } 111 | 112 | // swiftlint:disable:next large_tuple 113 | func indexValue(_ tuple: (T0, T1, T2)) -> Triplet { 114 | Triplet(t0: tuple.0, t1: tuple.1, t2: tuple.2) 115 | } 116 | 117 | // swiftlint:disable:next large_tuple 118 | func indexValue(_ tuple: (T0, T1, T2, T3)) -> Quadruple { 119 | Quadruple(t0: tuple.0, t1: tuple.1, t2: tuple.2, t3: tuple.3) 120 | } 121 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/RelationSave.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 13/07/2024. 6 | // 7 | 8 | import Foundation 9 | import OrderedCollections 10 | 11 | // MARK: - Save Relations and Attached Entities 12 | 13 | public extension EntityModelProtocol { 14 | func save( 15 | _ keyPath: KeyPath>, 16 | to context: inout Context) throws { 17 | 18 | try saveEntities(at: keyPath, in: &context) 19 | try saveRelation(at: keyPath, in: &context) 20 | } 21 | } 22 | 23 | public extension EntityModelProtocol { 24 | 25 | func save( 26 | _ keyPath: KeyPath>, 27 | inverse: KeyPath>, 28 | to context: inout Context) throws { 29 | 30 | try saveEntities(at: keyPath, in: &context) 31 | try saveRelation(at: keyPath, inverse: inverse, in: &context) 32 | } 33 | 34 | func save( 35 | _ keyPath: KeyPath>, 36 | inverse: KeyPath>, 37 | to context: inout Context) throws { 38 | 39 | try saveEntities(at: keyPath, in: &context) 40 | try saveRelation(at: keyPath, inverse: inverse, in: &context) 41 | } 42 | 43 | func save( 44 | _ keyPath: KeyPath>, 45 | inverse: KeyPath>, 46 | to context: inout Context) throws { 47 | 48 | try saveEntities(at: keyPath, in: &context) 49 | try saveRelation(at: keyPath, inverse: inverse, in: &context) 50 | } 51 | 52 | func save( 53 | _ keyPath: KeyPath>, 54 | inverse: KeyPath>, 55 | to context: inout Context) throws { 56 | 57 | try saveEntities(at: keyPath, in: &context) 58 | try saveRelation(at: keyPath, inverse: inverse, in: &context) 59 | } 60 | } 61 | 62 | // MARK: - Private 63 | 64 | private extension EntityModelProtocol { 65 | 66 | func saveRelation( 67 | at keyPath: KeyPath>, 68 | in context: inout Context) throws { 69 | 70 | try Link.update( 71 | id, relationIds(keyPath), 72 | keyPath: keyPath, 73 | in: &context, 74 | options: relation(keyPath).updateOption() 75 | ) 76 | } 77 | 78 | func saveRelation( 79 | at keyPath: KeyPath>, 80 | inverse: KeyPath>, 81 | in context: inout Context) throws { 82 | 83 | try Link.update( 84 | id, relationIds(keyPath), 85 | keyPath: keyPath, 86 | inverse: inverse, 87 | in: &context, 88 | options: relation(keyPath).updateOption() 89 | ) 90 | } 91 | } 92 | 93 | private extension EntityModelProtocol { 94 | func saveEntities( 95 | at keyPath: KeyPath>, 96 | in context: inout Context) throws { 97 | 98 | try relation(keyPath).save(&context) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/IndexTests/FilterIndexPerformanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 02/04/2025. 6 | // 7 | 8 | import SwiftletModel 9 | import Foundation 10 | import XCTest 11 | 12 | final class FilterPerformanceTests: XCTestCase { 13 | let count = 5000 14 | var context = Context() 15 | 16 | lazy var notIndexedModels = { 17 | TestingModels.NotIndexed.shuffled(count) 18 | }() 19 | 20 | lazy var indexedModels = { 21 | TestingModels.ExtensivelyIndexed.shuffled(count) 22 | }() 23 | 24 | override func setUp() async throws { 25 | context = Context() 26 | try notIndexedModels 27 | .forEach { try $0.save(to: &context) } 28 | 29 | try indexedModels 30 | .forEach { try $0.save(to: &context) } 31 | } 32 | 33 | func test_NoIndex_FilterPerformance() throws { 34 | measure { 35 | _ = TestingModels.NotIndexed 36 | .filter(\.numOf1 == 1) 37 | .resolve(in: context) 38 | } 39 | } 40 | 41 | func test_Indexed_FilterPerformance() throws { 42 | measure { 43 | _ = TestingModels.ExtensivelyIndexed 44 | .filter(\.numOf1 == 1) 45 | .resolve(in: context) 46 | } 47 | } 48 | 49 | func test_RawFilter_FilterPerformance() throws { 50 | measure { 51 | _ = TestingModels.ExtensivelyIndexed 52 | .query() 53 | .resolve(in: context) 54 | .filter { 55 | $0.numOf1 == 1 56 | } 57 | } 58 | } 59 | 60 | func test_NotIndexedComplexFilter_FilterPerformance() throws { 61 | measure { 62 | _ = TestingModels.NotIndexed 63 | .filter(\.numOf1 == 1) 64 | .filter(\.numOf10 != 5) 65 | .filter(\.numOf100 == 4) 66 | .filter(\.numOf1000 == 2) 67 | .resolve(in: context) 68 | } 69 | } 70 | 71 | func test_IndexedComplexFilter_FilterPerformance() throws { 72 | measure { 73 | _ = TestingModels.ExtensivelyIndexed 74 | .filter(\.numOf1 == 1) 75 | .filter(\.numOf10 != 5) 76 | .filter(\.numOf100 == 4) 77 | .filter(\.numOf1000 == 2) 78 | .resolve(in: context) 79 | } 80 | } 81 | 82 | func test_RawComplexFilter_FilterPerformance() throws { 83 | measure { 84 | _ = TestingModels.ExtensivelyIndexed 85 | .query() 86 | .resolve(in: context) 87 | .filter { 88 | $0.numOf1 == 1 89 | && $0.numOf10 != 5 90 | && $0.numOf100 == 4 91 | && $0.numOf1000 == 2 92 | } 93 | } 94 | } 95 | 96 | func test_IndexedComplexComparisonFilter_FilterPerformance() throws { 97 | measure { 98 | _ = TestingModels.ExtensivelyIndexed 99 | .filter(\.numOf1 >= 1) 100 | .filter(\.numOf10 <= 5) 101 | .filter(\.numOf100 > 4) 102 | .filter(\.numOf1000 < 2) 103 | .resolve(in: context) 104 | } 105 | } 106 | 107 | func test_RawComplexComparisonFilter_FilterPerformance() throws { 108 | measure { 109 | _ = TestingModels.ExtensivelyIndexed 110 | .query() 111 | .resolve(in: context) 112 | .filter { 113 | $0.numOf1 >= 1 114 | && $0.numOf10 <= 5 115 | && $0.numOf100 > 4 116 | && $0.numOf1000 < 2 117 | } 118 | } 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/Relation+Conveniece.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 04/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Relation Extensions 11 | 12 | public extension Relation { 13 | static var none: Self { 14 | Relation(state: .none) 15 | } 16 | } 17 | 18 | public extension Relation where Cardinality == Relations.ToOne, 19 | Constraints == Relations.Optional { 20 | 21 | static var null: Self { 22 | Relation(state: State(nil, fragment: false)) 23 | } 24 | } 25 | 26 | public extension Relation where Cardinality == Relations.ToOne { 27 | static func id(_ id: Entity.ID) -> Self { 28 | Relation(state: State(id: id)) 29 | } 30 | 31 | static func relation(_ entity: Entity) -> Self { 32 | Relation(state: State(entity, fragment: false)) 33 | } 34 | 35 | static func fragment(_ entity: Entity) -> Self { 36 | Relation(state: State(entity, fragment: true)) 37 | } 38 | } 39 | 40 | public extension Relation where Cardinality == Relations.ToMany { 41 | static func relation(_ entities: [Entity]) -> Self { 42 | Relation(state: State(entities, slice: false, fragment: false)) 43 | } 44 | 45 | static func fragment(_ entities: [Entity]) -> Self { 46 | Relation(state: State(entities, slice: false, fragment: true)) 47 | } 48 | 49 | static func ids(_ ids: [Entity.ID]) -> Self { 50 | Relation(state: State(ids: ids, slice: false)) 51 | } 52 | 53 | static func appending(relation entities: [Entity]) -> Self { 54 | Relation(state: State(entities, slice: true, fragment: false)) 55 | } 56 | 57 | static func appending(fragment entities: [Entity]) -> Self { 58 | Relation(state: State(entities, slice: true, fragment: true)) 59 | } 60 | 61 | static func appending(ids: [Entity.ID]) -> Self { 62 | Relation(state: State(ids: ids, slice: true)) 63 | } 64 | } 65 | 66 | // MARK: - Relationship Extensions 67 | 68 | public extension Relationship where Cardinality == Relations.ToOne { 69 | 70 | static func id(_ id: Entity.ID) -> Self { 71 | Relationship(relation: .id(id)) 72 | } 73 | 74 | static func relation(_ entity: Entity) -> Self { 75 | Relationship(relation: .relation(entity)) 76 | } 77 | 78 | static func fragment(_ entity: Entity) -> Self { 79 | Relationship(relation: .fragment( entity)) 80 | } 81 | } 82 | 83 | public extension Relationship where Cardinality == Relations.ToMany { 84 | 85 | static func ids(_ ids: [Entity.ID]) -> Self { 86 | Relationship(relation: .ids(ids)) 87 | } 88 | 89 | static func relation(_ entities: [Entity]) -> Self { 90 | Relationship(relation: .relation(entities)) 91 | } 92 | 93 | static func fragment(_ entities: [Entity]) -> Self { 94 | Relationship(relation: .fragment(entities)) 95 | } 96 | 97 | static func appending(ids: [Entity.ID]) -> Self { 98 | Relationship(relation: .appending(ids: ids)) 99 | } 100 | 101 | static func appending(relation entities: [Entity]) -> Self { 102 | Relationship(relation: .appending(relation: entities)) 103 | } 104 | 105 | static func appending(fragment entities: [Entity]) -> Self { 106 | Relationship(relation: .appending(fragment: entities)) 107 | } 108 | } 109 | 110 | public extension Relationship where Cardinality == Relations.ToOne, 111 | Constraints == Relations.Optional { 112 | 113 | static var null: Self { 114 | Relationship(relation: .null) 115 | } 116 | } 117 | 118 | public extension Relationship { 119 | 120 | static var none: Self { 121 | Relationship(relation: .none) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Query/QueryListNested.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryCollectionNested.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 02/04/2025. 6 | // 7 | 8 | public extension ContextQuery where Result == [Query], Key == Void { 9 | func with( 10 | _ keyPath: WritableKeyPath>, 11 | nested: @escaping QueryModifier = { $0 }) -> QueryList { 12 | 13 | then { _, queries in 14 | queries.map { $0.with(keyPath, fragment: false, nested: nested) } 15 | } 16 | } 17 | 18 | func with( 19 | _ keyPath: WritableKeyPath>, 20 | nested: @escaping QueryListModifier = { $0 } 21 | ) -> QueryList { 22 | 23 | then { _, queries in 24 | queries.map { $0.with(keyPath, slice: false, fragment: false, nested: nested) } 25 | } 26 | } 27 | 28 | func with( 29 | slice keyPath: WritableKeyPath>, 30 | nested: @escaping QueryListModifier = { $0 } 31 | ) -> QueryList { 32 | 33 | then { _, queries in 34 | queries.map { $0.with(keyPath, slice: true, fragment: false, nested: nested) } 35 | } 36 | } 37 | } 38 | 39 | // MARK: - Nested Fragment Collection 40 | 41 | public extension ContextQuery where Result == [Query], Key == Void { 42 | func fragment( 43 | _ keyPath: WritableKeyPath>, 44 | nested: @escaping QueryModifier = { $0 } 45 | ) -> QueryList { 46 | 47 | then { _, queries in 48 | queries.map { $0.with(keyPath, fragment: true, nested: nested) } 49 | } 50 | } 51 | 52 | func fragment( 53 | _ keyPath: WritableKeyPath>, 54 | nested: @escaping QueryListModifier = { $0 } 55 | ) -> QueryList { 56 | 57 | then { _, queries in 58 | queries.map { $0.with(keyPath, slice: false, fragment: true, nested: nested) } 59 | } 60 | } 61 | 62 | func fragment( 63 | slice keyPath: WritableKeyPath>, 64 | nested: @escaping QueryListModifier = { $0 } 65 | ) -> QueryList { 66 | 67 | then { _, queries in 68 | queries.map { $0.with(keyPath, slice: true, fragment: true, nested: nested) } 69 | } 70 | } 71 | } 72 | 73 | // MARK: - Nested Ids Collection 74 | 75 | public extension ContextQuery where Result == [Query], Key == Void { 76 | func id( 77 | _ keyPath: WritableKeyPath> 78 | ) -> QueryList { 79 | 80 | then { _, queries in 81 | queries.map { $0.id(keyPath) } 82 | } 83 | } 84 | 85 | func id( 86 | _ keyPath: WritableKeyPath> 87 | ) -> QueryList { 88 | 89 | then { _, queries in 90 | queries.map { $0.id(keyPath) } 91 | } 92 | } 93 | 94 | func id( 95 | slice keyPath: WritableKeyPath> 96 | ) -> QueryList { 97 | 98 | then { _, queries in 99 | queries.map { $0.id(slice: keyPath) } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+IndexHashable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 16/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EntityModelProtocol { 11 | func updateIndex( 12 | _ keyPath: KeyPath, 13 | in context: inout Context) throws 14 | where 15 | T: Hashable & Sendable { 16 | 17 | try Index.HashableValue.updateIndex( 18 | indexName: .indexName(keyPath), 19 | self, 20 | value: self[keyPath: keyPath], 21 | in: &context 22 | ) 23 | } 24 | 25 | func updateIndex( 26 | _ kp0: KeyPath, 27 | _ kp1: KeyPath, 28 | in context: inout Context) throws 29 | where 30 | T0: Hashable & Sendable, 31 | T1: Hashable & Sendable { 32 | try Index.HashableValue.updateIndex( 33 | indexName: .indexName(kp0, kp1), 34 | self, 35 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1])), 36 | in: &context 37 | ) 38 | } 39 | 40 | func updateIndex( 41 | _ kp0: KeyPath, 42 | _ kp1: KeyPath, 43 | _ kp2: KeyPath, 44 | in context: inout Context) throws 45 | where 46 | T0: Hashable & Sendable, 47 | T1: Hashable & Sendable, 48 | T2: Hashable & Sendable { 49 | try Index.HashableValue.updateIndex( 50 | indexName: .indexName(kp0, kp1, kp2), 51 | self, 52 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2])), 53 | in: &context 54 | ) 55 | } 56 | 57 | func updateIndex( 58 | _ kp0: KeyPath, 59 | _ kp1: KeyPath, 60 | _ kp2: KeyPath, 61 | _ kp3: KeyPath, 62 | in context: inout Context) throws 63 | where 64 | T0: Hashable & Sendable, 65 | T1: Hashable & Sendable, 66 | T2: Hashable & Sendable, 67 | T3: Hashable & Sendable { 68 | try Index.HashableValue.updateIndex( 69 | indexName: .indexName(kp0, kp1, kp2, kp3), 70 | self, 71 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2], self[keyPath: kp3])), 72 | in: &context 73 | ) 74 | } 75 | } 76 | 77 | public extension EntityModelProtocol { 78 | func removeFromIndex( 79 | _ keyPath: KeyPath, 80 | in context: inout Context) throws 81 | where 82 | T: Hashable & Sendable { 83 | try Index.HashableValue.removeFromIndex(indexName: .indexName(keyPath), self, in: &context) 84 | } 85 | 86 | func removeFromIndex( 87 | _ kp0: KeyPath, 88 | _ kp1: KeyPath, 89 | in context: inout Context) throws 90 | where 91 | T0: Hashable & Sendable, 92 | T1: Hashable & Sendable { 93 | try Index.HashableValue>.removeFromIndex(indexName: .indexName(kp0, kp1), self, in: &context) 94 | } 95 | 96 | func removeFromIndex( 97 | _ kp0: KeyPath, 98 | _ kp1: KeyPath, 99 | _ kp2: KeyPath, 100 | in context: inout Context) throws 101 | where 102 | T0: Hashable & Sendable, 103 | T1: Hashable & Sendable, 104 | T2: Hashable & Sendable { 105 | try Index.HashableValue> 106 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2), self, in: &context) 107 | } 108 | 109 | func removeFromIndex( 110 | _ kp0: KeyPath, 111 | _ kp1: KeyPath, 112 | _ kp2: KeyPath, 113 | _ kp3: KeyPath, 114 | in context: inout Context) throws 115 | where 116 | T0: Hashable & Sendable, 117 | T1: Hashable & Sendable, 118 | T2: Hashable & Sendable, 119 | T3: Hashable & Sendable { 120 | try Index.HashableValue> 121 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2, kp3), self, in: &context) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/Relationship.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 09/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | @propertyWrapper 11 | public struct Relationship: Hashable 12 | where 13 | Cardinality: CardinalityProtocol, 14 | Cardinality.Value == Value?, 15 | Cardinality.Entity == Entity, 16 | Entity: EntityModelProtocol, 17 | Directionality: DirectionalityProtocol, 18 | Constraints: ConstraintsProtocol { 19 | 20 | private var relation: Relation 21 | 22 | public var wrappedValue: Value? { 23 | Cardinality.entity(relation) 24 | } 25 | 26 | public var projectedValue: Relation { 27 | get { relation } 28 | set { relation = newValue } 29 | } 30 | 31 | init(relation: Relation) { 32 | self.relation = relation 33 | } 34 | } 35 | 36 | // MARK: - Mutual Relationship 37 | 38 | public extension Relationship where Directionality == Relations.Mutual, 39 | Constraints == Relations.Optional, 40 | Cardinality == Relations.ToOne { 41 | 42 | init(deleteRule: Relations.DeleteRule = .nullify, 43 | inverse: KeyPath) { 44 | self.init(relation: .none) 45 | } 46 | } 47 | 48 | public extension Relationship where Directionality == Relations.Mutual, 49 | Cardinality == Relations.ToOne { 50 | 51 | init(_ constraint: Constraint, 52 | deleteRule: Relations.DeleteRule = .nullify, 53 | inverse: KeyPath) { 54 | self.init(relation: .none) 55 | } 56 | } 57 | 58 | public extension Relationship where Directionality == Relations.Mutual, 59 | Constraints == Relations.Required, 60 | Cardinality == Relations.ToMany { 61 | 62 | init(deleteRule: Relations.DeleteRule = .nullify, 63 | inverse: KeyPath) { 64 | self.init(relation: .none) 65 | } 66 | } 67 | 68 | // MARK: - One Way Relationship 69 | 70 | public extension Relationship where Directionality == Relations.OneWay, 71 | Cardinality == Relations.ToOne, 72 | Constraints == Relations.Optional { 73 | 74 | init(wrappedValue: ToOneRelation?) { 75 | self.init(relation: wrappedValue ?? .none) 76 | } 77 | 78 | } 79 | 80 | public extension Relationship where Directionality == Relations.OneWay, 81 | Cardinality == Relations.ToOne { 82 | 83 | init(_ constraint: Constraint = .optional, 84 | deleteRule: Relations.DeleteRule = .nullify) { 85 | self.init(relation: .none) 86 | } 87 | } 88 | 89 | public extension Relationship where Directionality == Relations.OneWay, 90 | Cardinality == Relations.ToMany, 91 | Constraints == Relations.Required { 92 | 93 | init(deleteRule: Relations.DeleteRule = .nullify) { 94 | self.init(relation: .none) 95 | } 96 | 97 | init(wrappedValue: ToManyRelation?) { 98 | self.init(relation: wrappedValue ?? .none) 99 | } 100 | } 101 | 102 | // MARK: - Codable 103 | 104 | extension Relationship: Codable where Value: Codable, Entity: Codable, Entity.ID: Codable { 105 | 106 | public func encode(to encoder: Encoder) throws { 107 | try relation.encode(to: encoder) 108 | } 109 | 110 | public init(from decoder: Decoder) throws { 111 | try relation = .init(from: decoder) 112 | } 113 | } 114 | 115 | // MARK: - Sendable 116 | 117 | extension Relationship: Sendable where Entity: Sendable, Entity.ID: Sendable { } 118 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+IndexComparable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 16/08/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EntityModelProtocol { 11 | func updateIndex( 12 | _ keyPath: KeyPath, 13 | in context: inout Context) throws 14 | where 15 | T: Comparable & Sendable { 16 | 17 | try Index.ComparableValue.updateIndex( 18 | indexName: .indexName(keyPath), 19 | self, 20 | value: self[keyPath: keyPath], 21 | in: &context 22 | ) 23 | } 24 | 25 | func updateIndex( 26 | _ kp0: KeyPath, 27 | _ kp1: KeyPath, 28 | in context: inout Context) throws 29 | where 30 | T0: Comparable & Sendable, 31 | T1: Comparable & Sendable { 32 | try Index.ComparableValue.updateIndex( 33 | indexName: .indexName(kp0, kp1), 34 | self, 35 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1])), 36 | in: &context 37 | ) 38 | } 39 | 40 | func updateIndex( 41 | _ kp0: KeyPath, 42 | _ kp1: KeyPath, 43 | _ kp2: KeyPath, 44 | in context: inout Context) throws 45 | where 46 | T0: Comparable & Sendable, 47 | T1: Comparable & Sendable, 48 | T2: Comparable & Sendable { 49 | try Index.ComparableValue.updateIndex( 50 | indexName: .indexName(kp0, kp1, kp2), 51 | self, 52 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2])), 53 | in: &context 54 | ) 55 | } 56 | 57 | func updateIndex( 58 | _ kp0: KeyPath, 59 | _ kp1: KeyPath, 60 | _ kp2: KeyPath, 61 | _ kp3: KeyPath, 62 | in context: inout Context) throws 63 | where 64 | T0: Comparable & Sendable, 65 | T1: Comparable & Sendable, 66 | T2: Comparable & Sendable, 67 | T3: Comparable & Sendable { 68 | try Index.ComparableValue.updateIndex( 69 | indexName: .indexName(kp0, kp1, kp2, kp3), 70 | self, 71 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2], self[keyPath: kp3])), 72 | in: &context 73 | ) 74 | } 75 | } 76 | 77 | public extension EntityModelProtocol { 78 | func removeFromIndex( 79 | _ keyPath: KeyPath, 80 | in context: inout Context) throws 81 | where 82 | T: Comparable & Sendable { 83 | try Index.ComparableValue.removeFromIndex(indexName: .indexName(keyPath), self, in: &context) 84 | } 85 | 86 | func removeFromIndex( 87 | _ kp0: KeyPath, 88 | _ kp1: KeyPath, 89 | in context: inout Context) throws 90 | where 91 | T0: Comparable & Sendable, 92 | T1: Comparable & Sendable { 93 | try Index.ComparableValue> 94 | .removeFromIndex(indexName: .indexName(kp0, kp1), self, in: &context) 95 | } 96 | 97 | func removeFromIndex( 98 | _ kp0: KeyPath, 99 | _ kp1: KeyPath, 100 | _ kp2: KeyPath, 101 | in context: inout Context) throws 102 | where 103 | T0: Comparable & Sendable, 104 | T1: Comparable & Sendable, 105 | T2: Comparable & Sendable { 106 | try Index.ComparableValue> 107 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2), self, in: &context) 108 | } 109 | 110 | func removeFromIndex( 111 | _ kp0: KeyPath, 112 | _ kp1: KeyPath, 113 | _ kp2: KeyPath, 114 | _ kp3: KeyPath, 115 | in context: inout Context) throws 116 | where 117 | T0: Comparable & Sendable, 118 | T1: Comparable & Sendable, 119 | T2: Comparable & Sendable, 120 | T3: Comparable & Sendable { 121 | try Index.ComparableValue> 122 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2, kp3), self, in: &context) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/EntitiesTests/DeleteTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 24/07/2024. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import SwiftletModel 11 | 12 | final class DeleteTests: XCTestCase { 13 | var context = Context() 14 | 15 | override func setUpWithError() throws { 16 | let chat = Chat( 17 | id: "1", 18 | users: .relation([.bob, .alice, .tom, .john, .michael]), 19 | messages: .relation([ 20 | Message( 21 | id: "1", 22 | text: "hello", 23 | author: .relation(.alice), 24 | attachment: .relation(.imageOne) 25 | ) 26 | ]), 27 | admins: .relation([.bob]) 28 | ) 29 | 30 | try chat.save(to: &context) 31 | } 32 | 33 | func test_WhenEntityIsDeleted_EntityIsRemovedFromContext() { 34 | try! Chat.delete(id: "1", from: &context) 35 | 36 | let chat = Chat 37 | .query("1") 38 | .resolve(in: context) 39 | 40 | XCTAssertNil(chat) 41 | 42 | let deletedChat = Deleted 43 | .query("1") 44 | .resolve(in: context) 45 | 46 | XCTAssertNotNil(deletedChat) 47 | } 48 | 49 | func test_WhenSoftDeleteEntityIsSaved_EntityIsRemovedFromContext() { 50 | let softDeleteChat = Chat 51 | .query("1") 52 | .resolve(in: context)? 53 | .asDeleted(in: context) 54 | 55 | try! softDeleteChat!.save(to: &context) 56 | 57 | let chat = Chat 58 | .query("1") 59 | .resolve(in: context) 60 | 61 | XCTAssertNil(chat) 62 | } 63 | 64 | func test_WhenSoftDeleteEntityIsRestored_EntityIsRestoredInContext() { 65 | try! Chat.delete(id: "1", from: &context) 66 | 67 | try! Deleted 68 | .query("1") 69 | .resolve(in: context)? 70 | .restore(in: &context) 71 | 72 | let chat = Chat 73 | .query("1") 74 | .resolve(in: context) 75 | 76 | XCTAssertNotNil(chat) 77 | } 78 | 79 | func test_WhenEntityIsDeleted_EntityIsRemovedFromRelations() { 80 | try! Chat.delete(id: "1", from: &context) 81 | 82 | let userChats = User 83 | .query(User.bob.id) 84 | .related(\.$chats) 85 | .resolve(in: context) 86 | 87 | XCTAssertTrue(userChats.isEmpty) 88 | } 89 | 90 | func test_WhenEntityIsDeleted_CascadeDeleteIsFullfilled() { 91 | try! Chat.delete(id: "1", from: &context) 92 | 93 | let message = Message 94 | .query("1") 95 | .resolve(in: context) 96 | 97 | let attachment = Attachment 98 | .query("1") 99 | .resolve(in: context) 100 | 101 | XCTAssertNil(message) 102 | XCTAssertNil(attachment) 103 | 104 | let deletedMessage = Deleted 105 | .query("1") 106 | .resolve(in: context) 107 | 108 | XCTAssertNotNil(deletedMessage) 109 | 110 | let deletedAttachment = Deleted 111 | .query("1") 112 | .resolve(in: context) 113 | 114 | XCTAssertNotNil(deletedAttachment) 115 | } 116 | 117 | func test_WhenEntityIsDetached_EntityIsRemovedFromRelations() { 118 | let chat = Chat 119 | .query("1") 120 | .resolve(in: context)! 121 | 122 | try! chat.detach(\.$users, inverse: \.$chats, in: &context) 123 | 124 | let userChats = User 125 | .query(User.bob.id) 126 | .related(\.$chats) 127 | .resolve(in: context) 128 | 129 | XCTAssertTrue(userChats.isEmpty) 130 | } 131 | 132 | func test_WhenEntityIsDetached_EntityIsNotRemovedFromContext() { 133 | let chat = Chat 134 | .query("1") 135 | .resolve(in: context)! 136 | 137 | try! chat.detach(\.$users, inverse: \.$chats, in: &context) 138 | 139 | let user = User 140 | .query(User.bob.id) 141 | .resolve(in: context) 142 | 143 | XCTAssertNotNil(user) 144 | } 145 | 146 | func test_WhenEntityIsDetachedFromOneWayRelation_EntityIsRemovedFromRelations() { 147 | let message = Message 148 | .query("1") 149 | .resolve(in: context)! 150 | 151 | try! message.detach(\.$author, in: &context) 152 | 153 | let refetchedMessage = Message 154 | .query("1") 155 | .with(\.$author) 156 | .resolve(in: context)! 157 | 158 | XCTAssertNil(refetchedMessage.author) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/RelationTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 12/07/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | // swiftlint:disable line_length 11 | public typealias MutualRelation = Relation 14 | 15 | public typealias OneWayRelation = Relation 18 | 19 | public typealias ManyToOneRelation = Relation, Constraint> 21 | 22 | public typealias OneToOneRelation = Relation, Constraint> 24 | 25 | public typealias OneToManyRelation = Relation, Constraint> 27 | 28 | public typealias ManyToManyRelation = Relation, Constraint> 30 | 31 | public typealias ToOneRelation = Relation, Constraint> 34 | 35 | public typealias ToManyRelation = Relation, Constraint> 38 | 39 | // swiftlint:enable line_length 40 | 41 | public enum Relations { } 42 | 43 | public extension Relations { 44 | enum DeleteRule { 45 | case cascade 46 | case nullify 47 | } 48 | } 49 | 50 | // MARK: - Directionality 51 | 52 | public protocol DirectionalityProtocol { } 53 | 54 | public extension Relations { 55 | enum OneWay: DirectionalityProtocol { } 56 | 57 | enum Mutual: DirectionalityProtocol { } 58 | } 59 | 60 | // MARK: - Cardinality 61 | 62 | public protocol CardinalityProtocol { 63 | associatedtype Value 64 | associatedtype Entity: EntityModelProtocol 65 | 66 | static var isToMany: Bool { get } 67 | 68 | static func entity( 69 | _ relation: Relation 70 | ) -> Value 71 | } 72 | 73 | public extension Relations { 74 | enum ToMany { } 75 | 76 | enum ToOne { } 77 | } 78 | 79 | extension Relations.ToMany: CardinalityProtocol { 80 | 81 | public static var isToMany: Bool { true } 82 | 83 | public static func entity( 84 | _ relation: Relation) -> [Entity]? 85 | where 86 | Entity: EntityModelProtocol, 87 | Directionality: DirectionalityProtocol, 88 | Cardinality: CardinalityProtocol, 89 | Constraint: ConstraintsProtocol { 90 | 91 | relation.entities 92 | 93 | } 94 | } 95 | 96 | extension Relations.ToOne: CardinalityProtocol { 97 | 98 | public static var isToMany: Bool { false } 99 | 100 | public static func entity( 101 | _ relation: Relation) -> Entity? 102 | where 103 | Entity: EntityModelProtocol, 104 | Directionality: DirectionalityProtocol, 105 | Cardinality: CardinalityProtocol, 106 | Constraint: ConstraintsProtocol { 107 | 108 | relation.entities.first 109 | } 110 | } 111 | 112 | // MARK: - Constraints 113 | 114 | public protocol ConstraintsProtocol { } 115 | 116 | public extension Relations { 117 | enum Required: ConstraintsProtocol { } 118 | 119 | enum Optional: ConstraintsProtocol { } 120 | } 121 | 122 | public struct Constraint { } 123 | 124 | public extension Constraint { 125 | static var required: Constraint { 126 | Constraint() 127 | } 128 | 129 | static var optional: Constraint { 130 | Constraint() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+UniqueHashable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 13/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EntityModelProtocol { 11 | func updateUniqueIndex( 12 | _ keyPath: KeyPath, 13 | collisions resolver: CollisionResolver, 14 | in context: inout Context) throws 15 | 16 | where 17 | T: Hashable & Sendable { 18 | 19 | try Unique.HashableValue.updateIndex( 20 | indexName: .indexName(keyPath), 21 | self, 22 | value: self[keyPath: keyPath], 23 | in: &context, 24 | resolveCollisions: resolver 25 | ) 26 | } 27 | 28 | func updateUniqueIndex( 29 | _ kp0: KeyPath, 30 | _ kp1: KeyPath, 31 | collisions resolver: CollisionResolver, 32 | in context: inout Context) throws 33 | 34 | where 35 | T0: Hashable & Sendable, 36 | T1: Hashable & Sendable { 37 | 38 | try Unique.HashableValue.updateIndex( 39 | indexName: .indexName(kp0, kp1), 40 | self, 41 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1])), 42 | in: &context, 43 | resolveCollisions: resolver 44 | ) 45 | } 46 | 47 | func updateUniqueIndex( 48 | _ kp0: KeyPath, 49 | _ kp1: KeyPath, 50 | _ kp2: KeyPath, 51 | collisions resolver: CollisionResolver, 52 | in context: inout Context) throws 53 | 54 | where 55 | T0: Hashable & Sendable, 56 | T1: Hashable & Sendable, 57 | T2: Hashable & Sendable { 58 | 59 | try Unique.HashableValue.updateIndex( 60 | indexName: .indexName(kp0, kp1, kp2), 61 | self, 62 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2])), 63 | in: &context, 64 | resolveCollisions: resolver 65 | ) 66 | } 67 | 68 | // swiftlint:disable:next function_parameter_count 69 | func updateUniqueIndex( 70 | _ kp0: KeyPath, 71 | _ kp1: KeyPath, 72 | _ kp2: KeyPath, 73 | _ kp3: KeyPath, 74 | collisions resolver: CollisionResolver, 75 | in context: inout Context) throws 76 | 77 | where 78 | T0: Hashable & Sendable, 79 | T1: Hashable & Sendable, 80 | T2: Hashable & Sendable, 81 | T3: Hashable & Sendable { 82 | 83 | try Unique.HashableValue.updateIndex( 84 | indexName: .indexName(kp0, kp1, kp2, kp3), 85 | self, 86 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2], self[keyPath: kp3])), 87 | in: &context, 88 | resolveCollisions: resolver 89 | ) 90 | } 91 | } 92 | 93 | public extension EntityModelProtocol { 94 | func removeFromUniqueIndex( 95 | _ keyPath: KeyPath, 96 | in context: inout Context) throws 97 | 98 | where 99 | T: Hashable & Sendable { 100 | 101 | try Unique.HashableValue.removeFromIndex(indexName: .indexName(keyPath), self, in: &context) 102 | } 103 | 104 | func removeFromUniqueIndex( 105 | _ kp0: KeyPath, 106 | _ kp1: KeyPath, 107 | in context: inout Context) throws 108 | 109 | where 110 | T0: Hashable & Sendable, 111 | T1: Hashable & Sendable { 112 | 113 | try Unique.HashableValue>.removeFromIndex(indexName: .indexName(kp0, kp1), self, in: &context) 114 | } 115 | 116 | func removeFromUniqueIndex( 117 | _ kp0: KeyPath, 118 | _ kp1: KeyPath, 119 | _ kp2: KeyPath, 120 | in context: inout Context) throws 121 | 122 | where 123 | T0: Hashable & Sendable, 124 | T1: Hashable & Sendable, 125 | T2: Hashable & Sendable { 126 | 127 | try Unique.HashableValue> 128 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2), self, in: &context) 129 | } 130 | 131 | func removeFromUniqueIndex( 132 | _ kp0: KeyPath, 133 | _ kp1: KeyPath, 134 | _ kp2: KeyPath, 135 | _ kp3: KeyPath, 136 | in context: inout Context) throws 137 | 138 | where 139 | T0: Hashable & Sendable, 140 | T1: Hashable & Sendable, 141 | T2: Hashable & Sendable, 142 | T3: Hashable & Sendable { 143 | 144 | try Unique.HashableValue> 145 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2, kp3), self, in: &context) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+UniqueComparable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntityModel+UniqueIndex.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 12/03/2025. 6 | // 7 | 8 | public extension EntityModelProtocol { 9 | func updateUniqueIndex( 10 | _ keyPath: KeyPath, 11 | collisions resolver: CollisionResolver, 12 | in context: inout Context) throws 13 | 14 | where 15 | T: Comparable & Sendable { 16 | 17 | try Unique.ComparableValue.updateIndex( 18 | indexName: .indexName(keyPath), 19 | self, 20 | value: self[keyPath: keyPath], 21 | in: &context, 22 | resolveCollisions: resolver 23 | ) 24 | } 25 | 26 | func updateUniqueIndex( 27 | _ kp0: KeyPath, 28 | _ kp1: KeyPath, 29 | collisions resolver: CollisionResolver, 30 | in context: inout Context) throws 31 | 32 | where 33 | T0: Comparable & Sendable, 34 | T1: Comparable & Sendable { 35 | 36 | try Unique.ComparableValue.updateIndex( 37 | indexName: .indexName(kp0, kp1), 38 | self, 39 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1])), 40 | in: &context, 41 | resolveCollisions: resolver 42 | ) 43 | } 44 | 45 | func updateUniqueIndex( 46 | _ kp0: KeyPath, 47 | _ kp1: KeyPath, 48 | _ kp2: KeyPath, 49 | collisions resolver: CollisionResolver, 50 | in context: inout Context) throws 51 | 52 | where 53 | T0: Comparable & Sendable, 54 | T1: Comparable & Sendable, 55 | T2: Comparable & Sendable { 56 | 57 | try Unique.ComparableValue.updateIndex( 58 | indexName: .indexName(kp0, kp1, kp2), 59 | self, 60 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2])), 61 | in: &context, 62 | resolveCollisions: resolver 63 | ) 64 | } 65 | 66 | // swiftlint:disable:next function_parameter_count 67 | func updateUniqueIndex( 68 | _ kp0: KeyPath, 69 | _ kp1: KeyPath, 70 | _ kp2: KeyPath, 71 | _ kp3: KeyPath, 72 | collisions resolver: CollisionResolver, 73 | in context: inout Context) throws 74 | 75 | where 76 | T0: Comparable & Sendable, 77 | T1: Comparable & Sendable, 78 | T2: Comparable & Sendable, 79 | T3: Comparable & Sendable { 80 | 81 | try Unique.ComparableValue.updateIndex( 82 | indexName: .indexName(kp0, kp1, kp2, kp3), 83 | self, 84 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2], self[keyPath: kp3])), 85 | in: &context, 86 | resolveCollisions: resolver 87 | ) 88 | } 89 | } 90 | 91 | public extension EntityModelProtocol { 92 | func removeFromUniqueIndex( 93 | _ keyPath: KeyPath, 94 | in context: inout Context) throws 95 | 96 | where 97 | T: Comparable & Sendable { 98 | 99 | try Unique.ComparableValue.removeFromIndex(indexName: .indexName(keyPath), self, in: &context) 100 | } 101 | 102 | func removeFromUniqueIndex( 103 | _ kp0: KeyPath, 104 | _ kp1: KeyPath, 105 | in context: inout Context) throws 106 | 107 | where 108 | T0: Comparable & Sendable, 109 | T1: Comparable & Sendable { 110 | 111 | try Unique.ComparableValue>.removeFromIndex(indexName: .indexName(kp0, kp1), self, in: &context) 112 | } 113 | 114 | func removeFromUniqueIndex( 115 | _ kp0: KeyPath, 116 | _ kp1: KeyPath, 117 | _ kp2: KeyPath, 118 | in context: inout Context) throws 119 | 120 | where 121 | T0: Comparable & Sendable, 122 | T1: Comparable & Sendable, 123 | T2: Comparable & Sendable { 124 | 125 | try Unique.ComparableValue> 126 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2), self, in: &context) 127 | } 128 | 129 | func removeFromUniqueIndex( 130 | _ kp0: KeyPath, 131 | _ kp1: KeyPath, 132 | _ kp2: KeyPath, 133 | _ kp3: KeyPath, 134 | in context: inout Context) throws 135 | 136 | where 137 | T0: Comparable & Sendable, 138 | T1: Comparable & Sendable, 139 | T2: Comparable & Sendable, 140 | T3: Comparable & Sendable { 141 | 142 | try Unique.ComparableValue> 143 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2, kp3), self, in: &context) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Index/Entity+UniqueHashable&Comparable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // SwiftletModel 4 | // 5 | // Created by Serge Kazakov on 13/03/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EntityModelProtocol { 11 | func updateUniqueIndex( 12 | _ keyPath: KeyPath, 13 | collisions resolver: CollisionResolver, 14 | in context: inout Context) throws 15 | where 16 | T: Hashable & Comparable & Sendable { 17 | try Unique.HashableValue.updateIndex( 18 | indexName: .indexName(keyPath), 19 | self, 20 | value: self[keyPath: keyPath], 21 | in: &context, 22 | resolveCollisions: resolver 23 | ) 24 | } 25 | 26 | func updateUniqueIndex( 27 | _ kp0: KeyPath, 28 | _ kp1: KeyPath, 29 | collisions resolver: CollisionResolver, 30 | in context: inout Context) throws 31 | where 32 | T0: Hashable & Comparable & Sendable, 33 | T1: Hashable & Comparable & Sendable { 34 | try Unique.HashableValue>.updateIndex( 35 | indexName: .indexName(kp0, kp1), 36 | self, 37 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1])), 38 | in: &context, 39 | resolveCollisions: resolver 40 | ) 41 | } 42 | 43 | func updateUniqueIndex( 44 | _ kp0: KeyPath, 45 | _ kp1: KeyPath, 46 | _ kp2: KeyPath, 47 | collisions resolver: CollisionResolver, 48 | in context: inout Context) throws 49 | where 50 | T0: Hashable & Comparable & Sendable, 51 | T1: Hashable & Comparable & Sendable, 52 | T2: Hashable & Comparable & Sendable { 53 | try Unique.HashableValue>.updateIndex( 54 | indexName: .indexName(kp0, kp1, kp2), 55 | self, 56 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2])), 57 | in: &context, 58 | resolveCollisions: resolver 59 | ) 60 | } 61 | 62 | // swiftlint:disable:next function_parameter_count 63 | func updateUniqueIndex( 64 | _ kp0: KeyPath, 65 | _ kp1: KeyPath, 66 | _ kp2: KeyPath, 67 | _ kp3: KeyPath, 68 | collisions resolver: CollisionResolver, 69 | in context: inout Context) throws 70 | where 71 | T0: Hashable & Comparable & Sendable, 72 | T1: Hashable & Comparable & Sendable, 73 | T2: Hashable & Comparable & Sendable, 74 | T3: Hashable & Comparable & Sendable { 75 | 76 | try Unique.HashableValue>.updateIndex( 77 | indexName: .indexName(kp0, kp1, kp2, kp3), 78 | self, 79 | value: indexValue((self[keyPath: kp0], self[keyPath: kp1], self[keyPath: kp2], self[keyPath: kp3])), 80 | in: &context, 81 | resolveCollisions: resolver 82 | ) 83 | } 84 | } 85 | 86 | public extension EntityModelProtocol { 87 | func removeFromUniqueIndex( 88 | _ keyPath: KeyPath, 89 | in context: inout Context) throws 90 | where 91 | T: Hashable & Comparable & Sendable { 92 | 93 | try Unique.HashableValue.removeFromIndex(indexName: .indexName(keyPath), self, in: &context) 94 | } 95 | 96 | func removeFromUniqueIndex( 97 | _ kp0: KeyPath, 98 | _ kp1: KeyPath, 99 | in context: inout Context) throws 100 | where 101 | T0: Hashable & Comparable & Sendable, 102 | T1: Hashable & Comparable & Sendable { 103 | try Unique.HashableValue> 104 | .removeFromIndex(indexName: .indexName(kp0, kp1), self, in: &context) 105 | } 106 | 107 | func removeFromUniqueIndex( 108 | _ kp0: KeyPath, 109 | _ kp1: KeyPath, 110 | _ kp2: KeyPath, 111 | in context: inout Context) throws 112 | where 113 | T0: Hashable & Comparable & Sendable, 114 | T1: Hashable & Comparable & Sendable, 115 | T2: Hashable & Comparable & Sendable { 116 | try Unique.HashableValue> 117 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2), self, in: &context) 118 | } 119 | 120 | func removeFromUniqueIndex( 121 | _ kp0: KeyPath, 122 | _ kp1: KeyPath, 123 | _ kp2: KeyPath, 124 | _ kp3: KeyPath, 125 | in context: inout Context) throws 126 | where 127 | T0: Hashable & Comparable & Sendable, 128 | T1: Hashable & Comparable & Sendable, 129 | T2: Hashable & Comparable & Sendable, 130 | T3: Hashable & Comparable & Sendable { 131 | try Unique.HashableValue> 132 | .removeFromIndex(indexName: .indexName(kp0, kp1, kp2, kp3), self, in: &context) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/AllNestedModelsQueryTest/test_WhenQuerySchemaOlderRange_IncludesEntitiesUpdatedWithinRange.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "v1" : { 4 | "attachments" : [ 5 | 6 | ], 7 | "chats" : [ 8 | 9 | ], 10 | "deletedAttachments" : [ 11 | 12 | ], 13 | "deletedChats" : [ 14 | 15 | ], 16 | "deletedMessages" : [ 17 | 18 | ], 19 | "deletedUsers" : [ 20 | 21 | ], 22 | "messages" : [ 23 | { 24 | "attachment" : null, 25 | "author" : { 26 | "id" : "4" 27 | }, 28 | "chat" : { 29 | "id" : "1" 30 | }, 31 | "id" : "0", 32 | "replies" : [ 33 | { 34 | "id" : "1" 35 | }, 36 | { 37 | "id" : "2" 38 | }, 39 | { 40 | "id" : "3" 41 | }, 42 | { 43 | "id" : "4" 44 | } 45 | ], 46 | "replyTo" : null, 47 | "text" : "hello, ya'll", 48 | "timestamp" : -63114076800, 49 | "viewedBy" : [ 50 | 51 | ] 52 | }, 53 | { 54 | "attachment" : null, 55 | "author" : { 56 | "id" : "2" 57 | }, 58 | "chat" : { 59 | "id" : "1" 60 | }, 61 | "id" : "1", 62 | "replies" : [ 63 | 64 | ], 65 | "replyTo" : { 66 | "id" : "0" 67 | }, 68 | "text" : "hello", 69 | "timestamp" : -63114076800, 70 | "viewedBy" : [ 71 | 72 | ] 73 | }, 74 | { 75 | "attachment" : null, 76 | "author" : { 77 | "id" : "1" 78 | }, 79 | "chat" : { 80 | "id" : "1" 81 | }, 82 | "id" : "2", 83 | "replies" : [ 84 | 85 | ], 86 | "replyTo" : { 87 | "id" : "0" 88 | }, 89 | "text" : "howdy", 90 | "timestamp" : -63114076800, 91 | "viewedBy" : [ 92 | 93 | ] 94 | }, 95 | { 96 | "attachment" : null, 97 | "author" : { 98 | "id" : "5" 99 | }, 100 | "chat" : { 101 | "id" : "1" 102 | }, 103 | "id" : "3", 104 | "replies" : [ 105 | 106 | ], 107 | "replyTo" : { 108 | "id" : "0" 109 | }, 110 | "text" : "yo!", 111 | "timestamp" : -63114076800, 112 | "viewedBy" : [ 113 | 114 | ] 115 | }, 116 | { 117 | "attachment" : null, 118 | "author" : { 119 | "id" : "3" 120 | }, 121 | "chat" : { 122 | "id" : "1" 123 | }, 124 | "id" : "4", 125 | "replies" : [ 126 | 127 | ], 128 | "replyTo" : { 129 | "id" : "0" 130 | }, 131 | "text" : "wassap!", 132 | "timestamp" : -63114076800, 133 | "viewedBy" : [ 134 | 135 | ] 136 | } 137 | ], 138 | "users" : [ 139 | { 140 | "adminOf" : [ 141 | 142 | ], 143 | "chats" : [ 144 | { 145 | "id" : "1" 146 | } 147 | ], 148 | "email" : "michael@gmail.com", 149 | "id" : "4", 150 | "isCurrent" : false, 151 | "name" : "Michael", 152 | "username" : "@michael" 153 | }, 154 | { 155 | "adminOf" : [ 156 | 157 | ], 158 | "chats" : [ 159 | { 160 | "id" : "1" 161 | } 162 | ], 163 | "email" : "alice@gmail.com", 164 | "id" : "2", 165 | "isCurrent" : false, 166 | "name" : "Alice", 167 | "username" : "@alice" 168 | }, 169 | { 170 | "adminOf" : [ 171 | 172 | ], 173 | "chats" : [ 174 | { 175 | "id" : "1" 176 | } 177 | ], 178 | "email" : "tom@gmail.com", 179 | "id" : "5", 180 | "isCurrent" : false, 181 | "name" : "Tom", 182 | "username" : "@tom" 183 | }, 184 | { 185 | "adminOf" : [ 186 | 187 | ], 188 | "chats" : [ 189 | { 190 | "id" : "1" 191 | } 192 | ], 193 | "email" : "john@gmail.com", 194 | "id" : "3", 195 | "isCurrent" : false, 196 | "name" : "John", 197 | "username" : "@john" 198 | }, 199 | { 200 | "adminOf" : [ 201 | { 202 | "id" : "1" 203 | } 204 | ], 205 | "chats" : [ 206 | { 207 | "id" : "1" 208 | } 209 | ], 210 | "email" : "bob@gmail.com", 211 | "id" : "1", 212 | "isCurrent" : false, 213 | "name" : "Bob", 214 | "username" : "@bob" 215 | } 216 | ] 217 | } 218 | } 219 | ] -------------------------------------------------------------------------------- /Sources/SwiftletModel/Relationship/Relation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Relation: Hashable 11 | where 12 | Entity: EntityModelProtocol, 13 | Directionality: DirectionalityProtocol, 14 | Cardinality: CardinalityProtocol, 15 | Constraints: ConstraintsProtocol { 16 | 17 | private(set) var state: State 18 | 19 | init(state: State) { 20 | self.state = state 21 | } 22 | 23 | init() { 24 | state = .none 25 | } 26 | } 27 | 28 | public extension Relation { 29 | mutating func normalize() { 30 | state.normalize() 31 | } 32 | } 33 | 34 | public extension Relation { 35 | static func == (lhs: Self, rhs: Self) -> Bool { 36 | lhs.state == rhs.state 37 | } 38 | 39 | func hash(into hasher: inout Hasher) { 40 | hasher.combine(state) 41 | } 42 | } 43 | 44 | extension Relation where Cardinality == Relations.ToMany { 45 | static func appending(_ entities: [Entity], fragment: Bool) -> Self { 46 | Relation(state: State(entities, slice: true, fragment: fragment)) 47 | } 48 | 49 | static func relation(_ entities: [Entity], fragment: Bool) -> Self { 50 | Relation(state: State(entities, slice: false, fragment: fragment)) 51 | } 52 | } 53 | 54 | extension Relation where Cardinality == Relations.ToOne { 55 | static func relation(_ entity: Entity, fragment: Bool) -> Self { 56 | Relation(state: State(entity, fragment: fragment)) 57 | } 58 | } 59 | 60 | extension Relation { 61 | var ids: [Entity.ID] { 62 | state.ids 63 | } 64 | 65 | var entities: [Entity] { 66 | state.entities 67 | } 68 | } 69 | 70 | extension Relation { 71 | var isFragment: Bool { 72 | switch state { 73 | case .entity(_, let fragment), .entities(_, _, let fragment): 74 | return fragment 75 | case .id, .ids, .none: 76 | return false 77 | } 78 | } 79 | 80 | var isSlice: Bool { 81 | switch state { 82 | case .entity, .id: 83 | return false 84 | case .entities(_, let slice, _), .ids(_, let slice): 85 | return slice 86 | case .none: 87 | return true 88 | } 89 | } 90 | 91 | } 92 | 93 | extension Relation { 94 | public func save(_ context: inout Context) throws { 95 | try entities.forEach { 96 | try $0.save( 97 | to: &context, 98 | options: isFragment ? .fragment : .default 99 | ) 100 | } 101 | } 102 | } 103 | 104 | // MARK: - Codable 105 | 106 | extension Relation.State: Codable where T: Codable, T.ID: Codable { } 107 | 108 | // MARK: - Sendable 109 | 110 | extension Relation: Sendable where Entity: Sendable, Entity.ID: Sendable { } 111 | 112 | extension Relation.State: Sendable where T: Sendable, T.ID: Sendable { } 113 | 114 | // MARK: - Private State 115 | 116 | extension Relation { 117 | 118 | indirect enum State: Hashable { 119 | case id(id: T.ID?) 120 | case entity(entity: T?, fragment: Bool) 121 | case ids(ids: [T.ID], slice: Bool) 122 | case entities(entities: [T], slice: Bool, fragment: Bool) 123 | case none 124 | 125 | init(_ items: [T], slice: Bool, fragment: Bool) { 126 | self = .entities(entities: items, slice: slice, fragment: fragment) 127 | } 128 | 129 | init(ids: [T.ID], slice: Bool) { 130 | self = .ids(ids: ids, slice: slice) 131 | } 132 | 133 | init(id: T.ID?) { 134 | self = .id(id: id) 135 | } 136 | 137 | init(_ entity: T?, fragment: Bool) { 138 | self = .entity(entity: entity, fragment: fragment) 139 | } 140 | } 141 | } 142 | 143 | private extension Relation.State { 144 | var ids: [T.ID] { 145 | switch self { 146 | case .id(let id): 147 | return [id].compactMap { $0 } 148 | case .entity(let entity, _): 149 | return [entity].compactMap { $0?.id } 150 | case .ids(let ids, _): 151 | return ids 152 | case .entities(let entities, _, _): 153 | return entities.map { $0.id } 154 | case .none: 155 | return [] 156 | } 157 | } 158 | 159 | var entities: [T] { 160 | switch self { 161 | case .id, .ids: 162 | return [] 163 | case .entity(let entity, _): 164 | return [entity].compactMap { $0 } 165 | case .entities(let entities, _, _): 166 | return entities 167 | case .none: 168 | return [] 169 | } 170 | } 171 | } 172 | 173 | private extension Relation.State { 174 | mutating func normalize() { 175 | self = .none 176 | } 177 | } 178 | 179 | extension Relation.State { 180 | static func == (lhs: Self, rhs: Self) -> Bool { 181 | lhs.ids == rhs.ids 182 | } 183 | 184 | func hash(into hasher: inout Hasher) { 185 | hasher.combine(ids) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Sources/SwiftletModel/Model/EntityModelProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/03/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol EntityModelProtocol: Sendable { 11 | // swiftlint:disable:next type_name 12 | associatedtype ID: Hashable, LosslessStringConvertible, Sendable 13 | 14 | var id: ID { get } 15 | 16 | mutating func normalize() 17 | 18 | mutating func willSave(to context: inout Context) throws 19 | 20 | func didSave(to context: inout Context) throws 21 | 22 | func save(to context: inout Context, options: MergeStrategy) throws 23 | 24 | func willDelete(from context: inout Context) throws 25 | 26 | func didDelete(from context: inout Context) throws 27 | 28 | func delete(from context: inout Context) throws 29 | 30 | func asDeleted(in context: Context) -> Deleted? 31 | 32 | func saveMetadata(to context: inout Context) throws 33 | 34 | func deleteMetadata(from context: inout Context) throws 35 | 36 | static var defaultMergeStrategy: MergeStrategy { get } 37 | 38 | static var fragmentMergeStrategy: MergeStrategy { get } 39 | 40 | static var patch: MergeStrategy { get } 41 | 42 | static func queryAll(with nested: Nested...) -> QueryList 43 | 44 | static func nestedQueryModifier(_ query: Query, nested: [Nested]) -> Query 45 | 46 | static func indexedKeyPathName(_ keyPath: KeyPath) -> String 47 | } 48 | 49 | public extension EntityModelProtocol { 50 | 51 | static var defaultMergeStrategy: MergeStrategy { .replace } 52 | 53 | static var fragmentMergeStrategy: MergeStrategy { Self.patch } 54 | 55 | mutating func willSave(to context: inout Context) throws { } 56 | 57 | func didSave(to context: inout Context) throws { } 58 | 59 | func willDelete(from context: inout Context) throws { } 60 | 61 | func didDelete(from context: inout Context) throws { } 62 | 63 | func normalized() -> Self { 64 | var copy = self 65 | copy.normalize() 66 | return copy 67 | } 68 | 69 | func asDeleted(in context: Context) -> Deleted? { 70 | query() 71 | .with(.ids) 72 | .resolve(in: context) 73 | .map { Deleted($0) } 74 | } 75 | 76 | func saveMetadata(to context: inout Context) throws { 77 | try updateMetadata(.updatedAt, value: Date(), in: &context) 78 | } 79 | 80 | func deleteMetadata(from context: inout Context) throws { 81 | try removeFromMetadata(.updatedAt, valueType: Date.self, in: &context) 82 | } 83 | } 84 | 85 | public extension MergeStrategy where T: EntityModelProtocol { 86 | static var `default`: MergeStrategy { 87 | T.defaultMergeStrategy 88 | } 89 | 90 | static var fragment: MergeStrategy { 91 | T.fragmentMergeStrategy 92 | } 93 | } 94 | 95 | public extension EntityModelProtocol { 96 | static func delete(id: ID, from context: inout Context) throws { 97 | try Self.query(id) 98 | .resolve(in: context)? 99 | .delete(from: &context) 100 | } 101 | } 102 | 103 | public extension EntityModelProtocol { 104 | func query() -> Query { 105 | Self.query(id) 106 | } 107 | 108 | static func query(_ id: ID) -> Query { 109 | Query(id: id) 110 | } 111 | 112 | static func query(_ ids: [ID]) -> QueryList { 113 | QueryList(ids: ids) 114 | } 115 | 116 | static func query() -> QueryList { 117 | QueryList() 118 | } 119 | 120 | static func queryAll(with nested: Nested...) -> QueryList { 121 | Self.query() 122 | .with(nested) 123 | } 124 | } 125 | 126 | // MARK: - Filter Query 127 | 128 | public extension EntityModelProtocol { 129 | static func filter( 130 | _ predicate: Predicate) -> QueryList 131 | where 132 | T: Comparable { 133 | Query.filter(predicate) 134 | } 135 | 136 | static func filter( 137 | _ predicate: EqualityPredicate) -> QueryList 138 | where 139 | T: Hashable { 140 | Query.filter(predicate) 141 | } 142 | 143 | static func filter( 144 | _ predicate: Predicate) -> QueryList 145 | where 146 | T: Hashable & Comparable { 147 | Query.filter(predicate) 148 | } 149 | 150 | static func filter( 151 | _ predicate: StringPredicate) -> QueryList { 152 | Query.filter(predicate) 153 | } 154 | 155 | static func filter( 156 | _ predicate: MetadataPredicate) -> QueryList { 157 | Query.filter(predicate) 158 | } 159 | } 160 | 161 | extension EntityModelProtocol { 162 | func relationIds( 163 | _ keyPath: KeyPath> 164 | ) -> [Child.ID] { 165 | self[keyPath: keyPath].ids 166 | } 167 | 168 | func relation( 169 | _ keyPath: KeyPath> 170 | ) -> Relation { 171 | self[keyPath: keyPath] 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/__Snapshots__/AllNestedModelsQueryTest/test_WhenQueryWithNestedEntities_EqualExpectedJSON.1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attachment" : null, 4 | "author" : { 5 | "adminOf" : null, 6 | "chats" : null, 7 | "email" : "michael@gmail.com", 8 | "id" : "4", 9 | "isCurrent" : false, 10 | "name" : "Michael", 11 | "username" : "@michael" 12 | }, 13 | "chat" : { 14 | "admins" : null, 15 | "id" : "1", 16 | "messages" : null, 17 | "users" : null 18 | }, 19 | "id" : "0", 20 | "replies" : [ 21 | { 22 | "attachment" : null, 23 | "author" : null, 24 | "chat" : null, 25 | "id" : "1", 26 | "replies" : null, 27 | "replyTo" : null, 28 | "text" : "hello", 29 | "timestamp" : -63114076800, 30 | "viewedBy" : null 31 | }, 32 | { 33 | "attachment" : null, 34 | "author" : null, 35 | "chat" : null, 36 | "id" : "2", 37 | "replies" : null, 38 | "replyTo" : null, 39 | "text" : "howdy", 40 | "timestamp" : -63114076800, 41 | "viewedBy" : null 42 | }, 43 | { 44 | "attachment" : null, 45 | "author" : null, 46 | "chat" : null, 47 | "id" : "3", 48 | "replies" : null, 49 | "replyTo" : null, 50 | "text" : "yo!", 51 | "timestamp" : -63114076800, 52 | "viewedBy" : null 53 | }, 54 | { 55 | "attachment" : null, 56 | "author" : null, 57 | "chat" : null, 58 | "id" : "4", 59 | "replies" : null, 60 | "replyTo" : null, 61 | "text" : "wassap!", 62 | "timestamp" : -63114076800, 63 | "viewedBy" : null 64 | } 65 | ], 66 | "replyTo" : null, 67 | "text" : "hello, ya'll", 68 | "timestamp" : -63114076800, 69 | "viewedBy" : [ 70 | 71 | ] 72 | }, 73 | { 74 | "attachment" : null, 75 | "author" : { 76 | "adminOf" : null, 77 | "chats" : null, 78 | "email" : "alice@gmail.com", 79 | "id" : "2", 80 | "isCurrent" : false, 81 | "name" : "Alice", 82 | "username" : "@alice" 83 | }, 84 | "chat" : { 85 | "admins" : null, 86 | "id" : "1", 87 | "messages" : null, 88 | "users" : null 89 | }, 90 | "id" : "1", 91 | "replies" : [ 92 | 93 | ], 94 | "replyTo" : { 95 | "attachment" : null, 96 | "author" : null, 97 | "chat" : null, 98 | "id" : "0", 99 | "replies" : null, 100 | "replyTo" : null, 101 | "text" : "hello, ya'll", 102 | "timestamp" : -63114076800, 103 | "viewedBy" : null 104 | }, 105 | "text" : "hello", 106 | "timestamp" : -63114076800, 107 | "viewedBy" : [ 108 | 109 | ] 110 | }, 111 | { 112 | "attachment" : null, 113 | "author" : { 114 | "adminOf" : null, 115 | "chats" : null, 116 | "email" : "bob@gmail.com", 117 | "id" : "1", 118 | "isCurrent" : false, 119 | "name" : "Bob", 120 | "username" : "@bob" 121 | }, 122 | "chat" : { 123 | "admins" : null, 124 | "id" : "1", 125 | "messages" : null, 126 | "users" : null 127 | }, 128 | "id" : "2", 129 | "replies" : [ 130 | 131 | ], 132 | "replyTo" : { 133 | "attachment" : null, 134 | "author" : null, 135 | "chat" : null, 136 | "id" : "0", 137 | "replies" : null, 138 | "replyTo" : null, 139 | "text" : "hello, ya'll", 140 | "timestamp" : -63114076800, 141 | "viewedBy" : null 142 | }, 143 | "text" : "howdy", 144 | "timestamp" : -63114076800, 145 | "viewedBy" : [ 146 | 147 | ] 148 | }, 149 | { 150 | "attachment" : null, 151 | "author" : { 152 | "adminOf" : null, 153 | "chats" : null, 154 | "email" : "tom@gmail.com", 155 | "id" : "5", 156 | "isCurrent" : false, 157 | "name" : "Tom", 158 | "username" : "@tom" 159 | }, 160 | "chat" : { 161 | "admins" : null, 162 | "id" : "1", 163 | "messages" : null, 164 | "users" : null 165 | }, 166 | "id" : "3", 167 | "replies" : [ 168 | 169 | ], 170 | "replyTo" : { 171 | "attachment" : null, 172 | "author" : null, 173 | "chat" : null, 174 | "id" : "0", 175 | "replies" : null, 176 | "replyTo" : null, 177 | "text" : "hello, ya'll", 178 | "timestamp" : -63114076800, 179 | "viewedBy" : null 180 | }, 181 | "text" : "yo!", 182 | "timestamp" : -63114076800, 183 | "viewedBy" : [ 184 | 185 | ] 186 | }, 187 | { 188 | "attachment" : null, 189 | "author" : { 190 | "adminOf" : null, 191 | "chats" : null, 192 | "email" : "john@gmail.com", 193 | "id" : "3", 194 | "isCurrent" : false, 195 | "name" : "John", 196 | "username" : "@john" 197 | }, 198 | "chat" : { 199 | "admins" : null, 200 | "id" : "1", 201 | "messages" : null, 202 | "users" : null 203 | }, 204 | "id" : "4", 205 | "replies" : [ 206 | 207 | ], 208 | "replyTo" : { 209 | "attachment" : null, 210 | "author" : null, 211 | "chat" : null, 212 | "id" : "0", 213 | "replies" : null, 214 | "replyTo" : null, 215 | "text" : "hello, ya'll", 216 | "timestamp" : -63114076800, 217 | "viewedBy" : null 218 | }, 219 | "text" : "wassap!", 220 | "timestamp" : -63114076800, 221 | "viewedBy" : [ 222 | 223 | ] 224 | } 225 | ] -------------------------------------------------------------------------------- /Tests/SwiftletModelTests/QueriesTests/NestedModelsQueryTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Serge Kazakov on 02/08/2024. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import SwiftletModel 11 | import SnapshotTesting 12 | 13 | final class NestedModelsQueryTest: XCTestCase { 14 | var context = Context() 15 | 16 | override func setUpWithError() throws { 17 | 18 | let chat = Chat( 19 | id: "1", 20 | users: .relation([.bob, .alice, .tom, .john, .michael]), 21 | messages: .relation([ 22 | Message( 23 | id: "0", 24 | text: "hello, ya'll", 25 | author: .relation(.michael) 26 | ), 27 | 28 | Message( 29 | id: "1", 30 | text: "hello", 31 | author: .relation(.alice), 32 | replyTo: .id("0") 33 | ), 34 | 35 | Message( 36 | id: "2", 37 | text: "howdy", 38 | author: .relation(.bob), 39 | replyTo: .id("0") 40 | ), 41 | 42 | Message( 43 | id: "3", 44 | text: "yo!", 45 | author: .relation(.tom), 46 | replyTo: .id("0") 47 | ), 48 | 49 | Message( 50 | id: "4", 51 | text: "wassap!", 52 | author: .relation(.john), 53 | replyTo: .id("0") 54 | ) 55 | ]), 56 | admins: .relation([.bob]) 57 | ) 58 | 59 | try chat.save(to: &context) 60 | } 61 | 62 | func test_WhenQueryWithNestedModel_EqualExpectedJSON() { 63 | let encoder = JSONEncoder.prettyPrinting 64 | encoder.relationEncodingStrategy = .plain 65 | 66 | let messages = Message 67 | .query() 68 | .sorted(by: \.id) 69 | .with(\.$author) 70 | .resolve(in: context) 71 | 72 | assertSnapshot(of: messages, as: .json(encoder)) 73 | } 74 | 75 | func test_WhenQueryWithNestedModelId_EqualExpectedJSON() { 76 | let encoder = JSONEncoder.prettyPrinting 77 | encoder.relationEncodingStrategy = .plain 78 | 79 | let messages = Message 80 | .query() 81 | .sorted(by: \.id) 82 | .id(\.$author) 83 | .resolve(in: context) 84 | 85 | assertSnapshot(of: messages, as: .json(encoder)) 86 | } 87 | 88 | func test_WhenQueryWithNestedModelIds_EqualExpectedJSON() { 89 | let encoder = JSONEncoder.prettyPrinting 90 | encoder.relationEncodingStrategy = .plain 91 | 92 | let messages = Message 93 | .query() 94 | .sorted(by: \.id) 95 | .id(\.$replies) 96 | .resolve(in: context) 97 | 98 | assertSnapshot(of: messages, as: .json(encoder)) 99 | } 100 | 101 | func test_WhenQueryWithNestedModels_EqualExpectedJSON() { 102 | let encoder = JSONEncoder.prettyPrinting 103 | encoder.relationEncodingStrategy = .plain 104 | 105 | let messages = Message 106 | .query() 107 | .sorted(by: \.id) 108 | .with(\.$replies) { replies in 109 | replies 110 | .sorted(by: \.text.count) 111 | .filter(\.text.count > 3) 112 | .id(\.$replyTo) 113 | } 114 | .resolve(in: context) 115 | 116 | assertSnapshot(of: messages, as: .json(encoder)) 117 | } 118 | 119 | func test_WhenQueryWithNestedModelsAndFilter_EqualExpectedJSON() { 120 | let encoder = JSONEncoder.prettyPrinting 121 | encoder.relationEncodingStrategy = .plain 122 | 123 | let messages = Message 124 | .query() 125 | .sorted(by: \.id) 126 | .with(\.$replies) { replies in 127 | replies 128 | .sorted(by: \.text.count) 129 | .filter(\.text.count > 5) 130 | .id(\.$replyTo) 131 | } 132 | .resolve(in: context) 133 | 134 | assertSnapshot(of: messages, as: .json(encoder)) 135 | } 136 | 137 | func test_WhenQueryWithNestedModelsAndSort_EqualExpectedJSON() { 138 | let encoder = JSONEncoder.prettyPrinting 139 | encoder.relationEncodingStrategy = .plain 140 | 141 | let messages = Message 142 | .query() 143 | .sorted(by: \.id) 144 | .with(\.$replies) { replies in 145 | replies 146 | .sorted(by: \.text.count) 147 | .id(\.$replyTo) 148 | } 149 | .resolve(in: context) 150 | 151 | assertSnapshot(of: messages, as: .json(encoder)) 152 | } 153 | 154 | func test_WhenQueryWithNestedModelsSlice_EqualExpectedJSON() { 155 | let encoder = JSONEncoder.prettyPrinting 156 | encoder.relationEncodingStrategy = .explicitKeyedContainer 157 | 158 | let messages = Message 159 | .query() 160 | .sorted(by: \.id) 161 | .with(slice: \.$replies) { 162 | $0.id(\.$replyTo) 163 | } 164 | .resolve(in: context) 165 | 166 | assertSnapshot(of: messages, as: .json(encoder)) 167 | } 168 | 169 | func test_WhenQueryWithNestedIdsSlice_EqualExpectedJSON() { 170 | let encoder = JSONEncoder.prettyPrinting 171 | encoder.relationEncodingStrategy = .explicitKeyedContainer 172 | 173 | let messages = Message 174 | .query() 175 | .sorted(by: \.id) 176 | .id(slice: \.$replies) 177 | .resolve(in: context) 178 | 179 | assertSnapshot(of: messages, as: .json(encoder)) 180 | } 181 | } 182 | --------------------------------------------------------------------------------