namespace BitBadger.Documents.Postgres
open System.IO
open System.Text
/// The type of index to generate for the document
[]
type DocumentIndex =
/// A GIN index with standard operations (all operators supported)
| Full
///
/// A GIN index with JSON Path operations (optimized for @>, @?, @@ operators)
///
| Optimized
open Npgsql
/// Configuration for document handling
module Configuration =
/// The data source to use for query execution
let mutable private dataSourceValue : NpgsqlDataSource option = None
/// Register a data source to use for query execution (disposes the current one if it exists)
/// The data source to use
[]
let useDataSource source =
if Option.isSome dataSourceValue then dataSourceValue.Value.Dispose()
dataSourceValue <- Some source
/// Retrieve the currently configured data source
/// The current data source
/// If no data source has been configured
[]
let dataSource () =
match dataSourceValue with
| Some source -> source
| None -> invalidOp "Please provide a data source before attempting data access"
open Npgsql.FSharp
/// Helper functions
[]
module private Helpers =
/// Shorthand to retrieve the data source as SqlProps
let internal fromDataSource () =
Configuration.dataSource () |> Sql.fromDataSource
/// Execute a task and ignore the result
let internal ignoreTask<'T> (it : System.Threading.Tasks.Task<'T>) = backgroundTask {
let! _ = it
()
}
/// Create a number or string parameter, or use the given parameter derivation function if non-(numeric or string)
let internal parameterFor<'T> (value: 'T) (catchAllFunc: 'T -> SqlValue) =
match box value with
| :? int8 as it -> Sql.int8 it
| :? uint8 as it -> Sql.int8 (int8 it)
| :? int16 as it -> Sql.int16 it
| :? uint16 as it -> Sql.int16 (int16 it)
| :? int as it -> Sql.int it
| :? uint32 as it -> Sql.int (int it)
| :? int64 as it -> Sql.int64 it
| :? uint64 as it -> Sql.int64 (int64 it)
| :? decimal as it -> Sql.decimal it
| :? single as it -> Sql.double (double it)
| :? double as it -> Sql.double it
| :? string as it -> Sql.string it
| _ -> catchAllFunc value
open BitBadger.Documents
/// Functions for creating parameters
[]
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) =
"@id", parameterFor key (fun it -> Sql.string (string it))
/// 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: string) (it: 'TJson) =
name, Sql.jsonb (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 ($"{p}min", parameterFor min (fun v -> Sql.parameter (NpgsqlParameter($"{p}min", v))))
yield ($"{p}max", parameterFor max (fun v -> Sql.parameter (NpgsqlParameter($"{p}max", v))))
| In values ->
let p = name.Derive it.ParameterName
yield!
values
|> Seq.mapi (fun idx v ->
let paramName = $"{p}_{idx}"
paramName, Sql.parameter (NpgsqlParameter(paramName, v)))
| InArray (_, values) ->
let p = name.Derive it.ParameterName
yield (p, Sql.stringArray (values |> Seq.map string |> Array.ofSeq))
| Equal v | Greater v | GreaterOrEqual v | Less v | LessOrEqual v | NotEqual v ->
let p = name.Derive it.ParameterName
yield (p, parameterFor v (fun l -> Sql.parameter (NpgsqlParameter(p, l)))) })
|> Seq.collect id
|> Seq.append parameters
|> Seq.toList
|> Seq.ofList
/// Append JSON field name parameters for the given field names to the given parameters
/// The names of fields to be addressed
/// The name (@name) and parameter value for the field names
[]
let fieldNameParams (fieldNames: string seq) =
if Seq.length fieldNames = 1 then "@name", Sql.string (Seq.head fieldNames)
else "@name", Sql.stringArray (Array.ofSeq fieldNames)
/// An empty parameter sequence
[]
let noParams =
Seq.empty
/// Query construction functions
[]
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()
let isNumeric (it: obj) =
match it with
| :? int8 | :? uint8 | :? int16 | :? uint16 | :? int | :? uint32 | :? int64 | :? uint64
| :? decimal | :? single | :? double -> true
| _ -> false
fields
|> Seq.map (fun it ->
match it.Comparison with
| Exists | NotExists -> $"{it.Path PostgreSQL AsSql} {it.Comparison.OpSql}"
| InArray _ -> $"{it.Path PostgreSQL AsJson} {it.Comparison.OpSql} {name.Derive it.ParameterName}"
| _ ->
let p = name.Derive it.ParameterName
let param, value =
match it.Comparison with
| Between (min, _) -> $"{p}min AND {p}max", min
| In values ->
let paramNames = values |> Seq.mapi (fun idx _ -> $"{p}_{idx}") |> String.concat ", "
$"({paramNames})", defaultArg (Seq.tryHead values) (obj ())
| Equal v | Greater v | GreaterOrEqual v | Less v | LessOrEqual v | NotEqual v -> p, v
| _ -> p, ""
if isNumeric value then
$"({it.Path PostgreSQL AsSql})::numeric {it.Comparison.OpSql} {param}"
else $"{it.Path PostgreSQL AsSql} {it.Comparison.OpSql} {param}")
|> 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<'TKey> (docId: 'TKey) =
whereByFields Any [ { Field.Equal (Configuration.idField ()) docId with ParameterName = Some "@id" } ]
/// Table and index definition queries
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 "JSONB"
/// SQL statement to create an index on JSON documents in the specified table
/// The name of the table to be indexed (may include schema)
/// The type of document index to create
/// A query to create the index if it does not exist
[]
let ensureDocumentIndex (name: string) idxType =
let extraOps = match idxType with Full -> "" | Optimized -> " jsonb_path_ops"
let tableName = name.Split '.' |> Array.last
$"CREATE INDEX IF NOT EXISTS idx_{tableName}_document ON {name} USING GIN (data{extraOps})"
/// Create a WHERE clause fragment to implement a @> (JSON contains) condition
/// The parameter name for the query
/// A WHERE clause fragment for the contains condition
[]
let whereDataContains paramName =
$"data @> %s{paramName}"
/// Create a WHERE clause fragment to implement a @? (JSON Path match) condition
/// The parameter name for the query
/// A WHERE clause fragment for the JSON Path match condition
[]
let whereJsonPathMatches paramName =
$"data @? %s{paramName}::jsonpath"
/// 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 = data || @data"
/// Create an UPDATE statement to remove fields from documents
/// The table to be updated
/// A query to remove fields from documents
[]
let removeFields tableName =
$"UPDATE %s{tableName} SET data = data - @name"
/// 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 (whereById docId)
/// 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)
/// Create a JSON containment query
/// The SQL statement to be run against the containment query
/// A query addressing documents by a JSON containment query
[]
let byContains statement =
Query.statementWhere statement (whereDataContains "@criteria")
/// Create a JSON Path match query
/// The SQL statement to run against the JSON Path match
/// A query addressing documents by a JSON Path match
[]
let byPathMatch statement =
Query.statementWhere statement (whereJsonPathMatches "@path")
/// Functions for dealing with 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 row reader set to the row with the document to be constructed
/// The constructed domain item
[]
let fromDocument<'T> field (row: RowReader) : 'T =
Configuration.serializer().Deserialize<'T>(row.string field)
/// Create a domain item from a document
/// A row reader set to the row with the document to be constructed
/// The constructed domain item
[]
let fromData<'T> row : 'T =
fromDocument "data" row
/// Extract a count from the column it
/// A row reader set to the row with the count to retrieve
/// The count from the row
[]
let toCount (row: RowReader) =
row.int "it"
/// Extract a true/false value from the column it
/// A row reader set to the row with the true/false value to retrieve
/// The true/false value from the row
[]
let toExists (row: RowReader) =
row.bool "it"
/// Extract a JSON document, specifying the field in which the document is found
/// The field name containing the JSON document
/// A row reader set to the row with the document to be extracted
/// The JSON from the given field (an empty object if no field exists)
[]
let jsonFromDocument field (row: RowReader) =
row.stringOrNone field |> Option.defaultValue "{}"
/// Extract a JSON document
/// A row reader set to the row with the document to be extracted
/// The JSON from the row (an empty object if no field exists)
[]
let jsonFromData row =
jsonFromDocument "data" row
/// Create a JSON array of items for the results of a query
/// The mapping function to extract JSON from the query's results
/// The query from which JSON should be extracted
/// A JSON array as a string; no results will produce an empty array ("[]")
[]
let toJsonArray (mapFunc: RowReader -> string) sqlProps = backgroundTask {
let output = StringBuilder("[")
do! sqlProps
|> Sql.iterAsync (fun it ->
if output.Length > 2 then ignore (output.Append ",")
mapFunc it |> output.Append |> ignore)
return output.Append("]").ToString()
}
/// Create a JSON array of items for the results of a query
/// The mapping function to extract JSON from the query's results
/// The query from which JSON should be extracted
/// A JSON array as a string; no results will produce an empty array ("[]")
let ToJsonArray(mapFunc: System.Func, sqlProps) =
toJsonArray mapFunc.Invoke sqlProps
/// Write a JSON array of items for the results of a query to the given StreamWriter
/// The StreamWriter to which results should be written
/// The mapping function to extract JSON from the query's results
/// The query from which JSON should be extracted
[]
let writeJsonArray (writer: StreamWriter) (mapFunc: RowReader -> string) sqlProps = backgroundTask {
do! writer.WriteAsync "["
let mutable isFirst = true
do! sqlProps
|> Sql.iterAsync (fun it ->
if isFirst then isFirst <- false else writer.Write ","
writer.WriteAsync(mapFunc it).ConfigureAwait(false).GetAwaiter().GetResult())
do! writer.WriteAsync "]"
}
/// Write a JSON array of items for the results of a query to the given StreamWriter
/// The StreamWriter to which results should be written
/// The mapping function to extract JSON from the query's results
/// The query from which JSON should be extracted
let WriteJsonArray(writer, mapFunc: System.Func, sqlProps) =
writeJsonArray writer mapFunc.Invoke sqlProps