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