diff --git a/Directory.Packages.props b/Directory.Packages.props index 33ef3df7f2..f5470a21fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -61,8 +61,8 @@ - - + + diff --git a/src/Marten.EntityFrameworkCore.Tests/EfCoreToJsonTests.cs b/src/Marten.EntityFrameworkCore.Tests/EfCoreToJsonTests.cs new file mode 100644 index 0000000000..fb98c30689 --- /dev/null +++ b/src/Marten.EntityFrameworkCore.Tests/EfCoreToJsonTests.cs @@ -0,0 +1,232 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using JasperFx; +using Marten.Testing.Harness; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; +using Weasel.EntityFrameworkCore; +using Weasel.Postgresql.Tables; +using Xunit; + +namespace Marten.EntityFrameworkCore.Tests; + +#region Entities + +/// +/// Owned type that will be stored as a JSON column via ToJson(). +/// +public class ShippingAddress +{ + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; +} + +/// +/// Optional owned type mapped via ToJson() to verify nullable JSON columns. +/// +public class OrderMetadata +{ + public string? Source { get; set; } + public string? CouponCode { get; set; } + public int LoyaltyPoints { get; set; } +} + +/// +/// Entity with an owned type mapped to a JSON column via OwnsOne().ToJson(). +/// +public class CustomerOrder +{ + public Guid Id { get; set; } + public string CustomerName { get; set; } = string.Empty; + public decimal Amount { get; set; } + public ShippingAddress ShippingAddress { get; set; } = new(); + public OrderMetadata? Metadata { get; set; } +} + +#endregion + +#region DbContext + +/// +/// DbContext using OwnsOne().ToJson() to map owned types to JSON columns. +/// Reproduces https://github.com/JasperFx/weasel/issues/232 +/// +public class ToJsonDbContext : DbContext +{ + public const string EfSchema = "ef_tojson_test"; + + public ToJsonDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet CustomerOrders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(EfSchema); + + modelBuilder.Entity(entity => + { + entity.ToTable("customer_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.Amount).HasColumnName("amount"); + + // Map owned types to JSON columns - this is the scenario from GH-232 + entity.OwnsOne(e => e.ShippingAddress, b => + { + b.ToJson("shipping_address"); + }); + + entity.OwnsOne(e => e.Metadata, b => + { + b.ToJson("metadata"); + }); + }); + } +} + +#endregion + +/// +/// Tests verifying that EF Core OwnsOne().ToJson() JSON column mapping is correctly +/// picked up by Weasel's schema migration pipeline via AddEntityTablesFromDbContext. +/// See https://github.com/JasperFx/weasel/issues/232 +/// +public class EfCoreToJsonTests +{ + [Fact] + public void should_map_json_columns_from_owned_entities_with_to_json() + { + // GH-232: OwnsOne().ToJson() owned entities should produce JSON columns + // in the Weasel table definition. + using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "tojson_test"; + + opts.AddEntityTablesFromDbContext(); + }); + + var extendedObjects = store.Options.Storage.ExtendedSchemaObjects; + + var customerOrdersTable = extendedObjects.OfType() + .FirstOrDefault(t => t.Identifier.Name == "customer_orders"); + + customerOrdersTable.ShouldNotBeNull("customer_orders table should be registered"); + + // Verify the JSON columns are present + var columns = customerOrdersTable.Columns.Select(c => c.Name).ToList(); + columns.ShouldContain("shipping_address", + "shipping_address JSON column should be mapped from OwnsOne().ToJson()"); + columns.ShouldContain("metadata", + "metadata JSON column should be mapped from OwnsOne().ToJson()"); + } + + [Fact] + public void json_columns_should_be_jsonb_type() + { + using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "tojson_test"; + + opts.AddEntityTablesFromDbContext(); + }); + + var extendedObjects = store.Options.Storage.ExtendedSchemaObjects; + + var customerOrdersTable = extendedObjects.OfType
() + .FirstOrDefault(t => t.Identifier.Name == "customer_orders"); + + customerOrdersTable.ShouldNotBeNull(); + + var shippingCol = customerOrdersTable.Columns.FirstOrDefault(c => c.Name == "shipping_address"); + shippingCol.ShouldNotBeNull(); + shippingCol.Type.ShouldBe("jsonb", + "JSON columns from ToJson() should default to jsonb on PostgreSQL"); + + var metadataCol = customerOrdersTable.Columns.FirstOrDefault(c => c.Name == "metadata"); + metadataCol.ShouldNotBeNull(); + metadataCol.Type.ShouldBe("jsonb"); + } + + [Fact] + public async Task can_apply_migration_with_json_columns() + { + const string testSchema = "ef_tojson_migration"; + + // Clean up any previous test schema + await using var cleanupConn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await cleanupConn.OpenAsync(); + await using (var cmd = cleanupConn.CreateCommand()) + { + cmd.CommandText = $"DROP SCHEMA IF EXISTS {testSchema} CASCADE"; + await cmd.ExecuteNonQueryAsync(); + cmd.CommandText = $"DROP SCHEMA IF EXISTS {ToJsonDbContext.EfSchema} CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } + + await cleanupConn.CloseAsync(); + + try + { + await using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = testSchema; + opts.AutoCreateSchemaObjects = AutoCreate.All; + + opts.AddEntityTablesFromDbContext(b => + { + b.UseNpgsql("Host=localhost"); + }); + }); + + // This triggers schema creation - should succeed with JSON columns + await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + + // Verify the table was created with JSON columns + await using var verifyConn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await verifyConn.OpenAsync(); + await using var verifyCmd = verifyConn.CreateCommand(); + verifyCmd.CommandText = @" + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = @schema AND table_name = 'customer_orders' + ORDER BY ordinal_position"; + verifyCmd.Parameters.AddWithValue("schema", ToJsonDbContext.EfSchema); + + var columnMap = new System.Collections.Generic.Dictionary(); + await using var reader = await verifyCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + columnMap[reader.GetString(0)] = reader.GetString(1); + } + + columnMap.ShouldContainKey("shipping_address"); + columnMap["shipping_address"].ShouldBe("jsonb", + "shipping_address column should be jsonb in the actual database"); + + columnMap.ShouldContainKey("metadata"); + columnMap["metadata"].ShouldBe("jsonb", + "metadata column should be jsonb in the actual database"); + } + finally + { + // Clean up + await using var finalConn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await finalConn.OpenAsync(); + await using var finalCmd = finalConn.CreateCommand(); + finalCmd.CommandText = $"DROP SCHEMA IF EXISTS {testSchema} CASCADE"; + await finalCmd.ExecuteNonQueryAsync(); + finalCmd.CommandText = $"DROP SCHEMA IF EXISTS {ToJsonDbContext.EfSchema} CASCADE"; + await finalCmd.ExecuteNonQueryAsync(); + } + } +}