diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5a877bb..b6e8af2 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,16 +12,19 @@ concurrency: cancel-in-progress: true env: - XCODE_VERSION: "16.3" + XCODE_VERSION: "26.1" jobs: prepare: - runs-on: macos-15 + runs-on: macos-26 outputs: platforms: ${{ steps.platforms.outputs.platforms }} scheme: ${{ steps.scheme.outputs.scheme }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + submodules: true + - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app @@ -31,6 +34,7 @@ jobs: run: | curl https://mise.run | sh mise install + - name: Run linters run: mise lint @@ -62,13 +66,16 @@ jobs: build-and-test: needs: prepare - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: platform: ${{ fromJSON(needs.prepare.outputs.platforms) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + submodules: true + - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app @@ -81,16 +88,16 @@ jobs: destination="platform=macOS,variant=Mac Catalyst" ;; ios) - destination="platform=iOS Simulator,name=iPhone 16 Pro Max,OS=latest" + destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.1" ;; tvos) - destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=latest" + destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.1" ;; watchos) - destination="platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=latest" + destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.1" ;; visionos) - destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=latest" + destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.1" ;; *) echo "Unknown platform: ${{ matrix.platform }}" @@ -109,7 +116,8 @@ jobs: set -o pipefail xcodebuild build \ -scheme ${{ needs.prepare.outputs.scheme }} \ - -destination "${{ steps.destination.outputs.destination }}" | \ + -destination "${{ steps.destination.outputs.destination }}" \ + -IDEPackageEnablePrebuilts=NO | \ xcbeautify --renderer github-actions - name: Test (SPM) @@ -129,11 +137,12 @@ jobs: set -o pipefail xcodebuild test \ -scheme ${{ needs.prepare.outputs.scheme }} \ - -destination "${{ steps.destination.outputs.destination }}" | \ + -destination "${{ steps.destination.outputs.destination }}" \ + -IDEPackageEnablePrebuilts=NO | \ xcbeautify --renderer github-actions - name: Check coverage (SPM) if: ${{ matrix.platform == 'macos' }} uses: codecov/codecov-action@v5 with: - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d109093..b076f96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,15 +6,17 @@ on: - '*' env: - XCODE_VERSION: "16.3" + XCODE_VERSION: "26.1" jobs: release: - runs-on: macos-15 + runs-on: macos-26 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: + submodules: true fetch-depth: 0 + - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_$XCODE_VERSION.app diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a3bcbd3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Macros/Dependencies/PrincipleMacros"] + path = Macros/Dependencies/PrincipleMacros + url = https://github.com/NSFatalError/PrincipleMacros diff --git a/.mise.toml b/.mise.toml index 513f2cf..62cb361 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,12 +1,10 @@ [vars] -sources = "Sources" -tests = "Tests" swiftlint = '~/.local/bin/mise x -- swiftlint' swiftformat = '~/.local/bin/mise x -- swiftformat' [tools] -swiftlint = "0.58.2" -swiftformat = "0.55.5" +swiftlint = "0.62.2" +swiftformat = "0.58.5" [tasks.lint] description = 'Run all linters' @@ -19,7 +17,7 @@ run = """ {{ vars.swiftlint }} lint \ --config .swiftlint.yml \ --strict \ -{{ vars.sources }} +Sources Macros """ [tasks."swiftlint:tests"] @@ -30,7 +28,7 @@ run = """ --config .swiftlint.yml \ --config .swiftlint.tests.yml \ --strict \ -{{ vars.tests }} +Tests """ [tasks.swiftformat] diff --git a/.spi.yml b/.spi.yml index f780de7..71282c6 100644 --- a/.spi.yml +++ b/.spi.yml @@ -2,4 +2,4 @@ version: 1 builder: configs: - documentation_targets: - - Publishable + - Relay diff --git a/.swift-version b/.swift-version index 5049538..913671c 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -6.0 \ No newline at end of file +6.2 \ No newline at end of file diff --git a/.swiftformat b/.swiftformat index 25c4fbb..85fe404 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,106 +1,125 @@ --acronyms ID,URL,UUID --allman false ---anonymousforeach convert ---assetliterals visual-width ---asynccapturing ---beforemarks ---binarygrouping 4,8 ---callsiteparen default ---categorymark "MARK: %c" ---classthreshold 0 ---closingparen default ---closurevoid remove ---commas inline ---complexattrs prev-line ---computedvarattrs prev-line ---condassignment always ---conflictmarkers reject ---dateformat system ---decimalgrouping 3,6 ---doccomments before-declarations ---elseposition same-line ---emptybraces no-space ---enumnamespaces always ---enumthreshold 0 ---exponentcase lowercase ---exponentgrouping disabled ---extensionacl on-declarations ---extensionlength 0 ---extensionmark "MARK: - %t + %c" ---fractiongrouping disabled +--allow-partial-wrapping true +--anonymous-for-each convert +--asset-literals visual-width +--async-capturing +--before-marks +--binary-grouping 4,8 +--blank-line-after-switch-case multiline-only +--call-site-paren default +--category-mark "MARK: %c" +--class-threshold 0 +--closing-paren default +--closure-void remove +--complex-attributes prev-line +--computed-var-attributes prev-line +--conditional-assignment always +--conflict-markers reject +--date-format system +--decimal-grouping 3,6 +--default-test-suite-attributes +--doc-comments before-declarations +--else-position same-line +--empty-braces no-space +--enum-namespaces always +--enum-threshold 0 +--equatable-macro none +--exponent-case lowercase +--exponent-grouping disabled +--extension-acl on-declarations +--extension-mark "MARK: - %t + %c" +--extension-threshold 0 +--file-macro "#file" +--fraction-grouping disabled --fragment false ---funcattributes prev-line ---generictypes ---groupblanklines true ---groupedextension "MARK: %c" ---guardelse next-line +--func-attributes prev-line +--generic-types +--group-blank-lines true +--grouped-extension "MARK: %c" +--guard-else next-line --header ignore ---hexgrouping 4,8 ---hexliteralcase uppercase +--hex-grouping 4,8 +--hex-literal-case uppercase --ifdef indent ---importgrouping testable-first +--import-grouping testable-first --indent 4 ---indentcase false ---indentstrings false ---inferredtypes always ---initcodernil false ---inlinedforeach ignore +--indent-case false +--indent-strings false +--inferred-types always +--init-coder-nil false --lifecycle ---lineaftermarks true +--line-after-marks true +--line-between-guards false --linebreaks lf ---markcategories true ---markextensions always ---marktypes always ---maxwidth none ---modifierorder ---nevertrailing ---nilinit remove ---noncomplexattrs ---nospaceoperators ---nowrapoperators ---octalgrouping 4,8 ---operatorfunc spaced ---organizationmode visibility ---organizetypes actor,class,enum,struct ---patternlet hoist ---preservedecls ---preservedsymbols Package ---propertytypes inferred +--mark-categories true +--mark-class-threshold 0 +--mark-enum-threshold 0 +--mark-extension-threshold 0 +--mark-extensions always +--mark-struct-threshold 0 +--mark-types always +--markdown-files ignore +--max-width none +--modifier-order +--never-trailing +--nil-init remove +--no-space-operators +--no-wrap-operators +--non-complex-attributes +--octal-grouping 4,8 +--operator-func spaced +--organization-mode visibility +--organize-types actor,class,enum,struct +--pattern-let hoist +--preserve-acronyms +--preserve-decls +--preserved-property-types Package +--property-types inferred --ranges spaced +--redundant-async always +--redundant-throws always --self init-only ---selfrequired ---semicolons inline ---shortoptionals always ---smarttabs enabled ---someany true ---sortedpatterns ---storedvarattrs prev-line ---stripunusedargs always ---structthreshold 0 ---tabwidth unspecified ---throwcapturing +--self-required +--semicolons inline-only +--short-optionals always +--single-line-for-each ignore +--smart-tabs enabled +--some-any true +--sort-swiftui-properties none +--sorted-patterns +--stored-var-attributes prev-line +--strip-unused-args always +--struct-threshold 0 +--tab-width unspecified +--throw-capturing --timezone system ---trailingclosures ---trimwhitespace always ---typeattributes prev-line ---typeblanklines preserve ---typedelimiter space-after ---typemark "MARK: - %t" ---typemarks ---typeorder ---visibilitymarks ---visibilityorder ---voidtype void ---wraparguments before-first ---wrapcollections before-first ---wrapconditions after-first ---wrapeffects preserve ---wrapenumcases always ---wrapparameters before-first ---wrapreturntype preserve ---wrapternary before-operators ---wraptypealiases after-first ---xcodeindentation enabled ---yodaswap always ---disable enumNamespaces,fileHeader,headerFileName,redundantInternal,wrap,wrapMultilineStatementBraces,wrapSingleLineComments ---enable acronyms,blankLinesBetweenImports,blockComments,docComments,isEmpty,propertyTypes,redundantProperty,sortSwitchCases,unusedPrivateDeclarations,wrapConditionalBodies,wrapEnumCases +--trailing-closures +--trailing-commas never +--trim-whitespace always +--type-attributes prev-line +--type-blank-lines preserve +--type-body-marks preserve +--type-delimiter space-after +--type-mark "MARK: - %t" +--type-marks +--type-order +--url-macro none +--visibility-marks +--visibility-order +--void-type Void +--wrap-arguments before-first +--wrap-collections before-first +--wrap-conditions after-first +--wrap-effects preserve +--wrap-enum-cases always +--wrap-parameters before-first +--wrap-return-type preserve +--wrap-string-interpolation default +--wrap-ternary before-operators +--wrap-type-aliases after-first +--xcode-indentation enabled +--xctest-symbols +--yoda-swap always +--disable fileHeader,headerFileName,redundantInternal,wrap,wrapMultilineStatementBraces,wrapSingleLineComments +--enable acronyms,blankLinesBetweenImports,blockComments,docComments,emptyExtensions,environmentEntry,isEmpty,noForceTryInTests,noForceUnwrapInTests,noGuardInTests,propertyTypes,redundantAsync,redundantMemberwiseInit,redundantProperty,redundantThrows,singlePropertyPerLine,sortSwitchCases,unusedPrivateDeclarations,wrapConditionalBodies,wrapEnumCases,wrapMultilineFunctionChains diff --git a/.swiftlint.yml b/.swiftlint.yml index f035d14..5bc3ecb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -7,7 +7,7 @@ opt_in_rules: - accessibility_trait_for_button - anonymous_argument_in_multiline_closure - array_init - # async_without_await - not recognized + - async_without_await # attributes # balanced_xctest_lifecycle # closure_body_length @@ -21,7 +21,7 @@ opt_in_rules: - contains_over_first_not_nil - contains_over_range_nil_comparison # contrasted_opening_brace - # convenience_type - not working with Testing framework + # convenience_type - direct_return - discarded_notification_center_observer - discouraged_assert @@ -56,6 +56,7 @@ opt_in_rules: - identical_operands - implicit_return # implicitly_unwrapped_optional + # incompatible_concurrency_annotation # indentation_width - joined_default_parameter - last_where @@ -66,7 +67,7 @@ opt_in_rules: - local_doc_comment - lower_acl_than_parent # missing_docs - # modifier_order + - modifier_order - multiline_arguments - multiline_arguments_brackets - multiline_function_chains @@ -90,6 +91,8 @@ opt_in_rules: - override_in_extension - pattern_matching_keywords - period_spacing + - prefer_asset_symbols + - prefer_condition_list - prefer_key_path # prefer_nimble - prefer_self_in_static_references @@ -123,7 +126,7 @@ opt_in_rules: - static_operator # strict_fileprivate - strong_iboutlet - - superfluous_else + # superfluous_else - switch_case_on_newline - test_case_accessibility - toggle_bool @@ -154,17 +157,20 @@ file_header: required_pattern: | // // SWIFTLINT_CURRENT_FILENAME - // Publishable + // Relay // // Created by .+ on \d{2}/\d{2}/\d{4}\. // Copyright © \d{4} .+\. All rights reserved\. // +excluded: + - Macros/Dependencies + file_length: warning: 500 identifier_name: - excluded: [id, x, y, z] + excluded: [id, ui, x, y, z, dx, dy, dz] line_length: ignores_comments: true @@ -172,6 +178,9 @@ line_length: nesting: type_level: 2 +no_magic_numbers: + allowed_numbers: [0.0, 1.0, 2.0, 100.0] + type_name: allowed_symbols: ["_"] max_length: 50 @@ -183,11 +192,11 @@ custom_rules: global_actor_attribute_order: name: "Global actor attribute order" message: "Global actor should be the first attribute." - regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor)" + regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor\\s)" sendable_attribute_order: name: "Sendable attribute order" message: "Sendable should be the first attribute." - regex: "(?-s)(@.+[^,\\s]\\s+@Sendable)" + regex: "(?-s)(@.+[^,\\s]\\s+@Sendable\\s)" autoclosure_attribute_order: name: "Autoclosure attribute order" message: "Autoclosure should be the last attribute." diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros new file mode 160000 index 0000000..85aab93 --- /dev/null +++ b/Macros/Dependencies/PrincipleMacros @@ -0,0 +1 @@ +Subproject commit 85aab93496550f03b8888a96598fe06ad7685a53 diff --git a/Sources/PublishableMacros/Main/PublishablePlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift similarity index 56% rename from Sources/PublishableMacros/Main/PublishablePlugin.swift rename to Macros/RelayMacros/Main/RelayPlugin.swift index 3f15e31..9da9a12 100644 --- a/Sources/PublishableMacros/Main/PublishablePlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -1,18 +1,19 @@ // -// PublishablePlugin.swift -// Publishable +// RelayPlugin.swift +// Relay // // Created by Kamil Strzelecki on 11/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros import SwiftCompilerPlugin +import SwiftSyntaxMacros @main -internal struct PublishablePlugin: CompilerPlugin { +internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ - PublishableMacro.self + PublishableMacro.self, + MemoizedMacro.self ] } diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift new file mode 100644 index 0000000..e889b5e --- /dev/null +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -0,0 +1,97 @@ +// +// MemoizedDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 14/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + let propertyName: String + + let lexicalContext: [Syntax] + let preferredAccessControlLevel: AccessControlLevel? + let preferredGlobalActorIsolation: GlobalActorIsolation? + + func build() -> [DeclSyntax] { + [ + """ + \(inheritedGlobalActorIsolation)private \ + var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil + """, + """ + \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)\ + var \(raw: propertyName): \(trimmedReturnType) { + if let cached = _\(raw: propertyName) { + access(keyPath: \\._\(raw: propertyName)) + return cached + } + + nonisolated(unsafe) weak var instance = self + + \(assumeIsolatedIfNeededFunction()) + + \(invalidateCacheFunction()) + + \(observationTrackingBlock()) + } + """ + ] + } + + private func observationTrackingBlock() -> CodeBlockItemSyntax { + """ + return withObservationTracking { + let result = \(declaration.name.trimmed)() + _\(raw: propertyName) = result + return result + } onChange: { + invalidateCache() + } + """ + } + + private func invalidateCacheFunction() -> CodeBlockItemSyntax { + """ + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \\._\(raw: propertyName)) { + instance?._\(raw: propertyName) = nil + } + } + } + """ + } + + private func assumeIsolatedIfNeededFunction() -> CodeBlockItemSyntax { + if let globalActor = inheritedGlobalActorIsolation?.standardizedIsolationType { + // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift + """ + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @\(globalActor) () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + \(globalActor).shared.assumeIsolated { _ in + rawOperation() + } + } + } + """ + } else { + """ + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + """ + } + } +} diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift new file mode 100644 index 0000000..c8d66fa --- /dev/null +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -0,0 +1,156 @@ +// +// MemoizedMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 14/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +public enum MemoizedMacro { + + private struct Input { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + let propertyName: String + } + + private static func validate( + _ declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext, + with parameters: Parameters + ) -> Input? { + guard let declaration = declaration.as(FunctionDeclSyntax.self), + let trimmedReturnType = trimmedReturnType(of: declaration), + declaration.signature.parameterClause.parameters.isEmpty, + declaration.signature.effectSpecifiers == nil, + declaration.typeScopeSpecifier == nil + else { + context.diagnose( + node: declaration, + errorMessage: """ + Memoized macro can only be applied to non-void, non-async, non-throwing \ + methods that don't take any arguments + """ + ) + return nil + } + + guard let scope = context.lexicalContext.first?.as(ClassDeclSyntax.self), + scope.attributes.contains(likeOneOf: "@Observable", "@Model") + else { + context.diagnose( + node: declaration, + errorMessage: """ + Memoized macro can only be applied to methods declared in body (not extension) \ + of @Observable or @Model classes + """ + ) + return nil + } + + let propertyName = validatePropertyName( + for: declaration, + in: context, + preferred: parameters.preferredPropertyName + ) + + guard let propertyName else { + return nil + } + + return Input( + declaration: declaration, + trimmedReturnType: trimmedReturnType, + propertyName: propertyName + ) + } + + private static func validatePropertyName( + for declaration: FunctionDeclSyntax, + in context: some MacroExpansionContext, + preferred: String? + ) -> String? { + if let preferred { + guard !preferred.isEmpty else { + context.diagnose( + node: declaration, + errorMessage: "Memoized macro requires a non-empty property name" + ) + return nil + } + return preferred + } + + let inferred = defaultPropertyName(for: declaration) + guard !inferred.isEmpty else { + context.diagnose( + node: declaration, + errorMessage: """ + Memoized macro requires a method name with at least two words \ + or explicit property name + """ + ) + return nil + } + + return inferred + } + + static func defaultPropertyName(for declaration: FunctionDeclSyntax) -> String { + let functionName = declaration.name.trimmedDescription + var notation = CamelCaseNotation(string: functionName) + notation.removeFirst() + return notation.joined(as: .lowerCamelCase) + } + + static func trimmedReturnType(of declaration: FunctionDeclSyntax) -> TypeSyntax? { + declaration.signature.returnClause?.type.trimmed + } +} + +extension MemoizedMacro: PeerMacro { + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + let parameters = try Parameters(from: node) + let input = validate(declaration, in: context, with: parameters) + + guard let input else { + return [] + } + + let builder = MemoizedDeclBuilder( + declaration: input.declaration, + trimmedReturnType: input.trimmedReturnType, + propertyName: input.propertyName, + lexicalContext: context.lexicalContext, + preferredAccessControlLevel: parameters.preferredAccessControlLevel, + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + ) + + return builder.build() + } +} + +extension MemoizedMacro { + + struct Parameters { + + let preferredAccessControlLevel: AccessControlLevel? + let preferredPropertyName: String? + let preferredGlobalActorIsolation: GlobalActorIsolation? + + init(from node: AttributeSyntax) throws { + let extractor = ParameterExtractor(from: node) + self.preferredAccessControlLevel = try extractor.accessControlLevel(withLabel: nil) + self.preferredPropertyName = try extractor.rawString(withLabel: nil) + self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") + } + } +} diff --git a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift new file mode 100644 index 0000000..f0551e0 --- /dev/null +++ b/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift @@ -0,0 +1,194 @@ +// +// ObservationRegistrarDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 14/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { + + let declaration: ClassDeclSyntax + let properties: PropertiesList + let preferredGlobalActorIsolation: GlobalActorIsolation? + + private var registeredProperties: PropertiesList { + properties.stored.mutable.instance + } + + func build() -> [DeclSyntax] { + [ + """ + private enum Observation { + + struct ObservationRegistrar: \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + \(publishNewValueFunction()) + + \(subjectFunctions().formatted()) + + \(observationRegistrarWillSetDidSetAccessFunctions()) + + \(observationRegistrarWithMutationFunction()) + + \(assumeIsolatedIfNeededFunction()) + } + } + """ + ] + } + + private func publishNewValueFunction() -> MemberBlockItemListSyntax { + """ + \(inheritedGlobalActorIsolation)func publish( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + \(publishNewValueKeyPathCasting().formatted()) + } + """ + } + + @CodeBlockItemListBuilder + private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { + for inferredType in registeredProperties.uniqueInferredTypes { + """ + if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + """ + } + } + + @MemberBlockItemListBuilder + private func subjectFunctions() -> MemberBlockItemListSyntax { + for inferredType in registeredProperties.uniqueInferredTypes { + """ + \(inheritedGlobalActorIsolation)private func subject( + for keyPath: KeyPath<\(trimmedType), \(inferredType)>, + on object: \(trimmedType) + ) -> PassthroughSubject<\(inferredType), Never>? { + \(subjectKeyPathCasting(for: inferredType).formatted()) + } + """ + } + } + + @CodeBlockItemListBuilder + private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { + for property in registeredProperties.withInferredType(like: inferredType).all { + let name = property.trimmedName + """ + if keyPath == \\.\(name) { + return object.publisher._\(name) + } + """ + } + """ + return nil + """ + } + + private func observationRegistrarWillSetDidSetAccessFunctions() -> MemberBlockItemListSyntax { + """ + nonisolated func willSet( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any> + ) { + underlying.access(object, keyPath: keyPath) + } + """ + } + + private func observationRegistrarWithMutationFunction() -> MemberBlockItemListSyntax { + """ + nonisolated func withMutation( + of object: \(trimmedType), + keyPath: KeyPath<\(trimmedType), some Any>, + _ mutation: () throws -> T + ) rethrows -> T { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: T! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + """ + } + + private func assumeIsolatedIfNeededFunction() -> MemberBlockItemListSyntax { + if let globalActor = inheritedGlobalActorIsolation?.standardizedIsolationType { + // https://github.com/swiftlang/swift/blob/main/stdlib/public/Concurrency/MainActor.swift + """ + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @\(globalActor) () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try \(globalActor).shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + """ + } else { + """ + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + """ + } + } +} diff --git a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift new file mode 100644 index 0000000..de0564d --- /dev/null +++ b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift @@ -0,0 +1,99 @@ +// +// PropertyPublisherDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { + + let declaration: ClassDeclSyntax + let properties: PropertiesList + let preferredGlobalActorIsolation: GlobalActorIsolation? + + func build() -> [DeclSyntax] { + [ + """ + \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedType)> { + + \(deinitializer()) + + \(storedPropertiesPublishers().formatted()) + + \(computedPropertiesPublishers().formatted()) + + \(memoizedPropertiesPublishers().formatted()) + } + """ + ] + } + + private func deinitializer() -> MemberBlockItemListSyntax { + """ + deinit { + \(storedPropertiesPublishersFinishCalls().formatted()) + } + """ + } + + @CodeBlockItemListBuilder + private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { + for property in properties.stored.mutable.instance.all { + "_\(property.trimmedName).send(completion: .finished)" + } + } + + @MemberBlockItemListBuilder + private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { + for property in properties.stored.mutable.instance.all { + let globalActor = inheritedGlobalActorIsolation + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) + let name = property.trimmedName + let type = property.inferredType + """ + fileprivate let _\(name) = PassthroughSubject<\(type), Never>() + \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { + _storedPropertyPublisher(_\(name), for: \\.\(name)) + } + """ + } + } + + @MemberBlockItemListBuilder + private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { + for property in properties.computed.instance.all { + let globalActor = inheritedGlobalActorIsolation + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) + let name = property.trimmedName + let type = property.inferredType + """ + \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(name)) + } + """ + } + } + + @MemberBlockItemListBuilder + private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { + for member in declaration.memberBlock.members { + if let functionDecl = member.decl.as(FunctionDeclSyntax.self), + let attribute = functionDecl.attributes.first(like: "@Memoized"), + let parameters = try? MemoizedMacro.Parameters(from: attribute), + let trimmedReturnType = MemoizedMacro.trimmedReturnType(of: functionDecl) { + let globalActor = parameters.preferredGlobalActorIsolation ?? inheritedGlobalActorIsolation + let accessControlLevel = parameters.preferredAccessControlLevel + let name = parameters.preferredPropertyName ?? MemoizedMacro.defaultPropertyName(for: functionDecl) + let type = trimmedReturnType + """ + \(globalActor)\(accessControlLevel)var \(raw: name): AnyPublisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(raw: name)) + } + """ + } + } + } +} diff --git a/Sources/PublishableMacros/Main/PublishableMacro.swift b/Macros/RelayMacros/Publishable/PublishableMacro.swift similarity index 52% rename from Sources/PublishableMacros/Main/PublishableMacro.swift rename to Macros/RelayMacros/Publishable/PublishableMacro.swift index 81fd3a8..a8368d2 100644 --- a/Sources/PublishableMacros/Main/PublishableMacro.swift +++ b/Macros/RelayMacros/Publishable/PublishableMacro.swift @@ -1,12 +1,12 @@ // // PublishableMacro.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import PrincipleMacros +import SwiftSyntaxMacros public enum PublishableMacro { @@ -14,7 +14,7 @@ public enum PublishableMacro { _ declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) -> ClassDeclSyntax? { - guard let declaration = declaration as? ClassDeclSyntax, + guard let declaration = declaration.as(ClassDeclSyntax.self), declaration.attributes.contains(likeOneOf: "@Observable", "@Model"), declaration.isFinal else { @@ -31,7 +31,7 @@ public enum PublishableMacro { extension PublishableMacro: MemberMacro { public static func expansion( - of _: AttributeSyntax, + of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo _: [TypeSyntax], in context: some MacroExpansionContext @@ -40,15 +40,27 @@ extension PublishableMacro: MemberMacro { return [] } + let parameters = try Parameters(from: node) let properties = PropertiesParser.parse( memberBlock: declaration.memberBlock, in: context ) let builderTypes: [any ClassDeclBuilder] = [ - PublisherDeclBuilder(declaration: declaration, properties: properties), - PropertyPublisherDeclBuilder(declaration: declaration, properties: properties), - ObservationRegistrarDeclBuilder(declaration: declaration, properties: properties) + PublisherDeclBuilder( + declaration: declaration, + properties: properties + ), + PropertyPublisherDeclBuilder( + declaration: declaration, + properties: properties, + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + ), + ObservationRegistrarDeclBuilder( + declaration: declaration, + properties: properties, + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + ) ] return try builderTypes.flatMap { builderType in @@ -60,22 +72,33 @@ extension PublishableMacro: MemberMacro { extension PublishableMacro: ExtensionMacro { public static func expansion( - of _: AttributeSyntax, + of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, conformingTo _: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard validate(declaration, in: context) != nil else { + guard let declaration = validate(declaration, in: context) else { return [] } + let parameters = try Parameters(from: node) + let globalActorIsolation = GlobalActorIsolation.resolved( + for: declaration, + preferred: parameters.preferredGlobalActorIsolation + ) + return [ .init( extendedType: type, inheritanceClause: .init( inheritedTypes: [ - .init(type: IdentifierTypeSyntax(name: "Publishable")) + InheritedTypeSyntax( + type: AttributedTypeSyntax( + globalActorIsolation: globalActorIsolation, + baseType: IdentifierTypeSyntax(name: "Publishable") + ) + ) ] ), memberBlock: "{}" @@ -83,3 +106,16 @@ extension PublishableMacro: ExtensionMacro { ] } } + +extension PublishableMacro { + + private struct Parameters { + + let preferredGlobalActorIsolation: GlobalActorIsolation? + + init(from node: AttributeSyntax) throws { + let extractor = ParameterExtractor(from: node) + self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") + } + } +} diff --git a/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift new file mode 100644 index 0000000..c5c70c4 --- /dev/null +++ b/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift @@ -0,0 +1,30 @@ +// +// PublisherDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { + + let declaration: ClassDeclSyntax + let properties: PropertiesList + + func build() -> [DeclSyntax] { + [ + """ + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// + \(inheritedAccessControlLevel)private(set) lazy var publisher = PropertyPublisher(object: self) + """ + ] + } +} diff --git a/Package.resolved b/Package.resolved index f1e00b6..022f6eb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,22 +1,13 @@ { - "originHash" : "562722fee5c55759fdc591bf9e50bbc7e84d94e5f50c2034e511205e38982b5d", + "originHash" : "517cc7d6013773868fbf68195ad5536cab25006a94a1feafa5e9044137ac23d1", "pins" : [ - { - "identity" : "principlemacros", - "kind" : "remoteSourceControl", - "location" : "https://github.com/NSFatalError/PrincipleMacros", - "state" : { - "revision" : "b2671db08bc28ee2336bd33517a524de4abeb92a", - "version" : "1.0.6" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } } ], diff --git a/Package.swift b/Package.swift index 3b07436..f0dd9f2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport import PackageDescription let package = Package( - name: "Publishable", + name: "Relay", platforms: [ .macOS(.v14), .macCatalyst(.v17), @@ -16,46 +16,47 @@ let package = Package( ], products: [ .library( - name: "Publishable", - targets: ["Publishable"] + name: "Relay", + targets: ["Relay"] ) ], dependencies: [ - .package( - url: "https://github.com/NSFatalError/PrincipleMacros", - from: "1.0.6" - ), .package( url: "https://github.com/swiftlang/swift-syntax", - "600.0.0" ..< "602.0.0" + "602.0.0" ..< "603.0.0" ) ], targets: [ .target( - name: "Publishable", - dependencies: ["PublishableMacros"] + name: "Relay", + dependencies: ["RelayMacros"] ), .testTarget( - name: "PublishableTests", - dependencies: ["Publishable"] + name: "RelayTests", + dependencies: ["Relay"] ), .macro( - name: "PublishableMacros", + name: "RelayMacros", dependencies: [ .product( - name: "PrincipleMacros", - package: "PrincipleMacros" + name: "SwiftSyntaxMacros", + package: "swift-syntax" ), .product( name: "SwiftCompilerPlugin", package: "swift-syntax" ) + ], + path: "Macros", + sources: [ + "RelayMacros/", + "Dependencies/PrincipleMacros/Sources/PrincipleMacros/" ] ), .testTarget( - name: "PublishableMacrosTests", + name: "RelayMacrosTests", dependencies: [ - "PublishableMacros", + "RelayMacros", .product( name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax" @@ -68,6 +69,8 @@ let package = Package( for target in package.targets { target.swiftSettings = (target.swiftSettings ?? []) + [ .swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny") + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault") ] } diff --git a/README.md b/README.md index 08dcc0f..ded2b40 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,35 @@ -# Publishable +# Relay -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/NSFatalError/Publishable) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FPublishable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/NSFatalError/Publishable) -[![Codecov](https://codecov.io/github/NSFatalError/Publishable/graph/badge.svg?token=axMe8BnuvB)](https://codecov.io/github/NSFatalError/Publishable) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FRelay%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/NSFatalError/Relay) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FNSFatalError%2FRelay%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/NSFatalError/Relay) +[![Codecov](https://codecov.io/gh/NSFatalError/Relay/graph/badge.svg?token=axMe8BnuvB)](https://codecov.io/gh/NSFatalError/Relay) -Synchronous observation of `Observable` changes through `Combine` +Essential tools that extend the capabilities of `Observation`. #### Contents -- [What Problem Publishable Solves?](#what-problem-publishable-solves) -- [How Publishable Works?](#how-publishable-works) +- [Publishable](#publishable) +- [Memoized](#memoized) - [Documentation](#documentation) - [Installation](#installation) -## What Problem Publishable Solves? +## Publishable -With the introduction of [SE-0475: Transactional Observation of Values](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0475-observed.md), -Swift gains built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, as it -publishes the updates via an `AsyncSequence`. +
+ Observe changes to Observable types synchronously with Combine. +
+ +With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), +Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, +as it publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. -This is where `Publishable` comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you take advantage of the latest -`Observable` features, while processing changes synchronously when needed. It even works with the `SwiftData.Model` macro! +This is where `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates +with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: ```swift -import Publishable +import Relay @Publishable @Observable final class Person { @@ -58,30 +63,116 @@ person.surname = "Strzelecki" // Full name - Kamil Strzelecki ``` -## How Publishable Works? +### How Publishable Works? The `@Publishable` macro relies on two key properties of Swift Macros and `Observation` module: - Macro expansions are compiled in the context of the module where they’re used. This allows references in the macro to be overloaded by locally available symbols. - Swift exposes `ObservationRegistrar` as a documented, public API, making it possible to use it safely and directly. -`Publishable` leverages these facts to overload the default `ObservationRegistrar` with a custom one that: +By leveraging these facts, the `@Publishable` macro can overload the default `ObservationRegistrar` with a custom one that: - Forwards changes to Swift’s native `ObservationRegistrar` - Simultaneously emits values through generated `Combine` publishers While I acknowledge that this usage might not have been intended by the authors, I would refrain from calling it a hack. It relies solely on well-understood behaviors of Swift and its public APIs. -This approach has been carefully tested and verified to work with both `Observable` and `SwiftData.Model` macros. +This approach has been carefully tested and verified to work with both `@Observable` and `@Model` macros. + +
+ +## Memoized + +
+ Perform expensive computations lazily and cache their outputs until Observable inputs change. +
+ +Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. +However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial +and therefore call them repeatedly. + +With the conveniences afforded by `SwiftUI` and `Observation`, it’s easy to fall into this trap by performing expensive computations, +like mapping or filtering a collection, every time `View.body` is accessed: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + var filteredData: [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute +``` + +In the example above, it’s clear that we could save computing resources on repeated access to `filteredData` +when both `query` and `data` remain unchanged. The `@Memoized` macro allows you to do exactly that +by automatically caching and updating values derived from their underlying `Observable` inputs. + +To use it, refactor your computed property into a method and apply the `@Memoized` macro to it: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + @Memoized("filteredData") + private func filterData() -> [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData +``` + +
## Documentation -[Full documentation is available on the Swift Package Index.](https://swiftpackageindex.com/NSFatalError/Publishable/documentation/publishable) +[Full documentation is available on the Swift Package Index.](https://swiftpackageindex.com/NSFatalError/Relay/documentation/relay) ## Installation ```swift .package( - url: "https://github.com/NSFatalError/Publishable", - from: "1.0.0" + url: "https://github.com/NSFatalError/Relay", + from: "2.0.0" ) ``` diff --git a/Sources/Publishable/Documentation.docc/Publishable.md b/Sources/Publishable/Documentation.docc/Publishable.md deleted file mode 100644 index ad46d0c..0000000 --- a/Sources/Publishable/Documentation.docc/Publishable.md +++ /dev/null @@ -1,14 +0,0 @@ -# ``Publishable-module`` - -Observe changes to `Observable` types synchronously with `Combine`. - -## Topics - -### Making Types Publishable - -- ``Publishable()`` -- ``Publishable-protocol`` - -### Getting Property Publishers - -- ``AnyPropertyPublisher`` diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift deleted file mode 100644 index cc82b65..0000000 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Publishable.swift -// Publishable -// -// Created by Kamil Strzelecki on 12/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import Observation - -/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. -/// -/// - Note: This macro works only with `final` classes to which the `Observable` or `SwiftData.Model` macro has been applied directly. -/// -/// The `Publishable` macro adds a new ``Publishable/publisher`` property to your type, -/// which exposes `Combine` publishers for all mutable instance properties - both stored and computed. -/// -/// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. -/// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. -/// -/// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. -/// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. -/// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. -/// -@attached( - member, - names: named(_publisher), - named(publisher), - named(PropertyPublisher), - named(Observation) -) -@attached( - extension, - conformances: Publishable -) -public macro Publishable() = #externalMacro( - module: "PublishableMacros", - type: "PublishableMacro" -) - -/// A type that can be observed using both the `Observation` and `Combine` frameworks. -/// -/// You don't need to declare conformance to this protocol yourself. -/// It is generated automatically when you apply the ``Publishable()`` macro to your type. -/// -public protocol Publishable: AnyObject, Observable { - - /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, - /// containing publishers for all mutable instance properties of the type. - /// - associatedtype PropertyPublisher: AnyPropertyPublisher - - /// An instance that exposes `Combine` publishers for all mutable instance properties of the type. - /// - /// - Important: Don't store this instance in an external property. Accessing it after the original object has been deallocated - /// may result in a crash. Always access it directly through the object that exposes it. - /// - var publisher: PropertyPublisher { get } -} diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift deleted file mode 100644 index 245bf25..0000000 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// PublishableObservationRegistrar.swift -// Publishable -// -// Created by Kamil Strzelecki on 18/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import Observation - -@_documentation(visibility: private) -public protocol PublishableObservationRegistrar { - - associatedtype Object: Publishable, Observable - - var underlying: SwiftObservationRegistrar { get } - - func publish( - _ object: Object, - keyPath: KeyPath - ) -} - -extension PublishableObservationRegistrar { - - public func willSet( - _ object: Object, - keyPath: KeyPath - ) { - object.publisher.beginModifications() - underlying.willSet(object, keyPath: keyPath) - } - - public func didSet( - _ object: Object, - keyPath: KeyPath - ) { - underlying.didSet(object, keyPath: keyPath) - publish(object, keyPath: keyPath) - object.publisher.endModifications() - } - - public func access( - _ object: Object, - keyPath: KeyPath - ) { - underlying.access(object, keyPath: keyPath) - } - - public func withMutation( - of object: Object, - keyPath: KeyPath, - _ mutation: () throws -> T - ) rethrows -> T { - object.publisher.beginModifications() - let result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) - publish(object, keyPath: keyPath) - object.publisher.endModifications() - return result - } -} diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift deleted file mode 100644 index 9c5af0c..0000000 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// ObservationRegistrarDeclBuilder.swift -// Publishable -// -// Created by Kamil Strzelecki on 14/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import PrincipleMacros - -internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { - - let declaration: ClassDeclSyntax - let properties: PropertiesList - - var settings: DeclBuilderSettings { - .init(accessControlLevel: .init(inheritingDeclaration: .member)) - } - - private var registeredProperties: PropertiesList { - properties.stored.mutable.instance - } - - func build() -> [DeclSyntax] { - [ - """ - private enum Observation { - - struct ObservationRegistrar: PublishableObservationRegistrar { - - let underlying = SwiftObservationRegistrar() - - \(publishNewValueFunction()) - - \(subjectFunctions().formatted()) - } - } - """ - ] - } - - private func publishNewValueFunction() -> MemberBlockItemListSyntax { - """ - func publish( - _ object: \(trimmedTypeName), - keyPath: KeyPath<\(trimmedTypeName), some Any> - ) { - \(publishNewValueKeyPathCasting().formatted()) - } - """ - } - - @CodeBlockItemListBuilder - private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { - for inferredType in registeredProperties.uniqueInferredTypes { - """ - if let keyPath = keyPath as? KeyPath<\(trimmedTypeName), \(inferredType)>, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - """ - } - """ - assertionFailure("Unknown keyPath: \\(keyPath)") - """ - } - - @MemberBlockItemListBuilder - private func subjectFunctions() -> MemberBlockItemListSyntax { - for inferredType in registeredProperties.uniqueInferredTypes { - """ - private func subject( - for keyPath: KeyPath<\(trimmedTypeName), \(inferredType)>, - on object: \(trimmedTypeName) - ) -> PassthroughSubject<\(inferredType), Never>? { - \(subjectKeyPathCasting(for: inferredType).formatted()) - } - """ - } - } - - @CodeBlockItemListBuilder - private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in registeredProperties.withInferredType(like: inferredType) { - let name = property.trimmedName - """ - if keyPath == \\.\(name) { - return object.publisher._\(name) - } - """ - } - """ - return nil - """ - } -} diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift deleted file mode 100644 index fecdf5c..0000000 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// PropertyPublisherDeclBuilder.swift -// Publishable -// -// Created by Kamil Strzelecki on 12/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import PrincipleMacros - -internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { - - let declaration: ClassDeclSyntax - let properties: PropertiesList - - var settings: DeclBuilderSettings { - .init(accessControlLevel: .init(inheritingDeclaration: .member)) - } - - func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order - [ - """ - \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { - - \(deinitializer()) - - \(storedPropertiesPublishers().formatted()) - - \(computedPropertiesPublishers().formatted()) - } - """ - ] - } - - private func deinitializer() -> MemberBlockItemListSyntax { - """ - deinit { - \(storedPropertiesPublishersFinishCalls().formatted()) - } - """ - } - - @CodeBlockItemListBuilder - private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { - for property in properties.stored.mutable.instance { - "_\(property.trimmedName).send(completion: .finished)" - } - } - - @MemberBlockItemListBuilder - private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.stored.mutable.instance { - let accessControlLevel = property.declaration.accessControlLevel(inheritedBy: .peer, maxAllowed: .public) - let name = property.trimmedName - let type = property.inferredType - """ - fileprivate let _\(name) = PassthroughSubject<\(type), Never>() - \(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { - _storedPropertyPublisher(_\(name), for: \\.\(name)) - } - """ - } - } - - @MemberBlockItemListBuilder - private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.computed.instance { - let accessControlLevel = property.declaration.accessControlLevel(inheritedBy: .peer, maxAllowed: .public) - let name = property.trimmedName - let type = property.inferredType - """ - \(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(name)) - } - """ - } - } -} diff --git a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift deleted file mode 100644 index 1e71534..0000000 --- a/Sources/PublishableMacros/Builders/PublisherDeclBuilder.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PublisherDeclBuilder.swift -// Publishable -// -// Created by Kamil Strzelecki on 12/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import PrincipleMacros - -internal struct PublisherDeclBuilder: ClassDeclBuilder { - - let declaration: ClassDeclSyntax - let properties: PropertiesList - - var settings: DeclBuilderSettings { - .init(accessControlLevel: .init(inheritingDeclaration: .member)) - } - - func build() -> [DeclSyntax] { - [ - """ - \(inheritedAccessControlLevel)private(set) lazy var publisher = PropertyPublisher(object: self) - """ - ] - } -} diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md new file mode 100644 index 0000000..237e060 --- /dev/null +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -0,0 +1,92 @@ +# Memoized + +Perform expensive computations lazily and cache their outputs until `Observable` inputs change. + +## Overview + +Computed properties in Swift are a great way of getting an always-up-to-date values derived from other properties of a type. +However, they can also hide away computational complexity from the caller, who might assume that accessing them is trivial +and therefore call them repeatedly. + +With the conveniences afforded by `SwiftUI` and `Observation`, it’s easy to fall into this trap by performing expensive computations, +like mapping or filtering a collection, every time `View.body` is accessed: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + var filteredData: [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData // Prints: recompute +``` + +In the example above, it’s clear that we could save computing resources on repeated access to `filteredData` +when both `query` and `data` remain unchanged. The ``Memoized(_:_:)`` macro allows you to do exactly that +by automatically caching and updating values derived from their underlying `Observable` inputs. + +To use it, refactor your computed property into a method and apply the ``Memoized(_:_:)`` macro to it: + +```swift +@MainActor @Observable +final class ViewModel { + var data = [String]() + var query: String? + + @Memoized("filteredData") + private func filterData() -> [String] { + print("recompute") + guard let query else { + return data + } + return data.filter { + $0.localizedCaseInsensitiveContains(query) + } + } +} + +let model = ViewModel() +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.filteredData // Prints: recompute +model.filteredData + +model.data = [...] +model.query = "..." +model.filteredData // Prints: recompute +model.filteredData +``` + +## Topics + +### Memoizing Method Outputs + +- ``Memoized(_:_:)`` +- ``Memoized(_:_:isolation:)`` + +### Customizing Generated Declarations + +- ``AccessControlLevel`` diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md new file mode 100644 index 0000000..b50492f --- /dev/null +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -0,0 +1,62 @@ +# Publishable + +Observe changes to `Observable` types synchronously with `Combine`. + +## Overview + +With the introduction of [Observations](https://developer.apple.com/documentation/observation/observations), +Swift gained built-in support for observing changes to `Observable` types. This solution is great, but it only covers some of the use cases, +as it publishes the updates via an `AsyncSequence`. + +In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. + +This is where ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates +with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: + +```swift +import Relay + +@Publishable @Observable +final class Person { + var name = "John" + var surname = "Doe" + + var fullName: String { + "\(name) \(surname)" + } +} + +let person = Person() +let nameCancellable = person.publisher.name.sink { name in + print("Name -", name) +} +let fullNameCancellable = person.publisher.fullName.sink { fullName in + print("Full name -", fullName) +} + +// Initially prints (same as `Published` property wrapper): +// Name - John +// Full name - John Doe + +person.name = "Kamil" +// Prints: +// Name - Kamil +// Full name - Kamil Doe + +person.surname = "Strzelecki" +// Prints: +// Full name - Kamil Strzelecki +``` + +## Topics + +### Making Types Publishable + +- ``Publishable()`` +- ``Publishable(isolation:)`` +- ``Publishable-protocol`` + +### Observing Changes with Combine + +- ``AnyPropertyPublisher`` diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md new file mode 100644 index 0000000..57d66ce --- /dev/null +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -0,0 +1,8 @@ +# ``Relay-module`` + +Essential tools that extend the capabilities of `Observation`. + +## Topics + +- +- diff --git a/Sources/Publishable/Imports.swift b/Sources/Relay/Imports.swift similarity index 94% rename from Sources/Publishable/Imports.swift rename to Sources/Relay/Imports.swift index 145ec8d..bb19c57 100644 --- a/Sources/Publishable/Imports.swift +++ b/Sources/Relay/Imports.swift @@ -1,6 +1,6 @@ // // Imports.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Sources/Relay/Memoized/AccessControlLevel.swift b/Sources/Relay/Memoized/AccessControlLevel.swift new file mode 100644 index 0000000..2b97f1b --- /dev/null +++ b/Sources/Relay/Memoized/AccessControlLevel.swift @@ -0,0 +1,17 @@ +// +// AccessControlLevel.swift +// Relay +// +// Created by Kamil Strzelecki on 13/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +public enum AccessControlLevel: Hashable { + + case `private` + case `fileprivate` + case `internal` + case package + case `public` + case open +} diff --git a/Sources/Relay/Memoized/Memoized.swift b/Sources/Relay/Memoized/Memoized.swift new file mode 100644 index 0000000..7820aff --- /dev/null +++ b/Sources/Relay/Memoized/Memoized.swift @@ -0,0 +1,70 @@ +// +// Memoized.swift +// Relay +// +// Created by Kamil Strzelecki on 14/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +/// A macro allowing a method to be used as a computed property, whose value will be automatically cached +/// and updated when its underlying `Observable` inputs change. +/// +/// - Parameters: +/// - accessControlLevel: Access control level of the generated computed property. +/// Defaults to `nil`, meaning that no explicit access control level will be applied. +/// - propertyName: Name of the generated computed property. +/// Defaults to `nil`, meaning that the name will be derived from the method by trimming its first word. +/// +/// - Note: This macro infers the global actor isolation of the method and applies it to the generated declarations. +/// If this causes compilation errors, use ``Memoized(_:_:isolation:)`` instead. +/// +/// - Note: This macro works only with pure methods of classes to which the `@Observable` or `@Model` macro has been applied directly. +/// +/// The `@Memoized` macro adds a new computed property to your type that returns the same value as a direct call to the original method. +/// Unlike a direct method call, this computed property automatically caches its output and returns the cached value on subsequent accesses, +/// until any of its underlying `Observable` inputs change. After an input changes, the value will be recomputed on the next access. +/// If the computed property is never accessed again, the original method will not be invoked. +/// +/// Like any other property on an `Observable` type, the generated computed property can be tracked with the `Observation` APIs, +/// as well as `Combine` if the ``Publishable()`` macro has been applied to the enclosing class. +/// +@attached(peer, names: arbitrary) +public macro Memoized( + _ accessControlLevel: AccessControlLevel? = nil, + _ propertyName: StaticString? = nil +) = #externalMacro( + module: "RelayMacros", + type: "MemoizedMacro" +) + +/// A macro allowing a method to be used as a computed property, whose value will be automatically cached +/// and updated when its underlying `Observable` inputs change. +/// +/// - Parameters: +/// - accessControlLevel: Access control level of the generated computed property. +/// Defaults to `nil`, meaning that no explicit access control level will be applied. +/// - propertyName: Name of the generated computed property. +/// Defaults to `nil`, meaning that the name will be derived from the method by trimming its first word. +/// - isolation: The global actor to which the generated computed property is isolated. +/// If set to `nil`, the property will be `nonisolated`. +/// To infer isolation automatically, use the ``Memoized(_:_:)`` macro instead. +/// +/// - Note: This macro works only with pure methods of classes to which the `@Observable` or `@Model` macro has been applied directly. +/// +/// The `@Memoized` macro adds a new computed property to your type that returns the same value as a direct call to the original method. +/// Unlike a direct method call, this computed property automatically caches its output and returns the cached value on subsequent accesses, +/// until any of its underlying `Observable` inputs change. After an input changes, the value will be recomputed on the next access. +/// If the computed property is never accessed again, the original method will not be invoked. +/// +/// Like any other property on an `Observable` type, the generated computed property can be tracked with the `Observation` APIs, +/// as well as `Combine` if the ``Publishable()`` macro has been applied to the enclosing class. +/// +@attached(peer, names: arbitrary) +public macro Memoized( + _ accessControlLevel: AccessControlLevel? = nil, + _ propertyName: StaticString? = nil, + isolation: Isolation.Type? +) = #externalMacro( + module: "RelayMacros", + type: "MemoizedMacro" +) diff --git a/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift similarity index 90% rename from Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift rename to Sources/Relay/Publishable/AnyPropertyPublisher.swift index ba73ed8..9cdd966 100644 --- a/Sources/Publishable/PropertyPublisher/AnyPropertyPublisher.swift +++ b/Sources/Relay/Publishable/AnyPropertyPublisher.swift @@ -1,6 +1,6 @@ // // AnyPropertyPublisher.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 17/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. @@ -10,8 +10,8 @@ import Combine /// An object that exposes `Combine` publishers for ``willChange`` and ``didChange`` events. /// -/// Subclasses of this class are generated by the ``Publishable()`` macro, -/// and provide publishers for all mutable instance properties of the type the macro is applied to. +/// Subclasses of this class are generated by the ``Publishable()`` macro, and provide publishers +/// for all mutable or computed instance properties of the type the macro is applied to. /// open class AnyPropertyPublisher { @@ -44,25 +44,25 @@ open class AnyPropertyPublisher { } } +// swiftlint:disable identifier_name + extension AnyPropertyPublisher { - func beginModifications() { - if pendingModifications == 0 { + public func _beginModifications() { + pendingModifications += 1 + if pendingModifications == 1 { _willChange.send(object) } - pendingModifications += 1 } - func endModifications() { - pendingModifications -= 1 - if pendingModifications == 0 { + public func _endModifications() { + if pendingModifications == 1 { _didChange.send(object) } + pendingModifications -= 1 } } -// swiftlint:disable identifier_name - extension AnyPropertyPublisher { public func _storedPropertyPublisher( diff --git a/Sources/Relay/Publishable/Publishable.swift b/Sources/Relay/Publishable/Publishable.swift new file mode 100644 index 0000000..2c081db --- /dev/null +++ b/Sources/Relay/Publishable/Publishable.swift @@ -0,0 +1,98 @@ +// +// Publishable.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Observation + +/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// +/// - Note: This macro infers the global actor isolation of the type and applies it to the generated declarations. +/// If this causes compilation errors, use ``Publishable(isolation:)`` instead. +/// +/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied. +/// +/// The `@Publishable` macro adds a new `publisher` property to your type, +/// which exposes `Combine` publishers for all mutable or computed instance properties. +/// +/// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. +/// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. +/// +/// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. +/// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. +/// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. +/// +@attached( + member, + names: named(_publisher), + named(publisher), + named(PropertyPublisher), + named(Observation) +) +@attached( + extension, + conformances: Publishable +) +public macro Publishable() = #externalMacro( + module: "RelayMacros", + type: "PublishableMacro" +) + +/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// +/// - Parameter isolation: The global actor to which the type is isolated. +/// If set to `nil`, the generated members are `nonisolated`. +/// To infer isolation automatically, use the ``Publishable()`` macro instead. +/// +/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied directly. +/// +/// The `@Publishable` macro adds a new `publisher` property to your type, +/// which exposes `Combine` publishers for all mutable or computed instance properties. +/// +/// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. +/// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. +/// +/// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. +/// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. +/// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. +/// +@attached( + member, + names: named(_publisher), + named(publisher), + named(PropertyPublisher), + named(Observation) +) +@attached( + extension, + conformances: Publishable +) +public macro Publishable( + isolation: Isolation.Type? +) = #externalMacro( + module: "RelayMacros", + type: "PublishableMacro" +) + +/// A type that can be observed using both the `Observation` and `Combine` frameworks. +/// +/// You don't need to declare conformance to this protocol yourself. +/// It is generated automatically when you apply the ``Publishable()`` macro to your type. +/// +public protocol Publishable: AnyObject, Observable { + + /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, + /// containing publishers for all mutable or computed instance properties of the type. + /// + associatedtype PropertyPublisher: AnyPropertyPublisher + + /// An instance that exposes `Combine` publishers for all mutable or computed instance properties of the type. + /// + /// - Important: Don't store this instance in an external property. Accessing it after the original object has been deallocated + /// may result in a crash. Always access it directly through the object that exposes it. + /// + var publisher: PropertyPublisher { get } +} diff --git a/Sources/Relay/Publishable/PublishableObservationRegistrar.swift b/Sources/Relay/Publishable/PublishableObservationRegistrar.swift new file mode 100644 index 0000000..10fe6fb --- /dev/null +++ b/Sources/Relay/Publishable/PublishableObservationRegistrar.swift @@ -0,0 +1,38 @@ +// +// PublishableObservationRegistrar.swift +// Relay +// +// Created by Kamil Strzelecki on 18/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Observation + +@_documentation(visibility: private) +public protocol PublishableObservationRegistrar { + + associatedtype Object: Publishable, Observable + + init() + + func willSet( + _ object: Object, + keyPath: KeyPath + ) + + func didSet( + _ object: Object, + keyPath: KeyPath + ) + + func access( + _ object: Object, + keyPath: KeyPath + ) + + func withMutation( + of object: Object, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T +} diff --git a/Sources/Publishable/Registrars/SwiftObservationRegistrar.swift b/Sources/Relay/Publishable/SwiftObservationRegistrar.swift similarity index 95% rename from Sources/Publishable/Registrars/SwiftObservationRegistrar.swift rename to Sources/Relay/Publishable/SwiftObservationRegistrar.swift index 4afa00a..4286d11 100644 --- a/Sources/Publishable/Registrars/SwiftObservationRegistrar.swift +++ b/Sources/Relay/Publishable/SwiftObservationRegistrar.swift @@ -1,6 +1,6 @@ // // SwiftObservationRegistrar.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift new file mode 100644 index 0000000..d1597c7 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift @@ -0,0 +1,160 @@ +// +// MainActorMemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class MainActorMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + // swiftlint:disable global_actor_attribute_order + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + + @Memoized + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + @MainActor private var _area: Optional = nil + + @MainActor var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + MainActor.shared.assumeIsolated { _ in + rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + + @Memoized(.public, "customName") + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + @MainActor private var _customName: Optional = nil + + @MainActor public var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + MainActor.shared.assumeIsolated { _ in + rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + // swiftlint:enable global_actor_attribute_order + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift new file mode 100644 index 0000000..afd9c82 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -0,0 +1,144 @@ +// +// MemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class MemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Observable + public class Square { + + var side = 12.3 + + @Memoized + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + private var _area: Optional = nil + + var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @Observable + public final class Square { + + var side = 12.3 + + @Memoized(.public, "customName") + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public final class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + private var _customName: Optional = nil + + public var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift new file mode 100644 index 0000000..9c9537e --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -0,0 +1,254 @@ +// +// MainActorPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 24/08/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class MainActorPublishableMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Publishable": PublishableMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Publishable @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// + public private(set) lazy var publisher = PropertyPublisher(object: self) + + public final class PropertyPublisher: AnyPropertyPublisher { + + deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + } + + fileprivate let _age = PassthroughSubject() + @MainActor var age: AnyPublisher { + _storedPropertyPublisher(_age, for: \.age) + } + fileprivate let _name = PassthroughSubject() + @MainActor var name: AnyPublisher { + _storedPropertyPublisher(_name, for: \.name) + } + fileprivate let _surname = PassthroughSubject() + @MainActor public var surname: AnyPublisher { + _storedPropertyPublisher(_surname, for: \.surname) + } + + @MainActor internal var fullName: AnyPublisher { + _computedPropertyPublisher(for: \.fullName) + } + @MainActor fileprivate var initials: AnyPublisher { + _computedPropertyPublisher(for: \.initials) + } + + @MainActor public var label: AnyPublisher { + _computedPropertyPublisher(for: \.label) + } + } + + private enum Observation { + + struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor func publish( + _ object: Person, + keyPath: KeyPath + ) { + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + } + + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.age { + return object.publisher._age + } + return nil + } + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + if keyPath == \.surname { + return object.publisher._surname + } + return nil + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: T! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + + extension Person: @MainActor Publishable { + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift similarity index 57% rename from Tests/PublishableMacrosTests/PublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 9448f09..a225b80 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -1,14 +1,13 @@ // // PublishableMacroTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 12/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -#if canImport(PublishableMacros) - import PublishableMacros - import SwiftSyntaxMacros +#if canImport(RelayMacros) + import RelayMacros import SwiftSyntaxMacrosTestSupport import XCTest @@ -40,10 +39,15 @@ "\(name) \(surname)" } - package var initials: String { + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } } """#, expandedSource: @@ -67,11 +71,23 @@ "\(name) \(surname)" } - package var initials: String { + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// public private(set) lazy var publisher = PropertyPublisher(object: self) public final class PropertyPublisher: AnyPropertyPublisher { @@ -98,16 +114,20 @@ internal var fullName: AnyPublisher { _computedPropertyPublisher(for: \.fullName) } - package var initials: AnyPublisher { + fileprivate var initials: AnyPublisher { _computedPropertyPublisher(for: \.initials) } + + public var label: AnyPublisher { + _computedPropertyPublisher(for: \.label) + } } private enum Observation { struct ObservationRegistrar: PublishableObservationRegistrar { - let underlying = SwiftObservationRegistrar() + private let underlying = SwiftObservationRegistrar() func publish( _ object: Person, @@ -123,7 +143,6 @@ subject.send(object[keyPath: keyPath]) return } - assertionFailure("Unknown keyPath: \(keyPath)") } private func subject( @@ -147,6 +166,67 @@ } return nil } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: T! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } } } } diff --git a/Tests/PublishableTests/Helpers/Array+PopFirst.swift b/Tests/RelayTests/Helpers/Array+PopFirst.swift similarity index 95% rename from Tests/PublishableTests/Helpers/Array+PopFirst.swift rename to Tests/RelayTests/Helpers/Array+PopFirst.swift index 29f8f33..f39d4ea 100644 --- a/Tests/PublishableTests/Helpers/Array+PopFirst.swift +++ b/Tests/RelayTests/Helpers/Array+PopFirst.swift @@ -1,6 +1,6 @@ // // Array+PopFirst.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift new file mode 100644 index 0000000..0331aef --- /dev/null +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -0,0 +1,377 @@ +// +// MainActorMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +internal enum MainActorMemoizedTests { + + @MainActor + struct Independent { + + @Test + func access() { + let cube = Cube() + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + let access1 = cube.baseArea + #expect(access1 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let access3 = cube.baseArea + #expect(access3 == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.offset = 100.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } + + @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.baseArea + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + + cube.y = 4.0 + let access3 = cube.baseArea + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } + } +} + +extension MainActorMemoizedTests { + + @MainActor + struct Dependent { + + @Test + func access() { + let cube = Cube() + let accessVolume1 = cube.volume // accessBaseArea1 + #expect(accessVolume1 == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + let accessBaseArea2 = cube.baseArea + #expect(accessBaseArea2 == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let accessBaseArea3 = cube.baseArea + #expect(accessBaseArea3 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume2 = cube.volume // accessBaseArea4 + #expect(accessVolume2 == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume3 = cube.volume // accessBaseArea5 + #expect(accessVolume3 == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } + } + + let access1 = cube.volume + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } + } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.volume + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.volume + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 4.0 + let access3 = cube.volume + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } + } +} + +extension MainActorMemoizedTests { + + @MainActor @Observable + final class Cube { + + var offset = 0.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } + + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } + + @Memoized + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z + } + } +} diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift new file mode 100644 index 0000000..3e930b7 --- /dev/null +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -0,0 +1,375 @@ +// +// ObservationMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +internal enum ObservationMemoizedTests { + + struct Independent { + + @Test + func access() { + let cube = Cube() + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + let access1 = cube.baseArea + #expect(access1 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let access3 = cube.baseArea + #expect(access3 == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.offset = 100.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } + + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.baseArea + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + + cube.y = 4.0 + let access3 = cube.baseArea + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } + } +} + +extension ObservationMemoizedTests { + + struct Dependent { + + @Test + func access() { + let cube = Cube() + let accessVolume1 = cube.volume // accessBaseArea1 + #expect(accessVolume1 == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + let accessBaseArea2 = cube.baseArea + #expect(accessBaseArea2 == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let accessBaseArea3 = cube.baseArea + #expect(accessBaseArea3 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume2 = cube.volume // accessBaseArea4 + #expect(accessVolume2 == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume3 = cube.volume // accessBaseArea5 + #expect(accessVolume3 == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } + } + + let access1 = cube.volume + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } + } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.volume + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.volume + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 4.0 + let access3 = cube.volume + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } + } +} + +extension ObservationMemoizedTests { + + @Observable + final class Cube { + + var offset = 0.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } + + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } + + @Memoized + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z + } + } +} diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift new file mode 100644 index 0000000..2fa45ac --- /dev/null +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -0,0 +1,193 @@ +// +// PublishableMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +internal struct PublishableMemoizedTests { + + @Test + func independent() { + let cube = Cube() + var queue = [Double]() + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + // access2 + let cancellable = cube.publisher.baseArea.sink { baseArea in + queue.append(baseArea) + } + + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 // access3 + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let access4 = cube.baseArea + #expect(access4 == 2.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cancellable.cancel() + #expect(queue.isEmpty) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + } + + @Test + func dependent() { + let cube = Cube() + var volumeQueue = [Double]() + var baseAreaQueue = [Double]() + + let accessBaseArea1 = cube.baseArea + #expect(accessBaseArea1 == 1.0) + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 0) + #expect(!cube.isVolumeCached) + + // accessVolume1, accessBaseArea2, accessBaseArea3 + let volumeCancellable = cube.publisher.volume.sink { volume in + volumeQueue.append(volume) + } + let baseAreaCancellable = cube.publisher.baseArea.sink { baseArea in + baseAreaQueue.append(baseArea) + } + + #expect(baseAreaQueue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + cube.x = 2.0 // accessVolume2, accessBaseArea4, accessBaseArea5 + #expect(baseAreaQueue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + let accessVolume3 = cube.volume + #expect(accessVolume3 == 2.0) + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 // accessVolume4, accessBaseArea6 + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + + volumeCancellable.cancel() + #expect(volumeQueue.isEmpty) + + cube.y = 4.0 // accessBaseArea7 + #expect(baseAreaQueue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(!cube.isVolumeCached) + + baseAreaCancellable.cancel() + #expect(baseAreaQueue.isEmpty) + + cube.y = 5.0 + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(!cube.isBaseAreaCached) + } + + @Test + func share() { + let cube = Cube() + var queue1 = [Double]() + var queue2 = [Double]() + + // access1, access2 + let cancellable1 = cube.publisher.baseArea.sink { baseArea in + queue1.append(baseArea) + } + let cancellable2 = cube.publisher.baseArea.sink { baseArea in + queue2.append(baseArea) + } + + #expect(queue1.popFirst() == 1.0) + #expect(queue2.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 // access3, access4 + #expect(queue1.popFirst() == 2.0) + #expect(queue2.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cancellable1.cancel() + #expect(queue1.isEmpty) + + cube.y = 3.0 // access5 + #expect(queue2.popFirst() == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + cancellable2.cancel() + #expect(queue2.isEmpty) + } + +} + +extension PublishableMemoizedTests { + + @Publishable @Observable + final class Cube { + + var offset = 0.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } + + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } + + @Memoized + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z + } + } +} diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift new file mode 100644 index 0000000..53eb56b --- /dev/null +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -0,0 +1,380 @@ +// +// SwiftDataMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import SwiftData +import Testing + +internal enum SwiftDataMemoizedTests { + + struct Independent { + + @Test + func access() { + let cube = Cube() + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateBaseAreaCallsCount == 0) + #expect(!cube.isBaseAreaCached) + + let access1 = cube.baseArea + #expect(access1 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let access3 = cube.baseArea + #expect(access3 == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.offset = 100.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.baseArea + } onChange: { + queue.append(true) + } + } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.baseArea + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + } + + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.baseArea + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.y = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + + cube.y = 4.0 + let access3 = cube.baseArea + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } + } +} + +extension SwiftDataMemoizedTests { + + struct Dependent { + + @Test + func access() { + let cube = Cube() + let accessVolume1 = cube.volume // accessBaseArea1 + #expect(accessVolume1 == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + let accessBaseArea2 = cube.baseArea + #expect(accessBaseArea2 == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + let accessBaseArea3 = cube.baseArea + #expect(accessBaseArea3 == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume2 = cube.volume // accessBaseArea4 + #expect(accessVolume2 == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let accessVolume3 = cube.volume // accessBaseArea5 + #expect(accessVolume3 == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + } + + @Test + func trackWhenCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } + } + + let access1 = cube.volume + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + observe() // access2 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @Test + func trackWhenNotCached() { + let cube = Cube() + nonisolated(unsafe) var queue = [Bool]() + + func observe() { + withObservationTracking { + _ = cube.volume + } onChange: { + queue.append(true) + } + } + + observe() // access1 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + let access2 = cube.volume + #expect(access2 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.z = 2.0 + #expect(queue.popFirst() == true) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + } + + @MainActor @Test + @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) + func observations() async { + let cube = Cube() + var queue = [Double]() + + let task = Task.immediate { + let observations = Observations { + cube.volume + } + for await area in observations { + queue.append(area) + } + } + + try? await Task.sleep(for: .microseconds(10)) // access1 - not cached + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(!cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access2 - not cached + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 3.0 + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(!cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cube.z = 4.0 + let access3 = cube.volume + #expect(access3 == 8.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + try? await Task.sleep(for: .microseconds(10)) // access4 - cached + #expect(queue.popFirst() == 8.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + task.cancel() + await task.value + #expect(queue.isEmpty) + } + } +} + +extension SwiftDataMemoizedTests { + + @Model + final class Cube { + + var offset: Double + var x = 1.0 + var y = 1.0 + var z = 1.0 + + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } + + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + init() { + self.offset = 0.0 + } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } + + @Memoized + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z + } + } +} diff --git a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift similarity index 83% rename from Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift rename to Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift index 82cca83..dafca42 100644 --- a/Tests/PublishableTests/Suites/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift @@ -1,12 +1,12 @@ // // AnyPropertyPublisherTests.swift -// Publishable +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable +import Relay import Testing internal struct AnyPropertyPublisherTests { @@ -25,10 +25,10 @@ internal struct AnyPropertyPublisherTests { } @Test - func testNonEquatableStoredPropertyPublisher() { + func nonEquatableStoredProperty() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -40,7 +40,7 @@ internal struct AnyPropertyPublisherTests { withObservationTracking { _ = object?.storedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -54,7 +54,7 @@ internal struct AnyPropertyPublisherTests { object?.storedProperty = NonEquatableStruct() #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil @@ -65,10 +65,10 @@ internal struct AnyPropertyPublisherTests { } @Test - func testNonEquatableComputedPropertyPublisher() { + func nonEquatableComputedProperty() { var object: ObjectWithNonEquatableProperties? = .init() var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -80,7 +80,7 @@ internal struct AnyPropertyPublisherTests { withObservationTracking { _ = object?.computedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -94,7 +94,7 @@ internal struct AnyPropertyPublisherTests { object?.storedProperty = NonEquatableStruct() #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil @@ -119,10 +119,10 @@ extension AnyPropertyPublisherTests { } @Test - func testEquatableStoredPropertyPublisher() { + func equatableStoredProperty() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.storedProperty.sink( @@ -134,7 +134,7 @@ extension AnyPropertyPublisherTests { withObservationTracking { _ = object?.storedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -148,12 +148,11 @@ extension AnyPropertyPublisherTests { object?.storedProperty = 0 #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() != nil) - observe() + #expect(observationsQueue.popFirst() == nil) object?.storedProperty += 1 #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil @@ -164,10 +163,10 @@ extension AnyPropertyPublisherTests { } @Test - func testEquatableComputedPropertyPublisher() { + func equatableComputedProperty() { var object: ObjectWithEquatableProperties? = .init() var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = object?.publisher.computedProperty.sink( @@ -179,7 +178,7 @@ extension AnyPropertyPublisherTests { withObservationTracking { _ = object?.computedProperty } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -193,12 +192,11 @@ extension AnyPropertyPublisherTests { object?.storedProperty = 0 #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() != nil) - observe() + #expect(observationsQueue.popFirst() == nil) object?.storedProperty += 1 #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() object = nil diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift new file mode 100644 index 0000000..8c86215 --- /dev/null +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -0,0 +1,222 @@ +// +// MainActorPublishableTests.swift +// Relay +// +// Created by Kamil Strzelecki on 18/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Foundation +import Relay +import Testing + +@MainActor +internal struct MainActorPublishableTests { + + @Test + func storedProperty() { + var person: Person? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.name.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.name + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "John") + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() == "Kamil") + #expect(observationsQueue.popFirst() == true) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func computedProperty() { + var person: Person? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.fullName.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.fullName + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "John Doe") + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() == "John Strzelecki") + #expect(observationsQueue.popFirst() == true) + observe() + + person?.age += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() == "Kamil Strzelecki") + #expect(observationsQueue.popFirst() == true) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension MainActorPublishableTests { + + @Test + func willChange() { + var person: Person? = .init() + var publishableQueue = [Person]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.willChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.age + _ = person?.name + _ = person?.surname + _ = person?.fullName + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() == true) + observe() + + person?.age += 1 + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() == true) + observe() + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() == true) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func didChange() { + var person: Person? = .init() + var publishableQueue = [Person]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = person?.publisher.didChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = person?.age + _ = person?.name + _ = person?.surname + _ = person?.fullName + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + person?.surname = "Strzelecki" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() == true) + observe() + + person?.age += 1 + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() == true) + observe() + + person?.name = "Kamil" + #expect(publishableQueue.popFirst() === person) + #expect(observationsQueue.popFirst() == true) + observe() + + person = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension MainActorPublishableTests { + + @MainActor @Publishable @Observable + final class Person { + + let id = UUID() + var age = 25 + fileprivate(set) var name = "John" + var surname = "Doe" + + internal var fullName: String { + "\(name) \(surname)" + } + + package var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + } +} diff --git a/Tests/PublishableTests/Suites/ObservableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift similarity index 79% rename from Tests/PublishableTests/Suites/ObservableTests.swift rename to Tests/RelayTests/Publishable/ObservationPublishableTests.swift index fd30783..38f7cc2 100644 --- a/Tests/PublishableTests/Suites/ObservableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -1,22 +1,22 @@ // -// ObservableTests.swift -// Publishable +// ObservationPublishableTests.swift +// Relay // // Created by Kamil Strzelecki on 18/01/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable import Foundation +import Relay import Testing -internal struct ObservableTests { +internal struct ObservationPublishableTests { @Test - func testStoredPropertyPublisher() { + func storedProperty() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -28,7 +28,7 @@ internal struct ObservableTests { withObservationTracking { _ = person?.name } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -42,7 +42,7 @@ internal struct ObservableTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -53,10 +53,10 @@ internal struct ObservableTests { } @Test - func testComputedPropertyPublisher() { + func computedProperty() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -68,7 +68,7 @@ internal struct ObservableTests { withObservationTracking { _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -78,7 +78,7 @@ internal struct ObservableTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 @@ -87,7 +87,7 @@ internal struct ObservableTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -98,13 +98,13 @@ internal struct ObservableTests { } } -extension ObservableTests { +extension ObservationPublishableTests { @Test - func testWillChangePublisher() { + func willChange() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -119,7 +119,7 @@ extension ObservableTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -129,17 +129,17 @@ extension ObservableTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -150,10 +150,10 @@ extension ObservableTests { } @Test - func testDidChangePublisher() { + func didChange() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -168,7 +168,7 @@ extension ObservableTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -178,17 +178,17 @@ extension ObservableTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -199,15 +199,15 @@ extension ObservableTests { } } -extension ObservableTests { +extension ObservationPublishableTests { @Publishable @Observable - public final class Person { + final class Person { let id = UUID() var age = 25 fileprivate(set) var name = "John" - public var surname = "Doe" + var surname = "Doe" internal var fullName: String { "\(name) \(surname)" diff --git a/Tests/PublishableTests/Suites/SwiftDataTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift similarity index 80% rename from Tests/PublishableTests/Suites/SwiftDataTests.swift rename to Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift index 57c4413..8404c4b 100644 --- a/Tests/PublishableTests/Suites/SwiftDataTests.swift +++ b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift @@ -1,23 +1,23 @@ // -// SwiftDataTests.swift -// Publishable +// SwiftDataPublishableTests.swift +// Relay // // Created by Kamil Strzelecki on 15/05/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Publishable import Foundation +import Relay import SwiftData import Testing -internal struct SwiftDataTests { +internal struct SwiftDataPublishableTests { @Test - func testStoredPropertyPublisher() { + func storedProperty() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.name.sink( @@ -29,7 +29,7 @@ internal struct SwiftDataTests { withObservationTracking { _ = person?.name } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -43,7 +43,7 @@ internal struct SwiftDataTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -54,10 +54,10 @@ internal struct SwiftDataTests { } @Test - func testComputedPropertyPublisher() { + func computedProperty() { var person: Person? = .init() var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.fullName.sink( @@ -69,7 +69,7 @@ internal struct SwiftDataTests { withObservationTracking { _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -79,7 +79,7 @@ internal struct SwiftDataTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 @@ -88,7 +88,7 @@ internal struct SwiftDataTests { person?.name = "Kamil" #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -99,13 +99,13 @@ internal struct SwiftDataTests { } } -extension SwiftDataTests { +extension SwiftDataPublishableTests { @Test - func testWillChangePublisher() { + func willChange() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.willChange.sink( @@ -120,7 +120,7 @@ extension SwiftDataTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -130,17 +130,17 @@ extension SwiftDataTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -151,10 +151,10 @@ extension SwiftDataTests { } @Test - func testDidChangePublisher() { + func didChange() { var person: Person? = .init() var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue: [Void] = [] + nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? let cancellable = person?.publisher.didChange.sink( @@ -169,7 +169,7 @@ extension SwiftDataTests { _ = person?.surname _ = person?.fullName } onChange: { - observationsQueue.append(()) + observationsQueue.append(true) } } @@ -179,17 +179,17 @@ extension SwiftDataTests { person?.surname = "Strzelecki" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.age += 1 #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person?.name = "Kamil" #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) observe() person = nil @@ -200,14 +200,14 @@ extension SwiftDataTests { } } -extension SwiftDataTests { +extension SwiftDataPublishableTests { @Publishable @Model - public final class Person { + final class Person { var age: Int fileprivate(set) var name: String - public var surname: String + var surname: String internal var fullName: String { "\(name) \(surname)"