Skip to content

Commit b7ec7d0

Browse files
committed
copilot review addressed
1 parent 4c457ac commit b7ec7d0

3 files changed

Lines changed: 168 additions & 7 deletions

File tree

‎docs/RELEASE_NOTES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
- Fix: parameters of a sub-query nested in another sub-query are bound (IN, EXISTS, UNION and the like)
33
- Fix: groupBy keys with option or value-option columns, and with x.IsSome / x.IsNone in a tuple key
44
- Fix: a grouped sub-query of IN / NOT IN that selects an aggregate selects it without the group keys, on both sides of a union too, #746
5-
- IN / NOT IN sub-queries with a projection computed in .NET (e.g. `g.Max(...) + 1L`) fail with an error instead of comparing against the uncomputed column
5+
- IN / NOT IN sub-queries with a projection computed in .NET (e.g. `g.Max(...) + 1L`, a narrowing conversion, a user function) fail with an error instead of comparing against the uncomputed column
66
- Design time: a provider instance that goes away closes its connection instead of disposing it, as the cached types of other instances still use it ("Cannot access a disposed object")
77
- PostgreSQL: identity columns count as generated, so the typed ``Create(...)`` no longer takes them. Breaking for code that passed a value to a BY DEFAULT identity column: use Create() and set it, or Create(data). A saved ContextSchemaPath cache keeps the old signature until regenerated.
88

‎src/SQLProvider.Common/SqlRuntime.QueryExpression.fs‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -632,15 +632,67 @@ module internal QueryExpressionTransformer =
632632

633633
newProjection, projectionMap, groupProjectionMap
634634

