SQLProvider allows you to unit-test your SQL-logic
That's a clear advantage on large-scale projects, where there are multiple developers and the SQL-queries grow more complex over time.
- Debugging. It is faster to debug the unit test than to spin the full environment again and again.
- Refactoring: To ensure what the original functionality is actually doing before you modify it.
Why to unit-test?
F# strong typing provides safety over raw SQL: Instead of your customer finding an issue, your code will not compile if the database shape is wrong, for example, someone removed an important column.
SQLProvider does parametrized SQL, you can watch the executed SQL, and you can even open the parameterized SQL parameters for easier debugging:
FSharp.Data.Sql.Common.QueryEvents.SqlQueryEvent
|> Event.add (fun e -> System.Console.WriteLine (e.ToRawSqlWithParamInfo()))
But unit-testing is a good addition in scenarios where:
- Your database is very dynamic, and data is changing all the time
- You want to ensure the logic works over a period of time
- You have a big project where build-time takes long
- You want Continuous Integration, but your test-data or database is unstable.
How?
There are 2 helper functions to mock the database connection:
FSharp.Data.Sql.Common.OfflineTools.CreateMockEntities<'T>- With this, you can mock a single table.FSharp.Data.Sql.Common.OfflineTools.CreateMockSqlDataContext<'T>- With this, you can mock a context with multiple tables
You just feed anonymous records like they would be database rows. You don't need to add all the columns, just the ones you use in your query. But you can add extra-columns for easier asserts.
Example: executable business logic
open System
type OrderDateFilter =
| OrderDate
| ShippedDate
| Either
let someProductionFunction (ctx:sql.dataContext) (orderType:OrderDateFilter) (untilDate:System.DateTime) =
task {
let ignoreOrderDate, ignoreShippedDate =
match orderType with
| OrderDate -> false, true
| ShippedDate -> true, false
| Either -> false, false
let tomorrow = untilDate.AddDays(1.).Date
let someLegacyCondition = 0 // we don't need this anymore
let itms =
query {
for order in ctx.Main.Orders do
join cust in ctx.Main.Customers on (order.CustomerId = cust.CustomerId)
where ((cust.City = "London" || cust.City = "Paris" ) && (
(ignoreOrderDate || order.OrderDate < tomorrow) && (someLegacyCondition < 15)) &&
(ignoreShippedDate || order.ShippedDate < tomorrow) &&
cust.CustomerId <> null && order.Freight > 10m
)
select (cust.PostalCode, order.Freight)
}
let! res = itms |> Array.executeQueryAsync
//maybe some post-processing here...
return res
}
Example, unit-test part
Note: CustomerID, not CustomerId. These are the database field-names, not the nice LINQ names.
let ``mock for unit-testing: datacontext``() =
task {
let sampleDataMap =
[ "main.Customers",
[| {| CustomerID = "1"; City = "Paris"; PostalCode = "75000"; Description = "This is good"; |}
{| CustomerID = "2"; City = "London"; PostalCode = "E143AB"; Description = "This is good"; |}
{| CustomerID = "3"; City = "Espoo"; PostalCode = "02600"; Description = "Ignore this guy"; |}
|] :> obj
"main.Orders",
[| {| CustomerID = "1"; OrderDate = DateTime(2020,01,01); ShippedDate = DateTime(2020,01,04); Freight = 4m;|}
{| CustomerID = "1"; OrderDate = DateTime(2021,02,11); ShippedDate = DateTime(2021,02,12); Freight = 22m;|}
{| CustomerID = "2"; OrderDate = DateTime(2022,03,15); ShippedDate = DateTime(2022,03,22); Freight = 20m;|}
{| CustomerID = "2"; OrderDate = DateTime(2024,02,03); ShippedDate = DateTime(2024,02,17); Freight = 50m;|}
{| CustomerID = "3"; OrderDate = DateTime(2024,02,03); ShippedDate = DateTime(2024,02,17); Freight = 15m;|}
|] :> obj
] |> Map.ofList
let mockContext = FSharp.Data.Sql.Common.OfflineTools.CreateMockSqlDataContext<sql.dataContext> sampleDataMap
let! res = someProductionFunction mockContext OrderDateFilter.OrderDate (DateTime(2024,02,04))
//val res: (string * decimal) list =
// [("75000", 22M); ("E143AB", 20M); ("E143AB", 50M)]
assert_equal 3 res.Length
assert_contains ("75000", 22M) res
assert_contains ("E143AB", 20M) res
assert_contains ("E143AB", 50M) res
}
CreateMockSqlDataContext takes a Map<string,obj> where the string is the table name as in the database, and obj is an array of anonymous records.
The mock is meant to help create data-context objects to enable easier testing of your LINQ-logic, not to test SQLProvider itself.
There are some limitations with the SQLProvider mock a DB-context:
- The mock-context will not connect the DB, and you can't save entity modifications. SubmitUpdates() will do nothing.
- SQLProvider custom operators (like
x |=| xsandy %<> "A%") are not supported. So you have to use LINQ-ones (e.g.xs.Contains xandnot y.StartsWith "A") that do work in SQLProvider as well. - You can call database-table
.Createmethods to create new instances (it doesn't connect the database). You can call update entity columnsx.Col <- Some "hi", but it doesn't really do anything. - You cannot call stored procedures.
- Names are database names, and they are case-sensitive. If you miss a table, in your mock, there will be a clear error. If you mistyped the anonymous record column name, you will probably just get a zero-result or ValueNone.Value-error or some other unwanted behaviour.
If you are running an off-line solution like SSDT or ContextSchemaPath, you should be able to run also these unit-tests with your CI.
type LiteralAttribute = inherit Attribute new: unit -> LiteralAttribute
--------------------
new: unit -> LiteralAttribute
namespace FSharp
--------------------
namespace Microsoft.FSharp
namespace FSharp.Data
--------------------
namespace Microsoft.FSharp.Data
<summary> Specifies the database provider type for the SQL type provider. Each provider has its own specific implementation for SQL generation and data type mapping. </summary>
<summary> SQLite database using System.Data.SQLite or Microsoft.Data.Sqlite </summary>
<summary> Specifies which SQLite library to use for connections. Different libraries may have different capabilities and platform support. </summary>
<summary> .NET Framework default </summary>
<summary> Specifies how to handle case sensitivity when generating table and column names. </summary>
<summary> Keep original casing from the database </summary>
<summary> Contains events for monitoring SQL query execution and debugging. Use these events to log, debug, or analyze the SQL queries generated by the type provider. </summary>
<summary> Event that fires immediately before the execution of every generated query. Listen to this event to display or debug the content of your queries. This is useful for logging, performance monitoring, and debugging SQL generation. </summary>
module Event from Microsoft.FSharp.Control
--------------------
type Event<'T> = new: unit -> Event<'T> member Trigger: arg: 'T -> unit member Publish: IEvent<'T>
--------------------
type Event<'Delegate,'Args (requires delegate and 'Delegate :> Delegate and reference type)> = new: unit -> Event<'Delegate,'Args> member Trigger: sender: obj * args: 'Args -> unit member Publish: IEvent<'Delegate,'Args>
--------------------
new: unit -> Event<'T>
--------------------
new: unit -> Event<'Delegate,'Args>
<summary>Represents the standard input, output, and error streams for console applications. This class cannot be inherited.</summary>
(+0 other overloads)
System.Console.WriteLine(value: uint64) : unit
(+0 other overloads)
System.Console.WriteLine(value: uint32) : unit
(+0 other overloads)
System.Console.WriteLine(value: System.ReadOnlySpan<char>) : unit
(+0 other overloads)
System.Console.WriteLine(value: string) : unit
(+0 other overloads)
System.Console.WriteLine(value: float32) : unit
(+0 other overloads)
System.Console.WriteLine(value: obj) : unit
(+0 other overloads)
System.Console.WriteLine(value: int64) : unit
(+0 other overloads)
System.Console.WriteLine(value: int) : unit
(+0 other overloads)
System.Console.WriteLine(value: float) : unit
(+0 other overloads)
[<Struct>] type DateTime = new: date: DateOnly * time: TimeOnly -> unit + 16 overloads member Add: value: TimeSpan -> DateTime member AddDays: value: float -> DateTime member AddHours: value: float -> DateTime member AddMicroseconds: value: float -> DateTime member AddMilliseconds: value: float -> DateTime member AddMinutes: value: float -> DateTime member AddMonths: months: int -> DateTime member AddSeconds: value: float -> DateTime member AddTicks: value: int64 -> DateTime ...
<summary>Represents an instant in time, typically expressed as a date and time of day.</summary>
--------------------
DateTime ()
(+0 other overloads)
DateTime(ticks: int64) : DateTime
(+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly) : DateTime
(+0 other overloads)
DateTime(ticks: int64, kind: DateTimeKind) : DateTime
(+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly, kind: DateTimeKind) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, calendar: Globalization.Calendar) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: DateTimeKind) : DateTime
(+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: Globalization.Calendar) : DateTime
(+0 other overloads)
<summary> Extracts the date part from a datetime value </summary>
<summary>Provides methods for creating, manipulating, searching, and sorting arrays, thereby serving as the base class for all arrays in the common language runtime.</summary>
<summary> Execute SQLProvider query and release the OS thread while query is being executed. </summary>
module Map from Microsoft.FSharp.Collections
--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...
--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
<summary> This can be used for testing. Creates fake DB-context entities.. Example: FSharp.Data.Sql.Common.OfflineTools.CreateMockSqlDataContext ["schema.MyTable1"; [| {| MyColumn1 = "a"; MyColumn2 = 0 |} |] :> obj] |> Map.ofList See project unit-test for more examples. NOTE: Case-sensitivity. Tables and columns are DB-names, not Linq-names. Limitation of mockContext: You cannot Create new entities to the mock context. </summary>
<summary>Gets the total number of elements in all the dimensions of the <see cref="T:System.Array" />.</summary>
<exception cref="T:System.OverflowException">The array is multidimensional and contains more than <see cref="F:System.Int32.MaxValue">Int32.MaxValue</see> elements.</exception>
<returns>The total number of elements in all the dimensions of the <see cref="T:System.Array" />; zero if there are no elements in the array.</returns>