Skip to content

Add tracked read model relationship includes - #36

Merged
patrickleet merged 8 commits into
feat/ormfrom
feat/orm-relationships
May 24, 2026
Merged

patrickleet merged 8 commits into
feat/ormfrom
feat/orm-relationships

Conversation

@patrickleet

@patrickleet patrickleet commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add friendly tracked read-model session API for explicit includes and save_changes
  • generate relationship hydration/extraction from the ReadModel derive
  • add in-memory relational include conformance and docs

Verification

  • cargo test

Summary by CodeRabbit

  • New Features

    • Opt-in relational "relationship includes": query, hydrate, edit and persist has_many and belongs_to relationships; session-level unit-of-work and capability checks; in-memory store now supports relational schemas, registration and load-graphs.
  • Documentation

    • Expanded guide with examples, hydration rules, persistence semantics, and intended audience/capability notes.
  • Tests

    • New and updated tests covering include loading, save semantics, validation failures (missing schema, composite keys, unsupported patterns), idempotency, and patch/insert_missing behaviors.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c6e6c7ca-51c3-45b8-a32a-fbff36ec3509

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds opt-in, primary-key–anchored relationship includes for relational read models: macro-generated include hooks, in-memory relational row storage and schema registry, load_graph-based include loading, session unit-of-work tracking with diff-based save_changes, docs, and tests.

Changes

Relational Read Model Explicit Includes

Layer / File(s) Summary
Include API Contracts & Types
src/read_model/metadata.rs, src/read_model/session.rs, src/read_model/mod.rs
RelationalReadModelIncludes trait, ReadModelQueryCapabilities, ReadModelLoadGraph/ReadModelIncludeRows, and RelationalReadModelQueryStore define include/hydration contracts; Versioned<T> gains PartialEq and re-exports adjusted.
Macro Code Generation for Includes
sourced_rust_macros/src/read_model.rs
Derive macro now emits impl RelationalReadModelIncludes by collecting hydrate and include-rows arms per relationship, validating Vec<T>/Option<T> shapes and target types, and producing hydration and include_rows match arms with compile-time diagnostics.
Store Relational Row Storage & Schema Registry
src/read_model/in_memory.rs
Adds StoredRow (values + version), relational_rows map, and schema_registry in InMemoryReadModelStore, plus register_schema/register_read_model_schema APIs.
Store Write Plan Application & Optimistic Versioning
src/read_model/in_memory.rs
Replaces document-only applier with apply_read_model_write_plan handling UpsertRow/PatchRow/DeleteRow with expected-version checks mapped to ConcurrencyConflict/NotFound; commit_write_plan atomically applies document and relational staged maps.
Relational Query & Include Loading
src/read_model/in_memory.rs
Implements RelationalReadModelQueryStore::load_graph: resolve schemas from registry, validate includes vs capabilities, load root row by table+key, and materialize included rows for HasMany/BelongsTo (reject ManyToMany).
Session Unit-of-Work & Load Builder
src/read_model/session.rs
Adds ReadModelSessionUnitOfWork and ReadModelLoadBuilder for load(...).include(...).one() hydration, tracks baselines, diffs rows on save_changes to stage patches/upserts, and promotes helper utilities to pub(crate).
Documentation
docs/read-models.md
Adds "Explicit Relationship Includes" section with end-to-end Rust example (schema registration, load.include.one(), mutate, save_changes, commit) and notes on hydration shapes, delegated foreign-key filling, and default non-deletion.
Polish & Re-exports
src/lib.rs
Reformatted pub use re-export block; no API symbol changes.
Idempotency Test Updates
tests/read_model_distributed_idempotency/main.rs
Tests updated to expect "not found" from expect_version checks on relational rows and add relational_counter_key helper.
Relationship Includes Test Suite
tests/read_model_relationship_includes/main.rs
Adds comprehensive tests and helpers covering has_many, belongs_to, many-to-many validation, capability rejection, metadata errors, non-deletion defaults, and composite-key belongs_to failure case.
Session Tests: Upsert Patch Behavior
tests/read_model_session/main.rs
Adds tests for inserting missing rows via patch (building full row from key) and rejecting partial new rows missing required columns.

Sequence Diagram