635-
/// A projection that reads one selected column as it is: a column, an aggregate or a group key, through casts and
636-
/// an option's Value. Anything else is computed in .NET after the rows are read.
635+
/// Numeric conversions that keep every value of the source type.
636+
let private wideningConversions =
637+
let integralTo (from:Type) (targets:Type list) = targets |> List.map (fun t -> from, t)
638+
[ yield! integralTo typeof<sbyte> [typeof<int16>; typeof<int32>; typeof<int64>; typeof<single>; typeof<double>; typeof<decimal>]
639+
yield! integralTo typeof<byte> [typeof<int16>; typeof<uint16>; typeof<int32>; typeof<uint32>; typeof<int64>; typeof<uint64>; typeof<single>; typeof<double>; typeof<decimal>]
640+
yield! integralTo typeof<int16> [typeof<int32>; typeof<int64>; typeof<single>; typeof<double>; typeof<decimal>]
641+
yield! integralTo typeof<uint16> [typeof<int32>; typeof<uint32>; typeof<int64>; typeof<uint64>; typeof<single>; typeof<double>; typeof<decimal>]
642+
yield! integralTo typeof<int32> [typeof<int64>; typeof<double>; typeof<decimal>]
643+
yield! integralTo typeof<uint32> [typeof<int64>; typeof<uint64>; typeof<double>; typeof<decimal>]
644+
yield! integralTo typeof<int64> [typeof<decimal>]
645+
yield! integralTo typeof<uint64> [typeof<decimal>]
646+
typeof<single>, typeof<double> ]
647+
648+
/// A conversion that keeps the value: identity, Nullable wrapping, a reference cast, boxing or unboxing, an enum and
649+
/// its underlying type, or a numeric widening. A user-defined conversion operator runs .NET code, so it doesn't.
650+
let private isValuePreserving (u:UnaryExpression) =
651+
let underlying (t:Type) = match Nullable.GetUnderlyingType t with null -> t | n -> n
652+
let source, target = underlying u.Operand.Type, underlying u.Type
653+
isNull u.Method
654+
&& (Type.(=)(source, target)
655+
|| not source.IsValueType || not target.IsValueType
656+
|| (source.IsEnum && Type.(=)(Enum.GetUnderlyingType source, target))
657+
|| (target.IsEnum && Type.(=)(Enum.GetUnderlyingType target, source))
658+
|| wideningConversions |> List.exists (fun (s, t) -> Type.(=)(s, source) && Type.(=)(t, target)))
659+
660+
/// The row a projection reads: its parameter, a tuple or anonymous-object item of it, or a joined table's row of
661+
/// it, through casts.
662+
let rec private isRowReference (e:Expression) =
663+
match e with
664+
| :? ParameterExpression -> true
665+
| :? MethodCallExpression as c when Type.(=)(c.Method.DeclaringType, typeof<SqlEntity>) && (c.Method.Name = "GetSubTable" || c.Method.Name = "GetSubTableNullable") ->
666+
not (isNull c.Object) && c.Arguments |> Seq.forall (fun a -> a :? ConstantExpression) && isRowReference c.Object
667+
| :? UnaryExpression as u when (u.NodeType = ExpressionType.Convert || u.NodeType = ExpressionType.TypeAs) && isNull u.Method ->
668+
isRowReference u.Operand
669+
| :? MemberExpression as m when not (isNull m.Expression) ->
670+
let owner = m.Member.DeclaringType
671+
(Microsoft.FSharp.Reflection.FSharpType.IsTuple owner || owner.Name.StartsWith "AnonymousObject" || owner.Name.Contains "AnonymousType")
672+
&& isRowReference m.Expression
673+
| _ -> false
674+
675+
/// A projection that reads one selected column as it is: a column, an aggregate or a group key, through
676+
/// value-preserving casts and an option's Value. Anything else is computed in .NET after the rows are read.
637677
let rec private isColumnRead (e:Expression) =
638678
match e with
639679
| :? UnaryExpression as u when u.NodeType = ExpressionType.Convert || u.NodeType = ExpressionType.ConvertChecked || u.NodeType = ExpressionType.TypeAs ->
640-
isColumnRead u.Operand
641-
| :? MemberExpression as m when m.Member.Name = "Value" && not (isNull m.Expression) -> isColumnRead m.Expression
642-
| :? MemberExpression as m when m.Member.Name = "Key" -> true
643-
| :? MethodCallExpression as c -> c.Method.Name.StartsWith "GetColumn" || c.Method.Name.StartsWith "Aggregate"
680+
isValuePreserving u && isColumnRead u.Operand
681+
| :? MemberExpression as m when m.Member.Name = "Value" && not (isNull m.Expression) ->
682+
(Utilities.isCOpt m.Member.DeclaringType || Utilities.isVOpt m.Member.DeclaringType || not (isNull (Nullable.GetUnderlyingType m.Expression.Type)))
683+
&& isColumnRead m.Expression
684+
| :? MemberExpression as m when m.Member.Name = "Key" && not (isNull m.Expression) ->
685+
let owner = m.Member.DeclaringType
686+
(Utilities.isGrp owner || (owner.IsGenericType && Type.(=)(owner.GetGenericTypeDefinition(), typedefof<GroupResultItems<_,_>>)))
687+
&& isRowReference m.Expression
688+
| :? MethodCallExpression as c when not (isNull c.Object) && c.Arguments |> Seq.forall (fun a -> a :? ConstantExpression) ->
689+
let owner = c.Method.DeclaringType
690+
if Type.(=)(owner, typeof<IColumnHolder>) then
691+
(c.Method.Name = "GetColumn" || c.Method.Name = "GetColumnOption" || c.Method.Name = "GetColumnValueOption")
692+
&& isRowReference c.Object
693+
else
694+
owner.IsGenericType && Type.(=)(owner.GetGenericTypeDefinition(), typedefof<GroupResultItems<_,_>>)
695+
&& c.Method.Name.StartsWith "Aggregate" && isRowReference c.Object
644696
| _ -> false
645697

646698
/// As convertExpression; `membershipSubquery` true is for a sub-query of IN / NOT IN, which may return one column

‎tests/SqlProvider.Tests/more/SubqueryCompositionTests.fs‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,115 @@ let ``an IN sub-query calling an F# function the database cannot run is refused`
569569

570570
StringAssert.StartsWith("IN / NOT IN needs a sub-query that selects one column", error.Message)
571571

