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
40 changes: 40 additions & 0 deletions src/FSharpTypes/Library.fs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,46 @@ let toLinqExpression expr =
|> LeafExpressionConverter.QuotationToExpression
|> stripFSharpFunc
|> unbox<System.Linq.Expressions.Expression<System.Func<Target, bool>>>
// Types for Bug #4182 - F# record projection in GroupJoin/SelectMany
[<CLIMutable>]
type FSharpMembership = {
Id: Guid
UserId: Guid
OrganizationId: Guid
Role: string
AddedOn: DateTimeOffset
}

[<CLIMutable>]
type FSharpUser = {
Id: Guid
FirstName: string
LastName: string
Email: string
}

// CLIMutable version - uses MemberInit expression from C#
[<CLIMutable>]
type FSharpMemberDtoCLIMutable = {
UserId: Guid
FirstName: string
LastName: string
Email: string
Role: string
JoinedOn: DateTimeOffset
}

// Non-CLIMutable version - forces constructor call from C# which
// exposes the camelCase parameter name issue
type FSharpMemberDto = {
UserId: Guid
FirstName: string
LastName: string
Email: string
Role: string
JoinedOn: DateTimeOffset
}

let greaterThanWithFsharpDateOption =
<@ fun (o1: Target) -> o1.FSharpDateTimeOffsetOption >= Some DateTimeOffset.UtcNow @> |> toLinqExpression
let lesserThanWithFsharpDateOption = <@ (fun (o1: Target) -> o1.FSharpDateTimeOffsetOption <= Some DateTimeOffset.UtcNow ) @> |> toLinqExpression
Expand Down
134 changes: 134 additions & 0 deletions src/LinqTests/Bugs/Bug_4182_fsharp_record_projection_casing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using static FSharpTypes;
using Marten;
using Marten.Services;
using Marten.Testing.Harness;
using Shouldly;
using Xunit;

namespace LinqTests.Bugs;

public class Bug_4182_fsharp_record_projection_casing : OneOffConfigurationsContext
{
[Fact]
public async Task GroupJoin_SelectMany_with_fsharp_record_projection_using_stj()
{
var store = StoreOptions(opts =>
{
opts.UseSystemTextJsonForSerialization();
opts.Schema.For<FSharpMembership>();
opts.Schema.For<FSharpUser>();
});

await using var session = store.LightweightSession();

var userId = Guid.NewGuid();
var orgId = Guid.NewGuid();

session.Store(new FSharpUser(
id: userId,
firstName: "Alice",
lastName: "Smith",
email: "alice@example.com"
));

session.Store(new FSharpMembership(
id: Guid.NewGuid(),
userId: userId,
organizationId: orgId,
role: "Admin",
addedOn: DateTimeOffset.UtcNow
));

await session.SaveChangesAsync();

// This query projects into an F# record type (FSharpMemberDto).
// F# records have camelCase constructor params but PascalCase properties.
// Marten's SelectParser uses constructor param names for jsonb_build_object keys,
// which causes STJ deserialization to fail because it expects PascalCase property names.
var results = await session.Query<FSharpMembership>()
.GroupJoin(
session.Query<FSharpUser>(),
m => m.UserId,
u => u.Id,
(m, users) => new { m, users })
.SelectMany(
x => x.users,
(x, u) => new FSharpMemberDto(
x.m.UserId,
u.FirstName,
u.LastName,
u.Email,
x.m.Role,
x.m.AddedOn))
.ToListAsync();

results.Count.ShouldBe(1);
var dto = results.First();
dto.UserId.ShouldBe(userId);
dto.FirstName.ShouldBe("Alice");
dto.LastName.ShouldBe("Smith");
dto.Email.ShouldBe("alice@example.com");
dto.Role.ShouldBe("Admin");
}

[Fact]
public async Task GroupJoin_SelectMany_with_fsharp_record_projection_using_newtonsoft()
{
var store = StoreOptions(opts =>
{
// Default Newtonsoft - should work
opts.Schema.For<FSharpMembership>();
opts.Schema.For<FSharpUser>();
});

await using var session = store.LightweightSession();

var userId = Guid.NewGuid();
var orgId = Guid.NewGuid();

session.Store(new FSharpUser(
id: userId,
firstName: "Alice",
lastName: "Smith",
email: "alice@example.com"
));

session.Store(new FSharpMembership(
id: Guid.NewGuid(),
userId: userId,
organizationId: orgId,
role: "Admin",
addedOn: DateTimeOffset.UtcNow
));

await session.SaveChangesAsync();

var results = await session.Query<FSharpMembership>()
.GroupJoin(
session.Query<FSharpUser>(),
m => m.UserId,
u => u.Id,
(m, users) => new { m, users })
.SelectMany(
x => x.users,
(x, u) => new FSharpMemberDto(
x.m.UserId,
u.FirstName,
u.LastName,
u.Email,
x.m.Role,
x.m.AddedOn))
.ToListAsync();

results.Count.ShouldBe(1);
var dto = results.First();
dto.UserId.ShouldBe(userId);
dto.FirstName.ShouldBe("Alice");
dto.LastName.ShouldBe("Smith");
dto.Email.ShouldBe("alice@example.com");
dto.Role.ShouldBe("Admin");
}
}
2 changes: 1 addition & 1 deletion src/Marten/Linq/Parsing/JoinSelectParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ protected override Expression VisitNew(NewExpression node)
var parameters = node.Constructor.GetParameters();
for (var i = 0; i < parameters.Length; i++)
{
_currentField = parameters[i].Name;
_currentField = SelectParser.ResolveFieldName(node, parameters, i);
Visit(node.Arguments[i]);
}
return node;
Expand Down
38 changes: 37 additions & 1 deletion src/Marten/Linq/Parsing/SelectParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,49 @@ protected override Expression VisitNew(NewExpression node)

for (var i = 0; i < parameters.Length; i++)
{
_currentField = parameters[i].Name;
_currentField = ResolveFieldName(node, parameters, i);
Visit(node.Arguments[i]);
}

return node;
}

/// <summary>
/// Resolves the field name for a constructor parameter in a NewExpression.
/// Prefers NewExpression.Members (populated for C# anonymous types), then falls back
/// to matching properties by position for F# records where constructor parameters
/// are camelCase but properties are PascalCase.
/// </summary>
internal static string ResolveFieldName(NewExpression node, System.Reflection.ParameterInfo[] parameters, int index)
{
// Prefer NewExpression.Members when available (C# anonymous types, F# anonymous records)
if (node.Members != null && index < node.Members.Count)
{
return node.Members[index].Name;
}

// For F# records, constructor params are camelCase but properties are PascalCase.
// F# records are marked with CompilationMappingAttribute(SourceConstructFlags.RecordType).
// Match by position since F# guarantees property order matches constructor parameter order.
if (IsFSharpRecord(node.Type))
{
var properties = node.Type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
if (index < properties.Length &&
string.Equals(properties[index].Name, parameters[index].Name, System.StringComparison.OrdinalIgnoreCase))
{
return properties[index].Name;
}
}

return parameters[index].Name;
}

private static bool IsFSharpRecord(System.Type type)
{
return type.GetCustomAttributes(false)
.Any(a => a.GetType().FullName == "Microsoft.FSharp.Core.CompilationMappingAttribute"
&& a.GetType().GetProperty("SourceConstructFlags")?.GetValue(a)?.ToString() == "RecordType");
}
}


Expand Down
Loading