Skip to content

Commit 0a199d3

Browse files
committed
Initial support for leftOuterJoin
1 parent 5f591ce commit 0a199d3

45 files changed

Lines changed: 479 additions & 168 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
### 1.5.24 - 12.06.2026
2+
* 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
3+
14
### 1.5.23 - 12.06.2026
25
* SQLite: added missing string type mappings (longvarchar, longchar, longnvarchar, clob) #101
36
* PostgreSQL: skip columns with types unknown to Npgsql (e.g. PostGIS geometry) instead of failing the table #695

docs/content/core/querying.fsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ if |X |
218218
join |X | |
219219
last | | |
220220
lastOrDefault | | |
221-
leftOuterJoin | | |
221+
leftOuterJoin |x | Standard `leftOuterJoin .. into g` + `g.DefaultIfEmpty()`, see below |
222222
let |x | ...but not using tmp variables in where-clauses |
223223
maxBy |X | Single table (1) |
224224
maxByNullable |X | Single table (1) |
@@ -418,7 +418,28 @@ You can find some custom operators `using FSharp.Data.Sql`:
418418
* `|<>|` (Not in set)
419419
* `=%` (Like)
420420
* `<>%` (Not like)
421-
* `!!` (Left join)
421+
* `!!` (Left join: the joined entity is populated with default values when there is no match)
422+
423+
### Left outer joins
424+
425+
There are two ways to do a `LEFT OUTER JOIN`:
426+
427+
The standard F# `leftOuterJoin ... into g` query operator (with `g.DefaultIfEmpty()`) is supported.
428+
When there is no matching row the joined columns are `None` (under `UseOptionTypes`) / default, and
429+
the joined entity itself is `null` if you select it whole:
430+
431+
```fsharp
432+
let res =
433+
query {
434+
for cust in ctx.Main.Customers do
435+
leftOuterJoin order in ctx.Main.Orders on (cust.CustomerId = order.CustomerId) into result
436+
for order in result.DefaultIfEmpty() do
437+
select (cust.CustomerId, order.OrderDate) // OrderDate is None / default when the customer has no orders
438+
}
439+
```
440+
441+
Alternatively the `!!` operator does an inline left join, but (unlike the form above) the joined
442+
entity is populated with default values rather than being null when there is no match.
422443
423444
## Best practices working with queries
424445

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.23")>]
9-
[<assembly: AssemblyFileVersionAttribute("1.5.23")>]
8+
[<assembly: AssemblyVersionAttribute("1.5.24")>]
9+
[<assembly: AssemblyFileVersionAttribute("1.5.24")>]
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.23"
17-
let [<Literal>] AssemblyFileVersion = "1.5.23"
16+
let [<Literal>] AssemblyVersion = "1.5.24"
17+
let [<Literal>] AssemblyFileVersion = "1.5.24"

src/SQLProvider.Common/Operators.fs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,8 @@ module Operators =
289289
/// Used after "in" and before the context.tablename
290290
/// param a: The related table queryable
291291
/// A queryable that performs a left outer join
292-
let (!!) (a:IQueryable<'a>) = query { for x in a do select (leftJoin x) }
293-
292+
let (!!) (a:IQueryable<'a>) = query { for x in a do select (leftJoin x) }
293+
294294
/// Calculates the standard deviation of numeric values in a column.
295295
/// Used in aggregate queries with groupBy.
296296
/// param a: The column value

src/SQLProvider.Common/SqlRuntime.Common.fs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,31 @@ type SqlEntity(dc: ISqlDataContext, tableName, columns: ColumnLookup, activeColu
417417
aliasCache.Value.Add(alias,newEntity)
418418
newEntity
419419

420+
/// Like GetSubTable, but returns null when the aliased table produced no matching row in a
421+
/// left outer join (i.e. all of its columns are null). Used by the `leftOuterJoin ... into g`
422+
/// 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.
425+
member internal e.GetSubTableNullable(alias:string, tableName) =
426+
let sub = e.GetSubTable(alias, tableName)
427+
// Decide no-match by looking ONLY at columns that are genuinely qualified with this alias.
428+
// GetSubTable also copies unqualified/foreign columns as a fallback (Utilities.checkPred),
429+
// which must not count here. checkPred strips the alias prefix, so a column belongs to this
430+
// alias exactly when the returned key differs from the original (a prefix was removed).
431+
let pred = Utilities.checkPred alias
432+
let ownValues =
433+
e.ColumnValues
434+
|> Seq.choose (fun (k, v) ->
435+
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>
444+
420445
/// Maps database entity class to the type provided in generic attribute.
421446
/// You can define more detailed mapping via MappedColumnAttribute or propertyTypeMapping
422447
member x.MapTo<'a>(?propertyTypeMapping : (string * obj) -> obj) =
@@ -588,6 +613,10 @@ type LinkData =
588613
ForeignTable : Table
589614
ForeignKey : SqlColumnType list
590615
OuterJoin : bool
616+
// When true (leftOuterJoin), the joined result-set entity is materialised as
617+
// null (or None with UseOptionTypes) when there is no matching row, instead of
618+
// an entity populated with default values. Only meaningful when OuterJoin = true.
619+
IsNullableOuter : bool
591620
RelDirection : RelationshipDirection }
592621
with
593622
member x.Rev() =

0 commit comments

Comments
 (0)