diff --git a/src/Weasel.EntityFrameworkCore.Tests/JsonColumnEntities.cs b/src/Weasel.EntityFrameworkCore.Tests/JsonColumnEntities.cs new file mode 100644 index 0000000..30f5137 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/JsonColumnEntities.cs @@ -0,0 +1,17 @@ +namespace Weasel.EntityFrameworkCore.Tests; + +public class EntityWithJsonColumn +{ + public Guid Id { get; set; } + public string InternalName { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ExtendedProperties ExtendedProperties { get; set; } = new(); +} + +public class ExtendedProperties +{ + public string? Theme { get; set; } + public string? Language { get; set; } + public int MaxItems { get; set; } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/JsonColumnDbContext.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/JsonColumnDbContext.cs new file mode 100644 index 0000000..a8da02c --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/JsonColumnDbContext.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; + +namespace Weasel.EntityFrameworkCore.Tests.Postgresql; + +public class JsonColumnDbContext : DbContext +{ + public const string ConnectionString = "Host=localhost;Port=5432;Database=marten_testing;Username=postgres;Password=postgres"; + + public JsonColumnDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("entities", "ef_json_test"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.InternalName).HasColumnName("internal_name"); + entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(100); + entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(2000); + entity.OwnsOne(e => e.ExtendedProperties, b => + { + b.ToJson("extended_properties"); + }); + }); + } +} diff --git a/src/Weasel.EntityFrameworkCore.Tests/Postgresql/json_column_end_to_end.cs b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/json_column_end_to_end.cs new file mode 100644 index 0000000..e087960 --- /dev/null +++ b/src/Weasel.EntityFrameworkCore.Tests/Postgresql/json_column_end_to_end.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Weasel.Core; +using Weasel.Postgresql; +using Xunit; + +namespace Weasel.EntityFrameworkCore.Tests.Postgresql; + +/// +/// Tests that EF Core entities with OwnsOne().ToJson() mappings +/// produce a JSON column in the Weasel table definition. +/// See https://github.com/JasperFx/weasel/issues/232 +/// +public class json_column_end_to_end : IAsyncLifetime +{ + private IHost _host = null!; + + public async Task InitializeAsync() + { + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddDbContext(options => + options.UseNpgsql(JsonColumnDbContext.ConnectionString)); + + services.AddSingleton(); + }) + .Build(); + + await _host.StartAsync(); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public void should_map_json_column_from_owned_entity_with_to_json() + { + using var scope = _host.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var migrator = scope.ServiceProvider.GetRequiredService(); + + var entityType = context.Model.FindEntityType(typeof(EntityWithJsonColumn)); + entityType.ShouldNotBeNull(); + + var table = migrator.MapToTable(entityType); + + table.ShouldNotBeNull(); + table.Identifier.Name.ShouldBe("entities"); + + // Scalar columns should be mapped + table.HasColumn("id").ShouldBeTrue(); + table.HasColumn("internal_name").ShouldBeTrue(); + table.HasColumn("name").ShouldBeTrue(); + table.HasColumn("description").ShouldBeTrue(); + + // The JSON column from OwnsOne().ToJson() should be mapped + table.HasColumn("extended_properties").ShouldBeTrue("The JSON column 'extended_properties' should be mapped from OwnsOne().ToJson()"); + } + + [Fact] + public void json_column_should_be_jsonb_type() + { + using var scope = _host.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var migrator = scope.ServiceProvider.GetRequiredService(); + + var entityType = context.Model.FindEntityType(typeof(EntityWithJsonColumn)); + entityType.ShouldNotBeNull(); + + var table = migrator.MapToTable(entityType); + + // The JSON column should exist and have the correct type + table.HasColumn("extended_properties").ShouldBeTrue(); + var pgTable = table.ShouldBeOfType(); + var column = pgTable.ColumnFor("extended_properties"); + column.ShouldNotBeNull(); + column.Type.ShouldBe("jsonb"); + } + + [Fact] + public async Task can_apply_migration_with_json_column() + { + using var scope = _host.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Clean up any previous test schema + await context.Database.ExecuteSqlRawAsync("DROP SCHEMA IF EXISTS ef_json_test CASCADE"); + + // Use Weasel to create migration and apply it + var migration = await _host.Services.CreateMigrationAsync(context, CancellationToken.None); + migration.ShouldNotBeNull(); + + await migration.ExecuteAsync(JasperFx.AutoCreate.CreateOrUpdate, CancellationToken.None); + + // Verify data round-trip with JSON column + var entity = new EntityWithJsonColumn + { + Id = Guid.NewGuid(), + InternalName = "test-entity", + Name = "Test Entity", + Description = "A test entity with JSON properties", + ExtendedProperties = new ExtendedProperties + { + Theme = "dark", + Language = "en", + MaxItems = 50 + } + }; + + context.Entities.Add(entity); + await context.SaveChangesAsync(); + + var retrieved = await context.Entities.FindAsync(entity.Id); + retrieved.ShouldNotBeNull(); + retrieved.ExtendedProperties.Theme.ShouldBe("dark"); + retrieved.ExtendedProperties.Language.ShouldBe("en"); + retrieved.ExtendedProperties.MaxItems.ShouldBe(50); + + // Clean up + await context.Database.ExecuteSqlRawAsync("DROP SCHEMA IF EXISTS ef_json_test CASCADE"); + } +} diff --git a/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs b/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs index 31f5ac4..9f7688a 100644 --- a/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs +++ b/src/Weasel.EntityFrameworkCore/DbContextExtensions.cs @@ -355,6 +355,9 @@ public static ITable MapToTable(this Migrator migrator, IEntityType entityType) mapColumn(property, storeObjectIdentifier, primaryKeyPropertyNames, table); } } + + // Add JSON columns from owned entities mapped via OwnsOne().ToJson() + mapJsonColumns(et, addedColumns, table); } // Set primary key constraint name from EF Core metadata. @@ -462,6 +465,22 @@ private static void mapColumn(IProperty property, StoreObjectIdentifier storeObj } } + private static void mapJsonColumns(IEntityType entityType, HashSet addedColumns, ITable table) + { + foreach (var navigation in entityType.GetNavigations()) + { + var targetType = navigation.TargetEntityType; + if (!targetType.IsMappedToJson()) continue; + + var columnName = targetType.GetContainerColumnName(); + if (columnName == null || !addedColumns.Add(columnName)) continue; + + var columnType = targetType.GetContainerColumnType() ?? "jsonb"; + var column = table.AddColumn(columnName, columnType); + column.AllowNulls = !navigation.ForeignKey.IsRequired; + } + } + private static CascadeAction mapDeleteBehavior(DeleteBehavior deleteBehavior) { return deleteBehavior switch