sequenceDiagram
  participant App
  participant ReadModelSession
  participant InMemoryStore
  participant SchemaRegistry

  App->>ReadModelSession: load(root_key).include("weapons")
  ReadModelSession->>InMemoryStore: load_graph(request)
  InMemoryStore->>SchemaRegistry: resolve_request_schemas()
  SchemaRegistry-->>InMemoryStore: Player & Weapon schemas
  InMemoryStore->>InMemoryStore: load root row by id
  InMemoryStore->>InMemoryStore: load related weapons (HasMany)
  InMemoryStore-->>ReadModelSession: ReadModelLoadGraph
  ReadModelSession->>ReadModelSession: hydrate_include("weapons")
  ReadModelSession-->>App: Player with weapons Vec

  App->>App: mutate player.name & weapons
  App->>ReadModelSession: save_changes()
  ReadModelSession->>ReadModelSession: diff_rows vs tracked
  ReadModelSession-->>App: write mutations
  App->>InMemoryStore: commit(plan)
  InMemoryStore->>InMemoryStore: validate versions
  InMemoryStore->>InMemoryStore: apply UpsertRow/PatchRow
  InMemoryStore-->>App: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through schemas, bright and keen,

Loaded roots and gathered friends unseen,
Hydrated Vecs and Options with care,
Diffs turned to patches, stitched with flair,
A rabbit's small dance for relational repair.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add tracked read model relationship includes' directly and specifically describes the main change across the pull request: adding support for relationship includes in read models with tracking capabilities.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/orm-relationships

Comment @coderabbitai help to get the list of available commands and usage tips.

