Give InMemoryModelStorage partial-update and relationship-default semantics - #30
Open
colemancda wants to merge 13 commits into
Open
Give InMemoryModelStorage partial-update and relationship-default semantics#30colemancda wants to merge 13 commits into
colemancda wants to merge 13 commits into
Conversation
Code Coverage OverviewLanguages: Swift Swift / code-coverage/llvm-covThe overall coverage in commit 03a45d3 in the Show a code coverage summary of the most impacted files.
Updated |
colemancda
force-pushed
the
fix/InMemoryModelStorage-relationship-default
branch
from
August 1, 2026 16:37
5b9f727 to
b5ffe20
Compare
… behavior as SQL/Core Data insert() now merges into an existing row instead of replacing it outright, so providing only some attributes/relationships preserves whatever the row already had for the rest — the same "only touch the columns you provided" semantics a SQL ON CONFLICT DO UPDATE or a Core Data managed object gives for free. Without this, re-inserting a row from a batch that doesn't touch every relationship (e.g. a catalog sync that doesn't restate reservations written by an unrelated flow) silently wiped those links. fetch() now materializes every attribute and to-many relationship the schema declares, the way a SQL row or a Core Data managed object does automatically on read: - An attribute nobody has ever explicitly set to .null (rather than simply never provided) previously decoded as keyNotFound instead of the absence it actually represents — a SQL column binds NULL for anything an INSERT doesn't provide, and a Core Data managed object is never partially formed. - A to-many relationship is never actually stored on the row that owns it in the first place on either backend. A one-to-many (whose inverse is a to-one foreign key) is answered by scanning the destination table for rows whose foreign key points back, and a many-to-many (whose inverse is also to-many) by a join table either side can add a link to. A row whose to-many key was never explicitly set — which includes every one-to-many, since it's supposed to be entirely computed and never assigned — always decoded as keyNotFound regardless of what actually pointed to it. Both are now derived correctly. To-one relationships are left alone: an absent required reference is a real problem, not a default.
colemancda
force-pushed
the
fix/InMemoryModelStorage-relationship-default
branch
from
August 1, 2026 16:44
b5ffe20 to
bd071ce
Compare
… compile under Embedded Swift The closure-based merge overload does dynamic casting internally, disallowed by Embedded's restrictions. Replaced with explicit loops that do the same thing.
@Test/@suite can't be combined with a declaration-level @available, so each test guards its body with a runtime if #available instead of the declaration carrying the attribute directly. Also replaced the RunLoop.main.run(until:) spin in the observation test with a short async poll: it relied on XCTest's synchronous main-thread execution to pump the real run loop, which Swift Testing's concurrency model doesn't guarantee the same way.
The compound-operator and string-comparison-helper tests needed extra care: #expect wraps its argument in a macro-rewritten closure that doesn't always preserve the contextual type inference plain XCTAssertEqual calls had, so array literals like [.caseInsensitive] passed to Set<...>-typed parameters failed to infer their element type, and a && b == c parsed with the wrong operator precedence (== binds tighter than &&). Fixed by hoisting the Set<Comparison.Option> literals into typed local lets and adding explicit parens around the compound-operator comparisons.
XCTAssertThrowsError without a specific error check becomes #expect(throws: (any Error).self); with a case check it becomes a do/catch with Issue.record on the wrong-error/no-error paths.
Renamed testInferAttributeType to inferAttributeTypeMapping: stripping the 'test' prefix would have made the @test function's own name collide with the free function it calls (inferAttributeType(from:)), which Swift resolves to the instance method over the module-level function inside its own body.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
InMemoryModelStoragecurrently behaves very differently from the SQL/Core Databackends for both attributes and to-many relationships, in ways that make it
awkward to use as a genuine drop-in
ModelStorage. Neither of those backendsever leaves a row partially formed:
fetchnever materializes attributes. A SQL column bindsNULLforanything an
INSERTdoesn't provide, and a Core Data managed object alwayshas a value (
nilfor an unset optional) for every attribute its modeldeclares.
InMemoryModelStoragereturns exactly whatinsertwas given, soan attribute nobody has ever explicitly set to
.null(rather than simplynever provided) decoded as
keyNotFoundinstead of the absence it actuallyrepresents.
fetchnever materializes to-many relationships. Neither backendactually stores a to-many value on the row that owns it — a one-to-many
(whose inverse is a to-one foreign key, e.g. a
Site'sparkingReservations, backed byParkingReservation.site) is answered byscanning the destination table for rows whose foreign key points back, and a
many-to-many (whose inverse is also to-many) by a join table either side can
add a link to. A row whose to-many relationship key was never explicitly
set — which includes every one-to-many, since it's supposed to be entirely
computed and never assigned — either decoded as an empty collection
regardless of what actually pointed to it, or threw
keyNotFoundif nothinghad ever touched that key.
insertfully replaces a row. A SQLON CONFLICT DO UPDATE(or a CoreData managed object) only touches the columns/relationships a given write
actually provides — anything omitted is left as-is.
InMemoryModelStorage.insertinstead swapped in the whole
ModelDataunconditionally, so re-inserting arow from a write path that doesn't restate every relationship silently wiped
whatever that relationship previously held.
These are real correctness gaps, not just decode ergonomics — the relationship
one produces silently wrong (empty) data, and the insert one is silent data
loss on any partial update.
Separately, this also migrates the whole test suite off XCTest onto Swift
Testing. Running
swift teston Linux was crashing with:— the legacy XCTest runner failing to dynamically dispatch a
@MainActor-isolatedtest method on Linux. This was pre-existing on
master, unrelated to the storagefix above, and moving off XCTest entirely eliminates the whole crash class.
Changes
fetch(_:for:)/fetch(_:)now materialize:.null..toOne): derived live by scanning thedestination entity's rows for a foreign key pointing back, exactly the
way SQL/Core Data compute it.
.toMany): unions whatever this rowalready records with anything the destination rows record pointing
back, approximating join-table semantics without adding one.
real problem, not a default.
insert(_:)now merges into an existing row (provided keys overwrite,omitted keys are preserved) instead of replacing it outright.
@Suite/@Test/#expect). A couple of notes on the conversion:@Test/@Suitecan't be combined with a declaration-level@available—the two CoreData-gated suites that needed macOS 12+ guard each test body
with a runtime
if #available/guard #availableinstead.RunLoop.main.run(until:)pumpingthe real main run loop, which XCTest's synchronous main-thread execution
guaranteed but Swift Testing's concurrency model doesn't; replaced with a
short async poll.
Test plan
swift teston macOS — 125 tests in 18 suites, all pass (no test lostrelative to the pre-migration count; verified per-file during the
conversion)
swift teston Linux (swift:latest, matching CI) — 91 tests in 11suites (CoreData-gated suites don't build there), all pass, no crash
graph (a
Userrelated to four child entities via one-to-manyrelationships, each with several optional attributes): fetching/caching
a profile, inserting one of each related entity, then
re-fetching/re-caching the profile a second time now round-trips
correctly — the derived relationships correctly reflect the related
rows and survive the second insert, and fetching a just-created row with
unset optional attributes or no related rows yet no longer throws
keyNotFound.