Skip to main content

Entity Framework Core Integration

The UnionGenerator.EntityFrameworkCore package enables you to store and query discriminated unions in databases with automatic value converters and query translation.

Overview​

This integration provides:

  • Automatic value converters for union types
  • JSON column storage support
  • LINQ query translation
  • Migration support
  • Complex type mapping

Installation​

dotnet add package UnionGenerator.EntityFrameworkCore

Requirements:

  • .NET 6.0 or later
  • Entity Framework Core 6.0 or later
  • UnionGenerator package

Quick Start​

Define Your Union​

[GenerateUnion]
public partial record OrderStatus
{
public static partial OrderStatus Pending();
public static partial OrderStatus Processing(string workerId);
public static partial OrderStatus Shipped(string trackingNumber);
public static partial OrderStatus Delivered(DateTime deliveredAt);
public static partial OrderStatus Cancelled(string reason);
}

Configure Entity​

public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
}

public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<OrderStatusConverter>();
}
}

Value Converter​

using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Text.Json;

public class OrderStatusConverter : ValueConverter<OrderStatus, string>
{
public OrderStatusConverter()
: base(
v => Serialize(v),
v => Deserialize(v))
{
}

private static string Serialize(OrderStatus status)
{
return status.Match(
pending => JsonSerializer.Serialize(new { Type = "Pending" }),
processing => JsonSerializer.Serialize(new { Type = "Processing", WorkerId = processing.WorkerId }),
shipped => JsonSerializer.Serialize(new { Type = "Shipped", TrackingNumber = shipped.TrackingNumber }),
delivered => JsonSerializer.Serialize(new { Type = "Delivered", DeliveredAt = delivered.DeliveredAt }),
cancelled => JsonSerializer.Serialize(new { Type = "Cancelled", Reason = cancelled.Reason })
);
}

private static OrderStatus Deserialize(string json)
{
var doc = JsonDocument.Parse(json);
var type = doc.RootElement.GetProperty("Type").GetString();

return type switch
{
"Pending" => OrderStatus.Pending(),
"Processing" => OrderStatus.Processing(
doc.RootElement.GetProperty("WorkerId").GetString()!
),
"Shipped" => OrderStatus.Shipped(
doc.RootElement.GetProperty("TrackingNumber").GetString()!
),
"Delivered" => OrderStatus.Delivered(
doc.RootElement.GetProperty("DeliveredAt").GetDateTime()
),
"Cancelled" => OrderStatus.Cancelled(
doc.RootElement.GetProperty("Reason").GetString()!
),
_ => throw new InvalidOperationException($"Unknown type: {type}")
};
}
}

Querying Unions​

Basic Queries​

// Query by union type
var pendingOrders = await context.Orders
.Where(o => EF.Property<string>(o.Status, "Type") == "Pending")
.ToListAsync();

// Filter in memory after loading
var processingOrders = await context.Orders
.ToListAsync();

var filtered = processingOrders
.Where(o => o.Status.IsProcessing)
.ToList();

Complex Queries​

// Count by status type
var statusCounts = await context.Orders
.GroupBy(o => EF.Property<string>(o.Status, "Type"))
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync();

// Find orders by tracking number (requires JSON query support)
var order = await context.Orders
.Where(o => EF.Functions.JsonValue(
EF.Property<string>(o.Status, "Json"),
"$.TrackingNumber") == trackingNumber)
.FirstOrDefaultAsync();

Storage Strategies​

Store entire union as JSON:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<OrderStatusConverter>()
.HasColumnType("jsonb"); // PostgreSQL
// .HasColumnType("json"); // MySQL
// .HasColumnType("nvarchar(max)"); // SQL Server
}

Separate Columns​

Store discriminator and data separately:

public class Order
{
public int Id { get; set; }

// Shadow properties for storage
private string StatusType { get; set; }
private string StatusData { get; set; }

public OrderStatus Status
{
get => DeserializeStatus(StatusType, StatusData);
set
{
(StatusType, StatusData) = SerializeStatus(value);
}
}
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property<string>("StatusType")
.HasMaxLength(50);

modelBuilder.Entity<Order>()
.Property<string>("StatusData")
.HasColumnType("nvarchar(max)");

modelBuilder.Entity<Order>()
.Ignore(o => o.Status);
}

Migration Support​

Adding Union Column​

