diff --git a/Directory.Build.props b/Directory.Build.props index 47c0576132..216f443fa4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 9.12.0-alpha.1 + 9.12.0-alpha.2 13.0 Jeremy D. Miller;Babu Annamalai;Jaedyn Tonee https://martendb.io/logo.png diff --git a/Directory.Packages.props b/Directory.Packages.props index 26a238be44..f5b88463bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,13 +73,13 @@ AggregateType (TypeDescriptor), populated in the shared SubscriptionDescriptor(ISubscriptionSource, IEventStore) ctor that Marten's projections funnel through via ProjectionGraph.Describe. See marten#4772. --> - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/DocumentDbTests/Metadata/document_diagnostics_metadata_filter_tests.cs b/src/DocumentDbTests/Metadata/document_diagnostics_metadata_filter_tests.cs new file mode 100644 index 0000000000..93f72cdc5a --- /dev/null +++ b/src/DocumentDbTests/Metadata/document_diagnostics_metadata_filter_tests.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Documents; +using Marten; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Shouldly; +using Weasel.Postgresql; +using Xunit; + +namespace DocumentDbTests.Metadata; + +/// +/// Coverage for marten#4791 / JasperFx/CritterWatch#629 — exact-match metadata filters +/// on . Each of the three new +/// filters (, +/// , +/// ) is honored only when both the option +/// is set AND the document mapping has the corresponding metadata column enabled — emitting +/// a WHERE on a column that doesn't exist would throw 42703 undefined_column. +/// +/// Seeding strategy: 8 documents whose metadata cover every combination of +/// correlation ∈ {c0, c1} × causation ∈ {u0, u1} × last_modified_by ∈ {b0, b1}, +/// indexed 0..7 by the (corr, caus, lmb) binary tuple. Each filter Theory case names a target +/// value per axis (or null) and asserts the exact expected subset of docs returns. +/// +public class document_diagnostics_metadata_filter_tests +{ + /// + /// Every permutation of the three doc metadata filters being independently on/off + /// (2^3 = 8 combos). All three metadata columns are enabled on the mapping in this + /// suite; the "filter set but column disabled" cases are pinned in the dedicated + /// + /// / + /// / + /// facts below. + /// + [Theory] + // (corrFilter, causFilter, lmbFilter, expectedDocIndices) + [InlineData(null, null, null, new[] { 0, 1, 2, 3, 4, 5, 6, 7 })] // no filter: every doc + [InlineData("c0", null, null, new[] { 0, 1, 2, 3 })] // corr=c0 only: 4 docs + [InlineData(null, "u0", null, new[] { 0, 1, 4, 5 })] // caus=u0 only: 4 docs + [InlineData(null, null, "b0", new[] { 0, 2, 4, 6 })] // lmb=b0 only: 4 docs + [InlineData("c0", "u0", null, new[] { 0, 1 })] // corr+caus + [InlineData("c0", null, "b0", new[] { 0, 2 })] // corr+lmb + [InlineData(null, "u0", "b0", new[] { 0, 4 })] // caus+lmb + [InlineData("c0", "u0", "b0", new[] { 0 })] // all three: one doc + public async Task every_filter_combo_returns_the_expected_subset( + string? corr, string? caus, string? lmb, int[] expectedIndices) + { + const string schema = "diag4791_combo"; + using var host = await BuildHostWithAllMetadataEnabled(schema); + await seedMatrixOfEightDocs(host); + + var diagnostics = host.Services.GetRequiredService(); + + var result = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50) + { + CorrelationId = corr, + CausationId = caus, + LastModifiedBy = lmb + }, + CancellationToken.None); + + result.TotalCount.ShouldBe(expectedIndices.Length); + var returnedNames = result.DocumentsJson + .Select(json => JsonDocument.Parse(json).RootElement.GetProperty("Name").GetString()) + .ToList(); + returnedNames.ShouldBe(expectedIndices.Select(NameForIndex).ToList(), ignoreOrder: true); + } + + [Fact] + public async Task filter_on_an_unmatched_value_returns_zero_rows() + { + const string schema = "diag4791_unmatched"; + using var host = await BuildHostWithAllMetadataEnabled(schema); + await seedMatrixOfEightDocs(host); + + var diagnostics = host.Services.GetRequiredService(); + + var result = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50) + { + CorrelationId = "no-such-correlation" + }, + CancellationToken.None); + + result.TotalCount.ShouldBe(0); + result.DocumentsJson.Count.ShouldBe(0); + } + + [Fact] + public async Task filter_is_silently_ignored_when_correlation_id_column_is_disabled() + { + // CorrelationId column NOT enabled on the mapping. Setting the filter must NOT + // throw 42703 undefined_column — the implementation skips the WHERE entirely. + const string schema = "diag4791_corr_off"; + using var host = await BuildHost(schema, enableCorr: false, enableCaus: true, enableLmb: true); + await seedMatrixOfEightDocs(host); + + var diagnostics = host.Services.GetRequiredService(); + + var result = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50) + { + CorrelationId = "c0" // would otherwise narrow to 4 docs + }, + CancellationToken.None); + + // Filter silently ignored → every seeded doc returns. + result.TotalCount.ShouldBe(8); + } + + [Fact] + public async Task filter_is_silently_ignored_when_causation_id_column_is_disabled() + { + const string schema = "diag4791_caus_off"; + using var host = await BuildHost(schema, enableCorr: true, enableCaus: false, enableLmb: true); + await seedMatrixOfEightDocs(host); + + var diagnostics = host.Services.GetRequiredService(); + + var result = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50) + { + CausationId = "u0" + }, + CancellationToken.None); + + result.TotalCount.ShouldBe(8); + } + + [Fact] + public async Task filter_is_silently_ignored_when_last_modified_by_column_is_disabled() + { + const string schema = "diag4791_lmb_off"; + using var host = await BuildHost(schema, enableCorr: true, enableCaus: true, enableLmb: false); + await seedMatrixOfEightDocs(host); + + var diagnostics = host.Services.GetRequiredService(); + + var result = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50) + { + LastModifiedBy = "b0" + }, + CancellationToken.None); + + result.TotalCount.ShouldBe(8); + } + + [Fact] + public async Task filter_set_alongside_an_enabled_filter_when_a_column_is_disabled_still_narrows_via_the_enabled_one() + { + // Mixed scenario: causation disabled, correlation enabled. Setting BOTH filters + // must still narrow the result set via the enabled column — the disabled-column + // filter is silently ignored, NOT propagated as "no rows". + const string schema = "diag4791_mixed"; + using var host = await BuildHost(schema, enableCorr: true, enableCaus: false, enableLmb: true); + await seedMatrixOfEightDocs(host); + + var diagnostics = host.Services.GetRequiredService(); + + var result = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50) + { + CorrelationId = "c0", // honored — 4 docs + CausationId = "u0", // silently ignored — column disabled + LastModifiedBy = "b0" // honored — narrows to 2 + }, + CancellationToken.None); + + // Without causation filtering: corr=c0 (4) ∩ lmb=b0 (4 from disjoint axis) = docs 0, 2. + result.TotalCount.ShouldBe(2); + var returnedNames = result.DocumentsJson + .Select(json => JsonDocument.Parse(json).RootElement.GetProperty("Name").GetString()) + .ToList(); + returnedNames.ShouldBe(new[] { NameForIndex(0), NameForIndex(2) }, ignoreOrder: true); + } + + [Fact] + public async Task IdEquals_and_metadata_filters_compose_with_AND() + { + // Existing IdEquals filter conjoins with the new metadata filters — verify that the + // implementation truly ANDs them rather than overwriting either side. + const string schema = "diag4791_id_and_meta"; + using var host = await BuildHostWithAllMetadataEnabled(schema); + await seedMatrixOfEightDocs(host); + + var store = host.Services.GetRequiredService(); + await using var session = store.QuerySession(); + var doc0 = await session.Query().FirstAsync(x => x.Name == NameForIndex(0)); + + var diagnostics = host.Services.GetRequiredService(); + + // Target doc 0 by id AND by all three matching metadata values — should return that single row. + var hit = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50, IdEquals: doc0.Id.ToString()) + { + CorrelationId = "c0", + CausationId = "u0", + LastModifiedBy = "b0" + }, + CancellationToken.None); + hit.TotalCount.ShouldBe(1); + + // Same id but with a metadata value that doesn't match doc 0 → empty result. + var miss = await diagnostics.QueryDocumentsAsync( + typeof(DiagMetaDoc).FullName!, + new DocumentQueryOptions(PageNumber: 1, PageSize: 50, IdEquals: doc0.Id.ToString()) + { + CorrelationId = "c1" // doc 0 has c0 + }, + CancellationToken.None); + miss.TotalCount.ShouldBe(0); + } + + private static Task BuildHostWithAllMetadataEnabled(string schema) => + BuildHost(schema, enableCorr: true, enableCaus: true, enableLmb: true); + + private static async Task BuildHost(string schema, bool enableCorr, bool enableCaus, bool enableLmb) + { + await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString)) + { + await conn.OpenAsync(); + await conn.DropSchemaAsync(schema); + await conn.CloseAsync(); + } + + return await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = schema; + + opts.Schema.For().Metadata(m => + { + if (enableCorr) m.CorrelationId.Enabled = true; + if (enableCaus) m.CausationId.Enabled = true; + if (enableLmb) m.LastModifiedBy.Enabled = true; + }); + }); + }) + .StartAsync(); + } + + /// + /// Seed 8 docs with metadata covering every (correlation × causation × last_modified_by) combo. + /// Each doc's Name encodes its index so tests can assert exact row identity. A separate session + /// per doc lets each carry its own session-scoped metadata (CorrelationId / CausationId / + /// LastModifiedBy are session properties applied at SaveChanges time). + /// + private static async Task seedMatrixOfEightDocs(IHost host) + { + var store = host.Services.GetRequiredService(); + + for (var i = 0; i < 8; i++) + { + await using var session = store.LightweightSession(); + session.CorrelationId = (i & 0b100) == 0 ? "c0" : "c1"; + session.CausationId = (i & 0b010) == 0 ? "u0" : "u1"; + session.LastModifiedBy = (i & 0b001) == 0 ? "b0" : "b1"; + + session.Store(new DiagMetaDoc { Id = Guid.NewGuid(), Name = NameForIndex(i) }); + await session.SaveChangesAsync(); + } + } + + private static string NameForIndex(int i) => $"doc-{i}"; +} + +public class DiagMetaDoc +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; +} diff --git a/src/DocumentDbTests/Metadata/event_diagnostics_metadata_filter_tests.cs b/src/DocumentDbTests/Metadata/event_diagnostics_metadata_filter_tests.cs new file mode 100644 index 0000000000..8dceab4a6b --- /dev/null +++ b/src/DocumentDbTests/Metadata/event_diagnostics_metadata_filter_tests.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using Marten; +using Marten.Events; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Shouldly; +using Weasel.Postgresql; +using Xunit; + +namespace DocumentDbTests.Metadata; + +/// +/// Coverage for marten#4791 / JasperFx/CritterWatch#629 — exact-match metadata filters +/// on 's . +/// Each of the three new filters (, +/// , ) is honored only +/// when both the option is set AND the event store has the corresponding metadata column +/// enabled. When set but disabled, the filter is silently skipped — the LINQ member +/// registration in EventQueryMapping is gated by the same flag, so a Where on a +/// disabled member would fail to translate at runtime if we didn't skip it here. +/// +/// Seeding strategy mirrors the doc-filter suite: 8 events, each one appended in its own +/// session so each carries distinct (CorrelationId / CausationId / UserName) session-scoped +/// metadata. Indexed 0..7 by the (corr, caus, user) binary tuple. +/// +public class event_diagnostics_metadata_filter_tests +{ + [Theory] + // (corrFilter, causFilter, userFilter, expectedEventIndices) + [InlineData(null, null, null, new[] { 0, 1, 2, 3, 4, 5, 6, 7 })] // no filter: every event + [InlineData("c0", null, null, new[] { 0, 1, 2, 3 })] // corr=c0 only: 4 events + [InlineData(null, "u0", null, new[] { 0, 1, 4, 5 })] // caus=u0 only: 4 events + [InlineData(null, null, "n0", new[] { 0, 2, 4, 6 })] // user=n0 only: 4 events + [InlineData("c0", "u0", null, new[] { 0, 1 })] // corr+caus + [InlineData("c0", null, "n0", new[] { 0, 2 })] // corr+user + [InlineData(null, "u0", "n0", new[] { 0, 4 })] // caus+user + [InlineData("c0", "u0", "n0", new[] { 0 })] // all three: one event + public async Task every_filter_combo_returns_the_expected_subset( + string? corr, string? caus, string? user, int[] expectedIndices) + { + const string schema = "diag4791_evt_combo"; + using var host = await BuildHostWithAllMetadataEnabled(schema); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + var result = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + CorrelationId = corr, + CausationId = caus, + UserName = user + }, CancellationToken.None); + + result.TotalCount.ShouldBe(expectedIndices.Length); + var returnedIndices = result.Events + .Select(e => ((EventMetaPayload)e.Data).Index) + .OrderBy(i => i) + .ToList(); + returnedIndices.ShouldBe(expectedIndices.OrderBy(i => i).ToList()); + } + + [Fact] + public async Task filter_on_an_unmatched_value_returns_zero_events() + { + const string schema = "diag4791_evt_unmatched"; + using var host = await BuildHostWithAllMetadataEnabled(schema); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + var result = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + CorrelationId = "no-such-correlation" + }, CancellationToken.None); + + result.TotalCount.ShouldBe(0); + result.Events.Count.ShouldBe(0); + } + + [Fact] + public async Task filter_is_silently_ignored_when_correlation_id_column_is_disabled() + { + const string schema = "diag4791_evt_corr_off"; + using var host = await BuildHost(schema, enableCorr: false, enableCaus: true, enableUser: true); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + var result = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + CorrelationId = "c0" // would otherwise narrow to 4 events + }, CancellationToken.None); + + result.TotalCount.ShouldBe(8); + } + + [Fact] + public async Task filter_is_silently_ignored_when_causation_id_column_is_disabled() + { + const string schema = "diag4791_evt_caus_off"; + using var host = await BuildHost(schema, enableCorr: true, enableCaus: false, enableUser: true); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + var result = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + CausationId = "u0" + }, CancellationToken.None); + + result.TotalCount.ShouldBe(8); + } + + [Fact] + public async Task filter_is_silently_ignored_when_user_name_column_is_disabled() + { + const string schema = "diag4791_evt_user_off"; + using var host = await BuildHost(schema, enableCorr: true, enableCaus: true, enableUser: false); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + var result = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + UserName = "n0" + }, CancellationToken.None); + + result.TotalCount.ShouldBe(8); + } + + [Fact] + public async Task filter_set_alongside_an_enabled_filter_when_a_column_is_disabled_still_narrows_via_the_enabled_one() + { + const string schema = "diag4791_evt_mixed"; + using var host = await BuildHost(schema, enableCorr: true, enableCaus: false, enableUser: true); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + var result = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + CorrelationId = "c0", // honored — 4 events + CausationId = "u0", // silently ignored — column disabled + UserName = "n0" // honored — narrows to 2 + }, CancellationToken.None); + + // corr=c0 (events 0,1,2,3) ∩ user=n0 (events 0,2,4,6) = events 0, 2. + result.TotalCount.ShouldBe(2); + var returnedIndices = result.Events + .Select(e => ((EventMetaPayload)e.Data).Index) + .OrderBy(i => i) + .ToList(); + returnedIndices.ShouldBe(new[] { 0, 2 }); + } + + [Fact] + public async Task existing_EventTypeName_and_StreamKey_filters_compose_with_AND_metadata_filters() + { + // The new metadata filters layer onto the existing EventTypeName + StreamKey filters + // — verify the implementation ANDs them rather than overwriting either side. + const string schema = "diag4791_evt_compose"; + using var host = await BuildHostWithAllMetadataEnabled(schema); + await seedMatrixOfEightEvents(host); + + var store = host.Services.GetRequiredService(); + var readStore = ((IEventStore)store).OpenReadOnlyEventStore(); + + // Narrow by both event type AND CorrelationId=c0 — both should apply, returning 4 events. + var typeAndCorr = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + EventTypeName = "event_meta_payload", + CorrelationId = "c0" + }, CancellationToken.None); + typeAndCorr.TotalCount.ShouldBe(4); + + // Same as above but with an event type that no seeded event matches → empty. + var noSuchType = await readStore.QueryEventsAsync(new EventQuery + { + PageNumber = 1, + PageSize = 50, + EventTypeName = "not_a_real_event", + CorrelationId = "c0" + }, CancellationToken.None); + noSuchType.TotalCount.ShouldBe(0); + } + + private static Task BuildHostWithAllMetadataEnabled(string schema) => + BuildHost(schema, enableCorr: true, enableCaus: true, enableUser: true); + + private static async Task BuildHost(string schema, bool enableCorr, bool enableCaus, bool enableUser) + { + await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString)) + { + await conn.OpenAsync(); + await conn.DropSchemaAsync(schema); + await conn.CloseAsync(); + } + + return await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = schema; + opts.Events.StreamIdentity = StreamIdentity.AsString; + + if (enableCorr) opts.Events.MetadataConfig.CorrelationIdEnabled = true; + if (enableCaus) opts.Events.MetadataConfig.CausationIdEnabled = true; + if (enableUser) opts.Events.MetadataConfig.UserNameEnabled = true; + + opts.Events.AddEventType(); + }); + }) + .StartAsync(); + } + + /// + /// Append 8 events whose metadata cover every (correlation × causation × user) combo. + /// One session per event so each carries its own session-scoped metadata at SaveChanges time. + /// Events share one stream — order doesn't matter because filters dispatch on metadata columns, + /// not stream position. + /// + private static async Task seedMatrixOfEightEvents(IHost host) + { + var store = host.Services.GetRequiredService(); + var streamKey = "metadata-matrix-" + Guid.NewGuid().ToString("N"); + + for (var i = 0; i < 8; i++) + { + await using var session = store.LightweightSession(); + session.CorrelationId = (i & 0b100) == 0 ? "c0" : "c1"; + session.CausationId = (i & 0b010) == 0 ? "u0" : "u1"; + session.LastModifiedBy = (i & 0b001) == 0 ? "n0" : "n1"; + + session.Events.Append(streamKey, new EventMetaPayload { Index = i }); + await session.SaveChangesAsync(); + } + } +} + +public class EventMetaPayload +{ + public int Index { get; set; } +} diff --git a/src/Marten/DocumentStore.DocumentDiagnostics.cs b/src/Marten/DocumentStore.DocumentDiagnostics.cs index af1e9e8bb7..8be09eed6d 100644 --- a/src/Marten/DocumentStore.DocumentDiagnostics.cs +++ b/src/Marten/DocumentStore.DocumentDiagnostics.cs @@ -9,6 +9,7 @@ using JasperFx.Documents; using JasperFx.MultiTenancy; using Marten.Schema; +using Marten.Storage.Metadata; namespace Marten; @@ -64,6 +65,21 @@ async Task IDocumentStoreDiagnostics.QueryDocumentsAsync( var conditions = new List(); if (options.IdEquals != null) conditions.Add("id::text = @id"); if (filterByTenant) conditions.Add("tenant_id = @tenant"); + + // #4791 / CritterWatch #629: exact-match filters on the document's metadata columns + // (correlation_id, causation_id, last_modified_by). A filter is honored only when both + // the option is set AND the document mapping has the corresponding metadata column + // enabled — emitting a WHERE on a column the table doesn't have would throw + // 42703 (undefined_column). When the option is set but the column is disabled we + // silently skip the filter (per the JasperFx DocumentQueryOptions contract: "Only honored + // when the store advertises and captures the metadata column; otherwise ignored"). + var filterByCorrelationId = options.CorrelationId != null && mapping.Metadata.CorrelationId.Enabled; + var filterByCausationId = options.CausationId != null && mapping.Metadata.CausationId.Enabled; + var filterByLastModifiedBy = options.LastModifiedBy != null && mapping.Metadata.LastModifiedBy.Enabled; + if (filterByCorrelationId) conditions.Add($"{CorrelationIdColumn.ColumnName} = @corr"); + if (filterByCausationId) conditions.Add($"{CausationIdColumn.ColumnName} = @caus"); + if (filterByLastModifiedBy) conditions.Add($"{LastModifiedByColumn.ColumnName} = @lmb"); + var where = conditions.Count > 0 ? " where " + string.Join(" and ", conditions) : ""; await using var conn = database.CreateConnection(); @@ -73,14 +89,7 @@ async Task IDocumentStoreDiagnostics.QueryDocumentsAsync( await using (var countCmd = conn.CreateCommand()) { countCmd.CommandText = $"select count(*) from {table}{where}"; - if (options.IdEquals != null) - { - countCmd.Parameters.AddWithValue("id", options.IdEquals); - } - if (filterByTenant) - { - countCmd.Parameters.AddWithValue("tenant", options.TenantId!); - } + bindFilterParameters(countCmd); total = Convert.ToInt64(await countCmd.ExecuteScalarAsync(token).ConfigureAwait(false)); } @@ -90,14 +99,7 @@ async Task IDocumentStoreDiagnostics.QueryDocumentsAsync( { // data::text guarantees the jsonb column comes back as JSON text. cmd.CommandText = $"select data::text from {table}{where} order by id limit {pageSize} offset {offset}"; - if (options.IdEquals != null) - { - cmd.Parameters.AddWithValue("id", options.IdEquals); - } - if (filterByTenant) - { - cmd.Parameters.AddWithValue("tenant", options.TenantId!); - } + bindFilterParameters(cmd); await using var reader = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); while (await reader.ReadAsync(token).ConfigureAwait(false)) @@ -107,6 +109,15 @@ async Task IDocumentStoreDiagnostics.QueryDocumentsAsync( } return new DocumentQueryResult(rows, total, pageNumber, pageSize); + + void bindFilterParameters(Npgsql.NpgsqlCommand cmd) + { + if (options.IdEquals != null) cmd.Parameters.AddWithValue("id", options.IdEquals); + if (filterByTenant) cmd.Parameters.AddWithValue("tenant", options.TenantId!); + if (filterByCorrelationId) cmd.Parameters.AddWithValue("corr", options.CorrelationId!); + if (filterByCausationId) cmd.Parameters.AddWithValue("caus", options.CausationId!); + if (filterByLastModifiedBy) cmd.Parameters.AddWithValue("lmb", options.LastModifiedBy!); + } } async Task IDocumentStoreDiagnostics.LoadDocumentJsonAsync( diff --git a/src/Marten/Events/QueryEventStore.cs b/src/Marten/Events/QueryEventStore.cs index 92ee941e1d..23a1c786a8 100644 --- a/src/Marten/Events/QueryEventStore.cs +++ b/src/Marten/Events/QueryEventStore.cs @@ -267,6 +267,30 @@ public async Task QueryEventsAsync(EventQuery query, CancellationTo } } + // #4791 / CritterWatch #629: exact-match filters on the event's metadata columns + // (correlation_id, causation_id, user_name). A filter is honored only when both the + // EventQuery option is set AND the event store has the corresponding metadata column + // enabled — the LINQ member registration in EventQueryMapping is itself gated by the + // Metadata.X.Enabled flag (see EventQueryMapping.cs:46-59), so a Where on a disabled + // member would fail to translate. We mirror that gate here to silently skip the filter + // (per the JasperFx EventQuery contract: "Only honored when the store advertises and + // captures the metadata column"). + var eventMetadata = _store.Events.Metadata; + if (query.CorrelationId != null && eventMetadata.CorrelationId.Enabled) + { + queryable = (IMartenQueryable)queryable.Where(e => e.CorrelationId == query.CorrelationId); + } + + if (query.CausationId != null && eventMetadata.CausationId.Enabled) + { + queryable = (IMartenQueryable)queryable.Where(e => e.CausationId == query.CausationId); + } + + if (query.UserName != null && eventMetadata.UserName.Enabled) + { + queryable = (IMartenQueryable)queryable.Where(e => e.UserName == query.UserName); + } + var totalCount = await queryable.CountAsync(token).ConfigureAwait(false); var pageNumber = query.PageNumber <= 0 ? 1 : query.PageNumber;