572+
[<Test; SqlCheck.NoSql>]
573+
let ``an IN sub-query converting an aggregate to a narrower number in .NET is refused``() =
574+
let roundedAverages =
575+
query {
576+
for detail in dc.Main.OrderDetails do
577+
groupBy detail.ProductId into g
578+
select (int64 (g.Average(fun detail -> detail.UnitPrice)))
579+
}
580+
581+
let error =
582+
Assert.Throws<Exception>(fun () ->
583+
query {
584+
for product in dc.Main.Products do
585+
where (roundedAverages.Contains product.ProductId)
586+
select product.ProductId
587+
} |> Seq.toArray |> ignore)
588+
589+
StringAssert.StartsWith("IN / NOT IN needs a sub-query that selects one column", error.Message)
590+
591+
let private GetColumnDoubled (id: int64) = id * 2L
592+
593+
[<Test; SqlCheck.NoSql>]
594+
let ``an IN sub-query calling a function named like a column read is refused``() =
595+
let doubledIds =
596+
query {
597+
for order in dc.Main.Orders do
598+
select (GetColumnDoubled order.OrderId)
599+
}
600+
601+
let error =
602+
Assert.Throws<Exception>(fun () ->
603+
query {
604+
for order in dc.Main.Orders do
605+
where (doubledIds.Contains order.OrderId)
606+
select order.OrderId
607+
} |> Seq.toArray |> ignore)
608+
609+
StringAssert.StartsWith("IN / NOT IN needs a sub-query that selects one column", error.Message)
610+
611+
type private DoubledId(id: int64) =
612+
member _.Key = id * 2L
613+
614+
[<Test; SqlCheck.NoSql>]
615+
let ``an IN sub-query reading a Key that is not a group key is refused``() =
616+
let doubledIds =
617+
query {
618+
for order in dc.Main.Orders do
619+
select (DoubledId(order.OrderId).Key)
620+
}
621+
622+
let error =
623+
Assert.Throws<Exception>(fun () ->
624+
query {
625+
for order in dc.Main.Orders do
626+
where (doubledIds.Contains order.OrderId)
627+
select order.OrderId
628+
} |> Seq.toArray |> ignore)
629+
630+
StringAssert.StartsWith("IN / NOT IN needs a sub-query that selects one column", error.Message)
631+
632+
[<Test; SqlCheck.ExpectSql("GROUP BY", "IN")>]
633+
let ``an IN sub-query selecting a group key still runs``() =
634+
let popular =
635+
query {
636+
for detail in dc.Main.OrderDetails do
637+
groupBy detail.ProductId into g
638+
where (g.Count() > 40)
639+
select g.Key
640+
}
641+
642+
let result =
643+
query {
644+
for product in dc.Main.Products do
645+
where (popular.Contains product.ProductId)
646+
select product.ProductId
647+
} |> Seq.toArray |> Array.sort
648+
649+
let expected =
650+
query { for d in dc.Main.OrderDetails do select d.ProductId }
651+
|> Seq.countBy id |> Seq.filter (fun (_, n) -> n > 40) |> Seq.map fst |> Seq.toArray |> Array.sort
652+
653+
Assert.IsNotEmpty expected
654+
Assert.AreEqual(expected, result)
655+
656+
[<Test; SqlCheck.ExpectSql("GROUP BY", "HAVING", "MAX(", "IN")>]
657+
let ``a grouped IN sub-query with a having filter selects only its aggregate``() =
658+
let latestOfPopular =
659+
query {
660+
for detail in dc.Main.OrderDetails do
661+
groupBy detail.ProductId into g
662+
where (g.Count() > 40)
663+
select (g.Max(fun detail -> detail.OrderId))
664+
}
665+
666+
let result =
667+
query {
668+
for order in dc.Main.Orders do
669+
where (latestOfPopular.Contains order.OrderId)
670+
select order.OrderId
671+
} |> Seq.toArray |> Array.sort
672+
673+
let expected =
674+
query { for d in dc.Main.OrderDetails do select (d.ProductId, d.OrderId) }
675+
|> Seq.groupBy fst |> Seq.filter (fun (_, rows) -> Seq.length rows > 40)
676+
|> Seq.map (fun (_, rows) -> rows |> Seq.map snd |> Seq.max) |> Seq.distinct |> Seq.toArray |> Array.sort
677+
678+
Assert.IsNotEmpty expected
679+
Assert.AreEqual(expected, result)
680+
572681
[<Test; SqlCheck.ExpectSql("IN", "+ 1")>]
573682
let ``an IN sub-query with arithmetic the database computes still runs``() =
574683
let nextIds =

0 commit comments

Comments
 (0)