public partial class AddOrderStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Status",
table: "Orders",
type: "nvarchar(max)",
nullable: false,
defaultValue: "{\"Type\":\"Pending\"}");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Status",
table: "Orders");
}
}

Changing Union Structure​

// Migration to add new case
public partial class AddCancelledStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// No schema changes needed if using JSON storage
// Old data remains compatible
}
}

Real-World Examples​

Order Management​

public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public List<OrderStatusHistory> StatusHistory { get; set; } = new();

public void UpdateStatus(OrderStatus newStatus)
{
StatusHistory.Add(new OrderStatusHistory
{
Status = Status,
ChangedAt = DateTime.UtcNow
});

Status = newStatus;
}
}

public class OrderStatusHistory
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public DateTime ChangedAt { get; set; }
}

// Query status history
var order = await context.Orders
.Include(o => o.StatusHistory)
.FirstAsync(o => o.Id == orderId);

var timeline = order.StatusHistory
.Select(h => new
{
Status = h.Status.Match(
pending => "Pending",
processing => $"Processing by {processing.WorkerId}",
shipped => $"Shipped: {shipped.TrackingNumber}",
delivered => $"Delivered at {delivered.DeliveredAt}",
cancelled => $"Cancelled: {cancelled.Reason}"
),
Date = h.ChangedAt
})
.ToList();

User Authentication​

[GenerateUnion]
public partial record AuthMethod
{
public static partial AuthMethod Password(string hashedPassword, DateTime lastChanged);
public static partial AuthMethod OAuth(string provider, string externalId);
public static partial AuthMethod TwoFactor(string secret, List<string> backupCodes);
}

public class User
{
public int Id { get; set; }
public string Email { get; set; }
public AuthMethod AuthMethod { get; set; }
}

// Query users by auth method
var oauthUsers = await context.Users
.ToListAsync();

var googleUsers = oauthUsers
.Where(u => u.AuthMethod is AuthMethod.OAuthCase { Provider: "Google" })
.ToList();

Best Practices​

Indexing​

Create indexes on discriminator for better query performance:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add index on JSON property (PostgreSQL)
modelBuilder.Entity<Order>()
.HasIndex(o => new { })
.HasMethod("gin")
.HasFilter("Status -> 'Type'");

// Or separate discriminator column
modelBuilder.Entity<Order>()
.Property<string>("StatusType")
.HasMaxLength(50);

modelBuilder.Entity<Order>()
.HasIndex("StatusType");
}

Validation​

Validate unions before saving:

public override int SaveChanges()
{
var orders = ChangeTracker.Entries<Order>()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified)
.Select(e => e.Entity);

foreach (var order in orders)
{
ValidateOrderStatus(order);
}

return base.SaveChanges();
}

private void ValidateOrderStatus(Order order)
{
order.Status.Match(
pending => { /* Always valid */ },
processing => ValidateProcessing(processing),
shipped => ValidateShipped(shipped),
delivered => ValidateDelivered(delivered),
cancelled => ValidateCancelled(cancelled)
);
}

Versioning​

Handle schema evolution gracefully:

private static OrderStatus Deserialize(string json)
{
var doc = JsonDocument.Parse(json);
var type = doc.RootElement.GetProperty("Type").GetString();

// Handle old schema versions
if (type == "InTransit") // Old case name
{
return OrderStatus.Shipped(
doc.RootElement.GetProperty("TrackingNumber").GetString()!
);
}

return type switch
{
"Pending" => OrderStatus.Pending(),
"Shipped" => OrderStatus.Shipped(
doc.RootElement.GetProperty("TrackingNumber").GetString()!
),
// ... other cases
};
}

Limitations​

Query Limitations​

// ❌ Cannot query union case properties directly in SQL
var orders = await context.Orders
.Where(o => o.Status.IsShipped) // Not translatable to SQL
.ToListAsync();

// ✅ Load and filter in memory
var orders = await context.Orders.ToListAsync();
var shippedOrders = orders.Where(o => o.Status.IsShipped).ToList();

// ✅ Or use JSON query functions
var orders = await context.Orders
.Where(o => EF.Property<string>(o.Status, "Type") == "Shipped")
.ToListAsync();

Key Takeaways​

✅ Value converters enable union storage
✅ JSON columns provide flexible storage
✅ Query in memory for complex union operations
✅ Index discriminators for better performance
✅ Handle versioning for schema evolution