diff --git a/DCB_IMPLEMENTATION_PLAN.md b/DCB_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..14e77edafa --- /dev/null +++ b/DCB_IMPLEMENTATION_PLAN.md @@ -0,0 +1,431 @@ +# Dynamic Consistency Boundary (DCB) Implementation Plan + +**GitHub Issue**: https://github.com/JasperFx/marten/issues/4159 +**Scope**: JasperFx.Events abstractions + Marten storage/querying (Phase 1) +**Wolverine integration**: Deferred to Phase 2 + +--- + +## Context + +Marten already handles multi-stream consistency better than most event stores via `FetchForWriting` + `AlwaysEnforceConsistency`. DCB adds a complementary pattern: querying events by **tags** (cross-stream identifiers) rather than by stream, with consistency assertions over those tag-based queries. The main value-add is simpler code for certain cross-cutting patterns and joining the DCB ecosystem. + +Most DCB use cases (constraints across entities, global uniqueness, idempotency) are already well-served by Marten's existing capabilities. DCB primarily benefits scenarios where you want to project and enforce consistency over a set of events identified by shared tags rather than by stream identity. + +--- + +## Design Decisions (Resolved) + +1. **Tag value type resolution**: Use `JasperFx.Core.Reflection.ValueTypeInfo` to validate tag types and extract inner values. `ValueTypeInfo.ForType()` resolves the inner primitive type (`SimpleType`), provides `ValueProperty` for extraction, and compiles fast `UnWrapper`/`CreateWrapper` delegates. No need for user-supplied lambdas or Vogen-specific knowledge. + +2. **Tag-to-stream routing**: Derived from Marten's existing document mappings. If `Student` has identity type `StudentId` and `StudentId` is a registered tag type, then events tagged with a `StudentId` value route to the `Student` stream with that identity — but only if that stream is already open in the current session via `FetchForWriting()`. An event tagged with multiple tag types (e.g., `StudentId` + `CourseId`) is appended to all matching open streams. + +3. **DCB event stream type**: Use a **new `IEventBoundary` type**, separate from `IEventStream`. The behavior is fundamentally different — sequence-based (global `seq_id`) rather than version-based (per-stream), and consistency assertion mechanism differs. + +4. **Abstraction layering**: JasperFx.Events gets the abstractions and specs (`EventTag`, tag type registry, `EventTagQuery`). Marten gets all execution (SQL generation, tag table management, DCB fetch/assert operations). + +5. **Tag table storage**: Separate tables per registered tag type (e.g., `mt_event_tag_student_id`). Better query performance and simpler indexes than a shared discriminated table. The tables should have a composite primary key of the sequence and the value, but put the value first in the primary key + +6. **Tag table schema management**: Tag tables are `ISchemaObject` instances yielded from the existing `EventGraph.FeatureSchema.createAllSchemaObjects()`, so they are created/migrated alongside `mt_events`, `mt_streams`, and other event store schema objects. + +7. **DCB assertion performance**: Must be tightly constrained SQL to avoid unnecessary database work under concurrent load. Use `EXISTS` rather than `COUNT(*)`, composite indexes on `(value, seq_id)`, and narrow the assertion to only the relevant tag values and event types from the original query. Load testing required to validate the approach under contention. + +--- + +## Phase 1: JasperFx.Events — Tag Abstractions + +### 1a. Tag Value Model + +Add to `JasperFx.Events`: + +```csharp +/// +/// Represents a single tag on an event — a (TagType, Value) pair where TagType +/// is a strong-typed identifier (e.g., StudentId) and Value is the unwrapped primitive. +/// +public readonly record struct EventTag(Type TagType, object Value); +``` + +Extend `IEvent`: +- Add `IReadOnlyList? Tags { get; }` — lazy, like `Headers` +- Add `IEvent WithTag(TTag value)` fluent method +- Add multi-tag convenience: `IEvent WithTag(params object[] tags)` + +Extend `Event`: +- Add backing `List? _tags` field (lazy) +- Implement `WithTag()` — uses `ValueTypeInfo.ForType(typeof(TTag))` to extract the inner value and store `new EventTag(typeof(TTag), innerValue)` + +Any time we are extracting the inner value from a strong typed identifier, use a memoized copy of the UnWrapper() Lambda created by ValueTypeInfo to eliminate +the usage of Reflection at runtime + +### 1b. Tag Type Registry + +```csharp +public interface ITagTypeRegistration +{ + Type TagType { get; } // e.g., typeof(StudentId) + ValueTypeInfo ValueTypeInfo { get; } // resolved via ValueTypeInfo.ForType() + string TableSuffix { get; } // e.g., "student_id" for table naming + + // Convenience + Type SimpleType { get; } // e.g., typeof(string) — the inner primitive +} +``` + +Correction: just make this a concrete type with no interface abstraction + +Registration API on the event store options (in JasperFx.Events or Marten — TBD, but try to place in JasperFx.Events): + +```csharp +StoreOptions.Events.RegisterTagType(); +// Internally: ValueTypeInfo.ForType(typeof(StudentId)) validates the type +``` + +The registry is an `IReadOnlyList` accessible from event store configuration. + +Automatically register tag types for any SingleStreamProjection or MultiStreamProjection registered in the system +that uses a strong typed identifier for the identity type of its document. + +### 1c. DCB Query Specification + +```csharp +public class EventTagQuery +{ + /// + /// Add condition: events of type TEvent tagged with the given tag value + /// + public EventTagQuery Or(TTag tagValue); + + /// + /// Add condition: any event tagged with the given tag value + /// + public EventTagQuery Or(TTag tagValue); + + internal IReadOnlyList Conditions { get; } +} + +public record EventTagQueryCondition(Type? EventType, Type TagType, object TagValue); +``` + +This is the query spec that Marten translates to SQL with INNER JOINs on tag tables. + +--- + +## Phase 2: Marten — Tag Table Schema + +### 2a. EventTagTable Schema Object + +For each registered tag type, create a table: + +```sql +CREATE TABLE {schema}.mt_event_tag_{suffix} ( + seq_id BIGINT NOT NULL REFERENCES {schema}.mt_events(seq_id), + value {pg_type} NOT NULL, + PRIMARY KEY (seq_id) +); +CREATE INDEX ix_mt_event_tag_{suffix}_value + ON {schema}.mt_event_tag_{suffix} (value, seq_id); +``` + +- `{suffix}` derived from tag type name via snake_case (e.g., `StudentId` → `student_id`) +- `{pg_type}` derived from `ValueTypeInfo.SimpleType` → PostgreSQL type mapping (string→text, Guid→uuid, int→integer, long→bigint) +- Composite index on `(value, seq_id)` optimizes both tag queries and DCB assertion range scans +- Handle conjoined tenancy: add `tenant_id` column + adjust PK/indexes + +Implementation: +- New class `EventTagTable : Table` in `Marten.Events.Schema` +- Yielded from `EventGraph.FeatureSchema.createAllSchemaObjects()`: + +```csharp +// In createAllSchemaObjects(): +foreach (var tagRegistration in RegisteredTagTypes) +{ + yield return new EventTagTable(this, tagRegistration); +} +``` + +--- + +## Phase 3: Marten — Tag Persistence on Append + +### 3a. InsertEventTagOperation + +New `IStorageOperation` that inserts a row into a tag table: + +```sql +INSERT INTO {schema}.mt_event_tag_{suffix} (seq_id, value) VALUES (@seq_id, @value); +``` + +### 3b. Integration with Appenders + +**Quick path** (`QuickEventAppender`): +- After `QuickAppendEvents`, iterate events with tags +- For each tag on each event, queue an `InsertEventTagOperation` using the event's assigned `seq_id` + +**Rich path** (`RichEventAppender`): +- After assigning sequences via `EventSequenceFetcher`, iterate events with tags +- Queue `InsertEventTagOperation` for each tag +- Tags are written in the same transaction as events + +Both paths ensure tag inserts happen atomically with event inserts within the same `SaveChangesAsync()` transaction. + +--- + +## Phase 4: Marten — DCB Event Querying + +### 4a. Query API + +Add to `IEventStore`: + +```csharp +Task> QueryByTagsAsync(EventTagQuery query, CancellationToken ct = default); +Task AggregateByTagsAsync(EventTagQuery query, CancellationToken ct = default) where T : class; +``` + +### 4b. SQL Generation + +For a query like `query.Or(studentId).Or(courseId)`: + +```sql +SELECT e.* +FROM {schema}.mt_events e +INNER JOIN {schema}.mt_event_tag_student_id t1 ON e.seq_id = t1.seq_id +INNER JOIN {schema}.mt_event_tag_course_id t2 ON e.seq_id = t2.seq_id +WHERE (e.type = 'student_registered' AND t1.value = @p0) + OR (e.type = 'course_capacity_changed' AND t2.value = @p1) +ORDER BY e.seq_id +``` + +When tag type is the same across conditions, only one JOIN is needed. Multiple JOINs only when querying across different tag types. + +### 4c. AggregateByTagsAsync + +Runs the standard `AggregateTo()` pipeline (live fold) over the events returned by `QueryByTagsAsync`. Always a live aggregation — no inline projection support for DCB queries. + +--- + +## Phase 5: Marten — DCB FetchForWriting + +This is the key DCB primitive — load events by tag query, aggregate them, and assert no new matching events were added by `SaveChangesAsync()` time. + +### 5a. IEventBoundary + +```csharp +public interface IEventBoundary where T : notnull +{ + /// + /// The aggregate projected from the events matching the tag query + /// + T? Aggregate { get; } + + /// + /// The maximum seq_id from the tag query results. + /// Used as the consistency boundary marker. + /// + long LastSeenSequence { get; } + + /// + /// The events that matched the tag query + /// + IReadOnlyList Events { get; } + + /// + /// Append an event. The event MUST have tags set via WithTag() + /// so Marten can route it to the appropriate stream(s). + /// + void AppendOne(object @event); + void AppendMany(params object[] events); + void AppendMany(IEnumerable events); +} +``` + +Key differences from `IEventStream`: +- No stream identity (`Id`/`Key`) — this is a cross-stream query result +- Sequence-based assertion rather than version-based +- Events route to streams by their tags, not to a single predetermined stream +- Consistency is always enforced — no opt-in flag + +### 5b. FetchForWritingByTags API + +Add to `IEventStore`: + +```csharp +Task> FetchForWritingByTags( + EventTagQuery query, + CancellationToken ct = default) where T : class; +``` + +Implementation: +1. Execute the tag query (same SQL as `QueryByTagsAsync`) +2. Record `LastSeenSequence` = max `seq_id` from results +3. Aggregate events into `T` via live fold +4. Return `IEventBoundary` wrapping the aggregate, events, and sequence marker +5. Register the DCB assertion operation with the session's work tracker + +### 5c. DCB Assertion Operation + +New `IStorageOperation` that runs at `SaveChangesAsync()` time: + +```sql +SELECT EXISTS ( + SELECT 1 FROM {schema}.mt_event_tag_{suffix} t + WHERE t.value = @tagValue AND t.seq_id > @lastSeenSeqId + AND EXISTS ( + SELECT 1 FROM {schema}.mt_events e + WHERE e.seq_id = t.seq_id AND e.type = ANY(@eventTypes) + ) + LIMIT 1 +) +``` + +- If `true` → throw `ConcurrencyException` (or a DCB-specific subclass) +- One assertion per condition group in the original `EventTagQuery` +- Uses the `(value, seq_id)` composite index on the tag table for efficient range scans +- `EXISTS` + `LIMIT 1` avoids scanning all matching rows + +### 5d. Event Routing on Append + +When `IEventBoundary.AppendOne(event)` is called: +1. The event must have tags (set via `WithTag()`) +2. For each tag on the event: + - Resolve tag type → aggregate type (from document mapping: aggregate's identity type matches tag type) + - Look up `WorkTracker.TryFindStream()` for a `StreamAction` with matching aggregate type and identity value + - If found → append the event to that stream's `StreamAction` + - If no matching stream exists → create a new stream (or error — TBD, see open questions) +3. An event with multiple tags may be appended to multiple streams +4. Tag insert operations are also queued for persistence + +--- + +## Phase 6: Retroactive Tagging (Lower Priority) + +For migrating existing event stores to use tags: + +```csharp +session.Events.TagEvent(long sequenceId, params object[] tags); +session.Events.TagEvents(IEnumerable sequenceIds, params object[] tags); +``` + +Simple `INSERT` operations into tag tables. Does not participate in DCB consistency assertions. + +Add a second option that is destructive and completely rewrites any possible tag values for a single type of tags like: + +```csharp +session.Events.ReplaceTags(long sequenceId, params T[] tags); +session.Events.ReplaceTags(IEnumerable sequenceIds, params T[] tags); +``` + +--- + +## Open Questions + +### Tag Mutability and DCB Consistency + +Retroactive tagging (`TagEvent`) adds tags to existing events. If retroactive tagging is used concurrently with DCB operations, the assertion query (`seq_id > @lastSeenSeqId`) would miss tags added to older events after the read point. Options: + +- **Option A**: Retroactive tagging does not participate in DCB consistency (simplest). Tags added retroactively are for querying only, not for consistency boundaries. +- **Option B**: Add a `tag_added_at` timestamp or sequence to tag tables and include it in the assertion. More complex but fully consistent. + +**Recommendation**: Option A for now. Retroactive tagging is a migration/backfill tool, not a concurrent operation pattern. + +**Answer**: Use Option A. This is a very low level of risk + +### Multiple Tags of Same Type on One Event + +Is it valid to tag an event with two different values of the same tag type? E.g., an event tagged with `StudentId("s1")` AND `StudentId("s2")`? + +If yes: the tag table PK must be `(seq_id, value)` composite instead of `(seq_id)` alone. +If no: PK on `(seq_id)` alone is correct and simpler. + +**Answer**: Yes, we will need to support one to many + +### Auto-Tag from IEventBoundary + +Should `IEventBoundary.AppendOne()` auto-tag appended events based on the query that loaded the stream? Or must callers always explicitly tag via `WithTag()`? Auto-tagging reduces boilerplate but is implicit. + +**Answer**: Require users to explicitly set tags. we may have to revisit this + +### Stream Auto-Creation on Tag Routing + +When an event is tagged with a `StudentId` value but no `Student` stream exists yet in the session, should Marten auto-create the stream (via `StartStream`)? Or should it require the stream to already be open via `FetchForWriting()`? + +**Answer**: yes. + +### Tag Table Naming Collisions + +Using short type name for table suffix (`student_id` from `StudentId`). If two different tag types in different namespaces have the same short name, this would collide. Options: +- Short name (simpler, collision risk) +- Allow explicit table name override in `RegisterTagType()` +- Use full namespace-qualified name (verbose) + +**Recommendation**: Short name by default with optional override. + +**Answer**: use the recommendation + +### Tag Type to Aggregate Type Association + +Should the mapping from tag type to aggregate type be: +- Always inferred from document mapping (aggregate's identity type = tag type) +- Optionally explicit via `RegisterTagType().ForAggregate()` +- Both (infer by default, allow explicit override) + +**Recommendation**: Infer by default, allow explicit override for edge cases where the identity type doesn't match. + +**Answer**: yes, use the recommendation + +--- + +## Implementation Order + +| Step | Repo | What | Depends On | +|------|------|------|------------| +| 1 | JasperFx.Events | `EventTag` record, `IEvent.Tags`, `WithTag()` | — | +| 2 | JasperFx.Events | Tag type registry using `ValueTypeInfo` | Step 1 | +| 3 | JasperFx.Events | `EventTagQuery` specification | Step 2 | +| 4 | Marten | `EventTagTable` schema object + DDL generation | Steps 1-2 | +| 5 | Marten | `InsertEventTagOperation` + appender integration | Steps 1, 4 | +| 6 | Marten | `QueryByTagsAsync` + `AggregateByTagsAsync` | Steps 3-5 | +| 7 | Marten | `IEventBoundary` + `FetchForWritingByTags` | Step 6 | +| 8 | Marten | DCB assertion operation | Step 7 | +| 9 | Marten | Event routing by tags to open streams | Steps 7-8 | +| 10 | Marten | Retroactive tagging API | Step 4 | +| 11 | Marten | Load testing DCB assertions under contention | Steps 8-9 | +| 12 | Marten | Create a new documentation page under the event sourcing documentation for the usage of DCB with Marten. In the section on FetchForWriting, mention the new DCB support and link to the DCB content | +--- + +## Future Phase: Wolverine Integration + +Deferred to a follow-up phase. Will include: +- `[DcbAggregate]` attribute for handler parameters +- `LoadDcbAggregateFrame` code generation +- Convention-based tag discovery from command properties +- Tag-aware event routing in handler workflow +- Integration with existing `MartenBatchingPolicy` for batched loads +- Documentation and samples + +We will need to discuss possible usages for APIs that allow you to go from an incoming Wolverine message to the inputs to Marten +after the initial implementation. + +--- + +## Key Files to Modify + +### JasperFx.Events +- `src/JasperFx.Events/IEvent.cs` — add `Tags` property, `WithTag()` methods +- `src/JasperFx.Events/Event.cs` — implement tag storage +- New: `src/JasperFx.Events/EventTag.cs` — tag record +- New: `src/JasperFx.Events/Tags/TagTypeRegistration.cs` — registry +- New: `src/JasperFx.Events/Tags/EventTagQuery.cs` — query spec + +### Marten +- `src/Marten/Events/EventGraph.cs` — tag type registration API +- `src/Marten/Events/EventGraph.FeatureSchema.cs` — yield tag table schema objects +- New: `src/Marten/Events/Schema/EventTagTable.cs` — tag table DDL +- New: `src/Marten/Events/Operations/InsertEventTagOperation.cs` — tag persistence +- `src/Marten/Events/QuickEventAppender.cs` — queue tag inserts +- `src/Marten/Events/RichEventAppender.cs` — queue tag inserts +- New: `src/Marten/Events/Dcb/IEventBoundary.cs` — DCB stream interface +- New: `src/Marten/Events/Dcb/EventBoundary.cs` — DCB stream implementation +- New: `src/Marten/Events/Dcb/AssertDcbConsistency.cs` — assertion operation +- `src/Marten/Events/EventStore.cs` — new query/fetch APIs +- `src/Marten/Events/IEventStore.cs` — new API surface diff --git a/Directory.Packages.props b/Directory.Packages.props index 231a78a1ff..cea3cc7ee0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,9 +13,9 @@ - - - + + + @@ -59,11 +59,23 @@ - + + + + + + + + + + + + + diff --git a/centralization-for-marten-9.md b/centralization-for-marten-9.md new file mode 100644 index 0000000000..dec2d5ce77 --- /dev/null +++ b/centralization-for-marten-9.md @@ -0,0 +1,114 @@ +# Centralization Plan for Marten 9: Shared Infrastructure with Polecat via Weasel.Core + +## Context + +Marten (PostgreSQL) and Polecat (SQL Server 2025) share significant infrastructure patterns. Common base types and interfaces should move to Weasel.Core so both projects can share them without database-specific coupling. + +--- + +## Category 1: Metadata Interfaces — Identical, move to Weasel.Core + +These are exact duplicates (modulo namespace and nullability) with zero database-specific logic. They're pure document metadata contracts. + +| Interface | Marten (`Marten.Metadata`) | Polecat (`Polecat.Metadata`) | Status | +|-----------|--------|---------|--------| +| `IVersioned` | `Guid Version { get; set; }` | `Guid Version { get; set; }` | **Identical** | +| `ISoftDeleted` | `bool Deleted; DateTimeOffset? DeletedAt` | `bool Deleted; DateTimeOffset? DeletedAt` | **Identical** | +| `IRevisioned` | `int Version { get; set; }` | Does not exist yet | Marten-only; worth putting in Weasel.Core for Polecat to adopt later | +| `ITracked` | Non-nullable `string` members | Nullable `string?` members | **Needs alignment** — Polecat's nullable approach is more correct | + +**Action:** Move all four to `Weasel.Core.Metadata` namespace. Align `ITracked` on nullable strings. Both Marten and Polecat type-forward or alias from their own namespaces. + +--- + +## Category 2: Serialization Enums — Already partially shared + +| Type | Weasel.Core | Marten | Polecat | +|------|-------------|--------|---------| +| `EnumStorage` | Yes (canonical) | Uses Weasel.Core's | Own copy (duplicate) | +| `Casing` | **No** | Defined in `ISerializer.cs` | Own copy (duplicate) | +| `CollectionStorage` | **No** | Defined in `ISerializer.cs` | Own copy (duplicate) | +| `NonPublicMembersStorage` | **No** | Defined in `ISerializer.cs` | Own copy (duplicate) | + +**Action:** Move `Casing`, `CollectionStorage`, and `NonPublicMembersStorage` to Weasel.Core alongside the existing `EnumStorage`. Polecat already duplicates all three and can switch to the Weasel.Core versions. + +--- + +## Category 3: ISerializer Interface — Strong overlap, worth unifying + +### Shared surface (present in both Marten and Polecat) + +```csharp +EnumStorage EnumStorage { get; } +Casing Casing { get; } +string ToJson(object document); +T FromJson(Stream stream); +T FromJson(DbDataReader reader, int index); +object FromJson(Type type, Stream stream); +object FromJson(Type type, DbDataReader reader, int index); +ValueTask FromJsonAsync(Stream stream, CancellationToken cancellationToken = default); +ValueTask FromJsonAsync(Type type, Stream stream, CancellationToken cancellationToken = default); +``` + +### Marten-only additions + +- `ValueCasting ValueCasting { get; }` — controls LINQ Select() casting behavior +- `string ToCleanJson(object? document)` — serialize without type metadata +- `string ToJsonWithTypes(object document)` — serialize with embedded type info +- `ValueTask FromJsonAsync(DbDataReader reader, int index, CancellationToken)` — async reader deserialization +- `ValueTask FromJsonAsync(Type type, DbDataReader reader, int index, CancellationToken)` — async reader deserialization (non-generic) + +### Polecat-only additions + +- `T FromJson(string json)` — string-based deserialization +- `object FromJson(Type type, string json)` — string-based deserialization (non-generic) + +**Action:** Define a common `ISerializer` interface in Weasel.Core with the shared members. Both Marten and Polecat extend it with project-specific additions via their own derived interfaces. + +--- + +## Category 4: IStorageOperation — Similar but divergent patterns + +Both have a storage operation concept with `DocumentType`, `Role`, and `PostprocessAsync(DbDataReader)`, but the interfaces diverge: + +| Aspect | Marten | Polecat | +|--------|--------|---------| +| Inheritance | Inherits `IQueryHandler` (LINQ) | Standalone | +| Role | `OperationRole Role()` (method) | `OperationRole Role { get; }` (property) | +| Postprocess | `IList` parameter | No exceptions parameter | +| Command setup | Via `IQueryHandler` | `ConfigureCommand(ICommandBuilder)` | +| Document ID | Not on interface | `object? DocumentId` default method | +| Enum values | `Upsert, Insert, Update, Deletion, Patch, Events, Other` | `Upsert, Insert, Update, Delete, Patch` | + +**Action:** Extract a minimal common `OperationRole` enum and a slim `IStorageOperation` base to Weasel.Core. Both projects extend with their specific needs. Lower priority since the divergence is larger. + +--- + +## Category 5: Session Interfaces — Too divergent, do not share + +`IQuerySession`, `IDocumentSession`, `IDocumentOperations`, `IDocumentStore` are conceptually similar but: + +- **Marten** is much larger: `NpgsqlConnection Connection`, full-text search, bulk insert via PostgreSQL COPY, dirty tracking sessions, serializable isolation variants, 65+ members on `IDocumentStore` +- **Polecat** is intentionally minimal: ~10 members on `IDocumentStore`, no dirty tracking, no full-text search + +Forcing a common interface would either bloat Polecat or gut Marten. + +**Action:** Keep project-specific. No shared base. + +--- + +## Recommended Priority Order + +1. **Metadata interfaces** (`IVersioned`, `ISoftDeleted`, `ITracked`, `IRevisioned`) → Weasel.Core + - Easiest win, zero risk, zero database coupling + +2. **Serialization enums** (`Casing`, `CollectionStorage`, `NonPublicMembersStorage`) → Weasel.Core + - Alongside existing `EnumStorage`. Eliminates Polecat duplicates + +3. **ISerializer base interface** → Weasel.Core with shared members + - Both projects extend for their extras + +4. **OperationRole enum + minimal IStorageOperation** → Weasel.Core + - Lower priority, interfaces diverge more + +5. **Session interfaces** → Do not share diff --git a/dcb-concepts.md b/dcb-concepts.md new file mode 100644 index 0000000000..8851238962 --- /dev/null +++ b/dcb-concepts.md @@ -0,0 +1,191 @@ +# DCB (Dynamic Consistency Boundary) Implementation in Marten + +## What is DCB? + +DCB is a technique for enforcing consistency in event-driven systems without rigid aggregate-based transactional boundaries. It allows events to be assigned to **multiple domain concepts** via tags, and enforces consistency across them using conditional appends. + +**Core spec (https://dcb.events/):** +- **Read**: Filter events by event type (OR) and/or tags (AND within a query item, OR across items) +- **Write**: Atomically persist events, failing if any event matching a query exists after a given global sequence position + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Coexistence | DCB coexists with traditional stream-based event sourcing | Purely additive — users tag some events, use streams for others | +| Stream requirement | DCB events always belong to a stream | Keeps existing stream infrastructure intact | +| Condition model | First-class "condition" pattern | Encapsulates query + state + consistency check as a formal concept | +| Append atomicity | Condition check within `SaveChangesAsync()` transaction | True consistency, no check-then-act race | +| Append modes | Both Rich and Quick | Full compatibility | +| Tenancy | Scoped to current tenant by default | Opt-in for cross-tenant queries | +| Portability | Abstractions in JasperFx.Events, PostgreSQL impl in Marten | Maximizes portability | +| Tag extraction | Write-time with backfill tool | Pre-computed for query performance; backfill for migration | +| Schema | Opt-in `tags TEXT[]` column + GIN index on `mt_events` | No impact to existing users | +| Performance target | ~1ms per event at 1M events | Aligned with DCB FAQ benchmarks | + +## Storage Approach + +### Recommended: `TEXT[]` column with GIN index + +When DCB is enabled, add to `mt_events`: + +```sql +ALTER TABLE mt_events ADD COLUMN tags TEXT[]; +CREATE INDEX ix_mt_events_tags ON mt_events USING GIN (tags); +``` + +**Why this over alternatives:** +- **vs normalized junction table**: No JOINs, single-table queries, simpler writes +- **vs JSONB headers**: Dedicated column with native array operators, proper GIN indexing, no mixing concerns +- **vs query-time computation**: Pre-computed tags enable indexed conditional append checks — the core DCB performance requirement + +### Conditional Append SQL + +The append condition check runs within the same transaction as event insertion: + +```sql +SELECT EXISTS ( + SELECT 1 FROM mt_events + WHERE seq_id > @after + AND ( + -- Query Item 1 (types OR'd, tags AND'd within item) + (type = ANY(@types1) AND tags @> @tags1) + OR + -- Query Item 2 + (type = ANY(@types2) AND tags @> @tags2) + ) + -- Tenant scoping (default) + AND tenant_id = @tenantId +) +``` + +If this returns `true`, the append fails with a concurrency exception. + +## API Syntax + +### Tag Registration + +**Fluent configuration in StoreOptions:** + +```csharp +opts.Events.EnableDcb(); // opt-in, triggers schema addition + +opts.Events.TagEvent(e => new[] +{ + $"student:{e.StudentId}", + $"course:{e.CourseId}" +}); + +opts.Events.TagEvent(e => new[] +{ + $"course:{e.CourseId}" +}); +``` + +**Interface on the event (alternative):** + +```csharp +public record StudentSubscribedToCourse(Guid StudentId, Guid CourseId) : ITaggedEvent +{ + public IEnumerable GetTags() => [$"student:{StudentId}", $"course:{CourseId}"]; +} +``` + +### Condition Pattern + +A "condition" is a first-class concept that encapsulates: +1. **Query**: What event types and tags to look for +2. **State**: Projecting matching events into a decision model +3. **Check**: Whether the append should proceed +4. **Enforcement**: Atomic validation during `SaveChangesAsync()` + +```csharp +public class CourseSubscriptionCondition : AppendCondition +{ + public bool CourseExists { get; set; } + public int Capacity { get; set; } + public int Subscriptions { get; set; } + + // Define which events this condition queries + public override void ConfigureQuery(DcbQueryBuilder query, Guid courseId, Guid studentId) + { + query + .Match([$"course:{courseId}"]) + .Match([$"course:{courseId}"]) + .Match([$"course:{courseId}"]) + .Match([$"student:{studentId}"]); + } + + // Build state from matching events + public void Apply(CourseDefined e) => CourseExists = true; + public void Apply(CourseCapacityChanged e) => Capacity = e.NewCapacity; + public void Apply(StudentSubscribedToCourse e) => Subscriptions++; + + // Evaluate whether append is allowed + public override bool CanAppend() => + CourseExists && Subscriptions < Capacity; +} +``` + +**Usage:** + +```csharp +var condition = await session.Events + .BuildCondition(courseId, studentId); + +if (condition.CanAppend()) +{ + session.Events.AppendWithCondition( + streamId, + condition, + new StudentSubscribedToCourse(studentId, courseId) + ); + await session.SaveChangesAsync(); + // ^ condition check + event insert in same transaction + // throws concurrency exception if condition violated +} +``` + +### Backfill Tool + +For existing events when enabling DCB or adding new tag definitions: + +```csharp +await store.Events.BackfillTagsAsync(CancellationToken.None); +``` + +This reads events in batches, applies registered tag extractors, and updates the `tags` column. + +## Architecture Split + +### JasperFx.Events (portable) + +- `ITaggedEvent` interface +- `AppendCondition` base class / condition model +- `DcbQueryBuilder` — query construction +- `IDcbQuery` / `DcbQueryItem` — query representation +- Tag extraction registration abstractions +- Apply method conventions for condition state + +### Marten (PostgreSQL-specific) + +- `EnableDcb()` opt-in configuration +- `tags TEXT[]` column addition to `EventsTable` +- GIN index creation +- SQL generation for conditional append check +- Integration with Rich and Quick append paths +- `BackfillTagsAsync()` migration tool +- Tenant scoping in condition queries + +## DCB Spec Mapping + +| DCB Spec Concept | Marten Implementation | +|---|---| +| Sequence Position | `seq_id` (existing global sequence) | +| Event Type | `type` column (existing) | +| Tags | `tags TEXT[]` column (new, opt-in) | +| Query | `DcbQueryBuilder` → SQL with `type = ANY(...)` and `tags @> ARRAY[...]` | +| Query Item (types OR, tags AND) | Single `WHERE` clause per item, items combined with `OR` | +| Append Condition `failIfEventsMatch` | `SELECT EXISTS(...)` check in `SaveChangesAsync()` transaction | +| Append Condition `after` position | `WHERE seq_id > @after` in the condition query | +| Atomic append | PostgreSQL transaction wrapping condition check + event INSERT | diff --git a/dcb-summary.md b/dcb-summary.md new file mode 100644 index 0000000000..21ad0bb955 --- /dev/null +++ b/dcb-summary.md @@ -0,0 +1,65 @@ +# Marten DCB Implementation Summary + +## Shared Types (JasperFx.Events) +- **`EventTag`** — `readonly record struct(Type TagType, object Value)` stored on each event +- **`TagTypeRegistration`** — Wraps a strong-typed ID type (e.g. `StudentId(Guid Value)`) with `ValueTypeInfo`, `TableSuffix`, `SimpleType`, and optional `AggregateType` +- **`EventTagQuery`** — Builds OR'd conditions of `(EventType?, TagType, TagValue)` via fluent `Or(value)` API +- **`IEvent.WithTag()`** — Extension methods to attach tags to events; uses `TagValueExtractor` (compiled lambdas) to unwrap inner values + +## Schema & Registration +- **`EventGraph`** — Holds `List`, exposes `RegisterTagType()`, `FindTagType()`, `TagTypes` +- **`EventTagTable`** — One table per tag type: `mt_event_tag_{suffix}` with composite PK `(value, seq_id)`, FK to `mt_events.seq_id`. Value column type maps from SimpleType (Guid→uuid, string→text, int→integer, etc.) +- **`EventGraph.FeatureSchema`** — Yields `EventTagTable` instances in schema object generation + +## Tag Insert Operations (3 paths) +1. **`InsertEventTagOperation`** — Rich mode: direct `INSERT (value, seq_id) VALUES (...)` using pre-assigned sequence +2. **`InsertEventTagByEventIdOperation`** — Quick mode fallback: `INSERT ... SELECT seq_id FROM mt_events WHERE id = @eventId` for individual insert paths (Start/ExpectedVersion) +3. **`QuickAppendEventFunction`** — Quick mode optimized: tag value arrays passed as `varchar[]` parameters to the PL/pgSQL function; tags inserted inline in the same loop using the already-available `seq` variable +- **`EventTagOperations`** — Static helper dispatching to `QueueTagOperations()` (Rich) or `QueueTagOperationsByEventId()` (Quick individual paths) + +## Code Generation (Quick Append) +- **`EventDocumentStorageGenerator.buildQuickAppendOperation()`** — Conditionally emits `writeAllTagValues(parameterBuilder)` when `graph.TagTypes.Count > 0` +- **`QuickAppendEventsOperationBase.writeAllTagValues()`** — Builds parallel `string?[]` arrays per tag type from event tags, appends as `NpgsqlDbType.Array | Varchar` +- **`QuickEventAppender`** — Routes: function path skips separate tag ops (handled in-function); Start/ExpectedVersion paths queue `InsertEventTagByEventIdOperation` + +## Query & Consistency +- **`EventStore.Dcb.cs`** — Session APIs: `QueryByTagsAsync()`, `AggregateByTagsAsync()`, `FetchForWritingByTags()`. Builds SQL with INNER JOINs to tag tables + OR'd WHERE conditions +- **`AssertDcbConsistency`** — `IStorageOperation` queued at fetch time, runs at `SaveChangesAsync`: `EXISTS` query checks for events with `seq_id > lastSeenSequence` matching the tag query. Throws `DcbConcurrencyException` if violated +- **`IEventBoundary`** / **`EventBoundary`** — Wraps query result: `Aggregate`, `Events`, `LastSeenSequence`. `AppendOne()`/`AppendMany()` route new events to streams by tag values +- **`FetchForWritingByTagsHandler`** — `IQueryHandler` for batch query support; same SQL building + assertion registration + +## Batch Querying +- **`IBatchEvents.FetchForWritingByTags()`** — Interface on `IBatchedQuery` +- **`BatchedQuery.Events.cs`** — Delegates to `FetchForWritingByTagsHandler` via `AddItem()` + +## Key File Locations + +### JasperFx.Events (shared) +| File | Purpose | +|------|---------| +| `JasperFx.Events/EventTag.cs` | Core tag value type | +| `JasperFx.Events/Tags/TagTypeRegistration.cs` | Tag type registration & value extraction | +| `JasperFx.Events/Tags/EventTagQuery.cs` | Query specification with OR'd conditions | +| `JasperFx.Events/IEvent.cs` | `WithTag()` extension methods | +| `JasperFx.Events/Event.cs` | Tag storage on event instances | + +### Marten +| File | Purpose | +|------|---------| +| `Marten/Events/EventGraph.cs` | `RegisterTagType()`, tag type list | +| `Marten/Events/EventGraph.FeatureSchema.cs` | Schema generation for tag tables | +| `Marten/Events/Schema/EventTagTable.cs` | Tag table DDL (per tag type) | +| `Marten/Events/Schema/QuickAppendEventFunction.cs` | PL/pgSQL function with inline tag inserts | +| `Marten/Events/Operations/InsertEventTagOperation.cs` | Rich mode tag insert | +| `Marten/Events/Operations/InsertEventTagByEventIdOperation.cs` | Quick mode tag insert (by event ID) | +| `Marten/Events/Operations/EventTagOperations.cs` | Static tag operation dispatcher | +| `Marten/Events/Operations/QuickAppendEventsOperationBase.cs` | `writeAllTagValues()` for function path | +| `Marten/Events/QuickEventAppender.cs` | Append routing (function vs individual) | +| `Marten/Events/CodeGeneration/EventDocumentStorageGenerator.cs` | Conditional tag codegen | +| `Marten/Events/EventStore.Dcb.cs` | Session-level DCB APIs | +| `Marten/Events/Dcb/IEventBoundary.cs` | Public boundary interface | +| `Marten/Events/Dcb/EventBoundary.cs` | Boundary implementation & event routing | +| `Marten/Events/Dcb/AssertDcbConsistency.cs` | Consistency check operation | +| `Marten/Events/Dcb/DcbConcurrencyException.cs` | Concurrency violation exception | +| `Marten/Events/Dcb/FetchForWritingByTagsHandler.cs` | Batch query handler | +| `Marten/Services/BatchQuerying/BatchedQuery.Events.cs` | Batch query integration | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f3d0805b13..3c960926ea 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -171,6 +171,8 @@ const config: UserConfig = { { text: 'Querying Events', link: '/events/querying' }, { text: 'Metadata', link: '/events/metadata' }, { text: 'Archiving Streams', link: '/events/archiving' }, + { text: 'Natural Keys', link: '/events/natural-keys' }, + { text: 'Dynamic Consistency Boundary', link: '/events/dcb' }, { text: 'Optimizing Performance', link: '/events/optimizing' }, { @@ -196,6 +198,7 @@ const config: UserConfig = { { text: 'Asynchronous Projections', link: '/events/projections/async-daemon' }, { text: 'Testing Projections', link: '/events/projections/testing' }, { text: 'Rebuilding Projections', link: '/events/projections/rebuilding' }, + { text: 'EF Core Projections', link: '/events/projections/efcore' }, { text: 'Projections and IoC Services', link: '/events/projections/ioc' }, { text: 'Async Daemon HealthChecks', link: '/events/projections/healthchecks' },] }, diff --git a/docs/events/dcb.md b/docs/events/dcb.md new file mode 100644 index 0000000000..e2f232daaa --- /dev/null +++ b/docs/events/dcb.md @@ -0,0 +1,114 @@ +# Dynamic Consistency Boundary (DCB) + +The Dynamic Consistency Boundary (DCB) pattern allows you to query and enforce consistency across events from multiple streams using **tags** -- strong-typed identifiers attached to events at append time. This is useful when your consistency boundary doesn't align with a single event stream. + +## Concept + +In traditional event sourcing, consistency is enforced per-stream using optimistic concurrency on the stream version. DCB extends this by letting you: + +1. **Tag** events with one or more strong-typed identifiers +2. **Query** events across streams by those tags +3. **Aggregate** tagged events into a view (like a live aggregation, but cross-stream) +4. **Enforce consistency** at save time -- detecting if new matching events were appended since you last read + +## Registering Tag Types + +Tag types are strong-typed identifiers (typically `record` types wrapping a primitive). Register them during store configuration: + + + + +Each tag type gets its own table (`mt_event_tag_student`, `mt_event_tag_course`, etc.) with a composite primary key of `(value, seq_id)`. + +### Tag Type Requirements + +Tag types should be simple wrapper records around a primitive value: + + + + +Supported inner value types: `Guid`, `string`, `int`, `long`, `short`. + +Tags work with both **Rich** (default) and **Quick** append modes. In Rich mode, tags are inserted using pre-assigned sequence numbers. In Quick mode, tags are inserted using a subquery that looks up the sequence from the event's id. + +## Tagging Events + +Use `BuildEvent` and `WithTag` to attach tags before appending: + + + + +Events can have multiple tags of different types. Tags are persisted to their respective tag tables in the same transaction as the event. + +## Querying Events by Tags + +Use `EventTagQuery` to build a query, then execute it with `QueryByTagsAsync`: + + + + +### Multiple Tags (OR) + + + + +### Filtering by Event Type + + + + +Events are always returned ordered by sequence number (global append order). + +## Aggregating by Tags + +Build an aggregate from tagged events, similar to `AggregateStreamAsync` but across streams. First define an aggregate that applies the tagged events: + + + + +Then aggregate across streams by tag query: + + + + +Returns `null` if no matching events are found. + +## Fetch for Writing (Consistency Boundary) + +`FetchForWritingByTags` loads the aggregate and establishes a consistency boundary. At `SaveChangesAsync` time, Marten checks whether any new events matching the query have been appended since the read, throwing `DcbConcurrencyException` if so: + + + + +### Handling Concurrency Violations + + + + +::: tip +The consistency check only detects events that match the **same tag query**. Events appended to unrelated tags or streams will not cause a violation. +::: + +## How It Works + +### Storage + +Each registered tag type creates a PostgreSQL table: + +```sql +CREATE TABLE IF NOT EXISTS mt_event_tag_student ( + value uuid NOT NULL, + seq_id bigint NOT NULL, + CONSTRAINT pk_mt_event_tag_student PRIMARY KEY (value, seq_id), + CONSTRAINT fk_mt_event_tag_student_events + FOREIGN KEY (seq_id) REFERENCES mt_events(seq_id) ON DELETE CASCADE +); +``` + +### Consistency Check + +At `SaveChangesAsync` time, Marten executes an `EXISTS` query checking for new events matching the tag query with `seq_id > lastSeenSequence`. This runs in the same transaction as the event appends, providing serializable consistency for the tagged boundary. + +### Tag Routing + +Events appended via `IEventBoundary.AppendOne()` are automatically routed to streams based on their tags. Each tag value becomes the stream identity, so events with the same tag value end up in the same stream. diff --git a/docs/events/natural-keys.md b/docs/events/natural-keys.md new file mode 100644 index 0000000000..39bddf1b6d --- /dev/null +++ b/docs/events/natural-keys.md @@ -0,0 +1,76 @@ +# Natural Keys + +Natural keys let you look up an event stream by a domain-meaningful identifier (like an order number or invoice code) instead of by its internal stream id. Marten maintains a separate lookup table that maps natural key values to stream ids, so you can use `FetchForWriting` and `FetchLatest` with your natural key in a single database round-trip. + +## When to Use Natural Keys + +Use natural keys when: + +- External systems or users reference aggregates by a business identifier (e.g., `"ORD-12345"`) rather than a `Guid` stream id +- You need to look up streams by a human-readable identifier without maintaining your own separate index +- Your aggregate has a stable "business key" that may occasionally change (natural keys support mutation) + +## Declaring Natural Keys + +Mark a property on your aggregate with `[NaturalKey]`, and mark the methods that set or change the key value with `[NaturalKeySource]`: + + + + +The `[NaturalKeySource]` attribute tells Marten which `Create` / `Apply` methods produce or change the natural key value. Marten uses this information to keep the lookup table in sync whenever events are appended. + +## Event-to-Key Mappings + +Every event type that sets or changes the natural key must be declared through the `[NaturalKeySource]` attribute. When Marten processes events during an append operation, it extracts the key value from these mapped events and writes it to the lookup table. + +Events that do not affect the natural key (like `OrderItemAdded` in the example above) do not need any mapping. + +## Storage + +Marten automatically creates and manages a lookup table for each aggregate type that has a natural key configured. The table maps natural key values to stream ids and is: + +- Created automatically during schema migrations +- Partition-aware when using tenanted streams +- Updated transactionally alongside event appends +- Archive-aware (archived streams are excluded from lookups) + +You do not need to create or manage this table yourself. + +## FetchForWriting by Natural Key + +The primary use case for natural keys is looking up a stream for writing without knowing its stream id: + + + + +This resolves the natural key to a stream id and fetches the aggregate in a single database round-trip. + +## FetchLatest by Natural Key + +For read-only access, you can use `FetchLatest` with a natural key: + + + + +## Mutability + +Natural keys can change over the lifetime of a stream. When an event mapped with `[NaturalKeySource]` is appended, Marten updates the lookup table with the new value. The old key value is replaced, so lookups using the previous key will no longer resolve to that stream. + +## Null and Default Keys + +If a mapped event produces a `null` or default key value, Marten silently skips writing to the lookup table. This means streams where the natural key has not yet been assigned will not appear in natural key lookups, but will still be accessible by stream id. + +## Clean and Maintenance Operations + +The natural key lookup table is maintained automatically as part of normal event appending. If you need to rebuild the lookup table (for example, after a data migration), you can do so through Marten's schema management tools as part of a projection rebuild. + +## Testing Considerations + +When writing integration tests: + +- Natural key lookups work against the same session's uncommitted data, so you can append events and look up by natural key within the same unit of work +- If you are using `FetchForWriting` with a natural key that does not exist, the behavior is the same as with a stream id that does not exist + +## Integration with Wolverine + +Natural keys integrate with Wolverine's aggregate handler workflow. See the [Wolverine documentation on natural keys with Marten](/guide/durability/marten/event-sourcing#natural-keys) for details on how Wolverine resolves natural keys from command properties. diff --git a/docs/events/projections/efcore.md b/docs/events/projections/efcore.md new file mode 100644 index 0000000000..ddaed953e7 --- /dev/null +++ b/docs/events/projections/efcore.md @@ -0,0 +1,406 @@ +# EF Core Projections + +Marten provides first-class support for projecting events into Entity Framework Core `DbContext` entities. This lets you use EF Core's model configuration, change tracking, and migration tooling while still benefiting from Marten's event sourcing infrastructure. + +The `Marten.EntityFrameworkCore` NuGet package provides three projection base classes: + +| Base Class | Use Case | +| ------------ | ---------- | +| `EfCoreSingleStreamProjection` | Aggregate a single event stream into one EF Core entity | +| `EfCoreMultiStreamProjection` | Aggregate events across multiple streams into one EF Core entity | +| `EfCoreEventProjection` | React to individual events, writing to both EF Core and Marten | + +All three types support **Inline**, **Async**, and **Live** projection lifecycles. + +## Installation + +Add the `Marten.EntityFrameworkCore` NuGet package to your project: + +```bash +dotnet add package Marten.EntityFrameworkCore +``` + +## Defining a DbContext + +EF Core projections require a `DbContext` with entity mappings. Use `OnModelCreating` to configure table names and column mappings: + +```csharp +public class OrderDbContext : DbContext +{ + public OrderDbContext(DbContextOptions options) : base(options) { } + + public DbSet Orders => Set(); + public DbSet OrderSummaries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ef_orders"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CustomerName).HasColumnName("customer_name"); + entity.Property(e => e.TotalAmount).HasColumnName("total_amount"); + entity.Property(e => e.ItemCount).HasColumnName("item_count"); + entity.Property(e => e.IsShipped).HasColumnName("is_shipped"); + }); + } +} +``` + +::: tip +Entity tables defined in the DbContext are automatically migrated alongside Marten's own schema objects through Weasel. You do not need to run `dotnet ef database update` separately. +::: + +## Single Stream Projections + +Use `EfCoreSingleStreamProjection` to build an aggregate from a single event stream and persist it through EF Core. + +### Entity and Events + +```csharp +// Events +public record OrderPlaced(Guid OrderId, string CustomerName, decimal Amount, int Items); +public record OrderShipped(Guid OrderId); +public record OrderCancelled(Guid OrderId); + +// EF Core entity (the aggregate) +public class Order +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public bool IsShipped { get; set; } + public bool IsCancelled { get; set; } +} +``` + +### Projection Class + +Override `ApplyEvent` to handle each event. The `DbContext` is available for querying or writing side effects: + +```csharp +public class OrderAggregate + : EfCoreSingleStreamProjection +{ + public override Order? ApplyEvent( + Order? snapshot, Guid identity, IEvent @event, + OrderDbContext dbContext, IQuerySession session) + { + switch (@event.Data) + { + case OrderPlaced placed: + return new Order + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items + }; + + case OrderShipped: + if (snapshot != null) snapshot.IsShipped = true; + return snapshot; + + case OrderCancelled: + if (snapshot != null) snapshot.IsCancelled = true; + return snapshot; + } + + return snapshot; + } +} +``` + +### Registration + +Use the `StoreOptions.Add()` extension method to register the projection. This sets up EF Core storage, Weasel schema migration, and the projection lifecycle in one call: + +```csharp +var store = DocumentStore.For(opts => +{ + opts.Connection(connectionString); + opts.Add(new OrderAggregate(), ProjectionLifecycle.Inline); +}); +``` + +## Multi Stream Projections + +Use `EfCoreMultiStreamProjection` to aggregate events from multiple streams into a single EF Core entity. + +### Entity and Events + +```csharp +public record CustomerOrderPlaced(Guid OrderId, string CustomerName, decimal Amount); +public record CustomerOrderCompleted(Guid OrderId, string CustomerName); + +public class CustomerOrderHistory +{ + public string Id { get; set; } = string.Empty; + public int TotalOrders { get; set; } + public decimal TotalSpent { get; set; } +} +``` + +### Projection Class + +Use the constructor to configure event-to-aggregate identity mapping, then override `ApplyEvent`: + +```csharp +public class CustomerOrderHistoryProjection + : EfCoreMultiStreamProjection +{ + public CustomerOrderHistoryProjection() + { + // Map events to the aggregate identity (customer name in this case) + Identity(e => e.CustomerName); + Identity(e => e.CustomerName); + } + + public override CustomerOrderHistory? ApplyEvent( + CustomerOrderHistory? snapshot, string identity, + IEvent @event, OrderDbContext dbContext) + { + snapshot ??= new CustomerOrderHistory { Id = identity }; + + switch (@event.Data) + { + case CustomerOrderPlaced placed: + snapshot.TotalOrders++; + snapshot.TotalSpent += placed.Amount; + break; + } + + return snapshot; + } +} +``` + +### Registration + +```csharp +var store = DocumentStore.For(opts => +{ + opts.Connection(connectionString); + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.Add(new CustomerOrderHistoryProjection(), ProjectionLifecycle.Async); +}); +``` + +## Event Projections + +Use `EfCoreEventProjection` when you need to react to individual events and write to both EF Core entities and Marten documents in the same transaction: + +### Projection Class + +```csharp +public class OrderSummaryProjection : EfCoreEventProjection +{ + protected override async Task ProjectAsync( + IEvent @event, OrderDbContext dbContext, + IDocumentOperations operations, CancellationToken token) + { + switch (@event.Data) + { + case OrderPlaced placed: + // Write to EF Core + dbContext.OrderSummaries.Add(new OrderSummary + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items, + Status = "Placed" + }); + + // Also write to Marten + operations.Store(new Order + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items + }); + break; + + case OrderShipped shipped: + var summary = await dbContext.OrderSummaries + .FindAsync(new object[] { shipped.OrderId }, token); + if (summary != null) + { + summary.Status = "Shipped"; + } + break; + } + } +} +``` + +### Registration + +`EfCoreEventProjection` uses the standard `Projections.Add()` method with a separate call to register entity tables: + +```csharp +var store = DocumentStore.For(opts => +{ + opts.Connection(connectionString); + opts.Projections.Add(new OrderSummaryProjection(), ProjectionLifecycle.Inline); + opts.AddEntityTablesFromDbContext(); +}); +``` + +## Conjoined Multi-Tenancy + +EF Core single-stream and multi-stream projections support Marten's [conjoined multi-tenancy](/documents/multi-tenancy). When the event store uses `TenancyStyle.Conjoined`, the projection infrastructure automatically writes the tenant ID to each projected entity. + +### Requirements + +Your aggregate entity **must** implement `ITenanted` from `Marten.Metadata`. This interface adds a `TenantId` property that the projection infrastructure uses to write the tenant identifier: + +```csharp +using Marten.Metadata; + +public class TenantedOrder : ITenanted +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public bool IsShipped { get; set; } + public string? TenantId { get; set; } // Required by ITenanted +} +``` + +The DbContext must also map the `TenantId` property to a column: + +```csharp +public class TenantedOrderDbContext : DbContext +{ + public TenantedOrderDbContext(DbContextOptions options) + : base(options) { } + + public DbSet TenantedOrders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ef_tenanted_orders"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CustomerName).HasColumnName("customer_name"); + entity.Property(e => e.TotalAmount).HasColumnName("total_amount"); + entity.Property(e => e.ItemCount).HasColumnName("item_count"); + entity.Property(e => e.IsShipped).HasColumnName("is_shipped"); + entity.Property(e => e.TenantId).HasColumnName("tenant_id"); + }); + } +} +``` + +### Projection Class + +The projection class itself does not need any special tenancy logic. The base infrastructure sets `TenantId` automatically: + +```csharp +public class TenantedOrderAggregate + : EfCoreSingleStreamProjection +{ + public override TenantedOrder? ApplyEvent( + TenantedOrder? snapshot, Guid identity, IEvent @event, + TenantedOrderDbContext dbContext, IQuerySession session) + { + switch (@event.Data) + { + case OrderPlaced placed: + return new TenantedOrder + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items + }; + + case OrderShipped: + if (snapshot != null) snapshot.IsShipped = true; + return snapshot; + } + + return snapshot; + } +} +``` + +### Registration + +```csharp +var store = DocumentStore.For(opts => +{ + opts.Connection(connectionString); + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Add(new TenantedOrderAggregate(), ProjectionLifecycle.Inline); +}); +``` + +### Appending Events with a Tenant + +Use `ForTenant()` when opening a session to associate events with a specific tenant: + +```csharp +await using var session = store.LightweightSession("tenant-alpha"); +session.Events.StartStream(orderId, new OrderPlaced(orderId, "Alice", 100m, 3)); +await session.SaveChangesAsync(); +// The projected row in ef_tenanted_orders will have tenant_id = 'tenant-alpha' +``` + +### Validation + +Marten validates your configuration at startup. If the event store uses conjoined tenancy but your aggregate type does not implement `ITenanted`, Marten throws an `InvalidProjectionException` with a descriptive error message. + +### Limitations + +- **`EfCoreEventProjection` does not support conjoined tenancy validation.** The event projection base class (`EfCoreEventProjection`) is a lower-level `IProjection` implementation that does not participate in the aggregate tenancy validation. If you need multi-tenant event projections, you are responsible for reading the tenant ID from `@event.TenantId` and writing it yourself. + +- **Multi-stream projections with non-unique keys across tenants.** When using `EfCoreMultiStreamProjection` with conjoined tenancy, be aware that `DbContext.FindAsync` looks up entities by primary key only, not by a composite of primary key + tenant ID. If two tenants can produce the same aggregate key (e.g., a customer name), you must ensure globally unique aggregate IDs (such as GUIDs) or configure a composite primary key in EF Core that includes the tenant ID column. + +## Composite Projections + +EF Core projections can participate in [composite projections](/events/projections/composite) for multi-stage processing: + +```csharp +var store = DocumentStore.For(opts => +{ + opts.Connection(connectionString); + opts.Projections.Composite(composite => + { + composite.Add(opts, new OrderAggregate(), stageNumber: 1); + composite.Add(opts, new CustomerOrderHistoryProjection(), stageNumber: 2); + }, ProjectionLifecycle.Async); +}); +``` + +## DbContext Configuration + +All EF Core projection types expose a `ConfigureDbContext` method you can override to customize the `DbContextOptionsBuilder`. The Npgsql provider is already configured before this method is called: + +```csharp +public class MyProjection + : EfCoreSingleStreamProjection +{ + public override void ConfigureDbContext( + DbContextOptionsBuilder builder) + { + builder.EnableSensitiveDataLogging(); + } +} +``` + +## How It Works + +Under the hood, EF Core projections: + +1. **Create a per-slice DbContext** using the same PostgreSQL connection as the Marten session +2. **Register a transaction participant** so the DbContext's `SaveChangesAsync` is called within Marten's transaction, ensuring atomicity +3. **Migrate entity tables** through Weasel alongside Marten's own schema objects, so `dotnet ef` migrations are not needed +4. **Use EF Core change tracking** for insert vs. update detection (detached entities are added; unchanged entities are marked as modified) diff --git a/docs/scenarios/command_handler_workflow.md b/docs/scenarios/command_handler_workflow.md index 8f8ee871ba..fe12ba7fb0 100644 --- a/docs/scenarios/command_handler_workflow.md +++ b/docs/scenarios/command_handler_workflow.md @@ -270,6 +270,49 @@ public async Task Handle3(MarkItemReady command, IDocumentSession session) Do note that the `FetchForExclusiveWriting()` command can time out if it is unable to achieve a lock in a timely manner. In this case, Marten will throw a `StreamLockedException`. The lock will be released when either `IDocumentSession.SaveChangesAsync()` is called or the `IDocumentSession` is disposed. +## Enforcing Consistency Without Appending Events + +In some command handling scenarios, your business logic may evaluate the current aggregate state and decide that no new events need to be emitted. By default, if no events are appended to the stream returned by `FetchForWriting()`, Marten will not perform any concurrency check when `SaveChangesAsync()` is called. This means that if another process has modified the stream between your fetch and save, you won't know about it. + +If you need to guarantee that the stream has not been modified even when your handler doesn't emit events, you can set `AlwaysEnforceConsistency = true` on the stream: + +```cs +public async Task Handle(ValidateOrder command, IDocumentSession session) +{ + var stream = await session + .Events + .FetchForWriting(command.OrderId); + + // Tell Marten to enforce the optimistic concurrency check + // even if we don't append any events + stream.AlwaysEnforceConsistency = true; + + var order = stream.Aggregate; + + // Business logic that may or may not produce events + if (order.NeedsUpdate(command)) + { + stream.AppendOne(new OrderUpdated(command.Data)); + } + + // If no events were appended, Marten will still verify that the + // stream version hasn't changed since FetchForWriting() was called. + // Throws ConcurrencyException if another process modified the stream. + await session.SaveChangesAsync(); +} +``` + +When `AlwaysEnforceConsistency` is `true`: + +- **If events are appended**, Marten behaves exactly as before -- the normal optimistic concurrency check via `UpdateStreamVersion` is applied. +- **If no events are appended**, Marten issues an `AssertStreamVersion` check that reads the current stream version from the database and throws a `ConcurrencyException` if it doesn't match the version that was fetched. + +This is useful in workflows where: + +- A command handler conditionally emits events and you need to know if another process raced ahead +- You want to implement "read-then-validate" patterns where consistency of the read matters even without writes +- You're building saga or process manager patterns where skipping an event is a valid but concurrency-sensitive outcome + ## WriteToAggregate Lastly, there are several overloads of a method called `IEventStore.WriteToAggregate()` that just puts some syntactic sugar diff --git a/src/ContainerScopedProjectionTests/LetterProjection2.cs b/src/ContainerScopedProjectionTests/LetterProjection2.cs index 97601dfd50..549aed71bc 100644 --- a/src/ContainerScopedProjectionTests/LetterProjection2.cs +++ b/src/ContainerScopedProjectionTests/LetterProjection2.cs @@ -8,7 +8,7 @@ namespace ContainerScopedProjectionTests; -public class LetterProjection2: EventProjection +public partial class LetterProjection2: EventProjection { private readonly IPriceLookup _lookup; @@ -43,7 +43,7 @@ public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, C } [ProjectionVersion(3)] -public class LetterProjection2V3: EventProjection +public partial class LetterProjection2V3: EventProjection { private readonly IPriceLookup _lookup; diff --git a/src/CoreTests/Partitioning/partitioning_configuration.cs b/src/CoreTests/Partitioning/partitioning_configuration.cs index bf0d91064a..97fc4ba88e 100644 --- a/src/CoreTests/Partitioning/partitioning_configuration.cs +++ b/src/CoreTests/Partitioning/partitioning_configuration.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using Marten; using Marten.Schema; using Marten.Schema.Indexing.Unique; using Marten.Storage; @@ -264,4 +265,53 @@ public async Task unique_index_on_partitioned_table_can_be_applied_to_database() // that don't include all partition columns await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); } + + [Fact] + public async Task cannot_query_within_child_collections_across_partition_tenants() + { + StoreOptions(opts => + { + opts.Schema.For() + .MultiTenantedWithPartitioning(x => + { + x.ByHash(Enumerable.Range(0, 2).Select(i => $"h{i:000}").ToArray()); + }); + }); + + var targetBlue1 = Target.Random(); + targetBlue1.NestedObject = new([Target.Random()]); + targetBlue1.NestedObject.Targets[0].String = "not a green list"; + await using (var blue = theStore.LightweightSession("Blue")) + { + blue.Store(targetBlue1); + await blue.SaveChangesAsync(); + } + + var targetRed1 = Target.Random(); + targetRed1.NestedObject = new([Target.Random()]); + targetRed1.NestedObject.Targets[0].String = "not a green list"; + await using (var red = theStore.LightweightSession("Red")) + { + red.Store(targetRed1); + await red.SaveChangesAsync(); + } + + await using (var red = theStore.QuerySession("Red")) + { + (await red.Query() + .Where(x => x.NestedObject.Targets.Any(i => i.String != null && i.String == "not a green list")) + .ToListAsync()) + .ShouldHaveSingleItem() + .Id.ShouldBe(targetRed1.Id); + } + + await using (var blue = theStore.QuerySession("Blue")) + { + (await blue.Query() + .Where(x => x.NestedObject.Targets.Any(i => i.String != null && i.String == "not a green list")) + .ToListAsync()) + .ShouldHaveSingleItem() + .Id.ShouldBe(targetBlue1.Id); + } + } } diff --git a/src/CoreTests/reading_configuration_from_jasperfxoptions.cs b/src/CoreTests/reading_configuration_from_jasperfxoptions.cs index 4301a0ac2f..b3b1d43309 100644 --- a/src/CoreTests/reading_configuration_from_jasperfxoptions.cs +++ b/src/CoreTests/reading_configuration_from_jasperfxoptions.cs @@ -183,6 +183,49 @@ public void using_optimized_mode_in_production() rules.SourceCodeWritingEnabled.ShouldBeFalse(); } + [Fact] + public void application_assembly_from_critter_stack_defaults_flows_to_store_options() + { + var expectedAssembly = typeof(JasperFxOptions).Assembly; + + using var container = Container.For(services => + { + services.AddMarten(ConnectionSource.ConnectionString); + services.CritterStackDefaults(x => x.ApplicationAssembly = expectedAssembly); + }); + + var store = container.GetInstance().As(); + + #pragma warning disable CS0618 + store.Options.ApplicationAssembly.ShouldBe(expectedAssembly); + #pragma warning restore CS0618 + } + + [Fact] + public void explicit_store_options_application_assembly_takes_precedence_over_critter_stack_defaults() + { + var critterStackAssembly = typeof(JasperFxOptions).Assembly; + var explicitAssembly = typeof(StoreOptions).Assembly; + + using var container = Container.For(services => + { + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + #pragma warning disable CS0618 + opts.ApplicationAssembly = explicitAssembly; + #pragma warning restore CS0618 + }); + services.CritterStackDefaults(x => x.ApplicationAssembly = critterStackAssembly); + }); + + var store = container.GetInstance().As(); + + #pragma warning disable CS0618 + store.Options.ApplicationAssembly.ShouldBe(explicitAssembly); + #pragma warning restore CS0618 + } + public static void default_setup() { #region sample_simplest_possible_setup diff --git a/src/DaemonTests/Bugs/Bug_3080_WaitForNonStaleData_should_work_dammit.cs b/src/DaemonTests/Bugs/Bug_3080_WaitForNonStaleData_should_work_dammit.cs index b2c59a6d2a..ab364669e1 100644 --- a/src/DaemonTests/Bugs/Bug_3080_WaitForNonStaleData_should_work_dammit.cs +++ b/src/DaemonTests/Bugs/Bug_3080_WaitForNonStaleData_should_work_dammit.cs @@ -19,7 +19,7 @@ namespace DaemonTests.Bugs; -public class Bug_3080_WaitForNonStaleData_should_work_dammit +public partial class Bug_3080_WaitForNonStaleData_should_work_dammit { [Fact] public async Task WaitForNonStaleProjectionDataAsync_does_not_work() @@ -157,7 +157,7 @@ public MyAggregate Apply(MyAggregateUpdated @event) => this with { Name = @event.Name }; } - public class MyAggregateTableProjection: EventProjection + public partial class MyAggregateTableProjection: EventProjection { private readonly string _tableName; diff --git a/src/DaemonTests/Bugs/Bug_deletewhere_should_remove_inserted_item.cs b/src/DaemonTests/Bugs/Bug_deletewhere_should_remove_inserted_item.cs index d4d7f0b46e..f557b780ba 100644 --- a/src/DaemonTests/Bugs/Bug_deletewhere_should_remove_inserted_item.cs +++ b/src/DaemonTests/Bugs/Bug_deletewhere_should_remove_inserted_item.cs @@ -81,7 +81,7 @@ public record HardDeleteEvent(Guid Id); public record CreateDeletableProjection(Guid Id, Guid InnerGuid); -public class DeletableEventProjection : EventProjection +public partial class DeletableEventProjection : EventProjection { public DeletableEventProjection() { diff --git a/src/DaemonTests/EventProjections/EventProjectionWithCreate_follow_up_operations.cs b/src/DaemonTests/EventProjections/EventProjectionWithCreate_follow_up_operations.cs index b3051b4619..3df243359c 100644 --- a/src/DaemonTests/EventProjections/EventProjectionWithCreate_follow_up_operations.cs +++ b/src/DaemonTests/EventProjections/EventProjectionWithCreate_follow_up_operations.cs @@ -11,7 +11,7 @@ namespace DaemonTests.EventProjections; -public class EventProjectionWithCreate_follow_up_operations: DaemonContext +public partial class EventProjectionWithCreate_follow_up_operations: DaemonContext { [Fact] public async Task rebuild_with_follow_up_operations_should_work() @@ -72,7 +72,7 @@ public record EntityCreated(Guid Id, string Name); public record EntityNameUpdated(Guid Id, string Name); - public class EntityProjection: EventProjection + public partial class EntityProjection: EventProjection { public EntityProjection() { diff --git a/src/DaemonTests/EventProjections/EventProjection_follow_up_operations.cs b/src/DaemonTests/EventProjections/EventProjection_follow_up_operations.cs index a19bf962b0..6fdc1346c8 100644 --- a/src/DaemonTests/EventProjections/EventProjection_follow_up_operations.cs +++ b/src/DaemonTests/EventProjections/EventProjection_follow_up_operations.cs @@ -52,7 +52,7 @@ public record NestedEntityProjection(Guid Id, List Entity); public record SomeOtherEntityWithNestedIdentifierPublished(Guid Id); - public class NestedEntityEventProjection: EventProjection + public partial class NestedEntityEventProjection: EventProjection { public NestedEntityEventProjection() { diff --git a/src/DaemonTests/EventProjections/event_projection_scenario_tests.cs b/src/DaemonTests/EventProjections/event_projection_scenario_tests.cs index 28df57230c..a741770da7 100644 --- a/src/DaemonTests/EventProjections/event_projection_scenario_tests.cs +++ b/src/DaemonTests/EventProjections/event_projection_scenario_tests.cs @@ -286,7 +286,7 @@ public class DeleteUser #region sample_user_projection_of_event_projection -public class UserProjection: EventProjection +public partial class UserProjection: EventProjection { public User Create(CreateUser create) { diff --git a/src/DaemonTests/EventProjections/event_projections_end_to_end.cs b/src/DaemonTests/EventProjections/event_projections_end_to_end.cs index 0edce3f970..1b9539c388 100644 --- a/src/DaemonTests/EventProjections/event_projections_end_to_end.cs +++ b/src/DaemonTests/EventProjections/event_projections_end_to_end.cs @@ -142,7 +142,7 @@ public override string ToString() #region sample_using_create_in_event_projection -public class DistanceProjection: EventProjection +public partial class DistanceProjection: EventProjection { public DistanceProjection() { diff --git a/src/DaemonTests/EventProjections/using_patches_in_async_mode.cs b/src/DaemonTests/EventProjections/using_patches_in_async_mode.cs index b9286de986..1b03c0634c 100644 --- a/src/DaemonTests/EventProjections/using_patches_in_async_mode.cs +++ b/src/DaemonTests/EventProjections/using_patches_in_async_mode.cs @@ -56,7 +56,7 @@ public async Task do_some_patching() public record StartAggregate; -public class LetterPatcher: EventProjection +public partial class LetterPatcher: EventProjection { public SimpleAggregate Transform(IEvent e) => new SimpleAggregate { Id = e.StreamId }; diff --git a/src/DaemonTests/MultiTenancy/using_for_tenant_with_side_effects_and_subscriptions.cs b/src/DaemonTests/MultiTenancy/using_for_tenant_with_side_effects_and_subscriptions.cs index ac6173352b..a47664744c 100644 --- a/src/DaemonTests/MultiTenancy/using_for_tenant_with_side_effects_and_subscriptions.cs +++ b/src/DaemonTests/MultiTenancy/using_for_tenant_with_side_effects_and_subscriptions.cs @@ -74,7 +74,7 @@ public async Task try_to_append_with_for_tenant_in_subscription() } -public class NumbersSubscription: EventProjection +public partial class NumbersSubscription: EventProjection { public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation) { diff --git a/src/DaemonTests/Resiliency/when_skipping_events_in_daemon.cs b/src/DaemonTests/Resiliency/when_skipping_events_in_daemon.cs index 0c8704d657..bb416c9a0f 100644 --- a/src/DaemonTests/Resiliency/when_skipping_events_in_daemon.cs +++ b/src/DaemonTests/Resiliency/when_skipping_events_in_daemon.cs @@ -288,7 +288,7 @@ public async Task see_the_dead_letter_events() } } -public class ErrorRejectingEventProjection: EventProjection +public partial class ErrorRejectingEventProjection: EventProjection { public ErrorRejectingEventProjection() { diff --git a/src/DaemonTests/TeleHealth/AppointmentDurationProjection.cs b/src/DaemonTests/TeleHealth/AppointmentDurationProjection.cs index 0d0ffd8dc1..df694987bc 100644 --- a/src/DaemonTests/TeleHealth/AppointmentDurationProjection.cs +++ b/src/DaemonTests/TeleHealth/AppointmentDurationProjection.cs @@ -7,7 +7,7 @@ namespace DaemonTests.TeleHealth; -public class AppointmentDurationProjection : EventProjection +public partial class AppointmentDurationProjection : EventProjection { public AppointmentDurationProjection() { diff --git a/src/DaemonTests/catching_up_mode_for_projections_and_subscriptions.cs b/src/DaemonTests/catching_up_mode_for_projections_and_subscriptions.cs index a81e01c071..980482e3c4 100644 --- a/src/DaemonTests/catching_up_mode_for_projections_and_subscriptions.cs +++ b/src/DaemonTests/catching_up_mode_for_projections_and_subscriptions.cs @@ -106,7 +106,7 @@ public class ADoc public long Id { get; set; } } -public class ADocEventProjection: EventProjection +public partial class ADocEventProjection: EventProjection { public void Project(IEvent e, IDocumentOperations ops) { diff --git a/src/DcbLoadTest/DcbLoadTest.csproj b/src/DcbLoadTest/DcbLoadTest.csproj new file mode 100644 index 0000000000..832d4786eb --- /dev/null +++ b/src/DcbLoadTest/DcbLoadTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/src/DcbLoadTest/Program.cs b/src/DcbLoadTest/Program.cs new file mode 100644 index 0000000000..59fa1a58d9 --- /dev/null +++ b/src/DcbLoadTest/Program.cs @@ -0,0 +1,185 @@ +using System.Diagnostics; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; +using Marten.Events; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- +const int SEED_STREAMS = 1000; // Pre-seed this many streams to simulate a large DB +const int SEED_EVENTS_PER_STREAM = 10; +const int BENCH_STREAMS = 200; // Streams to append during measurement +const int BENCH_EVENTS_PER_STREAM = 10; + +var connectionString = Environment.GetEnvironmentVariable("marten_testing_database") + ?? "Host=localhost;Port=5433;Database=marten_testing;Username=postgres;Password=postgres"; + +Console.WriteLine("DCB Load Test — Append into large database"); +Console.WriteLine($" Connection: {connectionString}"); +Console.WriteLine($" Seed: {SEED_STREAMS} streams x {SEED_EVENTS_PER_STREAM} events = {SEED_STREAMS * SEED_EVENTS_PER_STREAM} events"); +Console.WriteLine($" Bench: {BENCH_STREAMS} streams x {BENCH_EVENTS_PER_STREAM} events = {BENCH_STREAMS * BENCH_EVENTS_PER_STREAM} events"); +Console.WriteLine(new string('-', 90)); + +var results = new List<(string Scenario, int Iterations, TimeSpan Elapsed)>(); + +// --------------------------------------------------------------------------- +// Helper: build a configured store +// --------------------------------------------------------------------------- +IDocumentStore BuildStore(EventAppendMode mode) +{ + return DocumentStore.For(opts => + { + opts.Connection(connectionString); + opts.Events.AppendMode = mode; + opts.Events.AddEventType(); + opts.Events.AddEventType(); + opts.Events.RegisterTagType("customer"); + opts.Events.RegisterTagType("region"); + }); +} + +// --------------------------------------------------------------------------- +// Helper: seed a large database with tagged events +// --------------------------------------------------------------------------- +async Task SeedDatabase(IDocumentStore store) +{ + Console.Write($" Seeding {SEED_STREAMS * SEED_EVENTS_PER_STREAM} tagged events..."); + var sw = Stopwatch.StartNew(); + + for (var s = 0; s < SEED_STREAMS; s++) + { + await using var session = store.LightweightSession(); + var streamId = Guid.NewGuid(); + var customerId = new CustomerId(Guid.NewGuid()); + var regionId = new RegionId($"region-{s % 10}"); + + for (var e = 0; e < SEED_EVENTS_PER_STREAM; e++) + { + object data = e % 2 == 0 + ? new OrderPlaced($"ORD-{s}-{e}", 99.99m + e) + : new OrderShipped($"TRACK-{s}-{e}"); + + var wrapped = session.Events.BuildEvent(data); + wrapped.WithTag(customerId, regionId); + session.Events.Append(streamId, wrapped); + } + + await session.SaveChangesAsync(); + } + + sw.Stop(); + Console.WriteLine($" done in {sw.Elapsed.TotalSeconds:N1}s"); +} + +// --------------------------------------------------------------------------- +// Helper: benchmark appending new streams with tags into the already-large DB +// --------------------------------------------------------------------------- +async Task BenchmarkAppend(string name, IDocumentStore store, bool withTags) +{ + Console.Write($" {name}..."); + var sw = Stopwatch.StartNew(); + var totalEvents = 0; + + for (var s = 0; s < BENCH_STREAMS; s++) + { + await using var session = store.LightweightSession(); + var streamId = Guid.NewGuid(); + var customerId = new CustomerId(Guid.NewGuid()); + var regionId = new RegionId($"region-{s % 10}"); + + for (var e = 0; e < BENCH_EVENTS_PER_STREAM; e++) + { + object data = e % 2 == 0 + ? new OrderPlaced($"ORD-bench-{s}-{e}", 99.99m + e) + : new OrderShipped($"TRACK-bench-{s}-{e}"); + + if (withTags) + { + var wrapped = session.Events.BuildEvent(data); + wrapped.WithTag(customerId, regionId); + session.Events.Append(streamId, wrapped); + } + else + { + session.Events.Append(streamId, data); + } + } + + await session.SaveChangesAsync(); + totalEvents += BENCH_EVENTS_PER_STREAM; + } + + sw.Stop(); + results.Add((name, totalEvents, sw.Elapsed)); + Console.WriteLine($" {totalEvents} events in {sw.Elapsed.TotalMilliseconds:N1}ms ({totalEvents / sw.Elapsed.TotalSeconds:N1} events/sec)"); +} + +// =========================================================================== +// Run benchmarks +// =========================================================================== + +// --- Quick mode benchmarks (the target for optimization) --- +Console.WriteLine("\n=== Quick Mode (into large DB) ==="); +{ + await using var store = BuildStore(EventAppendMode.Quick); + await store.Advanced.ResetAllData(); + await SeedDatabase(store); + + await BenchmarkAppend("Quick No Tags", store, withTags: false); + await BenchmarkAppend("Quick With Tags", store, withTags: true); +} + +// --- Rich mode benchmarks (for comparison) --- +Console.WriteLine("\n=== Rich Mode (into large DB) ==="); +{ + await using var store = BuildStore(EventAppendMode.Rich); + await store.Advanced.ResetAllData(); + await SeedDatabase(store); + + await BenchmarkAppend("Rich No Tags", store, withTags: false); + await BenchmarkAppend("Rich With Tags", store, withTags: true); +} + +// --------------------------------------------------------------------------- +// Print results +// --------------------------------------------------------------------------- +Console.WriteLine(); +Console.WriteLine(new string('=', 90)); +Console.WriteLine($"{"Scenario",-40} {"Events",10} {"Total (ms)",12} {"Events/sec",12}"); +Console.WriteLine(new string('-', 90)); +foreach (var (scenario, iterations, elapsed) in results) +{ + var opsPerSec = elapsed.TotalSeconds > 0 + ? (iterations / elapsed.TotalSeconds).ToString("N1") + : "N/A"; + Console.WriteLine($"{scenario,-40} {iterations,10} {elapsed.TotalMilliseconds,12:N1} {opsPerSec,12}"); +} +Console.WriteLine(new string('=', 90)); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +public record OrderPlaced(string OrderId, decimal Amount); +public record OrderShipped(string TrackingNumber); +public record CustomerId(Guid Value); +public record RegionId(string Value); + +public class OrderAggregate +{ + public Guid Id { get; set; } + public List OrderIds { get; set; } = new(); + public decimal TotalAmount { get; set; } + public int ShipmentCount { get; set; } + + public void Apply(OrderPlaced e) + { + OrderIds.Add(e.OrderId); + TotalAmount += e.Amount; + } + + public void Apply(OrderShipped e) + { + ShipmentCount++; + } +} diff --git a/src/DocumentDbTests/Internal/Generated/DocumentStorage/RevisionedDocProvider1212098993.cs b/src/DocumentDbTests/Internal/Generated/DocumentStorage/RevisionedDocProvider1212098993.cs index 721c3efe6c..34c648e1a7 100644 --- a/src/DocumentDbTests/Internal/Generated/DocumentStorage/RevisionedDocProvider1212098993.cs +++ b/src/DocumentDbTests/Internal/Generated/DocumentStorage/RevisionedDocProvider1212098993.cs @@ -1095,7 +1095,7 @@ public override async System.Threading.Tasks.Task LoadTempRowAsync(Npgsql.Npgsql { await writer.WriteAsync(document.GetType().FullName, NpgsqlTypes.NpgsqlDbType.Varchar, cancellation); await writer.WriteAsync(((DocumentDbTests.Concurrency.RevisionedDoc)document).Id, NpgsqlTypes.NpgsqlDbType.Uuid, cancellation); - writer.Write(document.Version <= 0 ? (object)DBNull.Value : (object)document.Version, NpgsqlTypes.NpgsqlDbType.Integer); + writer.Write(document.Version <= 0 ? (object)System.DBNull.Value : (object)document.Version, NpgsqlTypes.NpgsqlDbType.Integer); await writer.WriteAsync(1, NpgsqlTypes.NpgsqlDbType.Integer, cancellation); await writer.WriteAsync(serializer.ToJson(document), NpgsqlTypes.NpgsqlDbType.Jsonb, cancellation); } diff --git a/src/DocumentDbTests/MultiTenancy/conjoined_multi_tenancy_with_partitioning.cs b/src/DocumentDbTests/MultiTenancy/conjoined_multi_tenancy_with_partitioning.cs index f5e02fbda4..3fcfe357ae 100644 --- a/src/DocumentDbTests/MultiTenancy/conjoined_multi_tenancy_with_partitioning.cs +++ b/src/DocumentDbTests/MultiTenancy/conjoined_multi_tenancy_with_partitioning.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JasperFx; using Marten; +using Marten.Linq; using Marten.Schema; using Marten.Storage; using Marten.Testing; @@ -28,6 +29,10 @@ public class conjoined_multi_tenancy_with_partitioning: OneOffConfigurationsCont public conjoined_multi_tenancy_with_partitioning() { + targetBlue1.NestedObject = new([Target.Random()]); + targetBlue1.NestedObject.Targets[0].String = "not a green list"; + targetRed1.NestedObject = new([Target.Random()]); + targetRed1.NestedObject.Targets[0].String = "not a green list"; StoreOptions(opts => { @@ -84,6 +89,28 @@ public async Task cannot_load_by_id_across_tenants() } } + [Fact] + public async Task cannot_query_within_child_collections_across_tenants() + { + await using (var red = theStore.QuerySession("Red")) + { + (await red.Query() + .Where(x => x.NestedObject.Targets.Any(i => i.String != null && i.String == "not a green list")) + .ToListAsync()) + .ShouldHaveSingleItem() + .Id.ShouldBe(targetRed1.Id); + } + + await using (var blue = theStore.QuerySession("Blue")) + { + (await blue.Query() + .Where(x => x.NestedObject.Targets.Any(i => i.String != null && i.String == "not a green list")) + .ToListAsync()) + .ShouldHaveSingleItem() + .Id.ShouldBe(targetBlue1.Id); + } + } + [Fact] public async Task cannot_load_json_by_id_across_tenants_async() { diff --git a/src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs b/src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs new file mode 100644 index 0000000000..7d35841921 --- /dev/null +++ b/src/EventSourcingTests/Aggregation/auto_discover_aggregate_types.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Aggregation; + +/// +/// Tests that verify self-aggregating types with source-generated evolvers +/// are automatically discovered and registered in StoreOptions.Projections +/// even without explicit Snapshot<T>() registration. +/// +public class auto_discover_aggregate_types : IntegrationContext +{ + public auto_discover_aggregate_types(DefaultStoreFixture fixture) : base(fixture) + { + } + + [Fact] + public void self_aggregating_types_are_auto_discovered() + { + // These types have source-generated evolvers via Apply/Create methods + // but are NOT explicitly registered via Projections.Snapshot() + var aggregateTypes = theStore.Options.Projections.AllAggregateTypes().ToArray(); + + // MutableIEventEvolveAggregate has a generated evolver via Evolve(IEvent) + aggregateTypes.ShouldContain(typeof(MutableIEventEvolveAggregate)); + } + + [Fact] + public async Task auto_discovered_type_works_for_live_aggregation() + { + // No explicit Snapshot() registration — relies on auto-discovery + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.Events.AggregateStreamAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(1); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(1); + } +} diff --git a/src/EventSourcingTests/Aggregation/self_aggregating_evolve_method.cs b/src/EventSourcingTests/Aggregation/self_aggregating_evolve_method.cs new file mode 100644 index 0000000000..d559adbb1a --- /dev/null +++ b/src/EventSourcingTests/Aggregation/self_aggregating_evolve_method.cs @@ -0,0 +1,319 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Aggregation; + +#region sample_evolve_aggregates + +/// +/// Mutable aggregate using void Evolve(IEvent e) — switches on IEvent envelope +/// +public class MutableIEventEvolveAggregate +{ + public Guid Id { get; set; } + public int ACount { get; set; } + public int BCount { get; set; } + public int CCount { get; set; } + + public void Evolve(IEvent e) + { + switch (e) + { + case IEvent: + ACount++; + break; + case IEvent: + BCount++; + break; + case IEvent: + CCount++; + break; + } + } +} + +/// +/// Mutable aggregate using void Evolve(object o) — switches on event data +/// +public class MutableObjectEvolveAggregate +{ + public Guid Id { get; set; } + public int ACount { get; set; } + public int BCount { get; set; } + public int CCount { get; set; } + + public void Evolve(object o) + { + switch (o) + { + case AEvent: + ACount++; + break; + case BEvent: + BCount++; + break; + case CEvent: + CCount++; + break; + } + } +} + +/// +/// Immutable aggregate using TDoc Evolve(IEvent e) — returns new instance +/// +public record ImmutableIEventEvolveAggregate(Guid Id, int ACount = 0, int BCount = 0, int CCount = 0) +{ + public ImmutableIEventEvolveAggregate() : this(Guid.Empty) { } + + public ImmutableIEventEvolveAggregate Evolve(IEvent e) + { + return e switch + { + IEvent => this with { ACount = ACount + 1 }, + IEvent => this with { BCount = BCount + 1 }, + IEvent => this with { CCount = CCount + 1 }, + _ => this + }; + } +} + +/// +/// Immutable aggregate using TDoc Evolve(object o) — returns new instance +/// +public record ImmutableObjectEvolveAggregate(Guid Id, int ACount = 0, int BCount = 0, int CCount = 0) +{ + public ImmutableObjectEvolveAggregate() : this(Guid.Empty) { } + + public ImmutableObjectEvolveAggregate Evolve(object o) + { + return o switch + { + AEvent => this with { ACount = ACount + 1 }, + BEvent => this with { BCount = BCount + 1 }, + CEvent => this with { CCount = CCount + 1 }, + _ => this + }; + } +} + +/// +/// Mutable aggregate using async Task EvolveAsync(IEvent e, IQuerySession session) +/// +public class AsyncEvolveAggregate +{ + public Guid Id { get; set; } + public int ACount { get; set; } + public int BCount { get; set; } + + public Task EvolveAsync(IEvent e, IQuerySession session) + { + switch (e) + { + case IEvent: + ACount++; + break; + case IEvent: + BCount++; + break; + } + + return Task.CompletedTask; + } +} + +/// +/// Immutable aggregate using async ValueTask<TDoc> EvolveAsync(IEvent e, IQuerySession session) +/// +public record ImmutableAsyncEvolveAggregate(Guid Id, int ACount = 0, int BCount = 0) +{ + public ImmutableAsyncEvolveAggregate() : this(Guid.Empty) { } + + public ValueTask EvolveAsync(IEvent e, IQuerySession session) + { + var result = e switch + { + IEvent => this with { ACount = ACount + 1 }, + IEvent => this with { BCount = BCount + 1 }, + _ => this + }; + + return new ValueTask(result); + } +} + +#endregion + +/// +/// Tests for self-aggregating types that use Evolve/EvolveAsync methods +/// instead of conventional Apply/Create methods. The source generator +/// creates IGeneratedSyncEvolver or IGeneratedAsyncEvolver implementations +/// that delegate to the user's Evolve/EvolveAsync method. +/// +public class self_aggregating_evolve_method : IntegrationContext +{ + public self_aggregating_evolve_method(DefaultStoreFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task mutable_ievent_evolve_inline() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new AEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(2); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(1); + } + + [Fact] + public async Task mutable_object_evolve_inline() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(1); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(2); + } + + [Fact] + public async Task immutable_ievent_evolve_inline() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(2); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(0); + } + + [Fact] + public async Task immutable_object_evolve_inline() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new BEvent(), new CEvent(), new AEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(1); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(1); + } + + [Fact] + public async Task async_evolve_inline() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(2); + aggregate.BCount.ShouldBe(1); + } + + [Fact] + public async Task immutable_async_evolve_inline() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new AEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(2); + aggregate.BCount.ShouldBe(1); + } + + [Fact] + public async Task mutable_ievent_evolve_with_append_to_existing_stream() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + // Append more events + theSession.Events.Append(streamId, new AEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.LoadAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(2); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(1); + } + + [Fact] + public async Task live_aggregation_with_evolve() + { + StoreOptions(opts => + { + // No snapshot — live aggregation only + }); + + var streamId = Guid.NewGuid(); + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.Events.AggregateStreamAsync(streamId); + aggregate.ShouldNotBeNull(); + aggregate.ACount.ShouldBe(1); + aggregate.BCount.ShouldBe(1); + aggregate.CCount.ShouldBe(1); + } +} diff --git a/src/EventSourcingTests/Dcb/dcb_quick_append_tests.cs b/src/EventSourcingTests/Dcb/dcb_quick_append_tests.cs new file mode 100644 index 0000000000..5c0b1da74a --- /dev/null +++ b/src/EventSourcingTests/Dcb/dcb_quick_append_tests.cs @@ -0,0 +1,240 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; +using Marten.Events; +using Marten.Events.Dcb; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Dcb; + +[Collection("OneOffs")] +public class dcb_quick_append_tests: OneOffConfigurationsContext, IAsyncLifetime +{ + private void ConfigureStore() + { + StoreOptions(opts => + { + opts.Events.AddEventType(); + opts.Events.AddEventType(); + opts.Events.AddEventType(); + + opts.Events.RegisterTagType("student"); + opts.Events.RegisterTagType("course"); + + // Use Quick append mode + opts.Events.AppendMode = EventAppendMode.Quick; + + opts.Projections.LiveStreamAggregation(); + }); + } + + public Task InitializeAsync() + { + ConfigureStore(); + return Task.CompletedTask; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task can_query_events_by_single_tag_with_quick_append() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + var query = new EventTagQuery().Or(studentId); + var events = await theSession.Events.QueryByTagsAsync(query); + + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().StudentName.ShouldBe("Alice"); + } + + [Fact] + public async Task can_query_events_by_multiple_tags_with_quick_append() + { + var student1 = new StudentId(Guid.NewGuid()); + var student2 = new StudentId(Guid.NewGuid()); + var course = new CourseId(Guid.NewGuid()); + var stream1 = Guid.NewGuid(); + var stream2 = Guid.NewGuid(); + + var e1 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + e1.WithTag(student1, course); + theSession.Events.Append(stream1, e1); + + var e2 = theSession.Events.BuildEvent(new StudentEnrolled("Bob", "Math")); + e2.WithTag(student2, course); + theSession.Events.Append(stream2, e2); + + await theSession.SaveChangesAsync(); + + var query = new EventTagQuery() + .Or(student1) + .Or(student2); + + var events = await theSession.Events.QueryByTagsAsync(query); + events.Count.ShouldBe(2); + } + + [Fact] + public async Task can_aggregate_events_by_tags_with_quick_append() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + + var submitted = theSession.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + submitted.WithTag(studentId, courseId); + + theSession.Events.Append(streamId, enrolled, submitted); + await theSession.SaveChangesAsync(); + + var query = new EventTagQuery() + .Or(studentId) + .Or(courseId); + + var aggregate = await theSession.Events.AggregateByTagsAsync(query); + aggregate.ShouldNotBeNull(); + aggregate.StudentName.ShouldBe("Alice"); + aggregate.CourseName.ShouldBe("Math"); + aggregate.Assignments.ShouldContain("HW1"); + } + + [Fact] + public async Task can_fetch_for_writing_by_tags_with_quick_append() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + await using var session2 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session2.Events.FetchForWritingByTags(query); + + boundary.Aggregate.ShouldNotBeNull(); + boundary.Aggregate!.StudentName.ShouldBe("Alice"); + boundary.Events.Count.ShouldBe(1); + boundary.LastSeenSequence.ShouldBeGreaterThan(0); + + var assignment = session2.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(studentId, courseId); + boundary.AppendOne(assignment); + + await session2.SaveChangesAsync(); + } + + [Fact] + public async Task fetch_for_writing_detects_concurrency_violation_with_quick_append() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Session 1: fetch for writing + await using var session1 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session1.Events.FetchForWritingByTags(query); + + // Session 2: append a conflicting event BEFORE session 1 saves + await using var session2 = theStore.LightweightSession(); + var conflicting = session2.Events.BuildEvent(new AssignmentSubmitted("HW-conflict", 50)); + conflicting.WithTag(studentId, courseId); + session2.Events.Append(streamId, conflicting); + await session2.SaveChangesAsync(); + + // Session 1: try to save — should throw DcbConcurrencyException + var assignment = session1.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(studentId, courseId); + boundary.AppendOne(assignment); + + await Should.ThrowAsync(async () => + { + await session1.SaveChangesAsync(); + }); + } + + [Fact] + public async Task fetch_for_writing_no_violation_when_unrelated_events_with_quick_append() + { + var student1 = new StudentId(Guid.NewGuid()); + var student2 = new StudentId(Guid.NewGuid()); + var course = new CourseId(Guid.NewGuid()); + var stream1 = Guid.NewGuid(); + var stream2 = Guid.NewGuid(); + + var enrolled1 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled1.WithTag(student1, course); + theSession.Events.Append(stream1, enrolled1); + await theSession.SaveChangesAsync(); + + // Session 1: fetch for writing for student1 + await using var session1 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(student1); + var boundary = await session1.Events.FetchForWritingByTags(query); + + // Session 2: append event for DIFFERENT student — should NOT conflict + await using var session2 = theStore.LightweightSession(); + var enrolled2 = session2.Events.BuildEvent(new StudentEnrolled("Bob", "Math")); + enrolled2.WithTag(student2, course); + session2.Events.Append(stream2, enrolled2); + await session2.SaveChangesAsync(); + + // Session 1: save should succeed + var assignment = session1.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(student1, course); + boundary.AppendOne(assignment); + + await session1.SaveChangesAsync(); // Should not throw + } + + [Fact] + public async Task events_across_multiple_streams_queried_by_tag_with_quick_append() + { + var studentId = new StudentId(Guid.NewGuid()); + var course1 = new CourseId(Guid.NewGuid()); + var course2 = new CourseId(Guid.NewGuid()); + var stream1 = Guid.NewGuid(); + var stream2 = Guid.NewGuid(); + + var enrolled1 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled1.WithTag(studentId, course1); + theSession.Events.Append(stream1, enrolled1); + + var enrolled2 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Science")); + enrolled2.WithTag(studentId, course2); + theSession.Events.Append(stream2, enrolled2); + + await theSession.SaveChangesAsync(); + + var query = new EventTagQuery().Or(studentId); + var events = await theSession.Events.QueryByTagsAsync(query); + + events.Count.ShouldBe(2); + } +} diff --git a/src/EventSourcingTests/Dcb/dcb_tag_query_and_consistency_tests.cs b/src/EventSourcingTests/Dcb/dcb_tag_query_and_consistency_tests.cs new file mode 100644 index 0000000000..8c5646ef8d --- /dev/null +++ b/src/EventSourcingTests/Dcb/dcb_tag_query_and_consistency_tests.cs @@ -0,0 +1,651 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten; +using Marten.Events; +using Marten.Events.Dcb; +using Marten.Services.BatchQuerying; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Dcb; + +#region sample_marten_dcb_tag_type_definitions +// Strong-typed tag identifiers +public record StudentId(Guid Value); +public record CourseId(Guid Value); +#endregion + +#region sample_marten_dcb_domain_events +// Domain events +public record StudentEnrolled(string StudentName, string CourseName); +public record AssignmentSubmitted(string AssignmentName, int Score); +public record StudentDropped(string Reason); +#endregion + +// Event with tag-typed properties for inference testing +public record StudentGraded(StudentId StudentId, CourseId CourseId, int Grade); + +// Event with NO tag-typed properties — should fail inference +public record SystemNotification(string Message); + +#region sample_marten_dcb_aggregate +// Aggregate for DCB +public class StudentCourseEnrollment +{ + public Guid Id { get; set; } + public string StudentName { get; set; } = ""; + public string CourseName { get; set; } = ""; + public List Assignments { get; set; } = new(); + public bool IsDropped { get; set; } + + public void Apply(StudentEnrolled e) + { + StudentName = e.StudentName; + CourseName = e.CourseName; + } + + public void Apply(AssignmentSubmitted e) + { + Assignments.Add(e.AssignmentName); + } + + public void Apply(StudentDropped e) + { + IsDropped = true; + } +} +#endregion + +[Collection("OneOffs")] +public class dcb_tag_query_and_consistency_tests: OneOffConfigurationsContext, IAsyncLifetime +{ + #region sample_marten_dcb_registering_tag_types + private void ConfigureStore() + { + StoreOptions(opts => + { + opts.Events.AddEventType(); + opts.Events.AddEventType(); + opts.Events.AddEventType(); + opts.Events.AddEventType(); + + // Register tag types -- each gets its own table (mt_event_tag_student, mt_event_tag_course) + opts.Events.RegisterTagType("student") + .ForAggregate(); + opts.Events.RegisterTagType("course") + .ForAggregate(); + + opts.Projections.LiveStreamAggregation(); + }); + } + #endregion + + public Task InitializeAsync() + { + ConfigureStore(); + return Task.CompletedTask; + } + + public Task DisposeAsync() => Task.CompletedTask; + + private async Task AppendTaggedEvent(Guid streamId, object eventData, params object[] tags) + { + var wrapped = theSession.Events.BuildEvent(eventData); + wrapped.WithTag(tags); + theSession.Events.Append(streamId, wrapped); + await theSession.SaveChangesAsync(); + } + + [Fact] + public async Task can_query_events_by_single_tag() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + #region sample_marten_dcb_tagging_events + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + #endregion + + #region sample_marten_dcb_query_by_single_tag + var query = new EventTagQuery().Or(studentId); + var events = await theSession.Events.QueryByTagsAsync(query); + #endregion + + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().StudentName.ShouldBe("Alice"); + } + + [Fact] + public async Task can_query_events_by_multiple_tags_with_or() + { + var student1 = new StudentId(Guid.NewGuid()); + var student2 = new StudentId(Guid.NewGuid()); + var course = new CourseId(Guid.NewGuid()); + var stream1 = Guid.NewGuid(); + var stream2 = Guid.NewGuid(); + + // Student 1 enrolled + var e1 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + e1.WithTag(student1, course); + theSession.Events.Append(stream1, e1); + + // Student 2 enrolled + var e2 = theSession.Events.BuildEvent(new StudentEnrolled("Bob", "Math")); + e2.WithTag(student2, course); + theSession.Events.Append(stream2, e2); + + await theSession.SaveChangesAsync(); + + #region sample_marten_dcb_query_multiple_tags_or + // Query for either student + var query = new EventTagQuery() + .Or(student1) + .Or(student2); + + var events = await theSession.Events.QueryByTagsAsync(query); + #endregion + events.Count.ShouldBe(2); + } + + [Fact] + public async Task can_query_events_by_tag_with_event_type_filter() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + + var submitted = theSession.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + submitted.WithTag(studentId, courseId); + + theSession.Events.Append(streamId, enrolled, submitted); + await theSession.SaveChangesAsync(); + + #region sample_marten_dcb_query_by_event_type + // Query only AssignmentSubmitted events for this student + var query = new EventTagQuery() + .Or(studentId); + + var events = await theSession.Events.QueryByTagsAsync(query); + #endregion + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().AssignmentName.ShouldBe("HW1"); + } + + [Fact] + public async Task query_returns_empty_when_no_matching_tags() + { + var studentId = new StudentId(Guid.NewGuid()); + var otherStudentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Query for a different student + var query = new EventTagQuery().Or(otherStudentId); + var events = await theSession.Events.QueryByTagsAsync(query); + events.Count.ShouldBe(0); + } + + [Fact] + public async Task can_aggregate_events_by_tags() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + + var submitted = theSession.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + submitted.WithTag(studentId, courseId); + + theSession.Events.Append(streamId, enrolled, submitted); + await theSession.SaveChangesAsync(); + + #region sample_marten_dcb_aggregate_by_tags + var query = new EventTagQuery() + .Or(studentId) + .Or(courseId); + + var aggregate = await theSession.Events.AggregateByTagsAsync(query); + #endregion + aggregate.ShouldNotBeNull(); + aggregate.StudentName.ShouldBe("Alice"); + aggregate.CourseName.ShouldBe("Math"); + aggregate.Assignments.ShouldContain("HW1"); + } + + [Fact] + public async Task aggregate_by_tags_returns_null_when_no_events() + { + var studentId = new StudentId(Guid.NewGuid()); + + var query = new EventTagQuery().Or(studentId); + var aggregate = await theSession.Events.AggregateByTagsAsync(query); + aggregate.ShouldBeNull(); + } + + [Fact] + public async Task can_fetch_for_writing_by_tags_happy_path() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + // Seed initial events + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + #region sample_marten_dcb_fetch_for_writing_by_tags + // Fetch for writing + await using var session2 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session2.Events.FetchForWritingByTags(query); + + // Read current state + var aggregate = boundary.Aggregate; // may be null if no events yet + var lastSequence = boundary.LastSeenSequence; + + // Append via boundary + var assignment = session2.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(studentId, courseId); + boundary.AppendOne(assignment); + + // Save -- will throw DcbConcurrencyException if another session + // appended matching events after our read + await session2.SaveChangesAsync(); + #endregion + + boundary.Aggregate.ShouldNotBeNull(); + boundary.Aggregate!.StudentName.ShouldBe("Alice"); + boundary.Events.Count.ShouldBe(1); + } + + [Fact] + public async Task fetch_for_writing_by_tags_detects_concurrency_violation() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + // Seed initial events + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Session 1: fetch for writing + await using var session1 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session1.Events.FetchForWritingByTags(query); + + // Session 2: append a conflicting event BEFORE session 1 saves + await using var session2 = theStore.LightweightSession(); + var conflicting = session2.Events.BuildEvent(new AssignmentSubmitted("HW-conflict", 50)); + conflicting.WithTag(studentId, courseId); + session2.Events.Append(streamId, conflicting); + await session2.SaveChangesAsync(); + + // Session 1: try to save — should throw DcbConcurrencyException + var assignment = session1.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(studentId, courseId); + boundary.AppendOne(assignment); + + #region sample_marten_dcb_handling_concurrency + try + { + await session1.SaveChangesAsync(); + } + catch (DcbConcurrencyException ex) + { + // Reload and retry -- the boundary's tag query had new matching events + // ex.Query -- the original tag query + // ex.LastSeenSequence -- the sequence at time of read + } + #endregion + } + + [Fact] + public async Task fetch_for_writing_by_tags_no_violation_when_unrelated_events_appended() + { + var student1 = new StudentId(Guid.NewGuid()); + var student2 = new StudentId(Guid.NewGuid()); + var course = new CourseId(Guid.NewGuid()); + var stream1 = Guid.NewGuid(); + var stream2 = Guid.NewGuid(); + + // Seed student1 + var enrolled1 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled1.WithTag(student1, course); + theSession.Events.Append(stream1, enrolled1); + await theSession.SaveChangesAsync(); + + // Session 1: fetch for writing for student1 + await using var session1 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(student1); + var boundary = await session1.Events.FetchForWritingByTags(query); + + // Session 2: append event for DIFFERENT student — should NOT conflict + await using var session2 = theStore.LightweightSession(); + var enrolled2 = session2.Events.BuildEvent(new StudentEnrolled("Bob", "Math")); + enrolled2.WithTag(student2, course); + session2.Events.Append(stream2, enrolled2); + await session2.SaveChangesAsync(); + + // Session 1: save should succeed + var assignment = session1.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(student1, course); + boundary.AppendOne(assignment); + + await session1.SaveChangesAsync(); // Should not throw + } + + [Fact] + public async Task events_across_multiple_streams_can_be_queried_by_tag() + { + var studentId = new StudentId(Guid.NewGuid()); + var course1 = new CourseId(Guid.NewGuid()); + var course2 = new CourseId(Guid.NewGuid()); + var stream1 = Guid.NewGuid(); + var stream2 = Guid.NewGuid(); + + // Student enrolled in two courses (different streams) + var enrolled1 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled1.WithTag(studentId, course1); + theSession.Events.Append(stream1, enrolled1); + + var enrolled2 = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Science")); + enrolled2.WithTag(studentId, course2); + theSession.Events.Append(stream2, enrolled2); + + await theSession.SaveChangesAsync(); + + // Query all events for this student across streams + var query = new EventTagQuery().Or(studentId); + var events = await theSession.Events.QueryByTagsAsync(query); + + events.Count.ShouldBe(2); + } + + [Fact] + public async Task query_events_ordered_by_sequence() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + + var hw1 = theSession.Events.BuildEvent(new AssignmentSubmitted("HW1", 90)); + hw1.WithTag(studentId, courseId); + + var hw2 = theSession.Events.BuildEvent(new AssignmentSubmitted("HW2", 85)); + hw2.WithTag(studentId, courseId); + + theSession.Events.Append(streamId, enrolled, hw1, hw2); + await theSession.SaveChangesAsync(); + + var query = new EventTagQuery().Or(studentId); + var events = await theSession.Events.QueryByTagsAsync(query); + + events.Count.ShouldBe(3); + // Events should be ordered by sequence + events[0].Sequence.ShouldBeLessThan(events[1].Sequence); + events[1].Sequence.ShouldBeLessThan(events[2].Sequence); + } + + [Fact] + public async Task fetch_for_writing_with_empty_result_still_enforces_consistency() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + + // Fetch for writing when no events exist + await using var session1 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session1.Events.FetchForWritingByTags(query); + + boundary.Aggregate.ShouldBeNull(); + boundary.Events.Count.ShouldBe(0); + boundary.LastSeenSequence.ShouldBe(0); + + // Another session appends a matching event before save + await using var session2 = theStore.LightweightSession(); + var enrolled = session2.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + var streamId = Guid.NewGuid(); + session2.Events.Append(streamId, enrolled); + await session2.SaveChangesAsync(); + + // Session 1 tries to save — should detect the new matching event + var e = session1.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + e.WithTag(studentId, courseId); + boundary.AppendOne(e); + + await Should.ThrowAsync(async () => + { + await session1.SaveChangesAsync(); + }); + } + + [Fact] + public async Task can_fetch_for_writing_by_tags_via_batch_query() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + await using var session2 = theStore.LightweightSession(); + var batch = session2.CreateBatchQuery(); + var query = new EventTagQuery().Or(studentId); + var boundaryTask = batch.Events.FetchForWritingByTags(query); + await batch.Execute(); + + var boundary = await boundaryTask; + boundary.Aggregate.ShouldNotBeNull(); + boundary.Aggregate!.StudentName.ShouldBe("Alice"); + boundary.Events.Count.ShouldBe(1); + boundary.LastSeenSequence.ShouldBeGreaterThan(0); + + // Append via boundary and save + var assignment = session2.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(studentId, courseId); + boundary.AppendOne(assignment); + await session2.SaveChangesAsync(); + } + + [Fact] + public async Task batch_query_fetch_for_writing_by_tags_detects_concurrency_violation() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Session 1: fetch via batch query + await using var session1 = theStore.LightweightSession(); + var batch = session1.CreateBatchQuery(); + var query = new EventTagQuery().Or(studentId); + var boundaryTask = batch.Events.FetchForWritingByTags(query); + await batch.Execute(); + var boundary = await boundaryTask; + + // Session 2: append conflicting event + await using var session2 = theStore.LightweightSession(); + var conflicting = session2.Events.BuildEvent(new AssignmentSubmitted("HW-conflict", 50)); + conflicting.WithTag(studentId, courseId); + session2.Events.Append(streamId, conflicting); + await session2.SaveChangesAsync(); + + // Session 1: try to save — should throw + var assignment = session1.Events.BuildEvent(new AssignmentSubmitted("HW1", 95)); + assignment.WithTag(studentId, courseId); + boundary.AppendOne(assignment); + + await Should.ThrowAsync(async () => + { + await session1.SaveChangesAsync(); + }); + } + + [Fact] + public async Task fetch_for_writing_by_tags_throws_on_empty_query() + { + var query = new EventTagQuery(); + await Should.ThrowAsync(async () => + { + await theSession.Events.FetchForWritingByTags(query); + }); + } + + [Fact] + public async Task append_event_with_inferred_tags_from_properties() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + // Seed initial event with explicit tags + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Fetch for writing + await using var session2 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session2.Events.FetchForWritingByTags(query); + + // Append a raw event that has StudentId and CourseId properties — + // tags should be inferred automatically + boundary.AppendOne(new StudentGraded(studentId, courseId, 95)); + + // Should succeed — tags inferred from properties + await session2.SaveChangesAsync(); + + // Verify the event is discoverable by tag query + await using var session3 = theStore.LightweightSession(); + var events = await session3.Events.QueryByTagsAsync( + new EventTagQuery().Or(studentId)); + events.Count.ShouldBe(2); + events[1].Data.ShouldBeOfType().Grade.ShouldBe(95); + } + + [Fact] + public async Task append_event_with_no_tags_and_no_inferable_properties_throws() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + // Seed initial event + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Fetch for writing + await using var session2 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session2.Events.FetchForWritingByTags(query); + + // Append an event with no tags and no tag-typed properties — should throw + Should.Throw(() => + { + boundary.AppendOne(new SystemNotification("test")); + }); + } + + [Fact] + public async Task append_already_wrapped_event_with_explicit_tags_works() + { + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + // Seed initial event + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + // Fetch for writing + await using var session2 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session2.Events.FetchForWritingByTags(query); + + // Append an already-wrapped event with explicit tags + var graded = session2.Events.BuildEvent(new StudentGraded(studentId, courseId, 88)); + graded.WithTag(studentId, courseId); + boundary.AppendOne(graded); + + await session2.SaveChangesAsync(); + } + + [Fact] + public async Task append_event_with_tag_having_no_aggregate_type_creates_new_stream() + { + // Register a tag type WITHOUT an aggregate association + StoreOptions(opts => + { + opts.Events.AddEventType(); + opts.Events.AddEventType(); + + opts.Events.RegisterTagType("student"); + // CourseId registered WITHOUT ForAggregate + opts.Events.RegisterTagType("course"); + + opts.Projections.LiveStreamAggregation(); + }); + + var studentId = new StudentId(Guid.NewGuid()); + var courseId = new CourseId(Guid.NewGuid()); + var streamId = Guid.NewGuid(); + + var enrolled = theSession.Events.BuildEvent(new StudentEnrolled("Alice", "Math")); + enrolled.WithTag(studentId, courseId); + theSession.Events.Append(streamId, enrolled); + await theSession.SaveChangesAsync(); + + await using var session2 = theStore.LightweightSession(); + var query = new EventTagQuery().Or(studentId); + var boundary = await session2.Events.FetchForWritingByTags(query); + + // CourseId tag has no AggregateType — should create a new stream per event + var graded = session2.Events.BuildEvent(new StudentGraded(studentId, courseId, 90)); + graded.WithTag(courseId); + boundary.AppendOne(graded); + + // Should succeed — unrouted tag creates a new stream + await session2.SaveChangesAsync(); + } +} diff --git a/src/EventSourcingTests/Examples/SampleEventProjection.cs b/src/EventSourcingTests/Examples/SampleEventProjection.cs index 0fdd95c832..7e710bc097 100644 --- a/src/EventSourcingTests/Examples/SampleEventProjection.cs +++ b/src/EventSourcingTests/Examples/SampleEventProjection.cs @@ -72,7 +72,7 @@ public class Document2 #region sample_SampleEventProjection -public class SampleEventProjection : EventProjection +public partial class SampleEventProjection : EventProjection { public SampleEventProjection() { @@ -128,7 +128,7 @@ public void Project(ISpecialEvent e, IDocumentOperations ops) #endregion #region sample_explicit_event_projection -public class ExplicitSampleProjection: EventProjection +public partial class ExplicitSampleProjection: EventProjection { public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation) { diff --git a/src/EventSourcingTests/Examples/TrackedEventProjection.cs b/src/EventSourcingTests/Examples/TrackedEventProjection.cs index 6d4f0ab95b..b1965858ff 100644 --- a/src/EventSourcingTests/Examples/TrackedEventProjection.cs +++ b/src/EventSourcingTests/Examples/TrackedEventProjection.cs @@ -49,7 +49,7 @@ public record BaseballGame public ImmutableHashSet PlayersWithRuns { get; init; } } -public class TrackedEventProjection: EventProjection +public partial class TrackedEventProjection: EventProjection { public TrackedEventProjection() { diff --git a/src/EventSourcingTests/FetchForWriting/always_enforce_consistency.cs b/src/EventSourcingTests/FetchForWriting/always_enforce_consistency.cs new file mode 100644 index 0000000000..00d3e59293 --- /dev/null +++ b/src/EventSourcingTests/FetchForWriting/always_enforce_consistency.cs @@ -0,0 +1,249 @@ +using System; +using System.Threading.Tasks; +using EventSourcingTests.Aggregation; +using JasperFx; +using JasperFx.Events; +using Marten; +using Marten.Events; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.FetchForWriting; + +public class always_enforce_consistency: IntegrationContext +{ + public always_enforce_consistency(DefaultStoreFixture fixture): base(fixture) + { + } + + protected override Task fixtureSetup() + { + return theStore.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + [Fact] + public async Task default_value_is_false_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamId); + stream.AlwaysEnforceConsistency.ShouldBeFalse(); + } + + [Fact] + public async Task default_value_is_false_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamKey); + stream.AlwaysEnforceConsistency.ShouldBeFalse(); + } + + [Fact] + public async Task save_changes_without_events_and_no_consistency_flag_does_not_throw_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamId); + + // Do not append any events, do not set AlwaysEnforceConsistency + // This should succeed silently + await theSession.SaveChangesAsync(); + } + + [Fact] + public async Task enforce_consistency_happy_path_no_events_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamId); + stream.AlwaysEnforceConsistency = true; + + // Don't append events - should still succeed because version hasn't changed + await theSession.SaveChangesAsync(); + } + + [Fact] + public async Task enforce_consistency_happy_path_no_events_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamKey); + stream.AlwaysEnforceConsistency = true; + + // Don't append events - should still succeed because version hasn't changed + await theSession.SaveChangesAsync(); + } + + [Fact] + public async Task enforce_consistency_sad_path_no_events_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamId); + stream.AlwaysEnforceConsistency = true; + + // Sneak in events from another session + await using (var otherSession = theStore.LightweightSession()) + { + otherSession.Events.Append(streamId, new DEvent()); + await otherSession.SaveChangesAsync(); + } + + // Now save should fail - version has advanced + await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + } + + [Fact] + public async Task enforce_consistency_sad_path_no_events_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamKey); + stream.AlwaysEnforceConsistency = true; + + // Sneak in events from another session + await using (var otherSession = theStore.LightweightSession()) + { + otherSession.Events.Append(streamKey, new DEvent()); + await otherSession.SaveChangesAsync(); + } + + // Now save should fail - version has advanced + await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + } + + [Fact] + public async Task enforce_consistency_with_events_works_as_before_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamId); + stream.AlwaysEnforceConsistency = true; + + // Append events - this should use the normal UpdateStreamVersion path + stream.AppendOne(new CEvent()); + await theSession.SaveChangesAsync(); + + // Verify the event was stored + var aggregate = await theSession.Events.AggregateStreamAsync(streamId); + aggregate.CCount.ShouldBe(1); + } + + [Fact] + public async Task enforce_consistency_with_events_works_as_before_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamKey); + stream.AlwaysEnforceConsistency = true; + + // Append events - this should use the normal UpdateStreamVersion path + stream.AppendOne(new CEvent()); + await theSession.SaveChangesAsync(); + + // Verify the event was stored + var aggregate = await theSession.Events.AggregateStreamAsync(streamKey); + aggregate.CCount.ShouldBe(1); + } + + [Fact] + public async Task enforce_consistency_with_events_sad_path_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamId); + stream.AlwaysEnforceConsistency = true; + + // Append events + stream.AppendOne(new CEvent()); + + // Sneak in events from another session + await using (var otherSession = theStore.LightweightSession()) + { + otherSession.Events.Append(streamId, new DEvent()); + await otherSession.SaveChangesAsync(); + } + + // Save should fail because another session advanced the version + await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + } + + [Fact] + public async Task enforce_consistency_with_events_sad_path_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent(), new BEvent()); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(streamKey); + stream.AlwaysEnforceConsistency = true; + + // Append events + stream.AppendOne(new CEvent()); + + // Sneak in events from another session + await using (var otherSession = theStore.LightweightSession()) + { + otherSession.Events.Append(streamKey, new DEvent()); + await otherSession.SaveChangesAsync(); + } + + // Save should fail because another session advanced the version + await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + } +} diff --git a/src/EventSourcingTests/FetchForWriting/assert_stream_version.cs b/src/EventSourcingTests/FetchForWriting/assert_stream_version.cs new file mode 100644 index 0000000000..3b8ab35168 --- /dev/null +++ b/src/EventSourcingTests/FetchForWriting/assert_stream_version.cs @@ -0,0 +1,183 @@ +using System; +using System.Threading.Tasks; +using EventSourcingTests.Aggregation; +using JasperFx; +using JasperFx.Events; +using Marten; +using Marten.Events; +using Marten.Events.Operations; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.FetchForWriting; + +public class assert_stream_version: IntegrationContext +{ + public assert_stream_version(DefaultStoreFixture fixture): base(fixture) + { + } + + protected override Task fixtureSetup() + { + return theStore.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + [Fact] + public async Task happy_path_version_matches_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + // Build a StreamAction that matches the current version + var stream = new StreamAction(streamId, StreamActionType.Append) + { + ExpectedVersionOnServer = 3, + AggregateType = typeof(SimpleAggregate) + }; + + var operation = new AssertStreamVersionById(theStore.Options.EventGraph, stream); + + // Execute in a batch - should not throw + theSession.QueueOperation(operation); + await theSession.SaveChangesAsync(); + } + + [Fact] + public async Task happy_path_version_matches_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + // Build a StreamAction that matches the current version + var stream = new StreamAction(streamKey, StreamActionType.Append) + { + ExpectedVersionOnServer = 3, + AggregateType = typeof(SimpleAggregateAsString) + }; + + var operation = new AssertStreamVersionByKey(theStore.Options.EventGraph, stream); + + // Execute in a batch - should not throw + theSession.QueueOperation(operation); + await theSession.SaveChangesAsync(); + } + + [Fact] + public async Task sad_path_version_mismatch_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + // Build a StreamAction with the WRONG expected version + var stream = new StreamAction(streamId, StreamActionType.Append) + { + ExpectedVersionOnServer = 2, // actual is 3 + AggregateType = typeof(SimpleAggregate) + }; + + var operation = new AssertStreamVersionById(theStore.Options.EventGraph, stream); + + theSession.QueueOperation(operation); + + var ex = await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + + ex.ShouldBeOfType(); + ex.Message.ShouldContain(streamId.ToString()); + ex.Message.ShouldContain("expected 2"); + ex.Message.ShouldContain("was 3"); + } + + [Fact] + public async Task sad_path_version_mismatch_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, new AEvent(), new BEvent(), new CEvent()); + await theSession.SaveChangesAsync(); + + // Build a StreamAction with the WRONG expected version + var stream = new StreamAction(streamKey, StreamActionType.Append) + { + ExpectedVersionOnServer = 2, // actual is 3 + AggregateType = typeof(SimpleAggregateAsString) + }; + + var operation = new AssertStreamVersionByKey(theStore.Options.EventGraph, stream); + + theSession.QueueOperation(operation); + + var ex = await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + + ex.ShouldBeOfType(); + ex.Message.ShouldContain(streamKey); + ex.Message.ShouldContain("expected 2"); + ex.Message.ShouldContain("was 3"); + } + + [Fact] + public async Task sad_path_stream_does_not_exist_Guid_identifier() + { + var streamId = Guid.NewGuid(); + + // Build a StreamAction for a non-existent stream + var stream = new StreamAction(streamId, StreamActionType.Append) + { + ExpectedVersionOnServer = 1, + AggregateType = typeof(SimpleAggregate) + }; + + var operation = new AssertStreamVersionById(theStore.Options.EventGraph, stream); + + theSession.QueueOperation(operation); + + var ex = await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + + ex.ShouldBeOfType(); + } + + [Fact] + public async Task sad_path_stream_does_not_exist_string_identifier() + { + UseStreamIdentity(StreamIdentity.AsString); + + var streamKey = Guid.NewGuid().ToString(); + + // Build a StreamAction for a non-existent stream + var stream = new StreamAction(streamKey, StreamActionType.Append) + { + ExpectedVersionOnServer = 1, + AggregateType = typeof(SimpleAggregateAsString) + }; + + var operation = new AssertStreamVersionByKey(theStore.Options.EventGraph, stream); + + theSession.QueueOperation(operation); + + var ex = await Should.ThrowAsync(async () => + { + await theSession.SaveChangesAsync(); + }); + + ex.ShouldBeOfType(); + } +} diff --git a/src/EventSourcingTests/FetchForWriting/fetching_by_natural_key.cs b/src/EventSourcingTests/FetchForWriting/fetching_by_natural_key.cs new file mode 100644 index 0000000000..135a461c41 --- /dev/null +++ b/src/EventSourcingTests/FetchForWriting/fetching_by_natural_key.cs @@ -0,0 +1,517 @@ +using System; +using System.Threading.Tasks; +using JasperFx; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using Marten; +using Marten.Events; +using Marten.Events.Projections; +using Marten.Exceptions; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.FetchForWriting; + +#region sample_natural_key_aggregate_types + +public record OrderNumber(string Value); + +public record InvoiceNumber(string Value); + +public class OrderAggregate +{ + public Guid Id { get; set; } + + [NaturalKey] + public OrderNumber OrderNum { get; set; } + + public decimal TotalAmount { get; set; } + public string CustomerName { get; set; } + public bool IsComplete { get; set; } + + [NaturalKeySource] + public void Apply(OrderCreated e) + { + OrderNum = e.OrderNumber; + CustomerName = e.CustomerName; + } + + public void Apply(OrderItemAdded e) + { + TotalAmount += e.Price; + } + + [NaturalKeySource] + public void Apply(OrderNumberChanged e) + { + OrderNum = e.NewOrderNumber; + } + + public void Apply(OrderCompleted e) + { + IsComplete = true; + } +} + +public class OrderAggregateAsString +{ + public string Id { get; set; } + + [NaturalKey] + public OrderNumber OrderNum { get; set; } + + public decimal TotalAmount { get; set; } + public string CustomerName { get; set; } + + [NaturalKeySource] + public void Apply(OrderCreated e) + { + OrderNum = e.OrderNumber; + CustomerName = e.CustomerName; + } + + public void Apply(OrderItemAdded e) + { + TotalAmount += e.Price; + } + + [NaturalKeySource] + public void Apply(OrderNumberChanged e) + { + OrderNum = e.NewOrderNumber; + } +} + +public class InvoiceAggregate +{ + public Guid Id { get; set; } + + [NaturalKey] + public InvoiceNumber InvoiceCode { get; set; } + + public decimal Amount { get; set; } + + [NaturalKeySource] + public void Apply(InvoiceCreated e) + { + InvoiceCode = e.Code; + Amount = e.Amount; + } +} + +public record OrderCreated(OrderNumber OrderNumber, string CustomerName); +public record OrderItemAdded(string ItemName, decimal Price); +public record OrderNumberChanged(OrderNumber NewOrderNumber); +public record OrderCompleted; +public record InvoiceCreated(InvoiceNumber Code, decimal Amount); + +#endregion + +public class fetching_by_natural_key: OneOffConfigurationsContext +{ + #region Guid Stream Identity + Inline Lifecycle + + [Fact] + public async Task fetch_for_writing_new_stream_by_natural_key_returns_null_aggregate() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-999"); + + var stream = await theSession.Events.FetchForWriting(orderNumber); + + stream.Aggregate.ShouldBeNull(); + stream.CurrentVersion.ShouldBe(0); + } + + [Fact] + public async Task fetch_for_writing_existing_stream_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-001"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Alice"), + new OrderItemAdded("Widget", 9.99m)); + await theSession.SaveChangesAsync(); + + #region sample_marten_fetch_for_writing_by_natural_key + // FetchForWriting by the business identifier instead of stream id + var stream = await theSession.Events.FetchForWriting(orderNumber); + + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.OrderNum.ShouldBe(orderNumber); + + // Append new events through the stream + stream.AppendOne(new OrderItemAdded("Gadget", 19.99m)); + await theSession.SaveChangesAsync(); + #endregion + } + + [Fact] + public async Task fetch_for_writing_and_append_events_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-002"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Bob")); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(orderNumber); + stream.Aggregate.ShouldNotBeNull(); + stream.CurrentVersion.ShouldBe(1); + + stream.AppendOne(new OrderItemAdded("Gadget", 19.99m)); + stream.AppendOne(new OrderItemAdded("Doohickey", 5.50m)); + stream.AppendOne(new OrderCompleted()); + await theSession.SaveChangesAsync(); + + // Verify final state by fetching again + using var verifySession = theStore.LightweightSession(); + var verify = await verifySession.Events.FetchForWriting(orderNumber); + verify.Aggregate.ShouldNotBeNull(); + verify.Aggregate.TotalAmount.ShouldBe(25.49m); + verify.Aggregate.IsComplete.ShouldBeTrue(); + verify.CurrentVersion.ShouldBe(4); + } + + [Fact] + public async Task fetch_latest_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-003"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Charlie"), + new OrderItemAdded("Thingamajig", 15.00m)); + await theSession.SaveChangesAsync(); + + #region sample_marten_fetch_latest_by_natural_key + // Read-only access by natural key + var aggregate = await theSession.Events.FetchLatest(orderNumber); + #endregion + + aggregate.ShouldNotBeNull(); + aggregate.OrderNum.ShouldBe(orderNumber); + aggregate.CustomerName.ShouldBe("Charlie"); + aggregate.TotalAmount.ShouldBe(15.00m); + } + + [Fact] + public async Task fetch_latest_returns_null_for_nonexistent_natural_key() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var nonExistentKey = new OrderNumber("ORD-DOES-NOT-EXIST"); + + var aggregate = await theSession.Events.FetchLatest(nonExistentKey); + + aggregate.ShouldBeNull(); + } + + [Fact] + public async Task fetch_for_exclusive_writing_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-004"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Diana"), + new OrderItemAdded("Contraption", 42.00m)); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForExclusiveWriting(orderNumber); + + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.OrderNum.ShouldBe(orderNumber); + stream.Aggregate.CustomerName.ShouldBe("Diana"); + stream.Aggregate.TotalAmount.ShouldBe(42.00m); + stream.CurrentVersion.ShouldBe(2); + } + + [Fact] + public async Task natural_key_is_mutable_fetch_after_change() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var originalNumber = new OrderNumber("ORD-OLD"); + var newNumber = new OrderNumber("ORD-NEW"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(originalNumber, "Eve")); + await theSession.SaveChangesAsync(); + + // Change the natural key + theSession.Events.Append(streamId, new OrderNumberChanged(newNumber)); + await theSession.SaveChangesAsync(); + + // Fetch by the NEW natural key should succeed + var stream = await theSession.Events.FetchForWriting(newNumber); + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.OrderNum.ShouldBe(newNumber); + stream.Aggregate.CustomerName.ShouldBe("Eve"); + } + + [Fact] + public async Task null_natural_key_value_is_silently_skipped() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var streamId = Guid.NewGuid(); + + // OrderCreated with a null OrderNumber - the extractor will return null + // and the natural key projection should skip creating a mapping + theSession.Events.StartStream(streamId, + new OrderCreated(null, "Frank")); + await theSession.SaveChangesAsync(); + + // Appending events with non-null key afterwards should work + var orderNumber = new OrderNumber("ORD-LATE"); + theSession.Events.Append(streamId, new OrderNumberChanged(orderNumber)); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(orderNumber); + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.CustomerName.ShouldBe("Frank"); + } + + [Fact] + public async Task natural_key_with_wrapped_string_type() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var invoiceCode = new InvoiceNumber("INV-001"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new InvoiceCreated(invoiceCode, 250.00m)); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(invoiceCode); + + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.InvoiceCode.ShouldBe(invoiceCode); + stream.Aggregate.Amount.ShouldBe(250.00m); + stream.CurrentVersion.ShouldBe(1); + } + + #endregion + + #region Guid Stream Identity + Live Lifecycle + + [Fact] + public async Task live_fetch_for_writing_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.LiveStreamAggregation(); + }); + + var orderNumber = new OrderNumber("ORD-LIVE-001"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Grace"), + new OrderItemAdded("Sprocket", 7.77m)); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(orderNumber); + + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.OrderNum.ShouldBe(orderNumber); + stream.Aggregate.CustomerName.ShouldBe("Grace"); + stream.Aggregate.TotalAmount.ShouldBe(7.77m); + stream.CurrentVersion.ShouldBe(2); + } + + [Fact] + public async Task live_fetch_latest_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.LiveStreamAggregation(); + }); + + var orderNumber = new OrderNumber("ORD-LIVE-002"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Hank"), + new OrderItemAdded("Cog", 3.33m), + new OrderCompleted()); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.Events.FetchLatest(orderNumber); + + aggregate.ShouldNotBeNull(); + aggregate.OrderNum.ShouldBe(orderNumber); + aggregate.CustomerName.ShouldBe("Hank"); + aggregate.TotalAmount.ShouldBe(3.33m); + aggregate.IsComplete.ShouldBeTrue(); + } + + #endregion + + #region String Stream Identity + Inline Lifecycle + + [Fact] + public async Task string_identity_fetch_for_writing_by_natural_key() + { + StoreOptions(opts => + { + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-STR-001"); + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, + new OrderCreated(orderNumber, "Iris"), + new OrderItemAdded("Lever", 12.50m)); + await theSession.SaveChangesAsync(); + + var stream = await theSession.Events.FetchForWriting(orderNumber); + + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.OrderNum.ShouldBe(orderNumber); + stream.Aggregate.CustomerName.ShouldBe("Iris"); + stream.Aggregate.TotalAmount.ShouldBe(12.50m); + stream.CurrentVersion.ShouldBe(2); + } + + [Fact] + public async Task string_identity_fetch_latest_by_natural_key() + { + StoreOptions(opts => + { + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-STR-002"); + var streamKey = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(streamKey, + new OrderCreated(orderNumber, "Jack"), + new OrderItemAdded("Pulley", 8.00m)); + await theSession.SaveChangesAsync(); + + var aggregate = await theSession.Events.FetchLatest(orderNumber); + + aggregate.ShouldNotBeNull(); + aggregate.OrderNum.ShouldBe(orderNumber); + aggregate.CustomerName.ShouldBe("Jack"); + aggregate.TotalAmount.ShouldBe(8.00m); + } + + #endregion + + #region Concurrency + + [Fact] + public async Task fetch_for_writing_with_expected_version_by_natural_key() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-VER-001"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Karen"), + new OrderItemAdded("Bolt", 1.50m)); + await theSession.SaveChangesAsync(); + + // First fetch - version should be 2 + var stream = await theSession.Events.FetchForWriting(orderNumber); + stream.CurrentVersion.ShouldBe(2); + + // Append more events + stream.AppendOne(new OrderItemAdded("Nut", 0.75m)); + await theSession.SaveChangesAsync(); + + // Second fetch - version should now be 3 + using var session2 = theStore.LightweightSession(); + var stream2 = await session2.Events.FetchForWriting(orderNumber); + stream2.CurrentVersion.ShouldBe(3); + stream2.Aggregate.TotalAmount.ShouldBe(2.25m); + } + + [Fact] + public async Task concurrent_fetches_by_natural_key_with_optimistic_concurrency() + { + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderNumber = new OrderNumber("ORD-CONC-001"); + var streamId = Guid.NewGuid(); + + theSession.Events.StartStream(streamId, + new OrderCreated(orderNumber, "Leo")); + await theSession.SaveChangesAsync(); + + // Session 1 fetches for writing + await using var session1 = theStore.LightweightSession(); + var stream1 = await session1.Events.FetchForWriting(orderNumber); + stream1.AppendOne(new OrderItemAdded("Washer", 0.25m)); + + // Session 2 fetches for writing (same stream, same version) + await using var session2 = theStore.LightweightSession(); + var stream2 = await session2.Events.FetchForWriting(orderNumber); + stream2.AppendOne(new OrderItemAdded("Screw", 0.10m)); + + // First save succeeds + await session1.SaveChangesAsync(); + + // Second save should throw a concurrency exception because the version has moved + await Should.ThrowAsync(async () => + { + await session2.SaveChangesAsync(); + }); + } + + #endregion +} diff --git a/src/EventSourcingTests/GlobalUsings.cs b/src/EventSourcingTests/GlobalUsings.cs new file mode 100644 index 0000000000..6c7852349c --- /dev/null +++ b/src/EventSourcingTests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using IStorageOperation = Marten.Internal.Operations.IStorageOperation; +global using OperationRole = Marten.Internal.Operations.OperationRole; diff --git a/src/EventSourcingTests/Projections/EventProjectionTests.cs b/src/EventSourcingTests/Projections/EventProjectionTests.cs index b2f00c6fe4..0514b2f62f 100644 --- a/src/EventSourcingTests/Projections/EventProjectionTests.cs +++ b/src/EventSourcingTests/Projections/EventProjectionTests.cs @@ -220,11 +220,11 @@ public void empty_projection_throws_validation_error() } } -public class EmptyProjection: EventProjection +public partial class EmptyProjection: EventProjection { } -public class SimpleProjection: EventProjection +public partial class SimpleProjection: EventProjection { public void Project(UserCreated @event, IDocumentOperations operations) => operations.Store(new User { UserName = @event.UserName }); @@ -233,7 +233,7 @@ public void Project(UserDeleted @event, IDocumentOperations operations) => operations.DeleteWhere(x => x.UserName == @event.UserName); } -public class SimpleTransformProjection: EventProjection +public partial class SimpleTransformProjection: EventProjection { public User Transform(UserCreated @event) => new User { UserName = @event.UserName }; @@ -246,7 +246,7 @@ public class OtherCreationEvent: UserCreated { } -public class SimpleTransformProjectionUsingMetadata: EventProjection +public partial class SimpleTransformProjectionUsingMetadata: EventProjection { public User Transform(IEvent @event) { @@ -272,7 +272,7 @@ public void Project(UserDeleted @event, IDocumentOperations operations) => operations.DeleteWhere(x => x.UserName == @event.UserName); } -public class SimpleCreatorProjection: EventProjection +public partial class SimpleCreatorProjection: EventProjection { public User Create(UserCreated e) => new User { UserName = e.UserName }; @@ -280,7 +280,7 @@ public void Project(UserDeleted @event, IDocumentOperations operations) => operations.DeleteWhere(x => x.UserName == @event.UserName); } -public class SimpleCreatorProjection2: EventProjection +public partial class SimpleCreatorProjection2: EventProjection { public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation) { @@ -301,7 +301,7 @@ public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, C #region sample_lambda_definition_of_event_projection -public class LambdaProjection: EventProjection +public partial class LambdaProjection: EventProjection { public LambdaProjection() { diff --git a/src/EventSourcingTests/Projections/EventProjections/EventProjectionOrderingTests.cs b/src/EventSourcingTests/Projections/EventProjections/EventProjectionOrderingTests.cs index ac4725cb17..f5921eb9ca 100644 --- a/src/EventSourcingTests/Projections/EventProjections/EventProjectionOrderingTests.cs +++ b/src/EventSourcingTests/Projections/EventProjections/EventProjectionOrderingTests.cs @@ -64,7 +64,7 @@ public class OrderingTracker public record DummyEventForOrdering(int Order); -public class TestOrderingEventProjection: EventProjection +public partial class TestOrderingEventProjection: EventProjection { public OrderingTracker Transform(IEvent e) { diff --git a/src/EventSourcingTests/Projections/include_extra_schema_objects_from_projections.cs b/src/EventSourcingTests/Projections/include_extra_schema_objects_from_projections.cs index 23aac8ccc2..f7054416d5 100644 --- a/src/EventSourcingTests/Projections/include_extra_schema_objects_from_projections.cs +++ b/src/EventSourcingTests/Projections/include_extra_schema_objects_from_projections.cs @@ -61,7 +61,7 @@ public class NameAdded public string Name { get; set; } } -public class TableCreatingProjection: EventProjection +public partial class TableCreatingProjection: EventProjection { public TableCreatingProjection() { diff --git a/src/EventSourcingTests/Projections/inline_transformation_of_events.cs b/src/EventSourcingTests/Projections/inline_transformation_of_events.cs index c795e8afcb..6d909b414f 100644 --- a/src/EventSourcingTests/Projections/inline_transformation_of_events.cs +++ b/src/EventSourcingTests/Projections/inline_transformation_of_events.cs @@ -159,7 +159,7 @@ public inline_transformation_of_events() #region sample_MonsterDefeatedTransform -public class MonsterDefeatedTransform: EventProjection +public partial class MonsterDefeatedTransform: EventProjection { public MonsterDefeated Create(IEvent input) { diff --git a/src/EventSourcingTests/cannot_register_duplicate_projections_by_name.cs b/src/EventSourcingTests/cannot_register_duplicate_projections_by_name.cs index e50efbb86f..470947b400 100644 --- a/src/EventSourcingTests/cannot_register_duplicate_projections_by_name.cs +++ b/src/EventSourcingTests/cannot_register_duplicate_projections_by_name.cs @@ -11,7 +11,7 @@ namespace EventSourcingTests; -public class cannot_register_duplicate_projections_by_name +public partial class cannot_register_duplicate_projections_by_name { [Fact] public void cannot_register_duplicate_projection_names() @@ -27,7 +27,7 @@ public void cannot_register_duplicate_projection_names() }); } - public class Projection1: EventProjection + public partial class Projection1: EventProjection { public Projection1() { @@ -40,7 +40,7 @@ public AEvent Create(BEvent travel, IEvent e) } } - public class Projection2: EventProjection + public partial class Projection2: EventProjection { public Projection2() { diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreEventProjectionTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreEventProjectionTests.cs new file mode 100644 index 0000000000..62fc64168d --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreEventProjectionTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; +using Xunit; + +namespace Marten.EntityFrameworkCore.Tests; + +public class OrderSummaryProjection: EfCoreEventProjection +{ + protected override async Task ProjectAsync(IEvent @event, + TestDbContext dbContext, + IDocumentOperations operations, CancellationToken token) + { + switch (@event.Data) + { + case OrderPlaced placed: + // Write to EF Core + dbContext.OrderSummaries.Add(new OrderSummary + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items, + Status = "Placed" + }); + + // Also write to Marten if you want + operations.Store(new Order + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items + }); + break; + + case OrderShipped shipped: + var summary = await dbContext.OrderSummaries + .FindAsync(new object[] { shipped.OrderId }, token); + if (summary != null) + { + summary.Status = "Shipped"; + } + break; + } + } +} + +public class EfCoreEventProjectionTests: IAsyncLifetime +{ + private DocumentStore _store = null!; + + public async Task InitializeAsync() + { + _store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "efcore_tests"; + opts.Projections.Add(new OrderSummaryProjection(), ProjectionLifecycle.Inline); + // Register EF Core entity tables for Weasel migration + opts.AddEntityTablesFromDbContext(); + }); + + await _store.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + public Task DisposeAsync() + { + _store?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task can_project_event_to_ef_core_and_marten() + { + var orderId = Guid.NewGuid(); + await using var session = _store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Alice", 99.99m, 3)); + await session.SaveChangesAsync(); + + // Verify Marten document + var order = await session.LoadAsync(orderId); + order.ShouldNotBeNull(); + order.CustomerName.ShouldBe("Alice"); + order.TotalAmount.ShouldBe(99.99m); + + // Verify EF Core entity + await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + await using var setSchema = conn.CreateCommand(); + setSchema.CommandText = "SET search_path TO efcore_tests"; + await setSchema.ExecuteNonQueryAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, status FROM ef_order_summaries WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Alice"); + reader.GetString(1).ShouldBe("Placed"); + } + + [Fact] + public async Task can_project_multiple_events() + { + var orderId = Guid.NewGuid(); + await using var session = _store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Bob", 50.00m, 1), + new OrderShipped(orderId)); + await session.SaveChangesAsync(); + + // Verify EF Core entity was updated + await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + await using var setSchema = conn.CreateCommand(); + setSchema.CommandText = "SET search_path TO efcore_tests"; + await setSchema.ExecuteNonQueryAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT status FROM ef_order_summaries WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + var status = (string?)await cmd.ExecuteScalarAsync(); + status.ShouldBe("Shipped"); + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreMultiStreamProjectionTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreMultiStreamProjectionTests.cs new file mode 100644 index 0000000000..30c68240e9 --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreMultiStreamProjectionTests.cs @@ -0,0 +1,259 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten.Events; +using Marten.Testing.Harness; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; +using Xunit; + +namespace Marten.EntityFrameworkCore.Tests; + +// Multi-stream events +public record CustomerOrderPlaced(Guid OrderId, string CustomerName, decimal Amount); +public record CustomerOrderCompleted(Guid OrderId, string CustomerName); + +// Multi-stream aggregate keyed by customer name +public class CustomerOrderHistory +{ + public string Id { get; set; } = string.Empty; + public int TotalOrders { get; set; } + public decimal TotalSpent { get; set; } +} + +public class CustomerOrderHistoryProjection + : EfCoreMultiStreamProjection +{ + public CustomerOrderHistoryProjection() + { + Identity(e => e.CustomerName); + Identity(e => e.CustomerName); + } + + public override CustomerOrderHistory? ApplyEvent(CustomerOrderHistory? snapshot, + string identity, IEvent @event, TestDbContext dbContext) + { + snapshot ??= new CustomerOrderHistory { Id = identity }; + + switch (@event.Data) + { + case CustomerOrderPlaced placed: + snapshot.TotalOrders++; + snapshot.TotalSpent += placed.Amount; + break; + } + + return snapshot; + } +} + +public abstract class EfCoreMultiStreamProjectionTestsBase: IAsyncLifetime +{ + protected DocumentStore Store = null!; + + protected abstract ProjectionLifecycle Lifecycle { get; } + + public async Task InitializeAsync() + { + Store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = $"efcore_ms_{Lifecycle.ToString().ToLower()}"; + opts.Events.StreamIdentity = StreamIdentity.AsString; + // Use the new extension method that sets up EF Core storage + Weasel migrations + opts.Add(new CustomerOrderHistoryProjection(), Lifecycle); + }); + + await Store.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + public Task DisposeAsync() + { + Store?.Dispose(); + return Task.CompletedTask; + } + + /// + /// After appending events, ensure the projection has been applied. + /// For Inline this is a no-op; for Async this waits for the daemon. + /// + protected virtual Task WaitForProjectionAsync() => Task.CompletedTask; + + private string SchemaName => $"efcore_ms_{Lifecycle.ToString().ToLower()}"; + + protected async Task OpenConnectionAsync() + { + var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + await using var setSchema = conn.CreateCommand(); + setSchema.CommandText = $"SET search_path TO {SchemaName}"; + await setSchema.ExecuteNonQueryAsync(); + return conn; + } + + [Fact] + public async Task multi_stream_projection_aggregates_across_streams() + { + await using var session = Store.LightweightSession(); + + var stream1 = Guid.NewGuid().ToString(); + var stream2 = Guid.NewGuid().ToString(); + + session.Events.StartStream(stream1, + new CustomerOrderPlaced(Guid.NewGuid(), "Eve", 100.00m)); + session.Events.StartStream(stream2, + new CustomerOrderPlaced(Guid.NewGuid(), "Eve", 50.00m)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + // Verify via EF Core table + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT total_orders, total_spent FROM ef_customer_order_histories WHERE id = @id"; + cmd.Parameters.AddWithValue("id", "Eve"); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetInt32(0).ShouldBe(2); + reader.GetDecimal(1).ShouldBe(150.00m); + } + + [Fact] + public async Task multi_stream_projection_creates_separate_aggregates_per_identity() + { + await using var session = Store.LightweightSession(); + + session.Events.StartStream(Guid.NewGuid().ToString(), + new CustomerOrderPlaced(Guid.NewGuid(), "Alice", 80.00m)); + session.Events.StartStream(Guid.NewGuid().ToString(), + new CustomerOrderPlaced(Guid.NewGuid(), "Bob", 120.00m)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + await using var conn = await OpenConnectionAsync(); + + // Check Alice + await using var cmd1 = conn.CreateCommand(); + cmd1.CommandText = "SELECT total_orders, total_spent FROM ef_customer_order_histories WHERE id = @id"; + cmd1.Parameters.AddWithValue("id", "Alice"); + await using var reader1 = await cmd1.ExecuteReaderAsync(); + (await reader1.ReadAsync()).ShouldBeTrue(); + reader1.GetInt32(0).ShouldBe(1); + reader1.GetDecimal(1).ShouldBe(80.00m); + await reader1.CloseAsync(); + + // Check Bob + await using var cmd2 = conn.CreateCommand(); + cmd2.CommandText = "SELECT total_orders, total_spent FROM ef_customer_order_histories WHERE id = @id"; + cmd2.Parameters.AddWithValue("id", "Bob"); + await using var reader2 = await cmd2.ExecuteReaderAsync(); + (await reader2.ReadAsync()).ShouldBeTrue(); + reader2.GetInt32(0).ShouldBe(1); + reader2.GetDecimal(1).ShouldBe(120.00m); + } + + [Fact] + public async Task multi_stream_projection_handles_subsequent_appends() + { + await using var session = Store.LightweightSession(); + + var stream1 = Guid.NewGuid().ToString(); + session.Events.StartStream(stream1, + new CustomerOrderPlaced(Guid.NewGuid(), "Charlie", 60.00m)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + var stream2 = Guid.NewGuid().ToString(); + session.Events.StartStream(stream2, + new CustomerOrderPlaced(Guid.NewGuid(), "Charlie", 40.00m)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT total_orders, total_spent FROM ef_customer_order_histories WHERE id = @id"; + cmd.Parameters.AddWithValue("id", "Charlie"); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetInt32(0).ShouldBe(2); + reader.GetDecimal(1).ShouldBe(100.00m); + } +} + +public class EfCoreMultiStreamProjectionInlineTests: EfCoreMultiStreamProjectionTestsBase +{ + protected override ProjectionLifecycle Lifecycle => ProjectionLifecycle.Inline; +} + +public class EfCoreMultiStreamProjectionAsyncTests: EfCoreMultiStreamProjectionTestsBase +{ + protected override ProjectionLifecycle Lifecycle => ProjectionLifecycle.Async; + + protected override async Task WaitForProjectionAsync() + { + using var daemon = await Store.BuildProjectionDaemonAsync(); + await daemon.StartAllAsync(); + await Store.WaitForNonStaleProjectionDataAsync(15.Seconds()); + } +} + +public class EfCoreMultiStreamProjectionLiveTests: IAsyncLifetime +{ + private DocumentStore _store = null!; + + public async Task InitializeAsync() + { + _store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "efcore_ms_live"; + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.Projections.Add(new CustomerOrderHistoryProjection(), ProjectionLifecycle.Live); + }); + + await _store.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + public Task DisposeAsync() + { + _store?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task live_multi_stream_projection_does_not_persist_aggregate() + { + await using var session = _store.LightweightSession(); + + var stream1 = Guid.NewGuid().ToString(); + session.Events.StartStream(stream1, + new CustomerOrderPlaced(Guid.NewGuid(), "Dana", 70.00m)); + await session.SaveChangesAsync(); + + // Live multi-stream projections are not persisted + var history = await session.LoadAsync("Dana"); + history.ShouldBeNull(); + } + + [Fact] + public async Task live_multi_stream_projection_can_store_events_without_error() + { + // Verify the store can be configured and events appended with Live lifecycle + await using var session = _store.LightweightSession(); + + var streamKey = Guid.NewGuid().ToString(); + session.Events.StartStream(streamKey, + new CustomerOrderPlaced(Guid.NewGuid(), "Eve", 100.00m)); + await session.SaveChangesAsync(); + + // Events are stored successfully even with a Live multi-stream projection registered + var events = await session.Events.FetchStreamAsync(streamKey); + events.ShouldNotBeEmpty(); + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreMultiTenancyValidationTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreMultiTenancyValidationTests.cs new file mode 100644 index 0000000000..6b0fc57c12 --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreMultiTenancyValidationTests.cs @@ -0,0 +1,111 @@ +using System; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten.Storage; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace Marten.EntityFrameworkCore.Tests; + +public class EfCoreMultiTenancyValidationTests +{ + private string errorMessageFor(Action configure) + { + var ex = Should.Throw(() => + { + DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + configure(opts); + }); + }); + + return ex.Message; + } + + private void shouldNotThrow(Action configure) + { + using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + configure(opts); + }); + } + + [Fact] + public void single_stream_should_fail_when_conjoined_but_no_ITenanted() + { + errorMessageFor(opts => + { + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Add(new OrderAggregate(), ProjectionLifecycle.Inline); + }).ShouldContain("must implement ITenanted"); + } + + [Fact] + public void multi_stream_should_fail_when_conjoined_but_no_ITenanted() + { + errorMessageFor(opts => + { + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.Add(new CustomerOrderHistoryProjection(), ProjectionLifecycle.Inline); + }).ShouldContain("must implement ITenanted"); + } + + [Fact] + public void single_stream_should_pass_when_conjoined_with_ITenanted() + { + shouldNotThrow(opts => + { + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Add(new TenantedOrderAggregate(), ProjectionLifecycle.Inline); + }); + } + + [Fact] + public void single_stream_should_pass_without_conjoined_tenancy() + { + shouldNotThrow(opts => + { + opts.Add(new OrderAggregate(), ProjectionLifecycle.Inline); + }); + } +} + +// Tenanted single-stream projection for validation tests +public class TenantedOrderAggregate: EfCoreSingleStreamProjection +{ + public override TenantedOrder? ApplyEvent(TenantedOrder? snapshot, Guid identity, IEvent @event, + TenantedTestDbContext dbContext, IQuerySession session) + { + switch (@event.Data) + { + case OrderPlaced placed: + return new TenantedOrder + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items + }; + + case OrderShipped: + if (snapshot != null) + { + snapshot.IsShipped = true; + } + return snapshot; + + case OrderCancelled: + if (snapshot != null) + { + snapshot.IsCancelled = true; + } + return snapshot; + } + + return snapshot; + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreSingleStreamProjectionTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreSingleStreamProjectionTests.cs new file mode 100644 index 0000000000..0ee009f27b --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreSingleStreamProjectionTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten.Events; +using Marten.Testing.Harness; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; +using Xunit; + +namespace Marten.EntityFrameworkCore.Tests; + +public class OrderAggregate: EfCoreSingleStreamProjection +{ + public override Order ApplyEvent(Order snapshot, Guid identity, IEvent @event, + TestDbContext dbContext, IQuerySession session) + { + switch (@event.Data) + { + case OrderPlaced placed: + // Also write an OrderSummary side effect through the DbContext + dbContext.OrderSummaries.Add(new OrderSummary + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items, + Status = "Placed" + }); + return new Order + { + Id = placed.OrderId, + CustomerName = placed.CustomerName, + TotalAmount = placed.Amount, + ItemCount = placed.Items + }; + + case OrderShipped: + if (snapshot != null) + { + snapshot.IsShipped = true; + } + return snapshot; + + case OrderCancelled: + if (snapshot != null) + { + snapshot.IsCancelled = true; + } + return snapshot; + } + + return snapshot; + } +} + +public abstract class EfCoreSingleStreamProjectionTestsBase: IAsyncLifetime +{ + protected DocumentStore Store = null!; + + protected abstract ProjectionLifecycle Lifecycle { get; } + + public async Task InitializeAsync() + { + Store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = $"efcore_ss_{Lifecycle.ToString().ToLower()}"; + // Use the new extension method that sets up EF Core storage + Weasel migrations + opts.Add(new OrderAggregate(), Lifecycle); + }); + + await Store.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + public Task DisposeAsync() + { + Store?.Dispose(); + return Task.CompletedTask; + } + + /// + /// After appending events, ensure the projection has been applied. + /// For Inline this is a no-op; for Async this waits for the daemon. + /// + protected virtual Task WaitForProjectionAsync() => Task.CompletedTask; + + private string SchemaName => $"efcore_ss_{Lifecycle.ToString().ToLower()}"; + + protected async Task OpenConnectionAsync() + { + var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + await using var setSchema = conn.CreateCommand(); + setSchema.CommandText = $"SET search_path TO {SchemaName}"; + await setSchema.ExecuteNonQueryAsync(); + return conn; + } + + [Fact] + public async Task single_stream_projection_writes_aggregate_on_create() + { + var orderId = Guid.NewGuid(); + await using var session = Store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Carol", 200.00m, 5)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + // Verify aggregate was persisted via EF Core + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, total_amount, item_count FROM ef_orders WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Carol"); + reader.GetDecimal(1).ShouldBe(200.00m); + reader.GetInt32(2).ShouldBe(5); + } + + [Fact] + public async Task single_stream_projection_evolves_aggregate_with_subsequent_events() + { + var orderId = Guid.NewGuid(); + await using var session = Store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Dave", 75.00m, 2), + new OrderShipped(orderId)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + // Verify via EF Core + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, is_shipped FROM ef_orders WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Dave"); + reader.GetBoolean(1).ShouldBeTrue(); + } + + [Fact] + public async Task single_stream_projection_handles_multiple_appends() + { + var orderId = Guid.NewGuid(); + + await using var session = Store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Eve", 120.00m, 3)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + session.Events.Append(orderId, new OrderShipped(orderId)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + // Verify via EF Core + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, is_shipped FROM ef_orders WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Eve"); + reader.GetBoolean(1).ShouldBeTrue(); + } + + [Fact] + public async Task single_stream_projection_writes_ef_core_side_effects() + { + var orderId = Guid.NewGuid(); + await using var session = Store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Frank", 300.00m, 10)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + // Verify the OrderSummary side-effect was also written via EF Core + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, status, total_amount FROM ef_order_summaries WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Frank"); + reader.GetString(1).ShouldBe("Placed"); + reader.GetDecimal(2).ShouldBe(300.00m); + } +} + +public class EfCoreSingleStreamProjectionInlineTests: EfCoreSingleStreamProjectionTestsBase +{ + protected override ProjectionLifecycle Lifecycle => ProjectionLifecycle.Inline; +} + +public class EfCoreSingleStreamProjectionAsyncTests: EfCoreSingleStreamProjectionTestsBase +{ + protected override ProjectionLifecycle Lifecycle => ProjectionLifecycle.Async; + + protected override async Task WaitForProjectionAsync() + { + using var daemon = await Store.BuildProjectionDaemonAsync(); + await daemon.StartAllAsync(); + await Store.WaitForNonStaleProjectionDataAsync(15.Seconds()); + } +} + +public class EfCoreSingleStreamProjectionLiveTests: IAsyncLifetime +{ + private DocumentStore _store = null!; + + public async Task InitializeAsync() + { + _store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "efcore_ss_live"; + opts.Projections.Add(new OrderAggregate(), ProjectionLifecycle.Live); + }); + + await _store.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + public Task DisposeAsync() + { + _store?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task live_aggregation_builds_aggregate_on_the_fly() + { + var orderId = Guid.NewGuid(); + await using var session = _store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Grace", 50.00m, 1), + new OrderShipped(orderId)); + await session.SaveChangesAsync(); + + var order = await session.Events.AggregateStreamAsync(orderId); + order.ShouldNotBeNull(); + order.CustomerName.ShouldBe("Grace"); + order.IsShipped.ShouldBeTrue(); + } + + [Fact] + public async Task live_aggregation_returns_null_for_unknown_stream() + { + await using var session = _store.LightweightSession(); + var order = await session.Events.AggregateStreamAsync(Guid.NewGuid()); + order.ShouldBeNull(); + } + + [Fact] + public async Task live_aggregation_does_not_persist_aggregate() + { + var orderId = Guid.NewGuid(); + await using var session = _store.LightweightSession(); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Heidi", 90.00m, 4)); + await session.SaveChangesAsync(); + + // Live aggregation rebuilds on the fly + var order = await session.Events.AggregateStreamAsync(orderId); + order.ShouldNotBeNull(); + + // But the aggregate is NOT stored in the document table + var loaded = await session.LoadAsync(orderId); + loaded.ShouldBeNull(); + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreTenantedSingleStreamProjectionTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreTenantedSingleStreamProjectionTests.cs new file mode 100644 index 0000000000..c749f7c8fd --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreTenantedSingleStreamProjectionTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Events.Projections; +using Marten.Events; +using Marten.Storage; +using Marten.Testing.Harness; +using Npgsql; +using Shouldly; +using Xunit; + +namespace Marten.EntityFrameworkCore.Tests; + +public abstract class EfCoreTenantedSingleStreamProjectionTestsBase: IAsyncLifetime +{ + protected DocumentStore Store = null!; + + protected abstract ProjectionLifecycle Lifecycle { get; } + + public async Task InitializeAsync() + { + Store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = $"efcore_tss_{Lifecycle.ToString().ToLower()}"; + opts.Events.TenancyStyle = TenancyStyle.Conjoined; + opts.Add(new TenantedOrderAggregate(), Lifecycle); + }); + + await Store.Advanced.Clean.CompletelyRemoveAllAsync(); + } + + public Task DisposeAsync() + { + Store?.Dispose(); + return Task.CompletedTask; + } + + protected virtual Task WaitForProjectionAsync() => Task.CompletedTask; + + private string SchemaName => $"efcore_tss_{Lifecycle.ToString().ToLower()}"; + + protected async Task OpenConnectionAsync() + { + var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + await using var setSchema = conn.CreateCommand(); + setSchema.CommandText = $"SET search_path TO {SchemaName}"; + await setSchema.ExecuteNonQueryAsync(); + return conn; + } + + [Fact] + public async Task tenant_id_is_written_to_ef_core_table() + { + var orderId = Guid.NewGuid(); + await using var session = Store.LightweightSession("alpha"); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Alice", 100.00m, 3)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, tenant_id FROM ef_tenanted_orders WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Alice"); + reader.GetString(1).ShouldBe("alpha"); + } + + [Fact] + public async Task different_tenants_get_isolated_data() + { + var alphaOrderId = Guid.NewGuid(); + var betaOrderId = Guid.NewGuid(); + + await using (var alphaSession = Store.LightweightSession("alpha")) + { + alphaSession.Events.StartStream(alphaOrderId, + new OrderPlaced(alphaOrderId, "AlphaCustomer", 50.00m, 1)); + await alphaSession.SaveChangesAsync(); + } + + await using (var betaSession = Store.LightweightSession("beta")) + { + betaSession.Events.StartStream(betaOrderId, + new OrderPlaced(betaOrderId, "BetaCustomer", 75.00m, 2)); + await betaSession.SaveChangesAsync(); + } + + await WaitForProjectionAsync(); + + await using var conn = await OpenConnectionAsync(); + + // Check alpha + await using var cmd1 = conn.CreateCommand(); + cmd1.CommandText = "SELECT customer_name, tenant_id FROM ef_tenanted_orders WHERE id = @id"; + cmd1.Parameters.AddWithValue("id", alphaOrderId); + await using var reader1 = await cmd1.ExecuteReaderAsync(); + (await reader1.ReadAsync()).ShouldBeTrue(); + reader1.GetString(0).ShouldBe("AlphaCustomer"); + reader1.GetString(1).ShouldBe("alpha"); + await reader1.CloseAsync(); + + // Check beta + await using var cmd2 = conn.CreateCommand(); + cmd2.CommandText = "SELECT customer_name, tenant_id FROM ef_tenanted_orders WHERE id = @id"; + cmd2.Parameters.AddWithValue("id", betaOrderId); + await using var reader2 = await cmd2.ExecuteReaderAsync(); + (await reader2.ReadAsync()).ShouldBeTrue(); + reader2.GetString(0).ShouldBe("BetaCustomer"); + reader2.GetString(1).ShouldBe("beta"); + } + + [Fact] + public async Task subsequent_appends_preserve_tenant_id() + { + var orderId = Guid.NewGuid(); + + await using var session = Store.LightweightSession("alpha"); + session.Events.StartStream(orderId, + new OrderPlaced(orderId, "Carol", 200.00m, 5)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + session.Events.Append(orderId, new OrderShipped(orderId)); + await session.SaveChangesAsync(); + + await WaitForProjectionAsync(); + + await using var conn = await OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT customer_name, is_shipped, tenant_id FROM ef_tenanted_orders WHERE id = @id"; + cmd.Parameters.AddWithValue("id", orderId); + await using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue(); + reader.GetString(0).ShouldBe("Carol"); + reader.GetBoolean(1).ShouldBeTrue(); + reader.GetString(2).ShouldBe("alpha"); + } +} + +public class EfCoreTenantedSingleStreamProjectionInlineTests: EfCoreTenantedSingleStreamProjectionTestsBase +{ + protected override ProjectionLifecycle Lifecycle => ProjectionLifecycle.Inline; +} + +public class EfCoreTenantedSingleStreamProjectionAsyncTests: EfCoreTenantedSingleStreamProjectionTestsBase +{ + protected override ProjectionLifecycle Lifecycle => ProjectionLifecycle.Async; + + protected override async Task WaitForProjectionAsync() + { + using var daemon = await Store.BuildProjectionDaemonAsync(); + await daemon.StartAllAsync(); + await Store.WaitForNonStaleProjectionDataAsync(15.Seconds()); + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/Marten.EntityFrameworkCore.Tests.csproj b/src/Marten.EntityFrameworkCore.Tests/Marten.EntityFrameworkCore.Tests.csproj new file mode 100644 index 0000000000..227b7091c8 --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/Marten.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,31 @@ + + + net9.0;net10.0 + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + Harness\ConnectionSource.cs + + + Harness\TestsSettings.cs + + + diff --git a/src/Marten.EntityFrameworkCore.Tests/TestDbContext.cs b/src/Marten.EntityFrameworkCore.Tests/TestDbContext.cs new file mode 100644 index 0000000000..7609ef81b7 --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/TestDbContext.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace Marten.EntityFrameworkCore.Tests; + +public class OrderSummary +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public string Status { get; set; } = "Pending"; +} + +public class OrderDetail +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public bool IsShipped { get; set; } + public string Status { get; set; } = "Unknown"; +} + +public class TestDbContext: DbContext +{ + public TestDbContext(DbContextOptions options): base(options) + { + } + + public DbSet OrderSummaries => Set(); + public DbSet Orders => Set(); + public DbSet CustomerOrderHistories => Set(); + public DbSet OrderDetails => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ef_order_summaries"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CustomerName).HasColumnName("customer_name"); + entity.Property(e => e.TotalAmount).HasColumnName("total_amount"); + entity.Property(e => e.ItemCount).HasColumnName("item_count"); + entity.Property(e => e.Status).HasColumnName("status"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ef_orders"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CustomerName).HasColumnName("customer_name"); + entity.Property(e => e.TotalAmount).HasColumnName("total_amount"); + entity.Property(e => e.ItemCount).HasColumnName("item_count"); + entity.Property(e => e.IsShipped).HasColumnName("is_shipped"); + entity.Property(e => e.IsCancelled).HasColumnName("is_cancelled"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ef_customer_order_histories"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.TotalOrders).HasColumnName("total_orders"); + entity.Property(e => e.TotalSpent).HasColumnName("total_spent"); + }); + } +} + +public class TenantedTestDbContext: DbContext +{ + public TenantedTestDbContext(DbContextOptions options): base(options) + { + } + + public DbSet TenantedOrders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("ef_tenanted_orders"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CustomerName).HasColumnName("customer_name"); + entity.Property(e => e.TotalAmount).HasColumnName("total_amount"); + entity.Property(e => e.ItemCount).HasColumnName("item_count"); + entity.Property(e => e.IsShipped).HasColumnName("is_shipped"); + entity.Property(e => e.IsCancelled).HasColumnName("is_cancelled"); + entity.Property(e => e.TenantId).HasColumnName("tenant_id"); + }); + } +} diff --git a/src/Marten.EntityFrameworkCore.Tests/TestEvents.cs b/src/Marten.EntityFrameworkCore.Tests/TestEvents.cs new file mode 100644 index 0000000000..88ddaf29d8 --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/TestEvents.cs @@ -0,0 +1,32 @@ +using System; +using Marten.Metadata; + +namespace Marten.EntityFrameworkCore.Tests; + +// Events for testing projections +public record OrderPlaced(Guid OrderId, string CustomerName, decimal Amount, int Items); +public record OrderShipped(Guid OrderId); +public record OrderCancelled(Guid OrderId); + +// Marten aggregate document +public class Order +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public bool IsShipped { get; set; } + public bool IsCancelled { get; set; } +} + +// Tenanted aggregate document for conjoined multi-tenancy tests +public class TenantedOrder: ITenanted +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public bool IsShipped { get; set; } + public bool IsCancelled { get; set; } + public string? TenantId { get; set; } +} diff --git a/src/Marten.EntityFrameworkCore/DbContextTransactionParticipant.cs b/src/Marten.EntityFrameworkCore/DbContextTransactionParticipant.cs new file mode 100644 index 0000000000..9b4b556369 --- /dev/null +++ b/src/Marten.EntityFrameworkCore/DbContextTransactionParticipant.cs @@ -0,0 +1,52 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace Marten.EntityFrameworkCore; + +/// +/// Wraps a DbContext so that it can participate in Marten's database transaction. +/// When is called, the DbContext is enlisted in +/// the provided connection and transaction, then its tracked changes are flushed. +/// The initial placeholder connection (used only for provider registration) is +/// disposed after being swapped out. +/// +internal class DbContextTransactionParticipant: ITransactionParticipant + where TDbContext : DbContext +{ + public TDbContext DbContext { get; } + private readonly NpgsqlConnection _initialConnection; + private readonly string? _schemaName; + + public DbContextTransactionParticipant(TDbContext dbContext, NpgsqlConnection initialConnection, + string? schemaName = null) + { + DbContext = dbContext; + _initialConnection = initialConnection; + _schemaName = schemaName; + } + + public async Task BeforeCommitAsync(NpgsqlConnection connection, + NpgsqlTransaction transaction, CancellationToken token) + { + // Set search_path on Marten's real connection so EF Core targets the right schema + if (!string.IsNullOrEmpty(_schemaName)) + { + await using var setSchema = connection.CreateCommand(); + setSchema.CommandText = $"SET search_path TO {_schemaName}"; + setSchema.Transaction = transaction; + await setSchema.ExecuteNonQueryAsync(token).ConfigureAwait(false); + } + + // Swap to Marten's real connection and transaction + DbContext.Database.SetDbConnection(connection); + await DbContext.Database.UseTransactionAsync(transaction, token).ConfigureAwait(false); + + // Flush all tracked changes into the same transaction + await DbContext.SaveChangesAsync(token).ConfigureAwait(false); + + // Dispose the initial placeholder connection + await _initialConnection.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreDbContextFactory.cs b/src/Marten.EntityFrameworkCore/EfCoreDbContextFactory.cs new file mode 100644 index 0000000000..6fe31ee706 --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreDbContextFactory.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace Marten.EntityFrameworkCore; + +/// +/// Internal helper to create DbContext instances for projection use. +/// The DbContext is created with an NpgsqlConnection from Marten's database. +/// The connection is swapped to the real transaction connection later by +/// when the +/// transaction is ready. +/// +internal static class EfCoreDbContextFactory +{ + public static (TDbContext DbContext, NpgsqlConnection InitialConnection) Create( + this Storage.IMartenDatabase database, + Action>? configure = null, + string? schemaName = null) + where TDbContext : DbContext + { + var builder = new DbContextOptionsBuilder(); + + // Create a connection from Marten's database for provider registration. + var connection = database.CreateConnection(); + + // If a schema name is provided, open the connection and set the search_path + // so EF Core queries target the correct schema when it loads existing aggregates. + if (!string.IsNullOrEmpty(schemaName)) + { + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SET search_path TO {schemaName}"; + cmd.ExecuteNonQuery(); + } + + builder.UseNpgsql(connection); + configure?.Invoke(builder); + var dbContext = (TDbContext)Activator.CreateInstance(typeof(TDbContext), builder.Options)!; + return (dbContext, connection); + } +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreEventProjection.cs b/src/Marten.EntityFrameworkCore/EfCoreEventProjection.cs new file mode 100644 index 0000000000..17995b0f20 --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreEventProjection.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten.Events.Projections; +using Marten.Internal.Sessions; +using Marten.Storage; +using Microsoft.EntityFrameworkCore; + +namespace Marten.EntityFrameworkCore; + +/// +/// Base class for event projections that use both Marten document operations +/// and an EF Core DbContext. Both sets of writes are committed atomically +/// in the same database transaction. +/// +/// The EF Core DbContext type to use +public abstract class EfCoreEventProjection: IProjection + where TDbContext : DbContext +{ + /// + /// Optional configuration for the DbContextOptionsBuilder. + /// Override to customize EF Core options (e.g., model configuration). + /// The Npgsql provider is already configured before this is called. + /// + protected virtual void ConfigureDbContext(DbContextOptionsBuilder builder) + { + } + + public async Task ApplyAsync(IDocumentOperations operations, + IReadOnlyList events, CancellationToken cancellation) + { + // Ensure EF Core entity tables exist (e.g., after schema was dropped by Clean) + await operations.Database.EnsureStorageExistsAsync(typeof(StorageFeatures), cancellation) + .ConfigureAwait(false); + + // Create a DbContext with a connection object from Marten's database. + // The actual connection will be swapped in during BeforeCommitAsync. + var schemaName = (operations as QuerySession)?.Options.DatabaseSchemaName; + var (dbContext, initialConnection) = operations.Database.Create(ConfigureDbContext, schemaName); + var ops = new EfCoreOperations(operations, dbContext); + + foreach (var @event in events) + { + await ProjectAsync(@event, ops.DbContext, ops.Marten, cancellation).ConfigureAwait(false); + } + + if (operations is ITransactionParticipantRegistrar registrar) + { + registrar.AddTransactionParticipant( + new DbContextTransactionParticipant(dbContext, initialConnection, schemaName)); + } + } + + /// + /// Override to handle each event. Use operations.DbContext for EF Core writes + /// and operations.Marten for Marten document writes. + /// + protected abstract Task ProjectAsync(IEvent @event, + TDbContext dbContext, + IDocumentOperations operations, CancellationToken token); +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreMultiStreamProjection.cs b/src/Marten.EntityFrameworkCore/EfCoreMultiStreamProjection.cs new file mode 100644 index 0000000000..8109641dc8 --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreMultiStreamProjection.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten.Events.Projections; +using Marten.Metadata; +using Marten.Storage; +using Microsoft.EntityFrameworkCore; + +namespace Marten.EntityFrameworkCore; + +/// +/// Base class for multi-stream aggregate projections that persist the aggregate +/// through an EF Core DbContext instead of Marten's document storage. +/// The DbContext is also available in for data lookups. +/// +/// The aggregate document type persisted by EF Core +/// The aggregate identity type +/// The EF Core DbContext type to use +public abstract class EfCoreMultiStreamProjection + : MultiStreamProjection, IValidatedProjection + where TDoc : class where TId : notnull where TDbContext : DbContext +{ + private string? _schemaName; + + /// + /// Optional configuration for the DbContextOptionsBuilder. + /// Override to customize EF Core options. The Npgsql provider is already + /// configured before this is called. + /// + public virtual void ConfigureDbContext(DbContextOptionsBuilder builder) + { + } + + /// + /// Registers this projection's aggregate type for EF Core-based storage + /// with Marten's custom projection storage providers. + /// + internal void RegisterEfCoreStorage(StoreOptions options) + { + _schemaName = options.DatabaseSchemaName; + var schemaName = _schemaName; + options.CustomProjectionStorageProviders[typeof(TDoc)] = (session, tenantId) => + { + var (dbContext, initialConnection) = session.Database.Create(ConfigureDbContext, schemaName); + + if (session is ITransactionParticipantRegistrar registrar) + { + registrar.AddTransactionParticipant( + new DbContextTransactionParticipant(dbContext, initialConnection, schemaName)); + } + + return new EfCoreProjectionStorage(dbContext, tenantId); + }; + } + + [JasperFxIgnore] + public override ValueTask<(TDoc?, ActionType)> DetermineActionAsync( + IQuerySession session, + TDoc? snapshot, + TId identity, + IIdentitySetter identitySetter, + IReadOnlyList events, + CancellationToken cancellation) + { + // Extract the DbContext from the EfCoreProjectionStorage if available + TDbContext? dbContext = null; + if (identitySetter is EfCoreProjectionStorage efStorage) + { + dbContext = efStorage.DbContext; + } + + // Fallback: create a DbContext directly (e.g., for Live aggregation) + if (dbContext == null) + { + var (ctx, initialConnection) = session.Database.Create(ConfigureDbContext, _schemaName); + dbContext = ctx; + + if (session is ITransactionParticipantRegistrar registrar) + { + registrar.AddTransactionParticipant( + new DbContextTransactionParticipant(dbContext, initialConnection, _schemaName)); + } + } + + return ApplyEventsAsync(snapshot, identity, events, session, dbContext, cancellation); + } + + /// + /// Override to apply events with access to both the aggregate and + /// an EF Core DbContext. The default implementation calls + /// for each event. + /// + [JasperFxIgnore] + protected virtual ValueTask<(TDoc?, ActionType)> ApplyEventsAsync( + TDoc? snapshot, TId identity, IReadOnlyList events, + IQuerySession session, TDbContext dbContext, CancellationToken token) + { + foreach (var @event in events) + { + snapshot = ApplyEvent(snapshot, identity, @event, dbContext); + } + + return new ValueTask<(TDoc?, ActionType)>( + (snapshot, snapshot == null ? ActionType.Delete : ActionType.Store)); + } + + /// + /// Override to apply a single event. Use for EF Core data lookups. + /// The aggregate is persisted through EF Core's DbContext, not Marten. + /// + [JasperFxIgnore] + public virtual TDoc? ApplyEvent(TDoc? snapshot, TId identity, IEvent @event, + TDbContext dbContext) + { + return snapshot; + } + + /// + /// Validates configuration specific to EF Core projections. Overrides the base + /// validation which assumes Marten document storage. + /// + IEnumerable IValidatedProjection.ValidateConfiguration(StoreOptions options) + { + if (options.Events.TenancyStyle == TenancyStyle.Conjoined + && !typeof(TDoc).CanBeCastTo()) + { + yield return + $"The EF Core projection aggregate type {typeof(TDoc).FullNameInCode()} must implement " + + $"{nameof(ITenanted)} because the event store uses conjoined multi-tenancy. " + + $"Add a TenantId property via the {nameof(ITenanted)} interface so tenant_id is written to the EF Core table."; + } + } +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreOperations.cs b/src/Marten.EntityFrameworkCore/EfCoreOperations.cs new file mode 100644 index 0000000000..136b13f73a --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreOperations.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace Marten.EntityFrameworkCore; + +/// +/// Provides simultaneous access to Marten's and an +/// EF Core within a projection. Both sets of writes will be +/// committed atomically in the same database transaction. +/// +public class EfCoreOperations where TDbContext : DbContext +{ + public IDocumentOperations Marten { get; } + public TDbContext DbContext { get; } + + internal EfCoreOperations(IDocumentOperations marten, TDbContext dbContext) + { + Marten = marten; + DbContext = dbContext; + } +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreProjectionExtensions.cs b/src/Marten.EntityFrameworkCore/EfCoreProjectionExtensions.cs new file mode 100644 index 0000000000..799ef04413 --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreProjectionExtensions.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using JasperFx.Events.Projections; +using Marten.Events.Projections; +using Microsoft.EntityFrameworkCore; +using Weasel.Core; +using Weasel.EntityFrameworkCore; +using Weasel.Postgresql; +using Weasel.Postgresql.Tables; + +namespace Marten.EntityFrameworkCore; + +/// +/// Extension methods for registering EF Core projections with Marten. +/// +public static class EfCoreProjectionExtensions +{ + /// + /// Register an with Marten. + /// Automatically sets up EF Core-based aggregate persistence and Weasel schema migration + /// for all entity types in the DbContext. + /// + public static void Add(this StoreOptions options, + EfCoreSingleStreamProjection projection, + ProjectionLifecycle lifecycle) + where TDoc : class where TId : notnull where TDbContext : DbContext + { + projection.RegisterEfCoreStorage(options); + options.Projections.Add(projection, lifecycle); + options.AddEntityTablesFromDbContext(projection.ConfigureDbContext); + } + + /// + /// Register an with Marten. + /// Automatically sets up EF Core-based aggregate persistence and Weasel schema migration + /// for all entity types in the DbContext. + /// + public static void Add(this StoreOptions options, + EfCoreMultiStreamProjection projection, + ProjectionLifecycle lifecycle) + where TDoc : class where TId : notnull where TDbContext : DbContext + { + projection.RegisterEfCoreStorage(options); + options.Projections.Add(projection, lifecycle); + options.AddEntityTablesFromDbContext(projection.ConfigureDbContext); + } + + /// + /// Add an to a composite projection. + /// Registers EF Core-based aggregate persistence and Weasel schema migration. + /// + public static void Add(this CompositeProjection composite, + StoreOptions options, + EfCoreMultiStreamProjection projection, + int stageNumber = 1) + where TDoc : class where TId : notnull where TDbContext : DbContext + { + projection.RegisterEfCoreStorage(options); + composite.Add(projection, stageNumber); + options.AddEntityTablesFromDbContext(projection.ConfigureDbContext); + } + + /// + /// Add an to a composite projection. + /// Registers EF Core-based aggregate persistence and Weasel schema migration. + /// + public static void Add(this CompositeProjection composite, + StoreOptions options, + EfCoreSingleStreamProjection projection, + int stageNumber = 1) + where TDoc : class where TId : notnull where TDbContext : DbContext + { + projection.RegisterEfCoreStorage(options); + composite.Add(projection, stageNumber); + options.AddEntityTablesFromDbContext(projection.ConfigureDbContext); + } + + /// + /// Register EF Core entity tables from a with Marten's + /// Weasel migration pipeline. Tables defined in the DbContext's model will be created + /// and migrated automatically alongside Marten's own schema objects. + /// + public static void AddEntityTablesFromDbContext(this StoreOptions options, + Action>? configure = null) + where TDbContext : DbContext + { + var migrator = new PostgresqlMigrator(); + + // Create a temporary DbContext just to read its entity model. + // The connection is never opened; it's only needed to satisfy UseNpgsql's requirement. + var builder = new DbContextOptionsBuilder(); + builder.UseNpgsql("Host=localhost"); + configure?.Invoke(builder); + + using var dbContext = (TDbContext)Activator.CreateInstance(typeof(TDbContext), builder.Options)!; + + var schemaName = options.DatabaseSchemaName; + + foreach (var entityType in DbContextExtensions.GetEntityTypesForMigration(dbContext)) + { + var table = migrator.MapToTable(entityType); + + // Move EF Core tables into the same schema as the Marten store + // so they participate in schema migration and cleanup together + if (!string.IsNullOrEmpty(schemaName) && table is Table pgTable) + { + pgTable.MoveToSchema(schemaName); + } + + options.Storage.ExtendedSchemaObjects.Add(table); + } + } +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreProjectionStorage.cs b/src/Marten.EntityFrameworkCore/EfCoreProjectionStorage.cs new file mode 100644 index 0000000000..112ddf100d --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreProjectionStorage.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Daemon; +using Microsoft.EntityFrameworkCore; + +namespace Marten.EntityFrameworkCore; + +/// +/// An implementation that persists +/// aggregates through an EF Core instead of Marten's +/// document storage. Loaded entities are read from the DbContext (which queries +/// via its own connection); stored/deleted entities are tracked and flushed when +/// +/// swaps to Marten's connection and calls SaveChangesAsync. +/// +internal class EfCoreProjectionStorage : IProjectionStorage + where TDoc : class where TId : notnull where TDbContext : DbContext +{ + public TDbContext DbContext { get; } + private readonly string _tenantId; + + public EfCoreProjectionStorage(TDbContext dbContext, string tenantId) + { + DbContext = dbContext; + _tenantId = tenantId; + } + + public string TenantId => _tenantId; + public Type IdType => typeof(TId); + + public TId Identity(TDoc document) + { + var entityType = DbContext.Model.FindEntityType(typeof(TDoc)); + if (entityType == null) + throw new InvalidOperationException($"{typeof(TDoc).Name} is not mapped in {typeof(TDbContext).Name}"); + + var pk = entityType.FindPrimaryKey() + ?? throw new InvalidOperationException($"{typeof(TDoc).Name} has no primary key configured in {typeof(TDbContext).Name}"); + + var pkValue = DbContext.Entry(document).Property(pk.Properties[0].Name).CurrentValue; + return (TId)pkValue!; + } + + public void SetIdentity(TDoc document, TId identity) + { + var entityType = DbContext.Model.FindEntityType(typeof(TDoc)); + if (entityType == null) return; + + var pk = entityType.FindPrimaryKey(); + if (pk == null) return; + + DbContext.Entry(document).Property(pk.Properties[0].Name).CurrentValue = identity; + } + + public void Store(TDoc snapshot) + { + AddOrUpdate(snapshot); + } + + public void Store(TDoc snapshot, TId id, string tenantId) + { + SetIdentity(snapshot, id); + AddOrUpdate(snapshot); + } + + public void StoreProjection(TDoc aggregate, IEvent? lastEvent, AggregationScope scope) + { + AddOrUpdate(aggregate); + } + + public void Delete(TId identity) + { + var entity = DbContext.Find(identity); + if (entity != null) DbContext.Remove(entity); + } + + public void Delete(TId identity, string tenantId) + { + Delete(identity); + } + + public void HardDelete(TDoc snapshot) + { + DbContext.Remove(snapshot); + } + + public void HardDelete(TDoc snapshot, string tenantId) + { + DbContext.Remove(snapshot); + } + + public void UnDelete(TDoc snapshot) + { + // Not applicable for EF Core storage + } + + public void UnDelete(TDoc snapshot, string tenantId) + { + // Not applicable for EF Core storage + } + + public async Task> LoadManyAsync(TId[] identities, CancellationToken cancellationToken) + { + var dict = new Dictionary(); + foreach (var id in identities) + { + var entity = await DbContext.FindAsync(new object[] { id }, cancellationToken) + .ConfigureAwait(false); + if (entity != null) + { + dict[id] = entity; + } + } + return dict; + } + + public async Task LoadAsync(TId id, CancellationToken cancellation) + { + return await DbContext.FindAsync(new object?[] { id }, cancellation) + .ConfigureAwait(false); + } + + public void ArchiveStream(TId sliceId, string tenantId) + { + // Not applicable for EF Core storage + } + + private void AddOrUpdate(TDoc entity) + { + var entry = DbContext.Entry(entity); + switch (entry.State) + { + case EntityState.Detached: + DbContext.Add(entity); + break; + case EntityState.Unchanged: + entry.State = EntityState.Modified; + break; + // Already Added or Modified — no action needed + } + } +} diff --git a/src/Marten.EntityFrameworkCore/EfCoreSingleStreamProjection.cs b/src/Marten.EntityFrameworkCore/EfCoreSingleStreamProjection.cs new file mode 100644 index 0000000000..f61dc29604 --- /dev/null +++ b/src/Marten.EntityFrameworkCore/EfCoreSingleStreamProjection.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; +using Marten.Events.Aggregation; +using Marten.Metadata; +using Marten.Storage; +using Microsoft.EntityFrameworkCore; + +namespace Marten.EntityFrameworkCore; + +/// +/// Base class for single-stream aggregate projections that persist the aggregate +/// through an EF Core DbContext instead of Marten's document storage. +/// The DbContext is also available in for data lookups. +/// +/// The aggregate document type persisted by EF Core +/// The stream identity type +/// The EF Core DbContext type to use +public abstract class EfCoreSingleStreamProjection + : SingleStreamProjection, IValidatedProjection + where TDoc : class where TId : notnull where TDbContext : DbContext +{ + private string? _schemaName; + + /// + /// Optional configuration for the DbContextOptionsBuilder. + /// Override to customize EF Core options. The Npgsql provider is already + /// configured before this is called. + /// + public virtual void ConfigureDbContext(DbContextOptionsBuilder builder) + { + } + + /// + /// Registers this projection's aggregate type for EF Core-based storage + /// with Marten's custom projection storage providers. + /// + internal void RegisterEfCoreStorage(StoreOptions options) + { + _schemaName = options.DatabaseSchemaName; + var schemaName = _schemaName; + options.CustomProjectionStorageProviders[typeof(TDoc)] = (session, tenantId) => + { + var (dbContext, initialConnection) = session.Database.Create(ConfigureDbContext, schemaName); + + if (session is ITransactionParticipantRegistrar registrar) + { + registrar.AddTransactionParticipant( + new DbContextTransactionParticipant(dbContext, initialConnection, schemaName)); + } + + return new EfCoreProjectionStorage(dbContext, tenantId); + }; + } + + [JasperFxIgnore] + public sealed override ValueTask<(TDoc?, ActionType)> DetermineActionAsync( + IQuerySession session, + TDoc? snapshot, + TId identity, + IIdentitySetter identitySetter, + IReadOnlyList events, + CancellationToken cancellation) + { + // Extract the DbContext from the EfCoreProjectionStorage if available + TDbContext? dbContext = null; + if (identitySetter is EfCoreProjectionStorage efStorage) + { + dbContext = efStorage.DbContext; + } + + // Fallback: create a DbContext directly (e.g., for Live aggregation) + if (dbContext == null) + { + var (ctx, initialConnection) = session.Database.Create(ConfigureDbContext, _schemaName); + dbContext = ctx; + + if (session is ITransactionParticipantRegistrar registrar) + { + registrar.AddTransactionParticipant( + new DbContextTransactionParticipant(dbContext, initialConnection, _schemaName)); + } + } + + return ApplyEventsAsync(snapshot, identity, events, session, dbContext, cancellation); + } + + /// + /// Override to apply events with access to both the aggregate and + /// an EF Core DbContext. The default implementation calls + /// for each event. + /// + [JasperFxIgnore] + public virtual ValueTask<(TDoc?, ActionType)> ApplyEventsAsync( + TDoc? snapshot, TId identity, IReadOnlyList events, + IQuerySession session, TDbContext dbContext, CancellationToken token) + { + foreach (var @event in events) + { + snapshot = ApplyEvent(snapshot, identity, @event, dbContext, session); + } + + return new ValueTask<(TDoc?, ActionType)>( + (snapshot, snapshot == null ? ActionType.Delete : ActionType.Store)); + } + + /// + /// Override to apply a single event. Use for EF Core data lookups. + /// The aggregate is persisted through EF Core's DbContext, not Marten. + /// + [JasperFxIgnore] + public virtual TDoc? ApplyEvent(TDoc? snapshot, TId identity, IEvent @event, + TDbContext dbContext, IQuerySession session) + { + return snapshot; + } + + /// + /// Validates configuration specific to EF Core projections. Overrides the base + /// validation which assumes Marten document storage. + /// + IEnumerable IValidatedProjection.ValidateConfiguration(StoreOptions options) + { + if (options.Events.TenancyStyle == TenancyStyle.Conjoined + && !typeof(TDoc).CanBeCastTo()) + { + yield return + $"The EF Core projection aggregate type {typeof(TDoc).FullNameInCode()} must implement " + + $"{nameof(ITenanted)} because the event store uses conjoined multi-tenancy. " + + $"Add a TenantId property via the {nameof(ITenanted)} interface so tenant_id is written to the EF Core table."; + } + } +} diff --git a/src/Marten.EntityFrameworkCore/Marten.EntityFrameworkCore.csproj b/src/Marten.EntityFrameworkCore/Marten.EntityFrameworkCore.csproj new file mode 100644 index 0000000000..154bbec59d --- /dev/null +++ b/src/Marten.EntityFrameworkCore/Marten.EntityFrameworkCore.csproj @@ -0,0 +1,25 @@ + + + EF Core integration for Marten projections + net9.0;net10.0 + true + true + true + false + true + true + true + true + enable + + + + + + + + + + + + diff --git a/src/Marten.Testing/CodeTracker/CommitView.cs b/src/Marten.Testing/CodeTracker/CommitView.cs index 2c14bc781d..bf38a1b520 100644 --- a/src/Marten.Testing/CodeTracker/CommitView.cs +++ b/src/Marten.Testing/CodeTracker/CommitView.cs @@ -22,7 +22,7 @@ public class CommitView public DateTimeOffset Timestamp { get; set; } } -public class CommitViewTransform: EventProjection +public partial class CommitViewTransform: EventProjection { public CommitView Transform(IEvent input, Commit data) { diff --git a/src/Marten.Testing/Examples/FlatTableProjection.cs b/src/Marten.Testing/Examples/FlatTableProjection.cs index 23fe075461..e66887446c 100644 --- a/src/Marten.Testing/Examples/FlatTableProjection.cs +++ b/src/Marten.Testing/Examples/FlatTableProjection.cs @@ -34,7 +34,7 @@ public record ImportFailed; #region sample_import_sql_projection -public class ImportSqlProjection: EventProjection +public partial class ImportSqlProjection: EventProjection { public ImportSqlProjection() { diff --git a/src/Marten.sln b/src/Marten.sln index 618e5a92db..c7a7ffcfed 100644 --- a/src/Marten.sln +++ b/src/Marten.sln @@ -19,6 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\Analysis.Build.props = ..\Analysis.Build.props ..\Directory.Build.props = ..\Directory.Build.props ..\docker-compose.yml = ..\docker-compose.yml + ..\dcb-summary.md = ..\dcb-summary.md ..\package.json = ..\package.json ..\README.md = ..\README.md ..\.github\workflows\docs-prs.yml = ..\.github\workflows\docs-prs.yml @@ -104,6 +105,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "build", "..\build\build.csp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DaemonTests.ManualOnly", "DaemonTests.ManualOnly\DaemonTests.ManualOnly.csproj", "{61D46B0C-F347-42D3-887E-1A9CAFF21724}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Marten.EntityFrameworkCore", "Marten.EntityFrameworkCore\Marten.EntityFrameworkCore.csproj", "{3E6E6C8D-2CCF-4291-8C26-18505F99713F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Marten.EntityFrameworkCore.Tests", "Marten.EntityFrameworkCore.Tests\Marten.EntityFrameworkCore.Tests.csproj", "{A6CBC59C-7640-4886-8328-DB449BE5142B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -534,6 +539,30 @@ Global {61D46B0C-F347-42D3-887E-1A9CAFF21724}.Release|x64.Build.0 = Release|Any CPU {61D46B0C-F347-42D3-887E-1A9CAFF21724}.Release|x86.ActiveCfg = Release|Any CPU {61D46B0C-F347-42D3-887E-1A9CAFF21724}.Release|x86.Build.0 = Release|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Debug|x64.ActiveCfg = Debug|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Debug|x64.Build.0 = Debug|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Debug|x86.ActiveCfg = Debug|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Debug|x86.Build.0 = Debug|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Release|Any CPU.Build.0 = Release|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Release|x64.ActiveCfg = Release|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Release|x64.Build.0 = Release|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Release|x86.ActiveCfg = Release|Any CPU + {3E6E6C8D-2CCF-4291-8C26-18505F99713F}.Release|x86.Build.0 = Release|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Debug|x64.Build.0 = Debug|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Debug|x86.Build.0 = Debug|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Release|Any CPU.Build.0 = Release|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Release|x64.ActiveCfg = Release|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Release|x64.Build.0 = Release|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Release|x86.ActiveCfg = Release|Any CPU + {A6CBC59C-7640-4886-8328-DB449BE5142B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Marten/DocumentStore.cs b/src/Marten/DocumentStore.cs index b3e35b3c1e..a7a3f400fc 100644 --- a/src/Marten/DocumentStore.cs +++ b/src/Marten/DocumentStore.cs @@ -61,6 +61,7 @@ public DocumentStore(StoreOptions options) StorageFeatures.PostProcessConfiguration(); Events.Initialize(this); + Options.Projections.DiscoverGeneratedEvolvers(AppDomain.CurrentDomain.GetAssemblies()); Options.Projections.AssertValidity(Options); if (Options.LogFactory != null) diff --git a/src/Marten/Events/CodeGeneration/EventDocumentStorageGenerator.cs b/src/Marten/Events/CodeGeneration/EventDocumentStorageGenerator.cs index e5a44051a8..a725d65937 100644 --- a/src/Marten/Events/CodeGeneration/EventDocumentStorageGenerator.cs +++ b/src/Marten/Events/CodeGeneration/EventDocumentStorageGenerator.cs @@ -356,6 +356,11 @@ private static GeneratedType buildQuickAppendOperation(EventGraph graph, Generat configure.Frames.Code("writeTimestamps(parameterBuilder);"); } + if (graph.TagTypes.Count > 0) + { + configure.Frames.Code("writeAllTagValues(parameterBuilder);"); + } + configure.Frames.AppendSql(')'); return operationType; diff --git a/src/Marten/Events/Daemon/Internals/ProjectionDocumentSession.cs b/src/Marten/Events/Daemon/Internals/ProjectionDocumentSession.cs index 27e1ccab03..ec2741ce46 100644 --- a/src/Marten/Events/Daemon/Internals/ProjectionDocumentSession.cs +++ b/src/Marten/Events/Daemon/Internals/ProjectionDocumentSession.cs @@ -13,7 +13,7 @@ namespace Marten.Events.Daemon.Internals; /// Lightweight session specifically used to capture operations for a specific tenant /// in the asynchronous projections /// -internal class ProjectionDocumentSession: DocumentSessionBase +internal class ProjectionDocumentSession: DocumentSessionBase, ITransactionParticipantRegistrar { public ShardExecutionMode Mode { get; } @@ -63,4 +63,12 @@ protected internal override void ejectById(string id) { // nothing } + + public void AddTransactionParticipant(ITransactionParticipant participant) + { + if (_workTracker is ProjectionUpdateBatch batch) + { + batch.AddTransactionParticipant(participant); + } + } } diff --git a/src/Marten/Events/Daemon/Internals/ProjectionUpdateBatch.cs b/src/Marten/Events/Daemon/Internals/ProjectionUpdateBatch.cs index bb423bec34..d3ffb141d2 100644 --- a/src/Marten/Events/Daemon/Internals/ProjectionUpdateBatch.cs +++ b/src/Marten/Events/Daemon/Internals/ProjectionUpdateBatch.cs @@ -25,6 +25,7 @@ public class ProjectionUpdateBatch: IUpdateBatch, IAsyncDisposable, IDisposable, { private readonly List _documentTypes = new(); private readonly List _pages = new(); + private readonly List _transactionParticipants = new(); private readonly List _patches = new(); private readonly SemaphoreSlim _semaphore = new(1, 1); @@ -283,6 +284,13 @@ await listener.BeforeCommitAsync((IDocumentSession)session, unitOfWorkData, _tok } } + public IReadOnlyList TransactionParticipants => _transactionParticipants; + + public void AddTransactionParticipant(ITransactionParticipant participant) + { + _transactionParticipants.Add(participant); + } + public IReadOnlyList BuildPages(IMartenSession session) { if (_token.IsCancellationRequested) diff --git a/src/Marten/Events/Dcb/AssertDcbConsistency.cs b/src/Marten/Events/Dcb/AssertDcbConsistency.cs new file mode 100644 index 0000000000..23336258c7 --- /dev/null +++ b/src/Marten/Events/Dcb/AssertDcbConsistency.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten.Internal; +using Marten.Internal.Operations; +using Weasel.Postgresql; + +namespace Marten.Events.Dcb; + +internal class AssertDcbConsistency: IStorageOperation +{ + private readonly EventGraph _events; + private readonly EventTagQuery _query; + private readonly long _lastSeenSequence; + + public AssertDcbConsistency(EventGraph events, EventTagQuery query, long lastSeenSequence) + { + _events = events; + _query = query; + _lastSeenSequence = lastSeenSequence; + } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + // Build EXISTS query to check if any new matching events have been appended + // since our last seen sequence + builder.Append("select exists (select 1 from "); + + var conditions = _query.Conditions; + var distinctTagTypes = conditions.Select(c => c.TagType).Distinct().ToList(); + + // Start with the first tag table + var first = true; + for (var i = 0; i < distinctTagTypes.Count; i++) + { + var tagType = distinctTagTypes[i]; + var registration = _events.FindTagType(tagType) + ?? throw new InvalidOperationException( + $"Tag type '{tagType.Name}' is not registered. Call RegisterTagType<{tagType.Name}>() first."); + + var alias = $"t{i}"; + if (first) + { + builder.Append(_events.DatabaseSchemaName); + builder.Append(".mt_event_tag_"); + builder.Append(registration.TableSuffix); + builder.Append(" "); + builder.Append(alias); + first = false; + } + else + { + builder.Append(" inner join "); + builder.Append(_events.DatabaseSchemaName); + builder.Append(".mt_event_tag_"); + builder.Append(registration.TableSuffix); + builder.Append(" "); + builder.Append(alias); + builder.Append(" on t0.seq_id = "); + builder.Append(alias); + builder.Append(".seq_id"); + } + } + + // Join to mt_events only if we need event type filtering + var hasEventTypeFilter = conditions.Any(c => c.EventType != null); + if (hasEventTypeFilter) + { + builder.Append(" inner join "); + builder.Append(_events.DatabaseSchemaName); + builder.Append(".mt_events e on t0.seq_id = e.seq_id"); + } + + builder.Append(" where t0.seq_id > "); + builder.AppendParameter(_lastSeenSequence); + + // Build OR conditions + builder.Append(" and ("); + for (var i = 0; i < conditions.Count; i++) + { + if (i > 0) + { + builder.Append(" or "); + } + + var condition = conditions[i]; + var tagIndex = distinctTagTypes.IndexOf(condition.TagType); + var alias = $"t{tagIndex}"; + + builder.Append("("); + builder.Append(alias); + builder.Append(".value = "); + + var registration = _events.FindTagType(condition.TagType)!; + var value = registration.ExtractValue(condition.TagValue); + builder.AppendParameter(value); + + if (condition.EventType != null) + { + builder.Append(" and e.type = "); + var eventTypeName = _events.EventMappingFor(condition.EventType).EventTypeName; + builder.AppendParameter(eventTypeName); + } + + builder.Append(")"); + } + + builder.Append(") limit 1)"); + } + + public Type DocumentType => typeof(IEvent); + + public void Postprocess(DbDataReader reader, IList exceptions) + { + if (reader.Read() && reader.GetBoolean(0)) + { + exceptions.Add(new DcbConcurrencyException(_query, _lastSeenSequence)); + } + } + + public async Task PostprocessAsync(DbDataReader reader, IList exceptions, CancellationToken token) + { + if (await reader.ReadAsync(token).ConfigureAwait(false) && + await reader.GetFieldValueAsync(0, token).ConfigureAwait(false)) + { + exceptions.Add(new DcbConcurrencyException(_query, _lastSeenSequence)); + } + } + + public OperationRole Role() => OperationRole.Events; +} diff --git a/src/Marten/Events/Dcb/DcbConcurrencyException.cs b/src/Marten/Events/Dcb/DcbConcurrencyException.cs new file mode 100644 index 0000000000..624103e3a3 --- /dev/null +++ b/src/Marten/Events/Dcb/DcbConcurrencyException.cs @@ -0,0 +1,22 @@ +using System; +using JasperFx.Events.Tags; +using Marten.Exceptions; + +namespace Marten.Events.Dcb; + +/// +/// Thrown when a DCB consistency check fails — new events matching the tag query +/// were appended after the boundary was established. +/// +public class DcbConcurrencyException: MartenException +{ + public DcbConcurrencyException(EventTagQuery query, long lastSeenSequence) + : base($"DCB consistency violation: new events matching the tag query were appended after sequence {lastSeenSequence}") + { + Query = query; + LastSeenSequence = lastSeenSequence; + } + + public EventTagQuery Query { get; } + public long LastSeenSequence { get; } +} diff --git a/src/Marten/Events/Dcb/EventBoundary.cs b/src/Marten/Events/Dcb/EventBoundary.cs new file mode 100644 index 0000000000..5e9dd1d2c9 --- /dev/null +++ b/src/Marten/Events/Dcb/EventBoundary.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JasperFx.Events; +using JasperFx.Events.Tags; +using JasperFx.Core; +using Marten.Internal.Sessions; + +namespace Marten.Events.Dcb; + +internal class EventBoundary: IEventBoundary where T : notnull +{ + private readonly DocumentSessionBase _session; + private readonly EventGraph _events; + + public EventBoundary(DocumentSessionBase session, EventGraph events, T? aggregate, + IReadOnlyList loadedEvents, long lastSeenSequence) + { + _session = session; + _events = events; + Aggregate = aggregate; + Events = loadedEvents; + LastSeenSequence = lastSeenSequence; + } + + public T? Aggregate { get; } + public long LastSeenSequence { get; } + public IReadOnlyList Events { get; } + + public void AppendOne(object @event) + { + var wrapped = _events.BuildEvent(@event); + RouteEventByTags(wrapped); + } + + public void AppendMany(params object[] events) + { + foreach (var e in events) + { + AppendOne(e); + } + } + + public void AppendMany(IEnumerable events) + { + foreach (var e in events) + { + AppendOne(e); + } + } + + private void RouteEventByTags(IEvent wrapped) + { + var tags = wrapped.Tags; + + // If no explicit tags, try to infer from event properties + if (tags == null || tags.Count == 0) + { + var inferred = EventTagInference.InferTags(wrapped.Data, _events.TagTypes); + if (inferred.Count == 0) + { + throw new InvalidOperationException( + $"Cannot route event of type '{wrapped.Data.GetType().Name}' appended via IEventBoundary. " + + "The event has no explicit tags set via WithTag() and Marten could not infer any tags " + + "from its public properties matching registered tag types. Either set tags explicitly " + + "or ensure the event type has properties matching registered tag types."); + } + + foreach (var tag in inferred) + { + wrapped.AddTag(tag); + } + + tags = wrapped.Tags; + } + + // Find the stream to route to. An event belongs to exactly ONE stream, + // but its tags are written to ALL matching tag tables at save time. + // Use the first tag with an AggregateType to determine the target stream. + StreamAction? stream = null; + + foreach (var tag in tags!) + { + var registration = _events.FindTagType(tag.TagType); + if (registration?.AggregateType == null) continue; + + var streamId = registration.ExtractValue(tag.Value); + if (streamId is Guid guidId) + { + if (!_session.WorkTracker.TryFindStream(guidId, out stream)) + { + stream = StreamAction.Append(guidId, new[] { wrapped }); + stream.AggregateType = registration.AggregateType; + _session.WorkTracker.Streams.Add(stream); + } + else + { + stream.AddEvent(wrapped); + } + } + else if (streamId is string stringId) + { + if (!_session.WorkTracker.TryFindStream(stringId, out stream)) + { + stream = StreamAction.Append(stringId, new[] { wrapped }); + stream.AggregateType = registration.AggregateType; + _session.WorkTracker.Streams.Add(stream); + } + else + { + stream.AddEvent(wrapped); + } + } + + break; // Route to the first matching stream only + } + + // If no tag has an AggregateType, create a new orphan stream + // to avoid concurrency conflicts + if (stream == null) + { + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + var newId = CombGuidIdGeneration.NewGuid(); + stream = StreamAction.Start(newId, new[] { wrapped }); + _session.WorkTracker.Streams.Add(stream); + } + else + { + var newKey = Guid.NewGuid().ToString(); + stream = StreamAction.Start(newKey, new[] { wrapped }); + _session.WorkTracker.Streams.Add(stream); + } + } + + if (stream.Id != Guid.Empty) + { + wrapped.StreamId = stream.Id; + } + else if (stream.Key != null) + { + wrapped.StreamKey = stream.Key; + } + } +} diff --git a/src/Marten/Events/Dcb/FetchForWritingByTagsHandler.cs b/src/Marten/Events/Dcb/FetchForWritingByTagsHandler.cs new file mode 100644 index 0000000000..f4cca3a478 --- /dev/null +++ b/src/Marten/Events/Dcb/FetchForWritingByTagsHandler.cs @@ -0,0 +1,139 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten.Internal; +using Marten.Internal.Sessions; +using Marten.Linq.QueryHandlers; +using Weasel.Postgresql; + +namespace Marten.Events.Dcb; + +internal class FetchForWritingByTagsHandler: IQueryHandler> where T : class +{ + private readonly DocumentStore _store; + private readonly EventTagQuery _query; + + public FetchForWritingByTagsHandler(DocumentStore store, EventTagQuery query) + { + _store = store; + _query = query; + } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + var storage = (EventDocumentStorage)((DocumentSessionBase)session).EventStorage(); + var selectFields = storage.SelectFields(); + var conditions = _query.Conditions; + var distinctTagTypes = conditions.Select(c => c.TagType).Distinct().ToList(); + var schema = _store.Events.DatabaseSchemaName; + + builder.Append("select "); + for (var f = 0; f < selectFields.Length; f++) + { + if (f > 0) builder.Append(", "); + builder.Append("e."); + builder.Append(selectFields[f]); + } + + builder.Append(" from "); + builder.Append(schema); + builder.Append(".mt_events e"); + + for (var i = 0; i < distinctTagTypes.Count; i++) + { + var tagType = distinctTagTypes[i]; + var registration = _store.Events.FindTagType(tagType) + ?? throw new InvalidOperationException( + $"Tag type '{tagType.Name}' is not registered."); + + builder.Append(" left join "); + builder.Append(schema); + builder.Append(".mt_event_tag_"); + builder.Append(registration.TableSuffix); + builder.Append(" t"); + builder.Append(i.ToString()); + builder.Append(" on e.seq_id = t"); + builder.Append(i.ToString()); + builder.Append(".seq_id"); + } + + builder.Append(" where ("); + for (var i = 0; i < conditions.Count; i++) + { + if (i > 0) builder.Append(" or "); + + var condition = conditions[i]; + var tagIndex = distinctTagTypes.IndexOf(condition.TagType); + + builder.Append("(t"); + builder.Append(tagIndex.ToString()); + builder.Append(".value = "); + + var registration = _store.Events.FindTagType(condition.TagType)!; + var value = registration.ExtractValue(condition.TagValue); + builder.AppendParameter(value); + + if (condition.EventType != null) + { + builder.Append(" and e.type = "); + var eventTypeName = _store.Events.EventMappingFor(condition.EventType).EventTypeName; + builder.AppendParameter(eventTypeName); + } + + builder.Append(")"); + } + + builder.Append(") order by e.seq_id"); + } + + public IEventBoundary Handle(DbDataReader reader, IMartenSession session) + { + throw new NotSupportedException(); + } + + public async Task> HandleAsync(DbDataReader reader, IMartenSession session, + CancellationToken token) + { + var docSession = (DocumentSessionBase)session; + var storage = (EventDocumentStorage)docSession.EventStorage(); + + var events = new List(); + while (await reader.ReadAsync(token).ConfigureAwait(false)) + { + var @event = await storage.ResolveAsync(reader, token).ConfigureAwait(false); + events.Add(@event); + } + + var lastSeenSequence = events.Count > 0 ? events.Max(e => e.Sequence) : 0; + + T? aggregate = default; + if (events.Count > 0) + { + var aggregator = _store.Options.Projections.AggregatorFor(); + if (aggregator == null) + { + throw new InvalidOperationException( + $"Cannot find an aggregator for type '{typeof(T).Name}'."); + } + + aggregate = await aggregator.BuildAsync(events, docSession, default, token).ConfigureAwait(false); + } + + var assertion = new AssertDcbConsistency(_store.Events, _query, lastSeenSequence); + docSession.QueueOperation(assertion); + + return new EventBoundary(docSession, _store.Events, aggregate, events, lastSeenSequence); + } + + public Task StreamJson(Stream stream, DbDataReader reader, CancellationToken token) + { + throw new NotSupportedException(); + } +} diff --git a/src/Marten/Events/Dcb/IEventBoundary.cs b/src/Marten/Events/Dcb/IEventBoundary.cs new file mode 100644 index 0000000000..2ad53f57bc --- /dev/null +++ b/src/Marten/Events/Dcb/IEventBoundary.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using JasperFx.Events; + +namespace Marten.Events.Dcb; + +/// +/// Represents the result of a Dynamic Consistency Boundary (DCB) query with +/// consistency enforcement. Events are loaded by tag query, aggregated into T, +/// and Marten will assert no new matching events were added at SaveChangesAsync() time. +/// +/// The aggregate type projected from the matching events +public interface IEventBoundary where T : notnull +{ + /// + /// The aggregate projected from the events matching the tag query. + /// May be null if no matching events were found. + /// + T? Aggregate { get; } + + /// + /// The maximum seq_id from the tag query results. + /// Used as the consistency boundary marker. + /// + long LastSeenSequence { get; } + + /// + /// The events that matched the tag query, ordered by seq_id. + /// + IReadOnlyList Events { get; } + + /// + /// Append an event. The event MUST have tags set via WithTag() + /// so Marten can route it to the appropriate stream(s). + /// + void AppendOne(object @event); + + /// + /// Append multiple events. Each event MUST have tags set via WithTag(). + /// + void AppendMany(params object[] events); + + /// + /// Append multiple events. Each event MUST have tags set via WithTag(). + /// + void AppendMany(IEnumerable events); +} diff --git a/src/Marten/Events/EventGraph.FeatureSchema.cs b/src/Marten/Events/EventGraph.FeatureSchema.cs index 5ed0326997..8db77ed5ab 100644 --- a/src/Marten/Events/EventGraph.FeatureSchema.cs +++ b/src/Marten/Events/EventGraph.FeatureSchema.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using JasperFx.Events.Aggregation; using JasperFx.Events.Daemon; +using JasperFx.Events.Tags; using Marten.Events.Archiving; using Marten.Events.Projections; using Marten.Events.Schema; @@ -65,11 +67,25 @@ private IEnumerable createAllSchemaObjects() foreach (var schemaObject in objects) yield return schemaObject; } + // Natural key tables for aggregates with NaturalKeyDefinition + foreach (var aggregate in Options.Projections.All.OfType()) + { + if (aggregate.NaturalKeyDefinition != null) + { + yield return new NaturalKeyTable(this, aggregate.NaturalKeyDefinition); + } + } + if (EnableAdvancedAsyncTracking) { yield return new EventProgressionSkippingTable(this); yield return new SystemFunction(DatabaseSchemaName, "mt_mark_progression_with_skip", "varchar, bigint, bigint"); } + + foreach (var tagRegistration in _tagTypes) + { + yield return new EventTagTable(this, tagRegistration); + } } } diff --git a/src/Marten/Events/EventGraph.Processing.cs b/src/Marten/Events/EventGraph.Processing.cs index 69902679f6..0d2d84fd7f 100644 --- a/src/Marten/Events/EventGraph.Processing.cs +++ b/src/Marten/Events/EventGraph.Processing.cs @@ -20,15 +20,13 @@ public partial class EventGraph internal IEventAppender EventAppender { get; set; } = new RichEventAppender(); - private EventAppendMode _appendMode = EventAppendMode.Rich; - - public EventAppendMode AppendMode + public override EventAppendMode AppendMode { - get => _appendMode; + get => base.AppendMode; set { - _appendMode = value; - EventAppender = _appendMode == EventAppendMode.Rich ? new RichEventAppender() : new QuickEventAppender(); + base.AppendMode = value; + EventAppender = value == EventAppendMode.Rich ? new RichEventAppender() : new QuickEventAppender(); } } diff --git a/src/Marten/Events/EventGraph.cs b/src/Marten/Events/EventGraph.cs index 6194b43806..1969bc8883 100644 --- a/src/Marten/Events/EventGraph.cs +++ b/src/Marten/Events/EventGraph.cs @@ -14,6 +14,7 @@ using JasperFx.Events.Daemon; using JasperFx.Events.Projections; using JasperFx.Events.Subscriptions; +using JasperFx.Events.Tags; using Marten.Events.Aggregation; using Marten.Events.Schema; using Marten.Exceptions; @@ -30,8 +31,9 @@ namespace Marten.Events; -public partial class EventGraph: IEventStoreOptions, IReadOnlyEventStoreOptions, IDisposable, IAsyncDisposable, - IEventRegistry, IAggregationSourceFactory, IDescribeMyself, ICodeFileCollection +public partial class EventGraph: EventRegistry, IEventStoreOptions, IReadOnlyEventStoreOptions, + IDisposable, IAsyncDisposable, + IAggregationSourceFactory, IDescribeMyself, ICodeFileCollection { private readonly Cache _aggregateNameByType = new(type => type.IsGenericType ? type.ShortNameInCode() : type.Name.ToTableAlias()); @@ -52,7 +54,8 @@ public partial class EventGraph: IEventStoreOptions, IReadOnlyEventStoreOptions, private bool _isDisposed; private DocumentStore _store; - private StreamIdentity _streamIdentity = StreamIdentity.AsGuid; + + private readonly List _tagTypes = new(); internal EventGraph(StoreOptions options) { @@ -158,17 +161,12 @@ public void Dispose() _tombstones?.SafeDispose(); } - IEventType IEventRegistry.EventMappingFor(Type eventType) - { - return EventMappingFor(eventType); - } - - public Type AggregateTypeFor(string aggregateTypeName) + public override Type AggregateTypeFor(string aggregateTypeName) { return _aggregateTypeByName[aggregateTypeName]; } - public string AggregateAliasFor(Type aggregateType) + public override string AggregateAliasFor(Type aggregateType) { var alias = _aggregateNameByType[aggregateType]; @@ -177,7 +175,7 @@ public string AggregateAliasFor(Type aggregateType) return alias; } - public IEvent BuildEvent(object eventData) + public override IEvent BuildEvent(object eventData) { ArgumentNullException.ThrowIfNull(eventData); @@ -206,9 +204,6 @@ public IEvent BuildEvent(object eventData) public IMessageOutbox MessageOutbox { get; set; } = new NulloMessageOutbox(); - [IgnoreDescription] - public TimeProvider TimeProvider { get; set; } = TimeProvider.System; - public bool EnableUniqueIndexOnEventId { get; set; } = false; public bool EnableSideEffectsOnInlineProjections { get; set; } = false; @@ -216,12 +211,12 @@ public IEvent BuildEvent(object eventData) /// /// Configure whether event streams are identified with Guid or strings /// - public StreamIdentity StreamIdentity + public override StreamIdentity StreamIdentity { - get => _streamIdentity; + get => base.StreamIdentity; set { - _streamIdentity = value; + base.StreamIdentity = value; StreamIdDbType = value == StreamIdentity.AsGuid ? NpgsqlDbType.Uuid : NpgsqlDbType.Varchar; } } @@ -258,7 +253,7 @@ public IEventStoreOptions AddEventType() /// the event type /// /// - public void AddEventType(Type eventType) + public override void AddEventType(Type eventType) { _events.FillDefault(eventType); } @@ -274,6 +269,45 @@ public void AddEventTypes(IEnumerable types) types.Each(AddEventType); } + /// + /// Register a strong-typed identifier as a tag type for DCB support. + /// + public ITagTypeRegistration RegisterTagType() where TTag : notnull + { + var existing = _tagTypes.FirstOrDefault(t => t.TagType == typeof(TTag)); + if (existing != null) return existing; + + var registration = TagTypeRegistration.Create(); + _tagTypes.Add(registration); + return registration; + } + + /// + /// Register a strong-typed identifier as a tag type with a custom table name suffix. + /// + public ITagTypeRegistration RegisterTagType(string tableSuffix) where TTag : notnull + { + var existing = _tagTypes.FirstOrDefault(t => t.TagType == typeof(TTag)); + if (existing != null) return existing; + + var registration = TagTypeRegistration.Create(tableSuffix); + _tagTypes.Add(registration); + return registration; + } + + /// + /// The registered tag types for DCB support. + /// + public IReadOnlyList TagTypes => _tagTypes; + + /// + /// Find a tag type registration by type, or null if not registered. + /// + public ITagTypeRegistration? FindTagType(Type tagType) + { + return _tagTypes.FirstOrDefault(t => t.TagType == tagType); + } + public void MapEventType(string eventTypeName) where TEvent : class { MapEventType(typeof(TEvent), eventTypeName); @@ -418,7 +452,7 @@ private Type findAggregateType(string name) return null; } - internal EventMapping EventMappingFor(Type eventType) + public override EventMapping EventMappingFor(Type eventType) { return _events[eventType]; } diff --git a/src/Marten/Events/EventStore.Dcb.cs b/src/Marten/Events/EventStore.Dcb.cs new file mode 100644 index 0000000000..fd8fb40c5e --- /dev/null +++ b/src/Marten/Events/EventStore.Dcb.cs @@ -0,0 +1,173 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten.Events.Dcb; +using Marten.Internal.Sessions; +using Npgsql; + +namespace Marten.Events; + +internal partial class EventStore +{ + public async Task> QueryByTagsAsync(EventTagQuery query, + CancellationToken cancellation = default) + { + await _session.Database.EnsureStorageExistsAsync(typeof(IEvent), cancellation).ConfigureAwait(false); + + var storage = (EventDocumentStorage)_session.EventStorage(); + var (sql, paramValues) = BuildTagQuerySql(query, storage.SelectFields()); + var cmd = new NpgsqlCommand(sql); + for (var i = 0; i < paramValues.Count; i++) + { + cmd.Parameters.AddWithValue($"p{i}", paramValues[i]); + } + + await using var reader = await _session.ExecuteReaderAsync(cmd, cancellation).ConfigureAwait(false); + return await ReadEventsFromReaderAsync(reader, storage, cancellation).ConfigureAwait(false); + } + + public async Task AggregateByTagsAsync(EventTagQuery query, + CancellationToken cancellation = default) where T : class + { + var events = await QueryByTagsAsync(query, cancellation).ConfigureAwait(false); + if (events.Count == 0) return default; + + return await AggregateEventsAsync(events, cancellation).ConfigureAwait(false); + } + + public async Task> FetchForWritingByTags(EventTagQuery query, + CancellationToken cancellation = default) where T : class + { + var events = await QueryByTagsAsync(query, cancellation).ConfigureAwait(false); + var lastSeenSequence = events.Count > 0 ? events.Max(e => e.Sequence) : 0; + + T? aggregate = default; + if (events.Count > 0) + { + aggregate = await AggregateEventsAsync(events, cancellation).ConfigureAwait(false); + } + + // Register the DCB assertion to run at SaveChangesAsync time + var assertion = new AssertDcbConsistency(_store.Events, query, lastSeenSequence); + _session.QueueOperation(assertion); + + return new EventBoundary(_session, _store.Events, aggregate, events, lastSeenSequence); + } + + private async Task AggregateEventsAsync(IReadOnlyList events, + CancellationToken cancellation) where T : class + { + var aggregator = _store.Options.Projections.AggregatorFor(); + if (aggregator == null) + { + throw new InvalidOperationException( + $"Cannot find an aggregator for type '{typeof(T).Name}'. " + + "Ensure the type has Apply methods or a registered projection."); + } + + return await aggregator.BuildAsync(events, _session, default, cancellation) + .ConfigureAwait(false); + } + + private static async Task> ReadEventsFromReaderAsync(DbDataReader reader, + EventDocumentStorage storage, CancellationToken cancellation) + { + var events = new List(); + + while (await reader.ReadAsync(cancellation).ConfigureAwait(false)) + { + var @event = await storage.ResolveAsync(reader, cancellation).ConfigureAwait(false); + events.Add(@event); + } + + return events; + } + + private (string sql, List parameters) BuildTagQuerySql(EventTagQuery query, string[] selectFields) + { + var conditions = query.Conditions; + if (conditions.Count == 0) + { + throw new ArgumentException("EventTagQuery must have at least one condition."); + } + + var distinctTagTypes = conditions.Select(c => c.TagType).Distinct().ToList(); + var schema = _store.Events.DatabaseSchemaName; + var paramValues = new List(); + var sb = new StringBuilder(); + + // SELECT with explicit columns matching EventDocumentStorage expectations + sb.Append("select "); + for (var f = 0; f < selectFields.Length; f++) + { + if (f > 0) sb.Append(", "); + sb.Append("e."); + sb.Append(selectFields[f]); + } + + sb.Append(" from "); + sb.Append(schema); + sb.Append(".mt_events e"); + + // LEFT JOINs to tag tables — an event may only have tags in some of the + // tag tables, so inner joins would incorrectly filter out events that + // don't appear in every tag type table. + for (var i = 0; i < distinctTagTypes.Count; i++) + { + var tagType = distinctTagTypes[i]; + var registration = _store.Events.FindTagType(tagType) + ?? throw new InvalidOperationException( + $"Tag type '{tagType.Name}' is not registered. Call RegisterTagType<{tagType.Name}>() first."); + + sb.Append(" left join "); + sb.Append(schema); + sb.Append(".mt_event_tag_"); + sb.Append(registration.TableSuffix); + sb.Append(" t"); + sb.Append(i); + sb.Append(" on e.seq_id = t"); + sb.Append(i); + sb.Append(".seq_id"); + } + + // WHERE clause with OR conditions + sb.Append(" where ("); + for (var i = 0; i < conditions.Count; i++) + { + if (i > 0) sb.Append(" or "); + + var condition = conditions[i]; + var tagIndex = distinctTagTypes.IndexOf(condition.TagType); + + sb.Append("(t"); + sb.Append(tagIndex); + sb.Append(".value = @p"); + sb.Append(paramValues.Count); + + var registration = _store.Events.FindTagType(condition.TagType)!; + var value = registration.ExtractValue(condition.TagValue); + paramValues.Add(value); + + if (condition.EventType != null) + { + sb.Append(" and e.type = @p"); + sb.Append(paramValues.Count); + var eventTypeName = _store.Events.EventMappingFor(condition.EventType).EventTypeName; + paramValues.Add(eventTypeName); + } + + sb.Append(')'); + } + + sb.Append(") order by e.seq_id"); + + return (sb.ToString(), paramValues); + } +} diff --git a/src/Marten/Events/EventStore.FetchForWriting.cs b/src/Marten/Events/EventStore.FetchForWriting.cs index 81b8e9351c..54c11ce275 100644 --- a/src/Marten/Events/EventStore.FetchForWriting.cs +++ b/src/Marten/Events/EventStore.FetchForWriting.cs @@ -22,7 +22,7 @@ namespace Marten.Events; internal partial class EventStore: IEventIdentityStrategy, IEventIdentityStrategy { - private ImHashMap _fetchStrategies = ImHashMap.Empty; + private ImHashMap<(Type, Type), object> _fetchStrategies = ImHashMap<(Type, Type), object>.Empty; async Task IEventIdentityStrategy.EnsureEventStorageExists( DocumentSessionBase session, CancellationToken cancellation) @@ -128,6 +128,27 @@ IQueryHandler> IEventIdentityStrategy.BuildEventQu return new ListQueryHandler(statement, selector); } + public Task> FetchForWriting(TId id, CancellationToken cancellation = default) + where T : class where TId : notnull + { + var plan = FindFetchPlan(); + return plan.FetchForWriting(_session, id, false, cancellation); + } + + public Task> FetchForExclusiveWriting(TId id, CancellationToken cancellation = default) + where T : class where TId : notnull + { + var plan = FindFetchPlan(); + return plan.FetchForWriting(_session, id, true, cancellation); + } + + public ValueTask FetchLatest(TId id, CancellationToken cancellation = default) + where T : class where TId : notnull + { + var plan = FindFetchPlan(); + return plan.FetchForReading(_session, id, cancellation); + } + public Task> FetchForWriting(Guid id, CancellationToken cancellation = default) where T : class { var plan = FindFetchPlan(); @@ -199,30 +220,49 @@ internal IAggregateFetchPlan FindFetchPlan() where TDoc : { _session.Options.EventGraph.EnsureAsGuidStorage(_session); } - else + else if (typeof(TId) == typeof(string)) { _session.Options.EventGraph.EnsureAsStringStorage(_session); } + // else: natural key type — event storage initialization deferred to the plan - if (_fetchStrategies.TryFind(typeof(TDoc), out var stored)) + // Use (TDoc, TId) as cache key to support both stream id and natural key lookups + var cacheKey = (typeof(TDoc), typeof(TId)); + if (_fetchStrategies.TryFind(cacheKey, out var stored)) { return (IAggregateFetchPlan)stored; } var plan = determineFetchPlan(_session.Options); - _fetchStrategies = _fetchStrategies.AddOrUpdate(typeof(TDoc), plan); + _fetchStrategies = _fetchStrategies.AddOrUpdate(cacheKey, plan); return plan; } private IAggregateFetchPlan determineFetchPlan(StoreOptions options) where TDoc : class where TId : notnull { - foreach (var planner in options.Projections.allPlanners()) + // For natural key types (not Guid/string), try natural key planners first + // before attempting the cast to IEventIdentityStrategy + if (typeof(TId) != typeof(Guid) && typeof(TId) != typeof(string)) + { + foreach (var planner in options.Projections.allPlanners()) + { + // Pass null identity - natural key planners don't use it + if (planner.TryMatch(null!, options, out var naturalKeyPlan)) + { + return naturalKeyPlan; + } + } + } + else { - if (planner.TryMatch((IEventIdentityStrategy)this, options, out var plan)) + foreach (var planner in options.Projections.allPlanners()) { - return plan; + if (planner.TryMatch((IEventIdentityStrategy)this, options, out var plan)) + { + return plan; + } } } diff --git a/src/Marten/Events/Fetching/FetchNaturalKeyPlan.cs b/src/Marten/Events/Fetching/FetchNaturalKeyPlan.cs new file mode 100644 index 0000000000..f760acfe69 --- /dev/null +++ b/src/Marten/Events/Fetching/FetchNaturalKeyPlan.cs @@ -0,0 +1,702 @@ +using System; +using System.Data.Common; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using JasperFx; +using System.Collections.Generic; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Projections; +using Marten.Exceptions; +using Marten.Internal; +using Marten.Internal.Sessions; +using Marten.Internal.Storage; +using Marten.Linq.QueryHandlers; +using Npgsql; +using Weasel.Postgresql; + +namespace Marten.Events.Fetching; + +/// +/// Fetch plan that resolves a natural key to a stream identity, then fetches the aggregate. +/// The natural key is first looked up in the mt_natural_key_{type} table to resolve to a +/// stream id (Guid) or stream key (string), then the document is loaded by stream identity. +/// +internal class FetchNaturalKeyPlan: IAggregateFetchPlan + where TDoc : class where TNaturalKey : notnull +{ + private readonly EventGraph _events; + private readonly NaturalKeyDefinition _naturalKey; + private readonly string _naturalKeyTableName; + private readonly string _streamIdColumn; + private readonly bool _isConjoined; + private readonly bool _isGlobal; + private readonly StoreOptions _options; + + public FetchNaturalKeyPlan(EventGraph events, NaturalKeyDefinition naturalKey, + ProjectionLifecycle lifecycle, StoreOptions options) + { + _events = events; + _naturalKey = naturalKey; + _options = options; + Lifecycle = lifecycle; + + _naturalKeyTableName = + $"{events.DatabaseSchemaName}.mt_natural_key_{naturalKey.AggregateType.Name.ToLowerInvariant()}"; + _streamIdColumn = events.StreamIdentity == StreamIdentity.AsGuid ? "stream_id" : "stream_key"; + _isConjoined = events.TenancyStyle == Storage.TenancyStyle.Conjoined; + _isGlobal = events.GlobalAggregates.Contains(typeof(TDoc)); + } + + public ProjectionLifecycle Lifecycle { get; } + + public async Task> FetchForWriting(DocumentSessionBase session, TNaturalKey id, + bool forUpdate, CancellationToken cancellation = default) + { + await EnsureStorageExists(session, cancellation).ConfigureAwait(false); + + if (forUpdate) + { + await session.BeginTransactionAsync(cancellation).ConfigureAwait(false); + } + + var innerValue = _naturalKey.Unwrap(id)!; + + var builder = new BatchBuilder { TenantId = session.TenantId }; + BuildNaturalKeyToStreamQuery(builder, innerValue, forUpdate); + + try + { + // Read the natural key lookup result and extract stream identity, + // then close the reader BEFORE opening a second reader for the document + long version = 0; + object? streamIdentity = null; + bool found = false; + + await using (var reader = + await session.ExecuteReaderAsync(builder.Compile(), cancellation).ConfigureAwait(false)) + { + if (await reader.ReadAsync(cancellation).ConfigureAwait(false)) + { + version = await reader.GetFieldValueAsync(0, cancellation).ConfigureAwait(false); + streamIdentity = _events.StreamIdentity == StreamIdentity.AsGuid + ? await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false) + : (object)await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + found = true; + } + } + + if (!found) + { + return CreateNewStream(session, cancellation); + } + + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + return await FetchByStreamId(session, (Guid)streamIdentity!, version, cancellation).ConfigureAwait(false); + } + else + { + return await FetchByStreamKey(session, (string)streamIdentity!, version, cancellation).ConfigureAwait(false); + } + } + catch (Exception e) + { + if (e.InnerException is NpgsqlException { SqlState: PostgresErrorCodes.InFailedSqlTransaction }) + { + throw new StreamLockedException(id, e.InnerException); + } + + if (e.Message.Contains(MartenCommandException.MaybeLockedRowsMessage)) + { + throw new StreamLockedException(id, e.InnerException); + } + + throw; + } + } + + public async Task> FetchForWriting(DocumentSessionBase session, TNaturalKey id, + long expectedStartingVersion, CancellationToken cancellation = default) + { + await EnsureStorageExists(session, cancellation).ConfigureAwait(false); + + var innerValue = _naturalKey.Unwrap(id)!; + + var builder = new BatchBuilder { TenantId = session.TenantId }; + BuildNaturalKeyToStreamQuery(builder, innerValue, false); + + long version = 0; + object? streamIdentity = null; + bool found = false; + + await using (var reader = + await session.ExecuteReaderAsync(builder.Compile(), cancellation).ConfigureAwait(false)) + { + if (!await reader.ReadAsync(cancellation).ConfigureAwait(false)) + { + if (expectedStartingVersion != 0) + { + throw new ConcurrencyException( + $"Expected the existing version to be {expectedStartingVersion}, but was 0", + typeof(TDoc), id); + } + + return CreateNewStream(session, cancellation); + } + + version = await reader.GetFieldValueAsync(0, cancellation).ConfigureAwait(false); + + if (expectedStartingVersion != version) + { + throw new ConcurrencyException( + $"Expected the existing version to be {expectedStartingVersion}, but was {version}", + typeof(TDoc), id); + } + + streamIdentity = _events.StreamIdentity == StreamIdentity.AsGuid + ? await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false) + : (object)await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + found = true; + } + + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + return await FetchByStreamId(session, (Guid)streamIdentity!, version, cancellation).ConfigureAwait(false); + } + else + { + return await FetchByStreamKey(session, (string)streamIdentity!, version, cancellation).ConfigureAwait(false); + } + } + + public async ValueTask FetchForReading(DocumentSessionBase session, TNaturalKey id, + CancellationToken cancellation) + { + await EnsureStorageExists(session, cancellation).ConfigureAwait(false); + + var innerValue = _naturalKey.Unwrap(id)!; + + var builder = new BatchBuilder { TenantId = session.TenantId }; + BuildNaturalKeyToStreamQuery(builder, innerValue, false); + + object? streamIdentity = null; + + await using (var reader = + await session.ExecuteReaderAsync(builder.Compile(), cancellation).ConfigureAwait(false)) + { + if (!await reader.ReadAsync(cancellation).ConfigureAwait(false)) + { + return default; + } + + // Read stream identity column, skip version (index 0) + streamIdentity = _events.StreamIdentity == StreamIdentity.AsGuid + ? await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false) + : (object)await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + } + + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + return await FetchDocByGuid(session, (Guid)streamIdentity!, cancellation).ConfigureAwait(false); + } + else + { + return await FetchDocByString(session, (string)streamIdentity!, cancellation).ConfigureAwait(false); + } + } + + public async Task StreamForReading(DocumentSessionBase session, TNaturalKey id, Stream destination, + CancellationToken cancellation) + { + await EnsureStorageExists(session, cancellation).ConfigureAwait(false); + + var innerValue = _naturalKey.Unwrap(id)!; + + var builder = new BatchBuilder { TenantId = session.TenantId }; + BuildNaturalKeyToStreamQuery(builder, innerValue, false); + + object? streamIdentity = null; + + await using (var reader = + await session.ExecuteReaderAsync(builder.Compile(), cancellation).ConfigureAwait(false)) + { + if (!await reader.ReadAsync(cancellation).ConfigureAwait(false)) + { + return false; + } + + streamIdentity = _events.StreamIdentity == StreamIdentity.AsGuid + ? await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false) + : (object)await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + } + + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + var storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + var command = storage.BuildLoadCommand((Guid)streamIdentity!, session.TenantId); + return await session.StreamOne(command, destination, cancellation).ConfigureAwait(false); + } + else + { + var storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + var command = storage.BuildLoadCommand((string)streamIdentity!, session.TenantId); + return await session.StreamOne(command, destination, cancellation).ConfigureAwait(false); + } + } + + public IQueryHandler> BuildQueryHandler(QuerySession session, TNaturalKey id, + long expectedStartingVersion) + { + session.AssertIsDocumentSession(); + return new NaturalKeyQueryHandler(this, id, expectedStartingVersion, false, true); + } + + public IQueryHandler> BuildQueryHandler(QuerySession session, TNaturalKey id, bool forUpdate) + { + session.AssertIsDocumentSession(); + return new NaturalKeyQueryHandler(this, id, 0, forUpdate, false); + } + + public IQueryHandler BuildQueryHandler(QuerySession session, TNaturalKey id) + { + return new NaturalKeyReadQueryHandler(this, id); + } + + private async Task EnsureStorageExists(DocumentSessionBase session, CancellationToken cancellation) + { + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + _events.EnsureAsGuidStorage(session); + } + else + { + _events.EnsureAsStringStorage(session); + } + + await session.Database.EnsureStorageExistsAsync(typeof(IEvent), cancellation).ConfigureAwait(false); + await session.Database.EnsureStorageExistsAsync(typeof(TDoc), cancellation).ConfigureAwait(false); + } + + internal void BuildNaturalKeyToStreamQuery(BatchBuilder builder, object innerValue, bool forUpdate) + { + builder.Append("select s.version, nk."); + builder.Append(_streamIdColumn); + builder.Append(" from "); + builder.Append(_naturalKeyTableName); + builder.Append(" nk inner join "); + builder.Append(_events.DatabaseSchemaName); + builder.Append(".mt_streams s on s.id = nk."); + builder.Append(_streamIdColumn); + builder.Append(" where nk.natural_key_value = "); + builder.AppendParameter(innerValue); + builder.Append(" and nk.is_archived = false"); + + if (_isConjoined && !_isGlobal) + { + builder.Append(" and s.tenant_id = "); + builder.AppendParameter(builder.TenantId); + + builder.Append(" and nk.tenant_id = "); + builder.AppendParameter(builder.TenantId); + } + + if (forUpdate) + { + builder.Append(" for update of s"); + } + } + + private async Task> ReadStreamFromNaturalKey(DocumentSessionBase session, + TNaturalKey naturalKey, DbDataReader reader, CancellationToken cancellation) + { + if (!await reader.ReadAsync(cancellation).ConfigureAwait(false)) + { + return CreateNewStream(session, cancellation); + } + + var version = await reader.GetFieldValueAsync(0, cancellation).ConfigureAwait(false); + return await LoadDocumentAndBuildStream(session, reader, version, cancellation).ConfigureAwait(false); + } + + private async Task> LoadDocumentAndBuildStream(DocumentSessionBase session, + DbDataReader reader, long version, CancellationToken cancellation) + { + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + var streamId = await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + return await FetchByStreamId(session, streamId, version, cancellation).ConfigureAwait(false); + } + else + { + var streamKey = await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + return await FetchByStreamKey(session, streamKey, version, cancellation).ConfigureAwait(false); + } + } + + private async Task LoadDocumentByStreamIdentity(DocumentSessionBase session, DbDataReader reader, + CancellationToken cancellation) + { + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + var streamId = await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + return await FetchDocByGuid(session, streamId, cancellation).ConfigureAwait(false); + } + else + { + var streamKey = await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + return await FetchDocByString(session, streamKey, cancellation).ConfigureAwait(false); + } + } + + private async Task StreamDocumentByStreamIdentity(DocumentSessionBase session, DbDataReader reader, + Stream destination, CancellationToken cancellation) + { + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + var streamId = await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + var storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + var command = storage.BuildLoadCommand(streamId, session.TenantId); + return await session.StreamOne(command, destination, cancellation).ConfigureAwait(false); + } + else + { + var streamKey = await reader.GetFieldValueAsync(1, cancellation).ConfigureAwait(false); + var storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + var command = storage.BuildLoadCommand(streamKey, session.TenantId); + return await session.StreamOne(command, destination, cancellation).ConfigureAwait(false); + } + } + + private IEventStream CreateNewStream(DocumentSessionBase session, CancellationToken cancellation) + where T : class + { + if (_events.StreamIdentity == StreamIdentity.AsGuid) + { + var newId = Guid.NewGuid(); + var action = _events.StartEmptyStream(session, newId); + action.AggregateType = typeof(TDoc); + action.ExpectedVersionOnServer = 0; + return new EventStream(session, _events, newId, default, cancellation, action); + } + else + { + var newKey = Guid.NewGuid().ToString(); + var action = _events.StartEmptyStream(session, newKey); + action.AggregateType = typeof(TDoc); + action.ExpectedVersionOnServer = 0; + return new EventStream(session, _events, newKey, default, cancellation, action); + } + } + + private async Task> FetchByStreamId(DocumentSessionBase session, + Guid streamId, long version, CancellationToken cancellation) + { + TDoc? document; + if (Lifecycle == ProjectionLifecycle.Live) + { + document = await AggregateFromEventsAsync(session, streamId, cancellation).ConfigureAwait(false); + } + else + { + document = await LoadDocumentById(session, streamId, cancellation).ConfigureAwait(false); + } + + if (version == 0) + { + var action = _events.StartEmptyStream(session, streamId); + action.AggregateType = typeof(TDoc); + action.ExpectedVersionOnServer = 0; + return new EventStream(session, _events, streamId, document, cancellation, action); + } + else + { + var action = session.Events.Append(streamId); + action.ExpectedVersionOnServer = version; + return new EventStream(session, _events, streamId, document, cancellation, action); + } + } + + private async Task LoadDocumentById(DocumentSessionBase session, Guid streamId, CancellationToken cancellation) + { + IDocumentStorage storage; + if (session.Options.Events.UseIdentityMapForAggregates) + { + storage = _options.ResolveCorrectedDocumentStorage(DocumentTracking.IdentityOnly); + session.UseIdentityMapFor(); + } + else + { + storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + } + + var docBuilder = new BatchBuilder { TenantId = session.TenantId }; + docBuilder.Append(";"); + var handler = new LoadByIdHandler(storage, streamId); + handler.ConfigureCommand(docBuilder, session); + + await using var docReader = + await session.ExecuteReaderAsync(docBuilder.Compile(), cancellation).ConfigureAwait(false); + var document = await handler.HandleAsync(docReader, session, cancellation).ConfigureAwait(false); + + if (document != null && session.Options.Events.UseIdentityMapForAggregates) + { + session.StoreDocumentInItemMap(streamId, document); + } + + return document; + } + + private async Task AggregateFromEventsAsync(DocumentSessionBase session, Guid streamId, CancellationToken cancellation) + { + var events = await session.Events.FetchStreamAsync(streamId, token: cancellation).ConfigureAwait(false); + if (events.Count == 0) return default; + + var aggregator = _options.Projections.AggregatorFor(); + return await aggregator.BuildAsync(events, session, default, cancellation).ConfigureAwait(false); + } + + private async Task> FetchByStreamKey(DocumentSessionBase session, + string streamKey, long version, CancellationToken cancellation) + { + TDoc? document; + if (Lifecycle == ProjectionLifecycle.Live) + { + document = await AggregateFromEventsAsync(session, streamKey, cancellation).ConfigureAwait(false); + } + else + { + document = await LoadDocumentByKey(session, streamKey, cancellation).ConfigureAwait(false); + } + + if (version == 0) + { + var action = _events.StartEmptyStream(session, streamKey); + action.AggregateType = typeof(TDoc); + action.ExpectedVersionOnServer = 0; + return new EventStream(session, _events, streamKey, document, cancellation, action); + } + else + { + var action = session.Events.Append(streamKey); + action.ExpectedVersionOnServer = version; + return new EventStream(session, _events, streamKey, document, cancellation, action); + } + } + + private async Task LoadDocumentByKey(DocumentSessionBase session, string streamKey, CancellationToken cancellation) + { + IDocumentStorage storage; + if (session.Options.Events.UseIdentityMapForAggregates) + { + storage = _options.ResolveCorrectedDocumentStorage(DocumentTracking.IdentityOnly); + session.UseIdentityMapFor(); + } + else + { + storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + } + + var docBuilder = new BatchBuilder { TenantId = session.TenantId }; + docBuilder.Append(";"); + var handler = new LoadByIdHandler(storage, streamKey); + handler.ConfigureCommand(docBuilder, session); + + await using var docReader = + await session.ExecuteReaderAsync(docBuilder.Compile(), cancellation).ConfigureAwait(false); + var document = await handler.HandleAsync(docReader, session, cancellation).ConfigureAwait(false); + + if (document != null && session.Options.Events.UseIdentityMapForAggregates) + { + session.StoreDocumentInItemMap(streamKey, document); + } + + return document; + } + + private async Task AggregateFromEventsAsync(DocumentSessionBase session, string streamKey, CancellationToken cancellation) + { + var events = await session.Events.FetchStreamAsync(streamKey, token: cancellation).ConfigureAwait(false); + if (events.Count == 0) return default; + + var aggregator = _options.Projections.AggregatorFor(); + return await aggregator.BuildAsync(events, session, default, cancellation).ConfigureAwait(false); + } + + internal async Task FetchDocByGuid(DocumentSessionBase session, Guid streamId, + CancellationToken cancellation) + { + if (Lifecycle == ProjectionLifecycle.Live) + { + return await AggregateFromEventsAsync(session, streamId, cancellation).ConfigureAwait(false); + } + + var storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + var builder = new BatchBuilder { TenantId = session.TenantId }; + builder.Append(";"); + var handler = new LoadByIdHandler(storage, streamId); + handler.ConfigureCommand(builder, session); + + await using var reader = + await session.ExecuteReaderAsync(builder.Compile(), cancellation).ConfigureAwait(false); + return await handler.HandleAsync(reader, session, cancellation).ConfigureAwait(false); + } + + internal async Task FetchDocByString(DocumentSessionBase session, string streamKey, + CancellationToken cancellation) + { + if (Lifecycle == ProjectionLifecycle.Live) + { + return await AggregateFromEventsAsync(session, streamKey, cancellation).ConfigureAwait(false); + } + + var storage = _options.ResolveCorrectedDocumentStorage(session.TrackingMode); + var builder = new BatchBuilder { TenantId = session.TenantId }; + builder.Append(";"); + var handler = new LoadByIdHandler(storage, streamKey); + handler.ConfigureCommand(builder, session); + + await using var reader = + await session.ExecuteReaderAsync(builder.Compile(), cancellation).ConfigureAwait(false); + return await handler.HandleAsync(reader, session, cancellation).ConfigureAwait(false); + } + + internal class NaturalKeyQueryHandler: IQueryHandler> + { + private readonly FetchNaturalKeyPlan _parent; + private readonly TNaturalKey _id; + private readonly long _expectedVersion; + private readonly bool _forUpdate; + private readonly bool _checkVersion; + + public NaturalKeyQueryHandler(FetchNaturalKeyPlan parent, TNaturalKey id, + long expectedVersion, bool forUpdate, bool checkVersion) + { + _parent = parent; + _id = id; + _expectedVersion = expectedVersion; + _forUpdate = forUpdate; + _checkVersion = checkVersion; + } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + var innerValue = _parent._naturalKey.Unwrap(_id)!; + _parent.BuildNaturalKeyToStreamQuery((BatchBuilder)builder, innerValue, _forUpdate); + } + + public async Task> HandleAsync(DbDataReader reader, IMartenSession session, + CancellationToken token) + { + var documentSession = (DocumentSessionBase)session; + + if (!await reader.ReadAsync(token).ConfigureAwait(false)) + { + if (_checkVersion && _expectedVersion != 0) + { + throw new ConcurrencyException( + $"Expected the existing version to be {_expectedVersion}, but was 0", + typeof(TDoc), _id); + } + + return _parent.CreateNewStream(documentSession, token); + } + + var version = await reader.GetFieldValueAsync(0, token).ConfigureAwait(false); + + if (_checkVersion && _expectedVersion != version) + { + throw new ConcurrencyException( + $"Expected the existing version to be {_expectedVersion}, but was {version}", + typeof(TDoc), _id); + } + + // Extract stream identity from reader before it's closed by batch framework + var streamIdentity = _parent._events.StreamIdentity == StreamIdentity.AsGuid + ? await reader.GetFieldValueAsync(1, token).ConfigureAwait(false) + : (object)await reader.GetFieldValueAsync(1, token).ConfigureAwait(false); + + // Close the reader result set before opening second reader + while (await reader.ReadAsync(token).ConfigureAwait(false)) { } + + if (_parent._events.StreamIdentity == StreamIdentity.AsGuid) + { + return await _parent.FetchByStreamId(documentSession, (Guid)streamIdentity, version, token) + .ConfigureAwait(false); + } + else + { + return await _parent.FetchByStreamKey(documentSession, (string)streamIdentity, version, token) + .ConfigureAwait(false); + } + } + + public IEventStream Handle(DbDataReader reader, IMartenSession session) + { + throw new NotSupportedException(); + } + + public Task StreamJson(Stream stream, DbDataReader reader, CancellationToken token) + { + throw new NotSupportedException(); + } + } + + internal class NaturalKeyReadQueryHandler: IQueryHandler + { + private readonly FetchNaturalKeyPlan _parent; + private readonly TNaturalKey _id; + + public NaturalKeyReadQueryHandler(FetchNaturalKeyPlan parent, TNaturalKey id) + { + _parent = parent; + _id = id; + } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + var innerValue = _parent._naturalKey.Unwrap(_id)!; + _parent.BuildNaturalKeyToStreamQuery((BatchBuilder)builder, innerValue, false); + } + + public async Task HandleAsync(DbDataReader reader, IMartenSession session, + CancellationToken token) + { + var documentSession = (DocumentSessionBase)session; + + if (!await reader.ReadAsync(token).ConfigureAwait(false)) + { + return default; + } + + // Extract stream identity before closing reader + var streamIdentity = _parent._events.StreamIdentity == StreamIdentity.AsGuid + ? await reader.GetFieldValueAsync(1, token).ConfigureAwait(false) + : (object)await reader.GetFieldValueAsync(1, token).ConfigureAwait(false); + + while (await reader.ReadAsync(token).ConfigureAwait(false)) { } + + if (_parent._events.StreamIdentity == StreamIdentity.AsGuid) + { + return await _parent.FetchDocByGuid(documentSession, (Guid)streamIdentity, token) + .ConfigureAwait(false); + } + else + { + return await _parent.FetchDocByString(documentSession, (string)streamIdentity, token) + .ConfigureAwait(false); + } + } + + public TDoc? Handle(DbDataReader reader, IMartenSession session) + { + throw new NotSupportedException(); + } + + public Task StreamJson(Stream stream, DbDataReader reader, CancellationToken token) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Marten/Events/Fetching/NaturalKeyFetchPlanner.cs b/src/Marten/Events/Fetching/NaturalKeyFetchPlanner.cs new file mode 100644 index 0000000000..2f12b95ac6 --- /dev/null +++ b/src/Marten/Events/Fetching/NaturalKeyFetchPlanner.cs @@ -0,0 +1,45 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Projections; +using Marten.Internal.Storage; + +namespace Marten.Events.Fetching; + +/// +/// Fetch planner that activates when: +/// 1. The aggregate type has a NaturalKeyDefinition +/// 2. The TId being fetched matches the natural key's OuterType (not Guid/string stream id) +/// +/// This planner is registered BEFORE the built-in planners so it gets first crack at matching. +/// +internal class NaturalKeyFetchPlanner: IFetchPlanner +{ + public bool TryMatch(IEventIdentityStrategy identity, + StoreOptions options, + [NotNullWhen(true)] out IAggregateFetchPlan? plan) where TDoc : class where TId : notnull + { + if (options.Projections.TryFindAggregate(typeof(TDoc), out var projection)) + { + var naturalKey = projection.NaturalKeyDefinition; + if (naturalKey != null && naturalKey.OuterType == typeof(TId)) + { + // Only match if TId is NOT already a stream identity type (Guid/string) + // Those are handled by the existing planners + if (typeof(TId) != typeof(Guid) && typeof(TId) != typeof(string)) + { + plan = new FetchNaturalKeyPlan( + options.EventGraph, + naturalKey, + projection.Lifecycle, + options); + return true; + } + } + } + + plan = null; + return false; + } +} diff --git a/src/Marten/Events/IEventStoreOperations.cs b/src/Marten/Events/IEventStoreOperations.cs index cf52b60cdc..fc1f535c02 100644 --- a/src/Marten/Events/IEventStoreOperations.cs +++ b/src/Marten/Events/IEventStoreOperations.cs @@ -6,6 +6,8 @@ using System.Threading; using System.Threading.Tasks; using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten.Events.Dcb; namespace Marten.Events; @@ -414,5 +416,44 @@ Task WriteExclusivelyToAggregate(string id, Func, Task> writi /// Guid CompletelyReplaceEvent(long sequence, T eventBody) where T : class; + /// + /// Query events by their tags using the DCB pattern. + /// Returns events matching any of the OR'd conditions in the query, ordered by seq_id. + /// + Task> QueryByTagsAsync(EventTagQuery query, CancellationToken cancellation = default); + + /// + /// Query events by their tags and aggregate them into type T using a live fold. + /// + Task AggregateByTagsAsync(EventTagQuery query, CancellationToken cancellation = default) where T : class; + + /// + /// Fetch events by tag query, aggregate into T, and establish a DCB consistency boundary. + /// At SaveChangesAsync() time, Marten will assert no new matching events were added + /// since the query was executed. + /// + Task> FetchForWritingByTags(EventTagQuery query, + CancellationToken cancellation = default) where T : class; + + /// + /// Fetch projected aggregate T by a natural key or any registered identifier type with + /// built in optimistic concurrency checks. Use this for natural key lookups. + /// + Task> FetchForWriting(TId id, CancellationToken cancellation = default) + where T : class where TId : notnull; + + /// + /// Fetch projected aggregate T by a natural key for exclusive writing with row-level locking. + /// + Task> FetchForExclusiveWriting(TId id, CancellationToken cancellation = default) + where T : class where TId : notnull; + + /// + /// Fetch the projected aggregate T by a natural key or any registered identifier type. + /// This is a lightweight, read-only version of FetchForWriting. + /// + ValueTask FetchLatest(TId id, CancellationToken cancellation = default) + where T : class where TId : notnull; + } diff --git a/src/Marten/Events/IEventStoreOptions.cs b/src/Marten/Events/IEventStoreOptions.cs index 690f463168..85b10453cc 100644 --- a/src/Marten/Events/IEventStoreOptions.cs +++ b/src/Marten/Events/IEventStoreOptions.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using JasperFx.Events; using JasperFx.Events.Subscriptions; +using JasperFx.Events.Tags; using Marten.Events; using Marten.Events.Aggregation; using Marten.Exceptions; @@ -390,6 +391,27 @@ public IEventStoreOptions Upcast( /// Function to replace the event with a masked event /// void AddMaskingRuleForProtectedInformation(Func func); + + /// + /// Register a strong-typed identifier as a tag type for Dynamic Consistency Boundary (DCB) support. + /// This creates a dedicated tag table for efficient cross-stream querying and consistency checks. + /// + /// A strong-typed identifier type (e.g., StudentId) + /// The tag type registration for further configuration + ITagTypeRegistration RegisterTagType() where TTag : notnull; + + /// + /// Register a strong-typed identifier as a tag type with a custom table name suffix. + /// + /// A strong-typed identifier type + /// Custom table name suffix (e.g., "custom_student") + /// The tag type registration for further configuration + ITagTypeRegistration RegisterTagType(string tableSuffix) where TTag : notnull; + + /// + /// The registered tag types for DCB support. + /// + IReadOnlyList TagTypes { get; } } } diff --git a/src/Marten/Events/IEventStream.cs b/src/Marten/Events/IEventStream.cs index 958cc2b074..3daffbf3a0 100644 --- a/src/Marten/Events/IEventStream.cs +++ b/src/Marten/Events/IEventStream.cs @@ -31,6 +31,14 @@ public interface IEventStream where T: notnull void AppendMany(params object[] events); void AppendMany(IEnumerable events); + /// + /// If true, Marten will enforce an optimistic concurrency check on this stream even if no + /// events are appended at the time of calling SaveChangesAsync(). This is useful when you want + /// to ensure the stream version has not advanced since it was fetched, even if the command + /// handler decides not to emit any new events. + /// + bool AlwaysEnforceConsistency { get; set; } + /// /// Try to advance the expected starting version for optimistic concurrency checks to the current version /// so that you can reuse a stream object for multiple units of work. This is meant to only be used in @@ -108,6 +116,12 @@ public void AppendMany(IEnumerable events) public CancellationToken Cancellation { get; } + public bool AlwaysEnforceConsistency + { + get => _stream.AlwaysEnforceConsistency; + set => _stream.AlwaysEnforceConsistency = value; + } + public IReadOnlyList Events => _stream.Events; public void TryFastForwardVersion() diff --git a/src/Marten/Events/Operations/AssertStreamVersionById.cs b/src/Marten/Events/Operations/AssertStreamVersionById.cs new file mode 100644 index 0000000000..5aa5248012 --- /dev/null +++ b/src/Marten/Events/Operations/AssertStreamVersionById.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten.Internal; +using Marten.Internal.Operations; +using Weasel.Postgresql; + +namespace Marten.Events.Operations; + +internal class AssertStreamVersionById: IStorageOperation +{ + private readonly EventGraph _events; + + public AssertStreamVersionById(EventGraph events, StreamAction stream) + { + _events = events; + Stream = stream; + } + + public StreamAction Stream { get; } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + builder.Append("select version from "); + builder.Append(_events.DatabaseSchemaName); + builder.Append(".mt_streams where id = "); + builder.AppendParameter(Stream.Id); + } + + public Type DocumentType => typeof(IEvent); + + public void Postprocess(DbDataReader reader, IList exceptions) + { + if (!reader.Read()) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Id, Stream.AggregateType, + Stream.ExpectedVersionOnServer!.Value, 0); + exceptions.Add(ex); + return; + } + + var actualVersion = reader.GetInt64(0); + if (actualVersion != Stream.ExpectedVersionOnServer!.Value) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Id, Stream.AggregateType, + Stream.ExpectedVersionOnServer.Value, actualVersion); + exceptions.Add(ex); + } + } + + public async Task PostprocessAsync(DbDataReader reader, IList exceptions, CancellationToken token) + { + if (!await reader.ReadAsync(token).ConfigureAwait(false)) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Id, Stream.AggregateType, + Stream.ExpectedVersionOnServer!.Value, 0); + exceptions.Add(ex); + return; + } + + var actualVersion = await reader.GetFieldValueAsync(0, token).ConfigureAwait(false); + if (actualVersion != Stream.ExpectedVersionOnServer!.Value) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Id, Stream.AggregateType, + Stream.ExpectedVersionOnServer.Value, actualVersion); + exceptions.Add(ex); + } + } + + public OperationRole Role() => OperationRole.Events; +} diff --git a/src/Marten/Events/Operations/AssertStreamVersionByKey.cs b/src/Marten/Events/Operations/AssertStreamVersionByKey.cs new file mode 100644 index 0000000000..ac1dff4f83 --- /dev/null +++ b/src/Marten/Events/Operations/AssertStreamVersionByKey.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten.Internal; +using Marten.Internal.Operations; +using Weasel.Postgresql; + +namespace Marten.Events.Operations; + +internal class AssertStreamVersionByKey: IStorageOperation +{ + private readonly EventGraph _events; + + public AssertStreamVersionByKey(EventGraph events, StreamAction stream) + { + _events = events; + Stream = stream; + } + + public StreamAction Stream { get; } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + builder.Append("select version from "); + builder.Append(_events.DatabaseSchemaName); + builder.Append(".mt_streams where id = "); + builder.AppendParameter(Stream.Key!); + } + + public Type DocumentType => typeof(IEvent); + + public void Postprocess(DbDataReader reader, IList exceptions) + { + if (!reader.Read()) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Key!, Stream.AggregateType, + Stream.ExpectedVersionOnServer!.Value, 0); + exceptions.Add(ex); + return; + } + + var actualVersion = reader.GetInt64(0); + if (actualVersion != Stream.ExpectedVersionOnServer!.Value) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Key!, Stream.AggregateType, + Stream.ExpectedVersionOnServer.Value, actualVersion); + exceptions.Add(ex); + } + } + + public async Task PostprocessAsync(DbDataReader reader, IList exceptions, CancellationToken token) + { + if (!await reader.ReadAsync(token).ConfigureAwait(false)) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Key!, Stream.AggregateType, + Stream.ExpectedVersionOnServer!.Value, 0); + exceptions.Add(ex); + return; + } + + var actualVersion = await reader.GetFieldValueAsync(0, token).ConfigureAwait(false); + if (actualVersion != Stream.ExpectedVersionOnServer!.Value) + { + var ex = new EventStreamUnexpectedMaxEventIdException(Stream.Key!, Stream.AggregateType, + Stream.ExpectedVersionOnServer.Value, actualVersion); + exceptions.Add(ex); + } + } + + public OperationRole Role() => OperationRole.Events; +} diff --git a/src/Marten/Events/Operations/EventTagOperations.cs b/src/Marten/Events/Operations/EventTagOperations.cs new file mode 100644 index 0000000000..45741e21a1 --- /dev/null +++ b/src/Marten/Events/Operations/EventTagOperations.cs @@ -0,0 +1,55 @@ +using JasperFx.Events; +using Marten.Internal.Sessions; + +namespace Marten.Events.Operations; + +internal static class EventTagOperations +{ + /// + /// Queue tag inserts using pre-assigned sequence numbers (Rich append mode). + /// + public static void QueueTagOperations(EventGraph eventGraph, DocumentSessionBase session, StreamAction stream) + { + if (eventGraph.TagTypes.Count == 0) return; + + var schema = eventGraph.DatabaseSchemaName; + + foreach (var @event in stream.Events) + { + var tags = @event.Tags; + if (tags == null || tags.Count == 0) continue; + + foreach (var tag in tags) + { + var registration = eventGraph.FindTagType(tag.TagType); + if (registration == null) continue; + + session.QueueOperation(new InsertEventTagOperation(schema, registration, @event.Sequence, tag.Value)); + } + } + } + + /// + /// Queue tag inserts using event id lookup (Quick append mode where sequences aren't pre-assigned). + /// + public static void QueueTagOperationsByEventId(EventGraph eventGraph, DocumentSessionBase session, StreamAction stream) + { + if (eventGraph.TagTypes.Count == 0) return; + + var schema = eventGraph.DatabaseSchemaName; + + foreach (var @event in stream.Events) + { + var tags = @event.Tags; + if (tags == null || tags.Count == 0) continue; + + foreach (var tag in tags) + { + var registration = eventGraph.FindTagType(tag.TagType); + if (registration == null) continue; + + session.QueueOperation(new InsertEventTagByEventIdOperation(schema, registration, @event.Id, tag.Value)); + } + } + } +} diff --git a/src/Marten/Events/Operations/InsertEventTagByEventIdOperation.cs b/src/Marten/Events/Operations/InsertEventTagByEventIdOperation.cs new file mode 100644 index 0000000000..279f2c0831 --- /dev/null +++ b/src/Marten/Events/Operations/InsertEventTagByEventIdOperation.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten.Internal; +using Marten.Internal.Operations; +using Weasel.Postgresql; + +namespace Marten.Events.Operations; + +/// +/// Inserts a tag row by looking up seq_id from the event's id. +/// Used in Quick append mode where sequences aren't pre-assigned. +/// +internal class InsertEventTagByEventIdOperation: IStorageOperation +{ + private readonly string _schemaName; + private readonly ITagTypeRegistration _registration; + private readonly Guid _eventId; + private readonly object _value; + + public InsertEventTagByEventIdOperation(string schemaName, ITagTypeRegistration registration, Guid eventId, object tagValue) + { + _schemaName = schemaName; + _registration = registration; + _eventId = eventId; + _value = registration.ExtractValue(tagValue); + } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + builder.Append("insert into "); + builder.Append(_schemaName); + builder.Append(".mt_event_tag_"); + builder.Append(_registration.TableSuffix); + builder.Append(" (value, seq_id) select "); + builder.AppendParameter(_value); + builder.Append(", seq_id from "); + builder.Append(_schemaName); + builder.Append(".mt_events where id = "); + builder.AppendParameter(_eventId); + builder.Append(" on conflict do nothing"); + } + + public Type DocumentType => typeof(IEvent); + + public void Postprocess(DbDataReader reader, IList exceptions) + { + // No-op + } + + public Task PostprocessAsync(DbDataReader reader, IList exceptions, CancellationToken token) + { + return Task.CompletedTask; + } + + public OperationRole Role() => OperationRole.Events; +} diff --git a/src/Marten/Events/Operations/InsertEventTagOperation.cs b/src/Marten/Events/Operations/InsertEventTagOperation.cs new file mode 100644 index 0000000000..6fa8622675 --- /dev/null +++ b/src/Marten/Events/Operations/InsertEventTagOperation.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Tags; +using Marten.Internal; +using Marten.Internal.Operations; +using Weasel.Postgresql; + +namespace Marten.Events.Operations; + +internal class InsertEventTagOperation: IStorageOperation +{ + private readonly string _schemaName; + private readonly ITagTypeRegistration _registration; + private readonly long _seqId; + private readonly object _value; + + public InsertEventTagOperation(string schemaName, ITagTypeRegistration registration, long seqId, object tagValue) + { + _schemaName = schemaName; + _registration = registration; + _seqId = seqId; + _value = registration.ExtractValue(tagValue); + } + + public void ConfigureCommand(ICommandBuilder builder, IMartenSession session) + { + builder.Append("insert into "); + builder.Append(_schemaName); + builder.Append(".mt_event_tag_"); + builder.Append(_registration.TableSuffix); + builder.Append(" (value, seq_id) values ("); + builder.AppendParameter(_value); + builder.Append(", "); + builder.AppendParameter(_seqId); + builder.Append(") on conflict do nothing"); + } + + public Type DocumentType => typeof(IEvent); + + public void Postprocess(DbDataReader reader, IList exceptions) + { + // No-op + } + + public Task PostprocessAsync(DbDataReader reader, IList exceptions, CancellationToken token) + { + return Task.CompletedTask; + } + + public OperationRole Role() => OperationRole.Events; +} diff --git a/src/Marten/Events/Operations/QuickAppendEventsOperationBase.cs b/src/Marten/Events/Operations/QuickAppendEventsOperationBase.cs index 08bf5a772e..0dddecbeaa 100644 --- a/src/Marten/Events/Operations/QuickAppendEventsOperationBase.cs +++ b/src/Marten/Events/Operations/QuickAppendEventsOperationBase.cs @@ -175,6 +175,36 @@ protected void writeTimestamps(IGroupedParameterBuilder builder) param.NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.TimestampTz; } + protected void writeAllTagValues(IGroupedParameterBuilder builder) + { + var tagTypes = Events.TagTypes; + var events = Stream.Events; + var count = events.Count; + + foreach (var registration in tagTypes) + { + var values = new string?[count]; + for (int i = 0; i < count; i++) + { + var tags = events[i].Tags; + if (tags != null) + { + foreach (var tag in tags) + { + if (tag.TagType == registration.TagType) + { + values[i] = registration.ExtractValue(tag.Value)?.ToString(); + break; + } + } + } + } + + var param = builder.AppendParameter(values); + param.NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Varchar; + } + } + public async Task PostprocessAsync(DbDataReader reader, IList exceptions, CancellationToken token) { if (await reader.ReadAsync(token).ConfigureAwait(false)) diff --git a/src/Marten/Events/Projections/NaturalKeyProjection.cs b/src/Marten/Events/Projections/NaturalKeyProjection.cs new file mode 100644 index 0000000000..98f8392bc6 --- /dev/null +++ b/src/Marten/Events/Projections/NaturalKeyProjection.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten.Events.Schema; +using Marten.Storage; +using Weasel.Core; + +namespace Marten.Events.Projections; + +/// +/// Auto-registered inline projection that maintains natural key to stream id/key mappings +/// in a dedicated table. This projection upserts the natural key value when matching events +/// are appended and marks entries as archived when streams are archived. +/// +internal class NaturalKeyProjection: IInlineProjection, IProjectionSchemaSource +{ + private readonly EventGraph _events; + private readonly NaturalKeyDefinition _naturalKey; + private readonly NaturalKeyTable _table; + private readonly string _tableName; + private readonly bool _isConjoined; + private readonly bool _isGuid; + + public NaturalKeyProjection(EventGraph events, NaturalKeyDefinition naturalKey) + { + _events = events; + _naturalKey = naturalKey; + _table = new NaturalKeyTable(events, naturalKey); + _tableName = _table.Identifier.QualifiedName; + _isConjoined = events.TenancyStyle == TenancyStyle.Conjoined; + _isGuid = events.StreamIdentity == StreamIdentity.AsGuid; + } + + public Task ApplyAsync(IDocumentOperations operations, IReadOnlyList streams, + CancellationToken cancellation) + { + foreach (var stream in streams) + { + // Process events that carry natural key values + foreach (var @event in stream.Events) + { + foreach (var mapping in _naturalKey.EventMappings) + { + if (mapping.EventType.IsAssignableFrom(@event.Data.GetType())) + { + var rawValue = mapping.Extractor(@event.Data); + var innerValue = _naturalKey.Unwrap(rawValue); + if (innerValue != null) + { + queueUpsertSql(operations, stream, innerValue); + } + } + } + } + } + + return Task.CompletedTask; + } + + private void queueUpsertSql(IDocumentOperations operations, StreamAction stream, object innerValue) + { + var streamCol = _isGuid ? "stream_id" : "stream_key"; + object streamId = _isGuid ? (object)stream.Id : stream.Key!; + + if (_isConjoined) + { + var sql = $"INSERT INTO {_tableName} (natural_key_value, {streamCol}, tenant_id, is_archived) " + + $"VALUES (?, ?, ?, false) " + + $"ON CONFLICT (natural_key_value) DO UPDATE SET {streamCol} = ?, tenant_id = ?, is_archived = false"; + operations.QueueSqlCommand(sql, innerValue, streamId, stream.TenantId, + streamId, stream.TenantId); + } + else + { + var sql = $"INSERT INTO {_tableName} (natural_key_value, {streamCol}, is_archived) " + + $"VALUES (?, ?, false) " + + $"ON CONFLICT (natural_key_value) DO UPDATE SET {streamCol} = ?, is_archived = false"; + operations.QueueSqlCommand(sql, innerValue, streamId, streamId); + } + } + + public IEnumerable CreateSchemaObjects(EventGraph events) + { + yield return _table; + } +} diff --git a/src/Marten/Events/Projections/ProjectionOptions.cs b/src/Marten/Events/Projections/ProjectionOptions.cs index 938cd8c015..c93574c8c4 100644 --- a/src/Marten/Events/Projections/ProjectionOptions.cs +++ b/src/Marten/Events/Projections/ProjectionOptions.cs @@ -4,6 +4,7 @@ using JasperFx; using JasperFx.Core; using JasperFx.Core.Reflection; +using JasperFx.Events; using JasperFx.Events.Aggregation; using JasperFx.Events.Projections; using JasperFx.Events.Subscriptions; @@ -23,7 +24,7 @@ namespace Marten.Events.Projections; public class ProjectionOptions: ProjectionGraph { internal readonly IFetchPlanner[] _builtInPlanners = - [new InlineFetchPlanner(), new AsyncFetchPlanner(), new LiveFetchPlanner()]; + [new NaturalKeyFetchPlanner(), new InlineFetchPlanner(), new AsyncFetchPlanner(), new LiveFetchPlanner()]; private readonly StoreOptions _options; @@ -72,10 +73,21 @@ internal IEnumerable allPlanners() internal IInlineProjection[] BuildInlineProjections(DocumentStore store) { - return All + var projections = All .Where(x => x.Lifecycle == ProjectionLifecycle.Inline) .Select(x => x.BuildForInline()) - .ToArray(); + .ToList(); + + // Auto-register NaturalKeyProjection for any aggregate that has a NaturalKeyDefinition + foreach (var aggregate in All.OfType()) + { + if (aggregate.NaturalKeyDefinition != null) + { + projections.Add(new NaturalKeyProjection(_options.EventGraph, aggregate.NaturalKeyDefinition)); + } + } + + return projections.ToArray(); } /// @@ -249,4 +261,17 @@ internal void AttachLogging(ILoggerFactory loggerFactory) hasLogger.AttachLogger(loggerFactory); } } + + /// + /// Find a registered natural key definition for the given aggregate type, if any. + /// + public NaturalKeyDefinition? FindNaturalKeyDefinition(Type aggregateType) + { + if (TryFindAggregate(aggregateType, out var projection)) + { + return projection.NaturalKeyDefinition; + } + + return null; + } } diff --git a/src/Marten/Events/QuickEventAppender.cs b/src/Marten/Events/QuickEventAppender.cs index 913c11da31..1ae83d5c05 100644 --- a/src/Marten/Events/QuickEventAppender.cs +++ b/src/Marten/Events/QuickEventAppender.cs @@ -16,6 +16,21 @@ private static void registerOperationsForStreams(EventGraph eventGraph, Document { var storage = session.EventStorage(); + // Queue AssertStreamVersion operations for streams with AlwaysEnforceConsistency but no events + foreach (var stream in session.WorkTracker.Streams.Where(x => !x.Events.Any() && x.AlwaysEnforceConsistency && x.ExpectedVersionOnServer.HasValue)) + { + stream.TenantId ??= session.TenantId; + + if (stream.Key != null) + { + session.QueueOperation(new AssertStreamVersionByKey(eventGraph, stream)); + } + else + { + session.QueueOperation(new AssertStreamVersionById(eventGraph, stream)); + } + } + foreach (var stream in session.WorkTracker.Streams.Where(x => x.Events.Any())) { stream.TenantId ??= session.TenantId; @@ -31,6 +46,9 @@ private static void registerOperationsForStreams(EventGraph eventGraph, Document { session.QueueOperation(storage.QuickAppendEventWithVersion(stream, @event)); } + + // Individual inserts don't use the function, so queue separate tag operations + EventTagOperations.QueueTagOperationsByEventId(eventGraph, session, stream); } else { @@ -43,9 +61,13 @@ private static void registerOperationsForStreams(EventGraph eventGraph, Document { session.QueueOperation(storage.QuickAppendEventWithVersion(stream, @event)); } + + // Individual inserts don't use the function, so queue separate tag operations + EventTagOperations.QueueTagOperationsByEventId(eventGraph, session, stream); } else { + // Tags are handled inside the PostgreSQL function via array parameters stream.PrepareEvents(0, eventGraph, sequences, session); var quickAppendEvents = (QuickAppendEventsOperationBase)storage.QuickAppendEvents(stream); quickAppendEvents.Events = eventGraph; diff --git a/src/Marten/Events/RichEventAppender.cs b/src/Marten/Events/RichEventAppender.cs index 45c3e9c55d..c400449b87 100644 --- a/src/Marten/Events/RichEventAppender.cs +++ b/src/Marten/Events/RichEventAppender.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using JasperFx.Events; using JasperFx.Events.Projections; +using Marten.Events.Operations; using Marten.Events.Projections; using Marten.Exceptions; using Marten.Internal.Sessions; @@ -59,6 +60,23 @@ public async Task ProcessEventsAsync(EventGraph eventGraph, DocumentSessionBase { session.QueueOperation(storage.AppendEvent(eventGraph, session, stream, @event)); } + + EventTagOperations.QueueTagOperations(eventGraph, session, stream); + } + + // Queue AssertStreamVersion operations for streams with AlwaysEnforceConsistency but no events + foreach (var stream in session.WorkTracker.Streams.Where(x => !x.Events.Any() && x.AlwaysEnforceConsistency && x.ExpectedVersionOnServer.HasValue)) + { + stream.TenantId ??= session.TenantId; + + if (stream.Key != null) + { + session.QueueOperation(new AssertStreamVersionByKey(eventGraph, stream)); + } + else + { + session.QueueOperation(new AssertStreamVersionById(eventGraph, stream)); + } } // TODO -- look for opportunities to batch up the requests here too! diff --git a/src/Marten/Events/Schema/EventTagTable.cs b/src/Marten/Events/Schema/EventTagTable.cs new file mode 100644 index 0000000000..296457ebb1 --- /dev/null +++ b/src/Marten/Events/Schema/EventTagTable.cs @@ -0,0 +1,34 @@ +using System; +using JasperFx.Events.Tags; +using Weasel.Postgresql; +using Weasel.Postgresql.Tables; + +namespace Marten.Events.Schema; + +internal class EventTagTable: Table +{ + public EventTagTable(EventGraph events, ITagTypeRegistration registration) + : base(new PostgresqlObjectName(events.DatabaseSchemaName, $"mt_event_tag_{registration.TableSuffix}")) + { + var pgType = PostgresqlTypeFor(registration.SimpleType); + + // Composite primary key with value first for query performance + AddColumn("value", pgType).NotNull().AsPrimaryKey(); + AddColumn("seq_id", "bigint").NotNull().AsPrimaryKey() + .ForeignKeyTo(new PostgresqlObjectName(events.DatabaseSchemaName, "mt_events"), "seq_id"); + + PrimaryKeyName = $"pk_mt_event_tag_{registration.TableSuffix}"; + } + + private static string PostgresqlTypeFor(Type simpleType) + { + if (simpleType == typeof(string)) return "text"; + if (simpleType == typeof(Guid)) return "uuid"; + if (simpleType == typeof(int)) return "integer"; + if (simpleType == typeof(long)) return "bigint"; + if (simpleType == typeof(short)) return "smallint"; + + throw new ArgumentOutOfRangeException(nameof(simpleType), + $"Unsupported tag value type '{simpleType.Name}'. Supported types: string, Guid, int, long, short."); + } +} diff --git a/src/Marten/Events/Schema/NaturalKeyTable.cs b/src/Marten/Events/Schema/NaturalKeyTable.cs new file mode 100644 index 0000000000..42be3ed21e --- /dev/null +++ b/src/Marten/Events/Schema/NaturalKeyTable.cs @@ -0,0 +1,58 @@ +using JasperFx.Events; +using Marten.Events.Archiving; +using Marten.Storage; +using Marten.Storage.Metadata; +using Weasel.Postgresql; +using Weasel.Postgresql.Tables; + +namespace Marten.Events.Schema; + +internal class NaturalKeyTable: Table +{ + public NaturalKeyTable(EventGraph events, NaturalKeyDefinition naturalKey) + : base(new PostgresqlObjectName(events.DatabaseSchemaName, + $"mt_natural_key_{naturalKey.AggregateType.Name.ToLowerInvariant()}")) + { + // Determine the column type for the natural key value + var columnType = naturalKey.InnerType == typeof(int) ? "integer" + : naturalKey.InnerType == typeof(long) ? "bigint" + : "varchar(200)"; + + AddColumn("natural_key_value", columnType).AsPrimaryKey().NotNull(); + + // Stream identity column - matches mt_streams.id type + var streamCol = events.StreamIdentity == StreamIdentity.AsGuid ? "stream_id" : "stream_key"; + var streamColType = events.StreamIdentity == StreamIdentity.AsGuid ? "uuid" : "varchar(250)"; + + AddColumn(streamCol, streamColType).NotNull(); + + // FK to mt_streams with CASCADE delete + ForeignKeys.Add(new ForeignKey($"fk_{Identifier.Name}_stream") + { + ColumnNames = new[] { streamCol }, + LinkedNames = new[] { "id" }, + LinkedTable = new PostgresqlObjectName(events.DatabaseSchemaName, StreamsTable.TableName), + OnDelete = CascadeAction.Cascade + }); + + // Tenancy support + if (events.TenancyStyle == TenancyStyle.Conjoined) + { + AddColumn(); + } + + // Archive support + var archiving = AddColumn(); + if (events.UseArchivedStreamPartitioning) + { + archiving.PartitionByListValues().AddPartition("archived", true); + } + + // Index on stream id/key for reverse lookups + Indexes.Add(new IndexDefinition($"idx_{Identifier.Name}_{streamCol}") + { + IsUnique = false, + Columns = new[] { streamCol } + }); + } +} diff --git a/src/Marten/Events/Schema/QuickAppendEventFunction.cs b/src/Marten/Events/Schema/QuickAppendEventFunction.cs index b23b84263d..c1e97efd7f 100644 --- a/src/Marten/Events/Schema/QuickAppendEventFunction.cs +++ b/src/Marten/Events/Schema/QuickAppendEventFunction.cs @@ -1,6 +1,7 @@ using System.IO; using System.Linq; using JasperFx.Events; +using JasperFx.Events.Tags; using Marten.Schema; using Marten.Storage; using Marten.Storage.Metadata; @@ -72,9 +73,22 @@ public override void WriteCreateStatement(Migrator migrator, TextWriter writer) metadataParameters += ", timestamps timestamptz[]"; } + // Add tag type parameters + var tagParameters = ""; + var tagInserts = ""; + foreach (var tagType in _events.TagTypes) + { + var paramName = $"tag_{tagType.TableSuffix}_values"; + tagParameters += $", {paramName} varchar[]"; + + tagInserts += $@" + IF {paramName}[index] IS NOT NULL THEN + INSERT INTO {databaseSchema}.mt_event_tag_{tagType.TableSuffix} (value, seq_id) VALUES ({paramName}[index]::{PostgresqlTypeFor(tagType.SimpleType)}, seq) ON CONFLICT DO NOTHING; + END IF;"; + } writer.WriteLine($@" -CREATE OR REPLACE FUNCTION {Identifier}(stream {streamIdType}, stream_type varchar, tenantid varchar, event_ids uuid[], event_types varchar[], dotnet_types varchar[], bodies jsonb[]{metadataParameters}) RETURNS int[] AS $$ +CREATE OR REPLACE FUNCTION {Identifier}(stream {streamIdType}, stream_type varchar, tenantid varchar, event_ids uuid[], event_types varchar[], dotnet_types varchar[], bodies jsonb[]{metadataParameters}{tagParameters}) RETURNS int[] AS $$ DECLARE event_version int; event_type varchar; @@ -114,7 +128,7 @@ insert into {databaseSchema}.mt_events (seq_id, id, stream_id, version, data, type, tenant_id, timestamp, {SchemaConstants.DotNetTypeColumn}, is_archived{metadataColumns}) values (seq, event_id, stream, event_version, body, event_type, tenantid, {timestampValue}, dotnet_types[index], FALSE{metadataValues}); - +{tagInserts} index := index + 1; end loop; @@ -126,4 +140,15 @@ insert into {databaseSchema}.mt_events "); } + private static string PostgresqlTypeFor(System.Type simpleType) + { + if (simpleType == typeof(string)) return "text"; + if (simpleType == typeof(System.Guid)) return "uuid"; + if (simpleType == typeof(int)) return "integer"; + if (simpleType == typeof(long)) return "bigint"; + if (simpleType == typeof(short)) return "smallint"; + + return "text"; + } + } diff --git a/src/Marten/Events/StubEventStream.cs b/src/Marten/Events/StubEventStream.cs index 59a0d35da5..6c853ef7be 100644 --- a/src/Marten/Events/StubEventStream.cs +++ b/src/Marten/Events/StubEventStream.cs @@ -67,6 +67,8 @@ public void AppendMany(IEnumerable events) public Guid Id { get; set; } = Guid.NewGuid(); public string Key { get; set; } = Guid.NewGuid().ToString(); + public bool AlwaysEnforceConsistency { get; set; } + public IReadOnlyList Events => EventsAppended.Select(x => EventGraph.BuildEvent(x)).ToList(); /// diff --git a/src/Marten/GlobalUsings.cs b/src/Marten/GlobalUsings.cs new file mode 100644 index 0000000000..6c7852349c --- /dev/null +++ b/src/Marten/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using IStorageOperation = Marten.Internal.Operations.IStorageOperation; +global using OperationRole = Marten.Internal.Operations.OperationRole; diff --git a/src/Marten/ITransactionParticipant.cs b/src/Marten/ITransactionParticipant.cs new file mode 100644 index 0000000000..b31415b771 --- /dev/null +++ b/src/Marten/ITransactionParticipant.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace Marten; + +/// +/// Represents a participant that can execute additional work within Marten's +/// database transaction before it is committed. This is used to allow external +/// systems (like EF Core DbContext) to flush their changes into the same +/// transaction that Marten uses for its batch operations. +/// +public interface ITransactionParticipant +{ + /// + /// Called after Marten's batch pages have been executed but before the + /// transaction is committed. Implementations should use the provided + /// connection and transaction to execute any pending work. + /// + Task BeforeCommitAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, + CancellationToken token); +} diff --git a/src/Marten/ITransactionParticipantRegistrar.cs b/src/Marten/ITransactionParticipantRegistrar.cs new file mode 100644 index 0000000000..4023533529 --- /dev/null +++ b/src/Marten/ITransactionParticipantRegistrar.cs @@ -0,0 +1,10 @@ +namespace Marten; + +/// +/// Allows projections to register instances +/// that will participate in the same database transaction as Marten's batch operations. +/// +public interface ITransactionParticipantRegistrar +{ + void AddTransactionParticipant(ITransactionParticipant participant); +} diff --git a/src/Marten/Internal/IUpdateBatch.cs b/src/Marten/Internal/IUpdateBatch.cs index 07645764bd..ed98bfec96 100644 --- a/src/Marten/Internal/IUpdateBatch.cs +++ b/src/Marten/Internal/IUpdateBatch.cs @@ -13,4 +13,6 @@ public interface IUpdateBatch IReadOnlyList DocumentTypes(); Task PostUpdateAsync(IMartenSession session); Task PreUpdateAsync(IMartenSession session); + + IReadOnlyList TransactionParticipants { get; } } diff --git a/src/Marten/Internal/Sessions/AmbientTransactionLifetime.cs b/src/Marten/Internal/Sessions/AmbientTransactionLifetime.cs index 286a3525c2..8577dc6362 100644 --- a/src/Marten/Internal/Sessions/AmbientTransactionLifetime.cs +++ b/src/Marten/Internal/Sessions/AmbientTransactionLifetime.cs @@ -287,7 +287,8 @@ public void ExecuteBatchPages(IReadOnlyList pages, } public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, - List exceptions, CancellationToken token) + List exceptions, CancellationToken token, + IReadOnlyList? participants = null) { try { diff --git a/src/Marten/Internal/Sessions/AutoClosingLifetime.cs b/src/Marten/Internal/Sessions/AutoClosingLifetime.cs index 285226ebe9..a5d3987508 100644 --- a/src/Marten/Internal/Sessions/AutoClosingLifetime.cs +++ b/src/Marten/Internal/Sessions/AutoClosingLifetime.cs @@ -286,7 +286,7 @@ public void ExecuteBatchPages(IReadOnlyList pages, List pages, List exceptions, - CancellationToken token) + CancellationToken token, IReadOnlyList? participants = null) { await using var conn = _database.CreateConnection(); await conn.OpenAsync(token).ConfigureAwait(false); @@ -367,6 +367,14 @@ public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, Lis throw new AggregateException(exceptions); } + if (participants is { Count: > 0 }) + { + foreach (var participant in participants) + { + await participant.BeforeCommitAsync(conn, tx, token).ConfigureAwait(false); + } + } + await tx.CommitAsync(token).ConfigureAwait(false); } finally diff --git a/src/Marten/Internal/Sessions/DocumentSessionBase.ProjectionStorage.cs b/src/Marten/Internal/Sessions/DocumentSessionBase.ProjectionStorage.cs index ece6c87e9d..f21e8ac5c1 100644 --- a/src/Marten/Internal/Sessions/DocumentSessionBase.ProjectionStorage.cs +++ b/src/Marten/Internal/Sessions/DocumentSessionBase.ProjectionStorage.cs @@ -22,6 +22,14 @@ public abstract partial class DocumentSessionBase public async Task> FetchProjectionStorageAsync(string tenantId, CancellationToken cancellationToken) where TDoc : notnull where TId : notnull { + // Check for custom projection storage providers (e.g., EF Core) + if (Options.CustomProjectionStorageProviders.TryGetValue(typeof(TDoc), out var factory)) + { + // Ensure ExtendedSchemaObjects (e.g. EF Core entity tables) are created + await Database.EnsureStorageExistsAsync(typeof(StorageFeatures), cancellationToken).ConfigureAwait(false); + return (IProjectionStorage)factory(this, tenantId); + } + await Database.EnsureStorageExistsAsync(typeof(TDoc), cancellationToken).ConfigureAwait(false); if (tenantId == TenantId || tenantId.IsEmpty()) return new ProjectionStorage(this, StorageFor()); diff --git a/src/Marten/Internal/Sessions/DocumentSessionBase.SaveChanges.cs b/src/Marten/Internal/Sessions/DocumentSessionBase.SaveChanges.cs index cbeb302240..37637aff0a 100644 --- a/src/Marten/Internal/Sessions/DocumentSessionBase.SaveChanges.cs +++ b/src/Marten/Internal/Sessions/DocumentSessionBase.SaveChanges.cs @@ -99,7 +99,8 @@ private IEnumerable operationDocumentTypes() return _workTracker.Operations().Select(x => x.DocumentType).Where(x => x != null).Distinct(); } - internal record PagesExecution(IReadOnlyList Pages, IConnectionLifetime Connection) + internal record PagesExecution(IReadOnlyList Pages, IConnectionLifetime Connection, + IReadOnlyList? Participants) { public List Exceptions { get; } = new(); } @@ -118,7 +119,23 @@ internal async Task ExecuteBatchAsync(IUpdateBatch batch, CancellationToken toke var pages = batch.BuildPages(this); if (!pages.Any()) return; - var execution = new PagesExecution(pages, _connection); + // Merge participants from both the batch (async daemon) and the session (inline projections) + IReadOnlyList? participants = null; + if (batch.TransactionParticipants is { Count: > 0 } && _transactionParticipants.Count > 0) + { + var merged = new List(batch.TransactionParticipants); + merged.AddRange(_transactionParticipants); + participants = merged; + } + else if (batch.TransactionParticipants is { Count: > 0 }) + { + participants = batch.TransactionParticipants; + } + else if (_transactionParticipants.Count > 0) + { + participants = _transactionParticipants; + } + var execution = new PagesExecution(pages, _connection, participants); try { @@ -128,7 +145,7 @@ internal async Task ExecuteBatchAsync(IUpdateBatch batch, CancellationToken toke await executeBeforeCommitListeners(batch).ConfigureAwait(false); await Options.ResiliencePipeline.ExecuteAsync( - static (e, t) => new ValueTask(e.Connection.ExecuteBatchPagesAsync(e.Pages, e.Exceptions, t)), execution, token).ConfigureAwait(false); + static (e, t) => new ValueTask(e.Connection.ExecuteBatchPagesAsync(e.Pages, e.Exceptions, t, e.Participants)), execution, token).ConfigureAwait(false); await executeAfterCommitListeners(batch).ConfigureAwait(false); } diff --git a/src/Marten/Internal/Sessions/DocumentSessionBase.cs b/src/Marten/Internal/Sessions/DocumentSessionBase.cs index f52c1539fe..536a4e1f99 100644 --- a/src/Marten/Internal/Sessions/DocumentSessionBase.cs +++ b/src/Marten/Internal/Sessions/DocumentSessionBase.cs @@ -14,9 +14,10 @@ namespace Marten.Internal.Sessions; -public abstract partial class DocumentSessionBase: QuerySession, IDocumentSession +public abstract partial class DocumentSessionBase: QuerySession, IDocumentSession, ITransactionParticipantRegistrar { internal readonly ISessionWorkTracker _workTracker; + private readonly List _transactionParticipants = new(); private Dictionary? _byTenant; @@ -44,6 +45,13 @@ internal DocumentSessionBase( internal ISessionWorkTracker WorkTracker => _workTracker; + public virtual void AddTransactionParticipant(ITransactionParticipant participant) + { + _transactionParticipants.Add(participant); + } + + internal IReadOnlyList TransactionParticipants => _transactionParticipants; + public void EjectAllPendingChanges() { _workTracker.EjectAll(); diff --git a/src/Marten/Internal/Sessions/EventTracingConnectionLifetime.cs b/src/Marten/Internal/Sessions/EventTracingConnectionLifetime.cs index 6d16aa7f9c..57bb1b8309 100644 --- a/src/Marten/Internal/Sessions/EventTracingConnectionLifetime.cs +++ b/src/Marten/Internal/Sessions/EventTracingConnectionLifetime.cs @@ -192,13 +192,14 @@ public void ExecuteBatchPages(IReadOnlyList pages, List pages, List exceptions, CancellationToken token) + public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, List exceptions, + CancellationToken token, IReadOnlyList? participants = null) { _databaseActivity?.AddEvent(new ActivityEvent(MartenBatchPagesExecutionStarted)); try { - await InnerConnectionLifetime.ExecuteBatchPagesAsync(pages, exceptions, token).ConfigureAwait(false); + await InnerConnectionLifetime.ExecuteBatchPagesAsync(pages, exceptions, token, participants).ConfigureAwait(false); writeVerboseEvents(pages); } diff --git a/src/Marten/Internal/Sessions/ExternalTransaction.cs b/src/Marten/Internal/Sessions/ExternalTransaction.cs index 35e3eaa58e..33e09264e4 100644 --- a/src/Marten/Internal/Sessions/ExternalTransaction.cs +++ b/src/Marten/Internal/Sessions/ExternalTransaction.cs @@ -285,7 +285,8 @@ public void ExecuteBatchPages(IReadOnlyList pages, } public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, - List exceptions, CancellationToken token) + List exceptions, CancellationToken token, + IReadOnlyList? participants = null) { try { @@ -315,6 +316,14 @@ public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, throw new AggregateException(exceptions); } + if (participants is { Count: > 0 }) + { + foreach (var participant in participants) + { + await participant.BeforeCommitAsync(Connection, Transaction, token).ConfigureAwait(false); + } + } + await CommitAsync(token).ConfigureAwait(true); } diff --git a/src/Marten/Internal/Sessions/IConnectionLifetime.cs b/src/Marten/Internal/Sessions/IConnectionLifetime.cs index 2941a48a8d..7610c33ad6 100644 --- a/src/Marten/Internal/Sessions/IConnectionLifetime.cs +++ b/src/Marten/Internal/Sessions/IConnectionLifetime.cs @@ -64,7 +64,8 @@ Task ExecuteReaderAsync(NpgsqlBatch batch, void ExecuteBatchPages(IReadOnlyList pages, List exceptions); Task ExecuteBatchPagesAsync(IReadOnlyList pages, - List exceptions, CancellationToken token); + List exceptions, CancellationToken token, + IReadOnlyList? participants = null); } diff --git a/src/Marten/Internal/Sessions/TransactionalConnection.cs b/src/Marten/Internal/Sessions/TransactionalConnection.cs index 833337fa89..c7fb5bbd4f 100644 --- a/src/Marten/Internal/Sessions/TransactionalConnection.cs +++ b/src/Marten/Internal/Sessions/TransactionalConnection.cs @@ -367,7 +367,8 @@ public void ExecuteBatchPages(IReadOnlyList pages, } public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, - List exceptions, CancellationToken token) + List exceptions, CancellationToken token, + IReadOnlyList? participants = null) { try { @@ -400,6 +401,14 @@ public async Task ExecuteBatchPagesAsync(IReadOnlyList pages, throw new AggregateException(exceptions); } + if (participants is { Count: > 0 }) + { + foreach (var participant in participants) + { + await participant.BeforeCommitAsync(Connection, Transaction!, token).ConfigureAwait(false); + } + } + await CommitAsync(token).ConfigureAwait(true); } } diff --git a/src/Marten/Internal/UnitOfWork.cs b/src/Marten/Internal/UnitOfWork.cs index 96614f1239..a93904ef6c 100644 --- a/src/Marten/Internal/UnitOfWork.cs +++ b/src/Marten/Internal/UnitOfWork.cs @@ -223,7 +223,7 @@ public void EjectAllOfType(Type type) public bool HasOutstandingWork() { - return _operations.Any() || Streams.Any(x => x.Events.Count > 0) || _eventOperations.Any(); + return _operations.Any() || Streams.Any(x => x.Events.Count > 0 || x.AlwaysEnforceConsistency) || _eventOperations.Any(); } public void EjectAll() diff --git a/src/Marten/Internal/UpdateBatch.cs b/src/Marten/Internal/UpdateBatch.cs index d5c2c6e188..2177d24cb3 100644 --- a/src/Marten/Internal/UpdateBatch.cs +++ b/src/Marten/Internal/UpdateBatch.cs @@ -34,6 +34,8 @@ public Task PreUpdateAsync(IMartenSession session) return Task.CompletedTask; } + public IReadOnlyList TransactionParticipants => []; + public IReadOnlyList BuildPages(IMartenSession session) { return buildPages(session).ToList(); diff --git a/src/Marten/Schema/DocumentMapping.cs b/src/Marten/Schema/DocumentMapping.cs index 497a26fac9..e691be61ae 100644 --- a/src/Marten/Schema/DocumentMapping.cs +++ b/src/Marten/Schema/DocumentMapping.cs @@ -135,8 +135,6 @@ public DocumentMapping(Type documentType, StoreOptions storeOptions) StoreOptions.applyPostPolicies(this); - QueryMembers.TenancyStyle = TenancyStyle; - _schema = new Lazy(() => new DocumentSchema(this)); if (DisablePartitioningIfAny) @@ -286,7 +284,20 @@ public string Alias IReadOnlyHiloSettings IDocumentType.HiloSettings { get; } - public TenancyStyle TenancyStyle { get; set; } = TenancyStyle.Single; + private TenancyStyle _tenancyStyle = TenancyStyle.Single; + + public TenancyStyle TenancyStyle + { + get => _tenancyStyle; + set + { + _tenancyStyle = value; + if (QueryMembers != null) + { + QueryMembers.TenancyStyle = value; + } + } + } IDocumentType IDocumentType.Root => this; diff --git a/src/Marten/Services/BatchQuerying/BatchedQuery.Events.cs b/src/Marten/Services/BatchQuerying/BatchedQuery.Events.cs index 1ec5b88084..63b8748da1 100644 --- a/src/Marten/Services/BatchQuerying/BatchedQuery.Events.cs +++ b/src/Marten/Services/BatchQuerying/BatchedQuery.Events.cs @@ -5,7 +5,9 @@ using JasperFx.Core.Reflection; using JasperFx.Events; using JasperFx.Events.Projections; +using JasperFx.Events.Tags; using Marten.Events; +using Marten.Events.Dcb; using Marten.Events.Fetching; using StreamState = Marten.Events.StreamState; using Marten.Events.Querying; @@ -180,4 +182,13 @@ public async Task> FetchForExclusiveWriting(string key) where var handler = plan.BuildQueryHandler(Parent, id); return AddItem(handler); } + + public Task> FetchForWritingByTags(EventTagQuery query) where T : class + { + Parent.AssertIsDocumentSession(); + _documentTypes.Add(typeof(IEvent)); + var store = (DocumentStore)Parent.DocumentStore; + var handler = new FetchForWritingByTagsHandler(store, query); + return AddItem(handler); + } } diff --git a/src/Marten/Services/BatchQuerying/IBatchedQuery.cs b/src/Marten/Services/BatchQuerying/IBatchedQuery.cs index 6bcb596d0b..5a567b3609 100644 --- a/src/Marten/Services/BatchQuerying/IBatchedQuery.cs +++ b/src/Marten/Services/BatchQuerying/IBatchedQuery.cs @@ -4,7 +4,9 @@ using System.Threading; using System.Threading.Tasks; using JasperFx.Events; +using JasperFx.Events.Tags; using Marten.Events; +using Marten.Events.Dcb; using Marten.Internal.Sessions; using StreamState = Marten.Events.StreamState; using Marten.Linq; @@ -135,6 +137,15 @@ Task> FetchForExclusiveWriting(string key) /// /// Task FetchLatest(string id) where T : class; + + /// + /// Fetch events matching a tag query and aggregate them into type T with a DCB consistency boundary. + /// At SaveChangesAsync time, will throw DcbConcurrencyException if new matching events were appended. + /// + /// + /// + /// + Task> FetchForWritingByTags(EventTagQuery query) where T : class; } public interface IBatchedQuery diff --git a/src/Marten/Storage/MartenDatabase.DocumentCleaner.cs b/src/Marten/Storage/MartenDatabase.DocumentCleaner.cs index 2282eb13ae..4b09542e10 100644 --- a/src/Marten/Storage/MartenDatabase.DocumentCleaner.cs +++ b/src/Marten/Storage/MartenDatabase.DocumentCleaner.cs @@ -114,6 +114,11 @@ public async Task CompletelyRemoveAllAsync(CancellationToken ct = default) var builder = new CommandBuilder(); + // Drop extended schema objects (e.g., EF Core entity tables) registered via StoreOptions + foreach (var schemaObject in Options.Storage.ExtendedSchemaObjects) + { + builder.Append($"DROP TABLE IF EXISTS {schemaObject.Identifier} CASCADE;"); + } foreach (var table in tables) builder.Append($"DROP TABLE IF EXISTS {table} CASCADE;"); diff --git a/src/Marten/StoreOptions.cs b/src/Marten/StoreOptions.cs index 621c48a500..76d57de7da 100644 --- a/src/Marten/StoreOptions.cs +++ b/src/Marten/StoreOptions.cs @@ -409,6 +409,13 @@ public ITenancy Tenancy private Func _npgsqlDataSourceBuilderFactory = DefaultNpgsqlDataSourceBuilderFactory; private INpgsqlDataSourceFactory _npgsqlDataSourceFactory; private readonly List _compiledQueryTypes = new(); + + /// + /// Registry of custom projection storage factories, keyed by aggregate document type. + /// Used by EF Core projections to substitute Marten document storage with DbContext-based storage. + /// + public Dictionary> CustomProjectionStorageProviders { get; } = new(); + private int _applyChangesLockId = 4004; private bool _shouldApplyChangesOnStartup = false; private bool _shouldAssertDatabaseMatchesConfigurationOnStartup = false; diff --git a/src/StressTests/Bugs/Bug_3113_do_not_reorder_sql_operations.cs b/src/StressTests/Bugs/Bug_3113_do_not_reorder_sql_operations.cs index 66905d0a9a..c5bb0ad7fa 100644 --- a/src/StressTests/Bugs/Bug_3113_do_not_reorder_sql_operations.cs +++ b/src/StressTests/Bugs/Bug_3113_do_not_reorder_sql_operations.cs @@ -15,7 +15,7 @@ namespace StressTests.Bugs; -public sealed class Bug_3113_do_not_reorder_sql_operations : BugIntegrationContext +public sealed partial class Bug_3113_do_not_reorder_sql_operations : BugIntegrationContext { [Fact] public Task does_not_reorder_sql_commands_randomly_single_document_projections() @@ -123,7 +123,7 @@ public MyProjection2 Apply(ThingUsersAssigned @event) }; } - public class MyTableProjection : EventProjection + public partial class MyTableProjection : EventProjection { public const string MainTableName = "mt_tbl_bug_3113"; public const string UsersTableName = $"{MainTableName}_users"; diff --git a/src/samples/DocSamples/RegisteringProjections.cs b/src/samples/DocSamples/RegisteringProjections.cs index dea4295989..dfce795ca0 100644 --- a/src/samples/DocSamples/RegisteringProjections.cs +++ b/src/samples/DocSamples/RegisteringProjections.cs @@ -71,7 +71,7 @@ public static async Task register2() } -public class MySpecialProjection: EventProjection +public partial class MySpecialProjection: EventProjection { public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation) { diff --git a/src/samples/Helpdesk/Helpdesk.Api/Incidents/GetIncidentHistory/IncidentHistory.cs b/src/samples/Helpdesk/Helpdesk.Api/Incidents/GetIncidentHistory/IncidentHistory.cs index fbf243b534..15de54f221 100644 --- a/src/samples/Helpdesk/Helpdesk.Api/Incidents/GetIncidentHistory/IncidentHistory.cs +++ b/src/samples/Helpdesk/Helpdesk.Api/Incidents/GetIncidentHistory/IncidentHistory.cs @@ -10,7 +10,7 @@ public record IncidentHistory( string Description ); -public class IncidentHistoryTransformation: EventProjection +public partial class IncidentHistoryTransformation: EventProjection { public IncidentHistory Transform(IEvent input) {