Skip to content

Commit 5beb2a2

Browse files
committed
leftOuterJoin improvements
1 parent 0a199d3 commit 5beb2a2

32 files changed

Lines changed: 402 additions & 151 deletions

File tree

docs/RELEASE_NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
### 1.5.25 - 03.07.2026
2+
* leftOuterJoin: groupBy after a left outer join is now supported (SQL semantics: COUNT counts the unmatched NULL row)
3+
* groupJoin: `groupJoin ... into g` + `for x in g do` now works (inner join semantics); aggregating the group (e.g. `g.Count()`) fails with a clear message instead of returning wrong results
4+
* leftOuterJoin detection is now gated on the GroupJoin operator and the LINQ DefaultIfEmpty method, so an unrelated `DefaultIfEmpty` call in a projection can no longer turn an inner join into a left outer join
5+
* Unmatched left-join rows no longer construct a throwaway entity during materialisation
6+
17
### 1.5.24 - 12.06.2026
28
* Initial support for standard F# `leftOuterJoin .. into g` / `g.DefaultIfEmpty()` left outer joins (single/multi-key and chained over several tables): unmatched columns are None/default and a whole selected entity is null #540 #697
39

docs/content/core/querying.fsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ exactlyOneOrDefault |X |
210210
exists |X | |
211211
find |X | |
212212
groupBy |x | Simple support (2) |
213-
groupJoin | | |
213+
groupJoin |x | Only when flattened: `groupJoin .. into g` + `for x in g` (inner join), see below |
214214
groupValBy | | |
215215
head |X | |
216216
headOrDefault |X | |
@@ -441,6 +441,31 @@ let res =
441441
Alternatively the `!!` operator does an inline left join, but (unlike the form above) the joined
442442
entity is populated with default values rather than being null when there is no match.
443443
444+
The classic anti-join ("customers without orders") works by filtering the left-joined side with
445+
`IS NULL`, which under `UseOptionTypes` is an `IsNone` check:
446+
447+
```fsharp
448+
let customersWithoutOrders =
449+
query {
450+
for cust in ctx.Main.Customers do
451+
leftOuterJoin order in ctx.Main.Orders on (cust.CustomerId = order.CustomerId.Value) into result
452+
for order in result.DefaultIfEmpty() do
453+
where (order.CustomerId.IsNone)
454+
select cust.CustomerId
455+
}
456+
```
457+
458+
Notes and limitations:
459+
460+
* `groupJoin ... into g` followed by `for x in g do` is supported and behaves as an inner join.
461+
Aggregating the group itself (e.g. `select (c, g.Count())`) is not supported - use `groupBy`
462+
for aggregates.
463+
* A `groupBy` after a `leftOuterJoin` follows SQL semantics: `COUNT(*)` counts the unmatched
464+
NULL row, so a customer with no orders gets count 1, not 0.
465+
* `DefaultIfEmpty(customDefaultValue)` is not supported; only the parameterless form is.
466+
* A filtered sub-query is not supported as a left-join destination (the filter would belong in
467+
the JOIN's `ON` clause, which is not yet generated).
468+
444469
## Best practices working with queries
445470
446471
### When using Option types, check IsSome in where-clauses.

src/SQLProvider.Common/AssemblyInfo.fs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ open System.Reflection
55
[<assembly: AssemblyTitleAttribute("SQLProvider.Common")>]
66
[<assembly: AssemblyProductAttribute("SQLProvider")>]
77
[<assembly: AssemblyDescriptionAttribute("Type provider for SQL database access, common library")>]
8-
[<assembly: AssemblyVersionAttribute("1.5.24")>]
9-
[<assembly: AssemblyFileVersionAttribute("1.5.24")>]
8+
[<assembly: AssemblyVersionAttribute("1.5.25")>]
9+
[<assembly: AssemblyFileVersionAttribute("1.5.25")>]
1010
do ()
1111

1212
module internal AssemblyVersionInformation =
1313
let [<Literal>] AssemblyTitle = "SQLProvider.Common"
1414
let [<Literal>] AssemblyProduct = "SQLProvider"
1515
let [<Literal>] AssemblyDescription = "Type provider for SQL database access, common library"
16-
let [<Literal>] AssemblyVersion = "1.5.24"
17-
let [<Literal>] AssemblyFileVersion = "1.5.24"
16+
let [<Literal>] AssemblyVersion = "1.5.25"
17+
let [<Literal>] AssemblyFileVersion = "1.5.25"

src/SQLProvider.Common/SqlRuntime.Common.fs

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -420,27 +420,31 @@ type SqlEntity(dc: ISqlDataContext, tableName, columns: ColumnLookup, activeColu
420420
/// Like GetSubTable, but returns null when the aliased table produced no matching row in a
421421
/// left outer join (i.e. all of its columns are null). Used by the `leftOuterJoin ... into g`
422422
/// form so the joined result-set entity is null instead of an entity full of default values.
423-
/// Note: a real match always has a non-null primary key, so only genuine no-match rows
424-
/// (every selected column null) are mapped to null.
423+
/// Note: this heuristic is safe because the nullable path is only used for whole-entity
424+
/// selects, where every column - including the join key, which is non-null on any match -
425+
/// is selected. If partial-column materialisation ever reaches this path, a synthetic
426+
/// match-flag column in the SELECT would be needed instead.
425427
member internal e.GetSubTableNullable(alias:string, tableName) =
426-
let sub = e.GetSubTable(alias, tableName)
427428
// Decide no-match by looking ONLY at columns that are genuinely qualified with this alias.
428429
// GetSubTable also copies unqualified/foreign columns as a fallback (Utilities.checkPred),
429430
// which must not count here. checkPred strips the alias prefix, so a column belongs to this
430431
// alias exactly when the returned key differs from the original (a prefix was removed).
432+
// Single pass without intermediate collections; this runs once per materialised row, and
433+
// the sub-entity is only built when the row actually matched.
431434
let pred = Utilities.checkPred alias
432-
let ownValues =
433-
e.ColumnValues
434-
|> Seq.choose (fun (k, v) ->
435+
let mutable sawOwnColumn = false
436+
let mutable sawValue = false
437+
for (k, v) in e.ColumnValues do
438+
if not sawValue then
435439
match pred (k, v) with
436-
| Some(stripped, value) when stripped <> k -> Some value
437-
| _ -> None)
438-
|> Seq.toList
439-
let hasMatch =
440-
match ownValues with
441-
| [] -> true // no alias-qualified columns to judge by: keep the entity
442-
| _ -> ownValues |> List.exists (fun v -> not (isNull v))
443-
if hasMatch then sub else Unchecked.defaultof<SqlEntity>
440+
| Some(stripped, _) when stripped <> k ->
441+
sawOwnColumn <- true
442+
if not (isNull v) then sawValue <- true
443+
| _ -> ()
444+
// No alias-qualified columns to judge by (e.g. the provider returned unqualified column
445+
// names): fall back to the populated entity rather than guessing no-match.
446+
if sawValue || not sawOwnColumn then e.GetSubTable(alias, tableName)
447+
else Unchecked.defaultof<SqlEntity>
444448

445449
/// Maps database entity class to the type provided in generic attribute.
446450
/// You can define more detailed mapping via MappedColumnAttribute or propertyTypeMapping
@@ -930,7 +934,27 @@ type GroupResultItems<'key, 'SqlEntity>(keyname:String*String*String*String*Stri
930934
let ent = unbox<Microsoft.FSharp.Linq.RuntimeHelpers.AnonymousObject<SqlEntity,SqlEntity,SqlEntity,SqlEntity>> distinctItem
931935
Seq.concat [| ent.Item1.ColumnValues; ent.Item2.ColumnValues; ent.Item3.ColumnValues; ent.Item4.ColumnValues; |]
932936
|> Seq.distinct |> filterColumnValues
933-
| _ -> failwith ("Unknown aggregate item: " + typeof<'SqlEntity>.Name)
937+
| other ->
938+
// Generic fallback for join-row shapes the fixed cases above don't cover:
939+
// a groupBy after a leftOuterJoin / groupJoin carries the flattened group as an
940+
// IEnumerable<SqlEntity> slot, and chained joins nest the tuples.
941+
let rec collectEntities (o:obj) : SqlEntity seq =
942+
match o with
943+
| null -> Seq.empty
944+
| :? SqlEntity as e -> Seq.singleton e
945+
| :? System.Collections.Generic.IEnumerable<SqlEntity> as es -> es
946+
| o ->
947+
let t = o.GetType()
948+
if t.IsGenericType
949+
&& (let g = t.GetGenericTypeDefinition()
950+
g.Name.StartsWith "AnonymousObject" || g.Name.StartsWith "Tuple`") then
951+
t.GetProperties()
952+
|> Seq.filter(fun p -> p.Name.StartsWith "Item")
953+
|> Seq.collect(fun p -> collectEntities (p.GetValue o))
954+
else Seq.empty
955+
let ents = collectEntities other |> Seq.toArray
956+
if ents.Length = 0 then failwith ("Unknown aggregate item: " + typeof<'SqlEntity>.Name)
957+
else ents |> Seq.collect(fun e -> e.ColumnValues) |> Seq.distinct |> filterColumnValues
934958
let itm =
935959
if Seq.isEmpty itms then
936960
let cols = (keyname |> fun (x1,x2,x3,x4,x5,x6,x7) -> StringBuilder(x1).Append(" ").Append(x2).Append(" ").Append(x3).Append(" ").Append(x4).Append(" ").Append(x5).Append(" ").Append(x6).Append(" ").Append(x7).ToString()).Trim()

0 commit comments

Comments
 (0)