@patrickleet

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/read-models.md`:
- Around line 116-140: The PlayerView schema shown earlier is missing the
relationship and field referenced by the example (player.weapons and
.include("weapons")); update the PlayerView struct (the ReadModel definition
named PlayerView) to add a weapons field typed Vec<PlayerWeaponView> and
annotate it with the read-model relationship metadata your macro uses (e.g.
has_many/foreign_key or the equivalent attribute your ReadModel macro expects)
so that include("weapons") and accessing player.weapons in the example compile
and resolve to PlayerWeaponView.

In `@src/read_model/in_memory.rs`:
- Around line 601-621: The helper belongs_to_target_column currently tries to
pick a single column when the target has a composite primary key which leads to
building an invalid partial RowKey; change belongs_to_target_column to
immediately reject any composite primary key (primary_key.columns.len() != 1) by
returning a ReadModelError::Metadata indicating the target model and
source_column, and only return the sole primary key column when
primary_key.columns.len() == 1; also update any callers (e.g.,
load_belongs_to_rows) to rely on that error instead of receiving a single column
from a composite key.
- Around line 174-181: The InsertMissing branch writes
mutation.patch.into_values() directly which can omit primary-key or required
non-null fields; update the None if matches!(mutation.mode,
PatchMode::InsertMissing) branch so you build a complete StoredRow before
inserting: derive a full values map by starting from the key (the same key used
for staged_rows.insert) and overlaying mutation.patch.into_values(), validate
that all primary-key columns and non-null required columns are present (or
return an error/skip the insert), then insert StoredRow { values: full_values,
version: INITIAL_MODEL_VERSION }; reference PatchMode::InsertMissing,
mutation.patch.into_values(), staged_rows.insert, StoredRow,
INITIAL_MODEL_VERSION and the consumer key_from_row to locate where to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 51755964-bc69-46a3-8f5c-f70e0b02f837

📥 Commits

Reviewing files that changed from the base of the PR and between 5f23048 and c93f9ee.

📒 Files selected for processing (9)
  • docs/read-models.md
  • sourced_rust_macros/src/read_model.rs
  • src/lib.rs
  • src/read_model/in_memory.rs
  • src/read_model/metadata.rs
  • src/read_model/mod.rs
  • src/read_model/session.rs
  • tests/read_model_distributed_idempotency/main.rs
  • tests/read_model_relationship_includes/main.rs

Comment thread docs/read-models.md
Comment thread src/read_model/in_memory.rs
Comment thread src/read_model/in_memory.rs
@patrickleet

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/read_model_session/main.rs (1)

186-199: ⚡ Quick win

Assert that failed commit does not persist a partial row.

This checks the error text, but not write atomicity. Add a post-failure load assertion (None) so regressions that write before failing are caught.

Proposed test hardening
 #[test]
 fn insert_missing_patch_rejects_partial_new_row() {
     let store = InMemoryReadModelStore::new();
+    store.register_schema::<AccountSummary>().unwrap();
     let patch = RowPatch::new().set("owner", RowValue::String("Grace".into()));
     let mut session = ReadModelSession::new();

     session
         .upsert_patch::<AccountSummary>(account_key("acct-1"), patch)
         .unwrap();
     let err = session.commit(&store).unwrap_err();

     assert!(
         matches!(err, ReadModelError::Metadata(message) if message.contains("missing required column `balance_cents`"))
     );
+
+    let mut read_models = store.session();
+    let loaded = read_models
+        .load::<AccountSummary>(account_key("acct-1"))
+        .one()
+        .unwrap();
+    assert!(loaded.is_none());
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/read_model_session/main.rs` around lines 186 - 199, The test
insert_missing_patch_rejects_partial_new_row currently verifies the commit error
but not that a partial write wasn't persisted; after capturing err from
session.commit(&store).unwrap_err(), load the row from the
InMemoryReadModelStore (using the same key used in upsert_patch, e.g.
account_key("acct-1")) via the store.load::<AccountSummary>(...) or the
appropriate read method and assert it returns None to ensure atomicity; use the
same types (ReadModelSession, InMemoryReadModelStore, AccountSummary,
account_key, commit) to locate where to add this post-failure assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/read_model/in_memory.rs`:
- Around line 225-239: The patch loop in row_values_from_key_and_patch currently
lets patch_values overwrite primary-key columns copied from the RowKey; update
row_values_from_key_and_patch to reject any patch entry that targets a
primary-key column unless the patched value exactly equals the value from the
provided key (return an appropriate ReadModelError on mismatch), mirroring the
same PK-guard used in the existing-row patch path; keep copying non-PK columns
as before and then call validate_row_values(schema, &values, true) and return
Ok(values).

---

Nitpick comments:
In `@tests/read_model_session/main.rs`:
- Around line 186-199: The test insert_missing_patch_rejects_partial_new_row
currently verifies the commit error but not that a partial write wasn't
persisted; after capturing err from session.commit(&store).unwrap_err(), load
the row from the InMemoryReadModelStore (using the same key used in
upsert_patch, e.g. account_key("acct-1")) via the
store.load::<AccountSummary>(...) or the appropriate read method and assert it
returns None to ensure atomicity; use the same types (ReadModelSession,
InMemoryReadModelStore, AccountSummary, account_key, commit) to locate where to
add this post-failure assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 504282a1-f45f-4ef1-b28c-53f6605aedc1

📥 Commits

Reviewing files that changed from the base of the PR and between c93f9ee and 3e250a5.

📒 Files selected for processing (5)
  • docs/read-models.md
  • src/read_model/in_memory.rs
  • src/read_model/session.rs
  • tests/read_model_relationship_includes/main.rs
  • tests/read_model_session/main.rs
✅ Files skipped from review due to trivial changes (1)
  • docs/read-models.md

Comment thread src/read_model/in_memory.rs
@patrickleet

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@patrickleet

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

patrickleet and others added 4 commits May 23, 2026 17:08
Make `save_changes` reconcile included collections to the struct: an owned
has_many child dropped from the loaded Vec is deleted, lowering to an explicit
DeleteRow with the loaded expected version. belongs_to clear-to-None stays a
no-op on the target. Safe because has_many includes load the complete owned set.

Replaces the prior "removal does not delete by default" behavior, which was
asymmetric (auto-persisted adds/edits but silently dropped removals).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rework tests/distributed_read_model into a Catalog + Order CQRS slice over
normalized relational read models (ProductView, OrderView has_many
OrderLineView belongs_to ProductView, JSONB columns), and add a kanban
Board + Cards example. Add an order-fulfillment saga (inventory, payment,
saga orchestrator) driving confirm/cancel with a compensation path, projected
into an OrderFulfillmentStepView has_many child for a multi-include query.

Conventions: each write service is a microsvc::Service with service.rs +
handlers/ (one file per message) + models/ (aggregate); the projection service
is one dispatcher organized into handler modules; published domain events are
lowercase dot-namespaced. Services publish via the outbox and subscribe via
microsvc::subscribe — no bespoke transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant