namespace BitBadger.Documents.Sqlite
open System.IO
open System.Text
open BitBadger.Documents
open Microsoft.Data.Sqlite
/// Configuration for document handling
module Configuration =
/// The connection string to use for query execution
let mutable internal connectionString: string option = None
/// Register a connection string to use for query execution
/// The connection string to use for connections from this library
/// This also enables foreign keys
[]
let useConnectionString connStr =
let builder = SqliteConnectionStringBuilder connStr
builder.ForeignKeys <- Option.toNullable (Some true)
connectionString <- Some (string builder)
/// Retrieve a new connection using currently configured connection string
/// A new database connection
/// If no data source has been configured
/// If the connection cannot be opened
[]
let dbConn () =
match connectionString with
| Some connStr ->
let conn = new SqliteConnection(connStr)
conn.Open()
conn
| None -> invalidOp "Please provide a connection string before attempting data access"
/// Query definitions
[]
module Query =
/// Create a WHERE clause fragment to implement a comparison on fields in a JSON document
/// How the fields should be matched
/// The fields for the comparisons
/// A WHERE clause implementing the comparisons for the given fields
[]
let whereByFields (howMatched: FieldMatch) fields =
let name = ParameterName()
fields
|> Seq.map (fun it ->
match it.Comparison with
| Exists | NotExists -> $"{it.Path SQLite AsSql} {it.Comparison.OpSql}"
| Between _ ->
let p = name.Derive it.ParameterName
$"{it.Path SQLite AsSql} {it.Comparison.OpSql} {p}min AND {p}max"
| In values ->
let p = name.Derive it.ParameterName
let paramNames = values |> Seq.mapi (fun idx _ -> $"{p}_{idx}") |> String.concat ", "
$"{it.Path SQLite AsSql} {it.Comparison.OpSql} ({paramNames})"
| InArray (table, values) ->
let p = name.Derive it.ParameterName
let paramNames = values |> Seq.mapi (fun idx _ -> $"{p}_{idx}") |> String.concat ", "
$"EXISTS (SELECT 1 FROM json_each({table}.data, '$.{it.Name}') WHERE value IN ({paramNames}))"
| _ -> $"{it.Path SQLite AsSql} {it.Comparison.OpSql} {name.Derive it.ParameterName}")
|> String.concat $" {howMatched} "
/// Create a WHERE clause fragment to implement an ID-based query
/// The ID of the document
/// A WHERE clause fragment identifying a document by its ID
[]
let whereById (docId: 'TKey) =
whereByFields Any [ { Field.Equal (Configuration.idField ()) docId with ParameterName = Some "@id" } ]
/// Create an UPDATE statement to patch documents
/// The table to be updated
/// A query to patch documents
[]
let patch tableName =
$"UPDATE %s{tableName} SET data = json_patch(data, json(@data))"
/// Create an UPDATE statement to remove fields from documents
/// The table to be updated
/// The parameters with the field names to be removed
/// A query to remove fields from documents
[]
let removeFields tableName (parameters: SqliteParameter seq) =
let paramNames = parameters |> Seq.map _.ParameterName |> String.concat ", "
$"UPDATE %s{tableName} SET data = json_remove(data, {paramNames})"
/// Create a query by a document's ID
/// The SQL statement to be run against a document by its ID
/// The ID of the document targeted
/// A query addressing a document by its ID
[]
let byId<'TKey> statement (docId: 'TKey) =
Query.statementWhere
statement
(whereByFields Any [ { Field.Equal (Configuration.idField ()) docId with ParameterName = Some "@id" } ])
/// Create a query on JSON fields
/// The SQL statement to be run against matching fields
/// Whether to match any or all of the field conditions
/// The field conditions to be matched
/// A query addressing documents by field matching conditions
[]
let byFields statement howMatched fields =
Query.statementWhere statement (whereByFields howMatched fields)
/// Data definition
module Definition =
/// SQL statement to create a document table
/// The name of the table (may include schema)
/// A query to create the table if it does not exist
[]
let ensureTable name =
Query.Definition.ensureTableFor name "TEXT"
/// Parameter handling helpers
[]
module Parameters =
/// Create an ID parameter (name "@id")
/// The key value for the ID parameter
/// The name and parameter value for the ID
[]
let idParam (key: 'TKey) =
SqliteParameter("@id", string key)
/// Create a parameter with a JSON value
/// The name of the parameter to create
/// The criteria to provide as JSON
/// The name and parameter value for the JSON field
[]
let jsonParam name (it: 'TJson) =
SqliteParameter(name, Configuration.serializer().Serialize it)
/// Create JSON field parameters
/// The Fields to convert to parameters
/// The current parameters for the query
/// A unified sequence of parameter names and values
[]
let addFieldParams fields parameters =
let name = ParameterName()
fields
|> Seq.map (fun it ->
seq {
match it.Comparison with
| Exists | NotExists -> ()
| Between (min, max) ->
let p = name.Derive it.ParameterName
yield! [ SqliteParameter($"{p}min", min); SqliteParameter($"{p}max", max) ]
| In values | InArray (_, values) ->
let p = name.Derive it.ParameterName
yield! values |> Seq.mapi (fun idx v -> SqliteParameter($"{p}_{idx}", v))
| Equal v | Greater v | GreaterOrEqual v | Less v | LessOrEqual v | NotEqual v ->
yield SqliteParameter(name.Derive it.ParameterName, v) })
|> Seq.collect id
|> Seq.append parameters
|> Seq.toList
|> Seq.ofList
/// Create a JSON field parameter (name "@field")
[]
[]
let addFieldParam name field parameters =
addFieldParams [ { field with ParameterName = Some name } ] parameters
/// Append JSON field name parameters for the given field names to the given parameters
/// The name of the parameter to use for each field
/// The names of fields to be addressed
/// The name (@name) and parameter value for the field names
[]
let fieldNameParams paramName fieldNames =
fieldNames
|> Seq.mapi (fun idx name -> SqliteParameter($"%s{paramName}{idx}", $"$.%s{name}"))
|> Seq.toList
|> Seq.ofList
/// An empty parameter sequence
[]
let noParams =
Seq.empty
/// Helper functions for handling results
[]
module Results =
/// Create a domain item from a document, specifying the field in which the document is found
/// The field name containing the JSON document
/// A SqliteDataReader set to the row with the document to be constructed
/// The constructed domain item
[]
let fromDocument<'TDoc> field (rdr: SqliteDataReader) : 'TDoc =
Configuration.serializer().Deserialize<'TDoc>(rdr.GetString(rdr.GetOrdinal field))
/// Create a domain item from a document
/// A SqliteDataReader set to the row with the document to be constructed
/// The constructed domain item
[]
let fromData<'TDoc> rdr =
fromDocument<'TDoc> "data" rdr
///
/// Create a list of items for the results of the given command, using the specified mapping function
///
/// The command to execute
/// The mapping function from data reader to domain class instance
/// A list of items from the reader
[]
let toCustomList<'TDoc> (cmd: SqliteCommand) (mapFunc: SqliteDataReader -> 'TDoc) = backgroundTask {
use! rdr = cmd.ExecuteReaderAsync()
let mutable it = Seq.empty<'TDoc>
while! rdr.ReadAsync() do
it <- Seq.append it (Seq.singleton (mapFunc rdr))
return List.ofSeq it
}
///
/// Create a list of items for the results of the given command, using the specified mapping function
///
/// The command to execute
/// The mapping function from data reader to domain class instance
/// A list of items from the reader
let ToCustomList<'TDoc>(cmd: SqliteCommand, mapFunc: System.Func) = backgroundTask {
use! rdr = cmd.ExecuteReaderAsync()
let it = ResizeArray<'TDoc>()
while! rdr.ReadAsync() do
it.Add(mapFunc.Invoke rdr)
return it
}
/// Extract a count from the first column
/// A SqliteDataReader set to the row with the count to retrieve
/// The count from the row
[]
let toCount (rdr: SqliteDataReader) =
rdr.GetInt64 0
/// Extract a true/false value from the first column
/// A SqliteDataReader set to the row with the true/false value to retrieve
/// The true/false value from the row
/// SQLite implements boolean as 1 = true, 0 = false
[]
let toExists rdr =
toCount rdr > 0L
/// Retrieve a JSON document, specifying the field in which the document is found
/// The field name containing the JSON document
/// A SqliteDataReader set to the row with the document to be constructed
/// The JSON document (an empty JSON document if not found)
[]
let jsonFromDocument field (rdr: SqliteDataReader) =
try
let idx = rdr.GetOrdinal field
if rdr.IsDBNull idx then "{}" else rdr.GetString idx
with :? System.IndexOutOfRangeException -> "{}"
/// Retrieve a JSON document
/// A SqliteDataReader set to the row with the document to be constructed
/// The JSON document (an empty JSON document if not found)
[]
let jsonFromData rdr =
jsonFromDocument "data" rdr
///
/// Create a JSON array for the results of the given command, using the specified mapping function
///
/// The command to execute
/// The mapping function to extract JSON from the query's results
/// A JSON array of items from the reader
[]
let toJsonArray (cmd: SqliteCommand) (mapFunc: SqliteDataReader -> string) = backgroundTask {
use! rdr = cmd.ExecuteReaderAsync()
let it = StringBuilder "["
while! rdr.ReadAsync() do
if it.Length > 2 then ignore (it.Append ",")
it.Append(mapFunc rdr) |> ignore
return it.Append("]").ToString()
}
///
/// Create a JSON array for the results of the given command, using the specified mapping function
///
/// The command to execute
/// The mapping function to extract JSON from the query's results
/// A JSON array of items from the reader
let ToJsonArray (cmd: SqliteCommand) (mapFunc: System.Func) =
toJsonArray cmd mapFunc.Invoke
/// Write a JSON array of items for the results of a query to the given StreamWriter
/// The command to execute
/// The StreamWriter to which results should be written
/// The mapping function to extract JSON from the query's results
[]
let writeJsonArray (cmd: SqliteCommand) (writer: StreamWriter) (mapFunc: SqliteDataReader -> string) =
backgroundTask {
use! rdr = cmd.ExecuteReaderAsync()
do! writer.WriteAsync "["
let mutable isFirst = true
while! rdr.ReadAsync() do
if isFirst then isFirst <- false else do! writer.WriteAsync ","
do! writer.WriteAsync(mapFunc rdr)
do! writer.WriteAsync "]"
}
/// Write a JSON array of items for the results of a query to the given StreamWriter
/// The command to execute
/// The StreamWriter to which results should be written
/// The mapping function to extract JSON from the query's results
let WriteJsonArray (cmd: SqliteCommand) (writer: StreamWriter) (mapFunc: System.Func) =
writeJsonArray cmd writer mapFunc.Invoke
[]
module internal Helpers =
/// Execute a non-query command
/// The command to be executed
let internal write (cmd: SqliteCommand) = backgroundTask {
let! _ = cmd.ExecuteNonQueryAsync()
()
}