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() () }