The core library that enables you to work with F# and any ADO provider, consistently.
In most cases, this library is only used for creating other F# libraries leveraging the relevant ADO providers.
If you just wanna execute SQL commands a-la-F#, you might want to look at this section
We aim at following "highly controversial practices" to the best of our ability!
Status | Package |
---|---|
OK | |
OK (sorta) | |
TBD | |
TBD |
Name | Version | Command |
---|---|---|
Vp.FSharp.Sql |
Install-Package Vp.FSharp.Sql |
This library mostly aims at being used as some sort of foundation to build other libraries with the relevant ADO.NET providers to provide a strongly-typed experience.
You can check out the libraries below, each leveraging Vp.FSharp.Sql
and the relevant ADO.NET provider:
Name | ADO.NET Provider | Version | Command |
---|---|---|---|
Vp.FSharp.Sql.Sqlite |
System.Data.SQLite.Core |
Install-Package Vp.FSharp.Sql.Sqlite |
|
Vp.FSharp.Sql.SqlServer |
Microsoft.Data.SqlClient |
Install-Package Vp.FSharp.Sql.SqlServer |
|
Vp.FSharp.Sql.PostgreSql |
Npgsql |
Install-Package Vp.FSharp.Sql.PostgreSql |
In a Nutshell you can create your own complete provider, but you're free to just go with only some particular bits.
Let's walk-through the Vp.FSharp.Sql.Sqlite
provider implementation.
First you need the most important type of all, the database value type.
In the case of SQLite, SqliteDbValue
can modeled as a simple DU:
/// Native SQLite DB types.
/// See https://www.sqlite.org/datatype3.html
type SqliteDbValue =
| Null
| Integer of int64
| Real of double
| Text of string
| Blob of byte array
These cases are created after the official SQLite documentation.
This is where we convert the DU exposed in the public API to an actual DbParameter
-compatible class that can be consumed from the Core library functions.
In most scenarios, the implementation consists in writing a pattern match on the different database value type cases and creating the relevant DbParameter
specific type available in the ADO.NET provider, if any:
let dbValueToParameter name value =
let parameter = SQLiteParameter()
parameter.ParameterName <- name
match value with
| Null ->
parameter.TypeName <- (nameof Null).ToUpperInvariant()
| Integer value ->
parameter.TypeName <- (nameof Integer).ToUpperInvariant()
parameter.Value <- value
| Real value ->
parameter.TypeName <- (nameof Real).ToUpperInvariant()
parameter.Value <- value
| Text value ->
parameter.TypeName <- (nameof Text).ToUpperInvariant()
parameter.Value <- value
| Blob value ->
parameter.TypeName <- (nameof Blob).ToUpperInvariant()
parameter.Value <- value
parameter
Note: this function doesn't have to be public, only the DU has to be public.
The SqlDependencies
acts like the glue that sticks all the most important underlying ADO-specific operations:
/// SQLite Dependencies
type SqliteDependencies =
SqlDependencies<
SQLiteConnection,
SQLiteCommand,
SQLiteParameter,
SQLiteDataReader,
SQLiteTransaction,
SqliteDbValue>
An instance of this type can implemented with:
let beginTransactionAsync (connection: SQLiteConnection) (isolationLevel: IsolationLevel) _ =
ValueTask.FromResult(connection.BeginTransaction(isolationLevel))
let executeReaderAsync (command: SQLiteCommand) _ =
Task.FromResult(command.ExecuteReader())
let deps =
{ CreateCommand = fun connection -> connection.CreateCommand()
SetCommandTransaction = fun command transaction -> command.Transaction <- transaction
BeginTransactionAsync = beginTransactionAsync
ExecuteReaderAsync = executeReaderAsync
DbValueToParameter = Constants.DbValueToParameter }
In this particular case, System.Data.SQLite
the most specific types are only available through the non-asynchronous API.
For instance, we use command.ExecuteReader
instead of command.ExecuteDbDataReader
because of:
Also, as you may have noticed there is no occurence of an asynchronous API.
Meaning that the asynchronous relies on the base class implementation:
which is just an asynchronous wrapper around the synchronous version.
Similarly when it comes to connection.BeginTransaction
instead of command.BeginTransactionAsync
:
This example alone shows the kind of discrepancies you can expect to find in the most ADO.NET provider implementations available out there.
For the sake of simplicity, you can constraint the CommandDefinition
type with the relevant ADO provider types, as some sort of type binder:
/// SQLite Command Definition
type SqliteCommandDefinition =
CommandDefinition<
SQLiteConnection,
SQLiteCommand,
SQLiteParameter,
SQLiteDataReader,
SQLiteTransaction,
SqliteDbValue>
This can be later on used with the SqlCommand
functions which accept CommandDefinition
as one of their parameters.
This yet another specialization in terms of generic constraints:
/// SQLite Configuration
type SqliteConfiguration =
SqlConfigurationCache<
SQLiteConnection,
SQLiteCommand>
This type is also yet another binder for types and acts as a cache, it will be passed along with the command definition when executing a command.
It's fairly straightforward, all you need to do is:
- Create a new module (if you want to).
- Define the construction functions relevant to your library and pass the command definition to the
SqlCommand
core functions.
[<RequireQualifiedAccess>]
module Vp.FSharp.Sql.Sqlite.SqliteCommand
open Vp.FSharp.Sql
/// Initialize a new command definition with the given text contained in the given string.
let text value : SqliteCommandDefinition =
SqlCommand.text value
/// Initialize a new command definition with the given text spanning over several strings (ie. list).
let textFromList value : SqliteCommandDefinition =
SqlCommand.textFromList value
/// Update the command definition so that when executing the command, it doesn't use any logger.
/// Be it the default one (Global, if any.) or a previously overriden one.
let noLogger commandDefinition = { commandDefinition with Logger = LoggerKind.Nothing }
/// Update the command definition so that when executing the command, it use the given overriding logger.
/// instead of the default one, aka the Global logger, if any.
let overrideLogger value commandDefinition = { commandDefinition with Logger = LoggerKind.Override value }
/// Update the command definition with the given parameters.
let parameters value (commandDefinition: SqliteCommandDefinition) : SqliteCommandDefinition =
SqlCommand.parameters value commandDefinition
/// Update the command definition with the given cancellation token.
let cancellationToken value (commandDefinition: SqliteCommandDefinition) : SqliteCommandDefinition =
SqlCommand.cancellationToken value commandDefinition
/// Update the command definition with the given timeout.
/// Note: kludged because SQLite doesn't support per-command timeout values.
let timeout value (commandDefinition: SqliteCommandDefinition) : SqliteCommandDefinition =
SqlCommand.timeout value commandDefinition
/// Update the command definition and sets whether the command should be prepared or not.
let prepare value (commandDefinition: SqliteCommandDefinition) : SqliteCommandDefinition =
SqlCommand.prepare value commandDefinition
/// Update the command definition and sets whether the command should be wrapped in the given transaction.
let transaction value (commandDefinition: SqliteCommandDefinition) : SqliteCommandDefinition =
SqlCommand.transaction value commandDefinition
Likewise, the command execution follows the same principles, aka passing the relevant strongly-typed parameters (corresponding to your current and specific ADO.NET provider) to the SQLCommand core functions.
module Vp.FSharp.Sql.Sqlite.SqliteCommand
open Vp.FSharp.Sql
// [...Command Construction Functions...]
/// Execute the command and return the sets of rows as an AsyncSeq accordingly to the command definition.
let queryAsyncSeq connection read (commandDefinition: SqliteCommandDefinition) =
SqlCommand.queryAsyncSeq
connection (Constants.Deps) (SqliteConfiguration.Snapshot) read commandDefinition
/// Execute the command and return the sets of rows as a list accordingly to the command definition.
let queryList connection read (commandDefinition: SqliteCommandDefinition) =
SqlCommand.queryList
connection (Constants.Deps) (SqliteConfiguration.Snapshot) read commandDefinition
/// Execute the command and return the first set of rows as a list accordingly to the command definition.
let querySetList connection read (commandDefinition: SqliteCommandDefinition) =
SqlCommand.querySetList
connection (Constants.Deps) (SqliteConfiguration.Snapshot) read commandDefinition
/// Execute the command and return the 2 first sets of rows as a tuple of 2 lists accordingly to the command definition.
let querySetList2 connection read1 read2 (commandDefinition: SqliteCommandDefinition) =
SqlCommand.querySetList2
connection (Constants.Deps) (SqliteConfiguration.Snapshot) read1 read2 commandDefinition
/// Execute the command and return the 3 first sets of rows as a tuple of 3 lists accordingly to the command definition.
let querySetList3 connection read1 read2 read3 (commandDefinition: SqliteCommandDefinition) =
SqlCommand.querySetList3
connection (Constants.Deps) (SqliteConfiguration.Snapshot) read1 read2 read3 commandDefinition
/// Execute the command accordingly to its definition and,
/// - return the first cell value, if it is available and of the given type.
/// - throw an exception, otherwise.
let executeScalar<'Scalar> connection (commandDefinition: SqliteCommandDefinition) =
SqlCommand.executeScalar<'Scalar, _, _, _, _, _, _, _, _>
connection (Constants.Deps) (SqliteConfiguration.Snapshot) commandDefinition
/// Execute the command accordingly to its definition and,
/// - return Some, if the first cell is available and of the given type.
/// - return None, if first cell is DBNull.
/// - throw an exception, otherwise.
let executeScalarOrNone<'Scalar> connection (commandDefinition: SqliteCommandDefinition) =
SqlCommand.executeScalarOrNone<'Scalar, _, _, _, _, _, _, _, _>
connection (Constants.Deps) (SqliteConfiguration.Snapshot) commandDefinition
/// Execute the command accordingly to its definition and, return the number of rows affected.
let executeNonQuery connection (commandDefinition: SqliteCommandDefinition) =
SqlCommand.executeNonQuery
connection (Constants.Deps) (SqliteConfiguration.Snapshot) commandDefinition
Again, we can create another module and the rest is all about passing the relevant parameters to the underlying core functions.
[<RequireQualifiedAccess>]
module Vp.FSharp.Sql.Sqlite.SqliteNullDbValue
open Vp.FSharp.Sql
/// Return SQLite DB Null value if the given option is None, otherwise the underlying wrapped in Some.
let ifNone toDbValue = NullDbValue.ifNone toDbValue SqliteDbValue.Null
/// Return SQLite DB Null value if the option is Error, otherwise the underlying wrapped in Ok.
let ifError toDbValue = NullDbValue.ifError toDbValue (fun _ -> SqliteDbValue.Null)
Same old, same old here too.
[<RequireQualifiedAccess>]
module Vp.FSharp.Sql.Sqlite.SqliteTransaction
open Vp.FSharp.Sql
open Vp.FSharp.Sql.Sqlite
let private beginTransactionAsync = Constants.Deps.BeginTransactionAsync
/// Create and commit an automatically generated transaction with the given connection, isolation,
/// cancellation token and transaction body.
let commit cancellationToken isolationLevel connection body =
Transaction.commit cancellationToken isolationLevel connection beginTransactionAsync body
/// Create and do not commit an automatically generated transaction with the given connection, isolation,
/// cancellation token and transaction body.
let notCommit cancellationToken isolationLevel connection body =
Transaction.notCommit cancellationToken isolationLevel connection beginTransactionAsync body
/// Create and commit an automatically generated transaction with the given connection, isolation,
/// cancellation token and transaction body.
/// The commit phase only occurs if the transaction body returns Some.
let commitOnSome cancellationToken isolationLevel connection body =
Transaction.commitOnSome cancellationToken isolationLevel connection beginTransactionAsync body
/// Create and commit an automatically generated transaction with the given connection, isolation,
/// cancellation token and transaction body.
/// The commit phase only occurs if the transaction body returns Ok.
let commitOnOk cancellationToken isolationLevel connection body =
Transaction.commitOnOk cancellationToken isolationLevel connection beginTransactionAsync body
/// Create and commit an automatically generated transaction with the given connection and transaction body.
let defaultCommit connection body = Transaction.defaultCommit connection beginTransactionAsync body
/// Create and do not commit an automatically generated transaction with the given connection and transaction body.
let defaultNotCommit connection body = Transaction.defaultNotCommit connection beginTransactionAsync body
/// Create and commit an automatically generated transaction with the given connection and transaction body.
/// The commit phase only occurs if the transaction body returns Ok.
let defaultCommitOnSome connection body = Transaction.defaultCommitOnSome connection beginTransactionAsync body
/// Create and commit an automatically generated transaction with the given connection and transaction body.
/// The commit phase only occurs if the transaction body returns Some.
let defaultCommitOnOk connection body = Transaction.defaultCommitOnOk connection beginTransactionAsync body
And voila! You're now all settled and ready to execute the wildest commands against your favorite database!
These helpers work regardless of the ADO.NET provider you're using as long as it supports TransactionScope
.
โ A few things to consider โ
- ๐จ Bear in mind that the support for distributed transactions is not yet available since the .NET core era.
- ๐จ Using
TransactionScope
(with or without those helpers) is very error-prone and you might bump into unexpected behaviours without benefiting from clear error messages. - ๐จ Considering that there is very little evolution regarding this support and therefore there is somehow limited applications to use the
TransactionScope
without the support for distributed transactions, those helpers might move to a separate library (i.e. repository + nuget package).
Bug reports, feature requests, and pull requests are very welcome!
Please read the Contribution Guidelines to get started.
The project is licensed under MIT.
For more information on the license see the license file.