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 |=| xs
andy %<> "A%"
) are not supported. So you have to use LINQ-ones (e.g.xs.Contains x
andnot y.StartsWith "A"
) that do work in SQLProvider as well. - You can call database-table
.Create
methods 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>Typed representation of a database</summary> <param name='ConnectionString'>The connection string for the SQL database</param> <param name='ConnectionStringName'>The connection string name to select from a configuration file</param> <param name='DatabaseVendor'> The target database vendor</param> <param name='IndividualsAmount'>The amount of sample entities to project into the type system for each SQL entity type. Default 50. Note GDPR/PII regulations if using individuals with ContextSchemaPath.</param> <param name='UseOptionTypes'>If set, F# option types will be used in place of nullable database columns. If not, you will always receive the default value of the column's type even if it is null in the database.</param> <param name='ResolutionPath'>The location to look for dynamically loaded assemblies containing database vendor specific connections and custom types. Types used in desing-time: If no better clue, prefer .NET Standard 2.0 versions. Semicolon to separate multiple.</param> <param name='Owner'>Oracle: The owner of the schema for this provider to resolve. PostgreSQL: A list of schemas to resolve, separated by spaces, newlines, commas, or semicolons.</param> <param name='CaseSensitivityChange'>Should we do ToUpper or ToLower when generating table names?</param> <param name='TableNames'>Comma separated table names list to limit a number of tables in big instances. The names can have '%' sign to handle it as in the 'LIKE' query (Oracle and MSSQL Only)</param> <param name='ContextSchemaPath'>The location of the context schema previously saved with SaveContextSchema. When not empty, will be used to populate the database schema instead of retrieving it from then database.</param> <param name='OdbcQuote'>Odbc quote characters: Quote characters for the table and column names: `alias`, [alias]</param> <param name='SQLiteLibrary'>Use System.Data.SQLite or Mono.Data.SQLite or select automatically (SQLite only)</param> <param name='SsdtPath'>A path to an SSDT .dacpac file.'</param>
<summary> This event fires immediately before the execution of every generated query. Listen to this event to display or debug the content of your queries. </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: 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)
System.Console.WriteLine(value: decimal) : 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> The table Orders belonging to schema main</summary>
<summary> The table Customers belonging to schema main</summary>
<summary>CustomerID: nchar(5)</summary>
<summary>City: nvarchar(15)</summary>
<summary>OrderDate: datetime</summary>
<summary>ShippedDate: datetime</summary>
<summary>Freight: money</summary>
<summary>PostalCode: nvarchar(10)</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>