Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/events/projections/flat.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,57 @@ A couple notes on this version of the code:

The `FlatTableProjection` in its first incarnation is not yet able to use event metadata.

### Enums, Nullable Values, and Registered Value Types <Badge type="tip" text="8.x" />

`FlatTableProjection.Map(...)` honors three things on top of the property type
itself:

1. **Enum columns** follow `StoreOptions.Advanced.DuplicatedFieldEnumStorage`,
which defaults to your serializer's `EnumStorage`. Set it explicitly when
you want to lock the column type:

```cs
var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
// Store all duplicated-field-style enums (including FlatTableProjection
// columns) as text. The matching table column should be `varchar`/`text`.
opts.Advanced.DuplicatedFieldEnumStorage = EnumStorage.AsString;

opts.Projections.Add<MyFlatProjection>(ProjectionLifecycle.Inline);
});
```

With `EnumStorage.AsString`, `Map(x => x.Status)` writes `Status.ToString()`
into a text column. With `EnumStorage.AsInteger` (the default for the
built-in JSON serializers), the same call writes the underlying integer
value into an int column. Make sure the column type you declare on
`Table.AddColumn<T>(...)` matches your `DuplicatedFieldEnumStorage` setting.

2. **Nullable enums and nullable value-typed properties** are handled
automatically — when the property value is `null`, Marten writes `DBNull`;
otherwise the same enum / value-type projection rules apply. The target
column needs to be marked `AllowNulls()` (or otherwise be nullable).

3. **Registered value types** (e.g. Vogen-style single-value wrappers
registered through `opts.RegisterValueType<TWrapper>()`) are unwrapped to
their inner primitive automatically. `Map(x => x.MyValueObject)` projects
the value type's inner property into the column without any manual
`cfg.Map(x => x.MyValueObject.Value, "...")` workaround. The column type
should match the wrapped primitive (e.g. `Table.AddColumn<int>(...)` for a
`record struct WrapperId(int Value)`).

::: warning
Prior to Marten 8.x, `FlatTableProjection` ignored
`DuplicatedFieldEnumStorage` and could not handle registered value types or
nullable wrappers. Mapping a `string` enum column threw
`Writing values of '<EnumType>' is not supported for parameters having
NpgsqlDbType 'Integer'`, and mapping a registered value-type property threw
`Can't infer NpgsqlDbType for type <Wrapper>`. See
[#4290](https://github.com/JasperFx/marten/issues/4290) and
[#4291](https://github.com/JasperFx/marten/issues/4291).
:::

### Partial-Mapping Events (Update-Only) <Badge type="tip" text="8.x" />

When an event mapped into a `FlatTableProjection` does not populate every non-primary-key
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
using System;
using System.Threading.Tasks;
using JasperFx.Events.Projections;
using Marten;
using Marten.Events.Projections.Flattened;
using Marten.Testing.Harness;
using Shouldly;
using Weasel.Core;
using Weasel.Postgresql;
using Xunit;

namespace EventSourcingTests.Projections.Flattened;

/// <summary>
/// Regression coverage for:
/// - https://github.com/JasperFx/marten/issues/4291
/// FlatTableProjection mapping enums as strings throws.
/// - https://github.com/JasperFx/marten/issues/4290
/// FlatTableProjection throws when mapping a value object or nullable value
/// object.
///
/// FlatTableProjection now honors StoreOptions.Advanced.DuplicatedFieldEnumStorage
/// for enum columns, supports nullable enums, and auto-unwraps registered
/// value types (and nullable value types) to their inner primitive.
/// </summary>
public class Bug_4290_4291_flat_table_enum_and_value_types : OneOffConfigurationsContext
{
[Fact]
public async Task enum_stored_as_string_when_DuplicatedFieldEnumStorage_is_AsString()
{
StoreOptions(opts =>
{
opts.Advanced.DuplicatedFieldEnumStorage = EnumStorage.AsString;
opts.Projections.Add<Bug4290StatusProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290StatusChanged(Bug4290Status.Active, null));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var statusValue = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select status from {SchemaName}.bug_4290_status where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

statusValue.ShouldBe(Bug4290Status.Active.ToString());
}

[Fact]
public async Task enum_stored_as_int_when_DuplicatedFieldEnumStorage_is_AsInteger()
{
StoreOptions(opts =>
{
opts.Advanced.DuplicatedFieldEnumStorage = EnumStorage.AsInteger;
opts.Projections.Add<Bug4290IntStatusProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290StatusChanged(Bug4290Status.Suspended, null));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var statusValue = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select status from {SchemaName}.bug_4290_int_status where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

statusValue.ShouldBe((int)Bug4290Status.Suspended);
}

[Fact]
public async Task nullable_enum_stored_as_string_with_value_present()
{
StoreOptions(opts =>
{
opts.Advanced.DuplicatedFieldEnumStorage = EnumStorage.AsString;
opts.Projections.Add<Bug4290StatusProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290StatusChanged(Bug4290Status.Active, Bug4290Status.Suspended));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var previous = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select previous_status from {SchemaName}.bug_4290_status where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

previous.ShouldBe(Bug4290Status.Suspended.ToString());
}

[Fact]
public async Task nullable_enum_stored_as_dbnull_when_value_is_null()
{
StoreOptions(opts =>
{
opts.Advanced.DuplicatedFieldEnumStorage = EnumStorage.AsString;
opts.Projections.Add<Bug4290StatusProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290StatusChanged(Bug4290Status.Active, null));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var previous = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select previous_status from {SchemaName}.bug_4290_status where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

previous.ShouldBe(DBNull.Value);
}

[Fact]
public async Task value_type_property_is_auto_unwrapped_to_inner_primitive()
{
StoreOptions(opts =>
{
opts.RegisterValueType(typeof(Bug4290LegacyId));
opts.Projections.Add<Bug4290LegacyProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290LegacyAssigned(new Bug4290LegacyId(42), null));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var primaryValue = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select primary_id from {SchemaName}.bug_4290_legacy where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

primaryValue.ShouldBe(42);
}

[Fact]
public async Task nullable_value_type_with_value_unwraps_to_inner_primitive()
{
StoreOptions(opts =>
{
opts.RegisterValueType(typeof(Bug4290LegacyId));
opts.Projections.Add<Bug4290LegacyProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290LegacyAssigned(new Bug4290LegacyId(7), new Bug4290LegacyId(99)));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var secondaryValue = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select secondary_id from {SchemaName}.bug_4290_legacy where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

secondaryValue.ShouldBe(99);
}

[Fact]
public async Task nullable_value_type_with_null_writes_dbnull()
{
StoreOptions(opts =>
{
opts.RegisterValueType(typeof(Bug4290LegacyId));
opts.Projections.Add<Bug4290LegacyProjection>(ProjectionLifecycle.Inline);
});

var streamId = Guid.NewGuid();
theSession.Events.Append(streamId,
new Bug4290LegacyAssigned(new Bug4290LegacyId(7), null));
await theSession.SaveChangesAsync();

await using var conn = theStore.Storage.Database.CreateConnection();
await conn.OpenAsync();

var secondaryValue = await Weasel.Core.CommandExtensions
.CreateCommand(conn,
$"select secondary_id from {SchemaName}.bug_4290_legacy where id = :id")
.With("id", streamId)
.ExecuteScalarAsync();

secondaryValue.ShouldBe(DBNull.Value);
}
}

// ─────────────────────────── fixtures ───────────────────────────

public enum Bug4290Status
{
Active,
Suspended,
Closed
}

public record Bug4290StatusChanged(Bug4290Status Status, Bug4290Status? PreviousStatus);

// Mirrors the Vogen-style "single-value wrapper struct" pattern from #4290 with
// a single public Value property over an int. Registered with Marten via
// opts.RegisterValueType(typeof(Bug4290LegacyId)).
public readonly struct Bug4290LegacyId
{
public Bug4290LegacyId(int value) => Value = value;
public int Value { get; }
}

public record Bug4290LegacyAssigned(Bug4290LegacyId PrimaryId, Bug4290LegacyId? SecondaryId);

// Status column is text — should accept enum-as-string when configured so
public class Bug4290StatusProjection : FlatTableProjection
{
public Bug4290StatusProjection() : base("bug_4290_status", SchemaNameSource.EventSchema)
{
Table.AddColumn<Guid>("id").AsPrimaryKey();
Table.AddColumn<string>("status");
Table.AddColumn<string>("previous_status").AllowNulls();

Project<Bug4290StatusChanged>(map =>
{
map.Map(x => x.Status);
map.Map(x => x.PreviousStatus);
});
}
}

// Status column is int — should accept enum-as-int when configured so
public class Bug4290IntStatusProjection : FlatTableProjection
{
public Bug4290IntStatusProjection() : base("bug_4290_int_status", SchemaNameSource.EventSchema)
{
Table.AddColumn<Guid>("id").AsPrimaryKey();
Table.AddColumn<int>("status");

Project<Bug4290StatusChanged>(map =>
{
map.Map(x => x.Status);
});
}
}

public class Bug4290LegacyProjection : FlatTableProjection
{
public Bug4290LegacyProjection() : base("bug_4290_legacy", SchemaNameSource.EventSchema)
{
Table.AddColumn<Guid>("id").AsPrimaryKey();
Table.AddColumn<int>("primary_id");
Table.AddColumn<int>("secondary_id").AllowNulls();

Project<Bug4290LegacyAssigned>(map =>
{
map.Map(x => x.PrimaryId);
map.Map(x => x.SecondaryId);
});
}
}
14 changes: 8 additions & 6 deletions src/Marten/Events/Projections/Flattened/EventDeleter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,26 @@ internal class EventDeleter<T> : IEventHandler
{
private string _sql;
private IParameterSetter<IEvent>? _parameter;
private readonly MemberInfo[] _pkMembers;

public EventDeleter(MemberInfo[] members, Table table)
{
if (members.Length != 0)
{
_parameter = FlatTableProjection.BuildPrimaryKeySetter<T>(members);
}
_pkMembers = members;
}

public void Compile(EventGraph events, Table table)
{
if (_parameter == null)
if (_pkMembers.Length != 0)
{
_parameter = FlatTableProjection.BuildPrimaryKeySetter<T>(_pkMembers, events.Options);
}
else
{
_parameter = events.StreamIdentity == StreamIdentity.AsGuid
? (IParameterSetter<IEvent>)new ParameterSetter<IEvent, Guid>(e => e.StreamId)
: new ParameterSetter<IEvent, string>(e => e.StreamKey);

}

_sql = $"delete from {table.Identifier} where {table.PrimaryKeyColumns[0]} = ?";
}

Expand Down
Loading
Loading