Files
BitBadger.Documents/src/Common/Library.fs
Daniel J. Summers 2c24e2e912 Version 4 rc1 (#6)
Changes in this version:
- **BREAKING CHANGE**: All `*byField`/`*ByField` functions are now `*byFields`/`*ByFields`, and take a `FieldMatch` case before the list of fields. The `Compat` namespace in both libraries will assist in this transition. In support of this change, the `Field` parameter name is optional; the library will generate parameter names for it if they are not specified.
- **BREAKING CHANGE**: The `Query` namespaces have had some significant work, particularly from the full-query perspective. Most have been broken up into the base query and modifiers `by*` that will combine the base query with the `WHERE` clause needed to satisfy the criteria.
- **FEATURE / BREAKING CHANGE**: PostgreSQL document fields will now be cast to numeric if the parameter value passed to the query is numeric. This drove the `Query` breaking changes, as the fields need to have their intended value for the library to generate the appropriate SQL. Additionally, if code assumes the library can be given something like `8` and transform it to `"8"`, this is no longer the case.
- **FEATURE**: All `Find` queries (except `byId`/`ById`) now have a version with the `Ordered` suffix. These take a list of fields by which the query should be ordered. A new `Field` method called `Named` can assist with creating these fields. Prefixing the field name with `n:` will cast the field to numeric in PostgreSQL (and will be ignored by SQLite); adding " DESC" to the field name will sort it descending (Z-A, high to low) instead of ascending (A-Z, low to high).
- **BREAKING CHANGE** (PostgreSQL only): `fieldNameParam`/`Parameters.FieldName` are now plural. The function still only generates one parameter, but the name is now the same between PostgreSQL and SQLite. The goal of this library is to abstract the differences away as much as practical, and this furthers that end. There are functions with these names in the `Compat` namespace.
- **FEATURE**: In the F# v3 library, lists of parameters were expected to be F#'s `List` type, and the C# version took either `List<T>` or `IEnumerable<T>`. In this version, these all expect `seq`/`IEnumerable<T>`. F#'s `List` satisfies the `seq` constraints, so this should not be a breaking change.
- **FEATURE**: `Field`s now may have qualifiers; this allows tables to be aliased when joining multiple tables (as all have the same `data` column). F# users can use `with` to specify this at creation, and both F# and C# can use the `WithQualifier` method to create a field with the qualifier specified. Parameter names for fields may be specified in a similar way, substituting `ParameterName` for `Qualifier`.

Reviewed-on: #6
2024-08-19 23:30:38 +00:00

411 lines
15 KiB
Forth

namespace BitBadger.Documents
open System.Security.Cryptography
/// The types of logical operations available for JSON fields
[<Struct>]
type Op =
/// Equals (=)
| EQ
/// Greater Than (>)
| GT
/// Greater Than or Equal To (>=)
| GE
/// Less Than (<)
| LT
/// Less Than or Equal To (<=)
| LE
/// Not Equal to (<>)
| NE
/// Between (BETWEEN)
| BT
/// Exists (IS NOT NULL)
| EX
/// Does Not Exist (IS NULL)
| NEX
override this.ToString() =
match this with
| EQ -> "="
| GT -> ">"
| GE -> ">="
| LT -> "<"
| LE -> "<="
| NE -> "<>"
| BT -> "BETWEEN"
| EX -> "IS NOT NULL"
| NEX -> "IS NULL"
/// The dialect in which a command should be rendered
[<Struct>]
type Dialect =
| PostgreSQL
| SQLite
/// Criteria for a field WHERE clause
type Field = {
/// The name of the field
Name: string
/// The operation by which the field will be compared
Op: Op
/// The value of the field
Value: obj
/// The name of the parameter for this field
ParameterName: string option
/// The table qualifier for this field
Qualifier: string option
} with
/// Create an equals (=) field criterion
static member EQ name (value: obj) =
{ Name = name; Op = EQ; Value = value; ParameterName = None; Qualifier = None }
/// Create a greater than (>) field criterion
static member GT name (value: obj) =
{ Name = name; Op = GT; Value = value; ParameterName = None; Qualifier = None }
/// Create a greater than or equal to (>=) field criterion
static member GE name (value: obj) =
{ Name = name; Op = GE; Value = value; ParameterName = None; Qualifier = None }
/// Create a less than (<) field criterion
static member LT name (value: obj) =
{ Name = name; Op = LT; Value = value; ParameterName = None; Qualifier = None }
/// Create a less than or equal to (<=) field criterion
static member LE name (value: obj) =
{ Name = name; Op = LE; Value = value; ParameterName = None; Qualifier = None }
/// Create a not equals (<>) field criterion
static member NE name (value: obj) =
{ Name = name; Op = NE; Value = value; ParameterName = None; Qualifier = None }
/// Create a BETWEEN field criterion
static member BT name (min: obj) (max: obj) =
{ Name = name; Op = BT; Value = [ min; max ]; ParameterName = None; Qualifier = None }
/// Create an exists (IS NOT NULL) field criterion
static member EX name =
{ Name = name; Op = EX; Value = obj (); ParameterName = None; Qualifier = None }
/// Create a not exists (IS NULL) field criterion
static member NEX name =
{ Name = name; Op = NEX; Value = obj (); ParameterName = None; Qualifier = None }
/// Transform a field name (a.b.c) to a path for the given SQL dialect
static member NameToPath (name: string) dialect =
let path =
if name.Contains '.' then
match dialect with
| PostgreSQL -> "#>>'{" + String.concat "," (name.Split '.') + "}'"
| SQLite -> "->>'" + String.concat "'->>'" (name.Split '.') + "'"
else $"->>'{name}'"
$"data{path}"
/// Create a field with a given name, but no other properties filled (op will be EQ, value will be "")
static member Named name =
{ Name = name; Op = EQ; Value = ""; ParameterName = None; Qualifier = None }
/// Specify the name of the parameter for this field
member this.WithParameterName name =
{ this with ParameterName = Some name }
/// Specify a qualifier (alias) for the table from which this field will be referenced
member this.WithQualifier alias =
{ this with Qualifier = Some alias }
/// Get the qualified path to the field
member this.Path dialect =
(this.Qualifier |> Option.map (fun q -> $"{q}.") |> Option.defaultValue "") + Field.NameToPath this.Name dialect
/// How fields should be matched
[<Struct>]
type FieldMatch =
/// Any field matches (OR)
| Any
/// All fields match (AND)
| All
/// The SQL value implementing each matching strategy
override this.ToString() =
match this with Any -> "OR" | All -> "AND"
/// Derive parameter names (each instance wraps a counter to uniquely name anonymous fields)
type ParameterName() =
/// The counter for the next field value
let mutable currentIdx = -1
/// Return the specified name for the parameter, or an anonymous parameter name if none is specified
member this.Derive paramName =
match paramName with
| Some it -> it
| None ->
currentIdx <- currentIdx + 1
$"@field{currentIdx}"
#if NET6_0
open System.Text
#endif
/// Automatically-generated document ID strategies
[<Struct>]
type AutoId =
/// No automatic IDs will be generated
| Disabled
/// Generate a MAX-plus-1 numeric value for documents
| Number
/// Generate a GUID for each document (as a lowercase, no-dashes, 32-character string)
| Guid
/// Generate a random string of hexadecimal characters for each document
| RandomString
with
/// Generate a GUID string
static member GenerateGuid () =
System.Guid.NewGuid().ToString "N"
/// Generate a string of random hexadecimal characters
static member GenerateRandomString (length: int) =
#if NET8_0_OR_GREATER
RandomNumberGenerator.GetHexString(length, lowercase = true)
#else
RandomNumberGenerator.GetBytes((length / 2) + 1)
|> Array.fold (fun (str: StringBuilder) byt -> str.Append(byt.ToString "x2")) (StringBuilder length)
|> function it -> it.Length <- length; it.ToString()
#endif
/// Does the given document need an automatic ID generated?
static member NeedsAutoId<'T> strategy (document: 'T) idProp =
match strategy with
| Disabled -> false
| _ ->
let prop = document.GetType().GetProperty idProp
if isNull prop then invalidOp $"{idProp} not found in document"
else
match strategy with
| Number ->
if prop.PropertyType = typeof<int8> then
let value = prop.GetValue document :?> int8
value = int8 0
elif prop.PropertyType = typeof<int16> then
let value = prop.GetValue document :?> int16
value = int16 0
elif prop.PropertyType = typeof<int> then
let value = prop.GetValue document :?> int
value = 0
elif prop.PropertyType = typeof<int64> then
let value = prop.GetValue document :?> int64
value = int64 0
else invalidOp "Document ID was not a number; cannot auto-generate a Number ID"
| Guid | RandomString ->
if prop.PropertyType = typeof<string> then
let value =
prop.GetValue document
|> Option.ofObj
|> Option.map (fun it -> it :?> string)
|> Option.defaultValue ""
value = ""
else invalidOp "Document ID was not a string; cannot auto-generate GUID or random string"
| Disabled -> false
/// The required document serialization implementation
type IDocumentSerializer =
/// Serialize an object to a JSON string
abstract Serialize<'T> : 'T -> string
/// Deserialize a JSON string into an object
abstract Deserialize<'T> : string -> 'T
/// Document serializer defaults
module DocumentSerializer =
open System.Text.Json
open System.Text.Json.Serialization
/// The default JSON serializer options to use with the stock serializer
let private jsonDefaultOpts =
let o = JsonSerializerOptions()
o.Converters.Add(JsonFSharpConverter())
o
/// The default JSON serializer
[<CompiledName "Default">]
let ``default`` =
{ new IDocumentSerializer with
member _.Serialize<'T>(it: 'T) : string =
JsonSerializer.Serialize(it, jsonDefaultOpts)
member _.Deserialize<'T>(it: string) : 'T =
JsonSerializer.Deserialize<'T>(it, jsonDefaultOpts)
}
/// Configuration for document handling
[<RequireQualifiedAccess>]
module Configuration =
/// The serializer to use for document manipulation
let mutable private serializerValue = DocumentSerializer.``default``
/// Register a serializer to use for translating documents to domain types
[<CompiledName "UseSerializer">]
let useSerializer ser =
serializerValue <- ser
/// Retrieve the currently configured serializer
[<CompiledName "Serializer">]
let serializer () =
serializerValue
/// The serialized name of the ID field for documents
let mutable private idFieldValue = "Id"
/// Specify the name of the ID field for documents
[<CompiledName "UseIdField">]
let useIdField it =
idFieldValue <- it
/// Retrieve the currently configured ID field for documents
[<CompiledName "IdField">]
let idField () =
idFieldValue
/// The automatic ID strategy used by the library
let mutable private autoIdValue = Disabled
/// Specify the automatic ID generation strategy used by the library
[<CompiledName "UseAutoIdStrategy">]
let useAutoIdStrategy it =
autoIdValue <- it
/// Retrieve the currently configured automatic ID generation strategy
[<CompiledName "AutoIdStrategy">]
let autoIdStrategy () =
autoIdValue
/// The length of automatically generated random strings
let mutable private idStringLengthValue = 16
/// Specify the length of automatically generated random strings
[<CompiledName "UseIdStringLength">]
let useIdStringLength length =
idStringLengthValue <- length
/// Retrieve the currently configured length of automatically generated random strings
[<CompiledName "IdStringLength">]
let idStringLength () =
idStringLengthValue
/// Query construction functions
[<RequireQualifiedAccess>]
module Query =
/// Combine a query (select, update, etc.) and a WHERE clause
[<CompiledName "StatementWhere">]
let statementWhere statement where =
$"%s{statement} WHERE %s{where}"
/// Queries to define tables and indexes
module Definition =
/// SQL statement to create a document table
[<CompiledName "EnsureTableFor">]
let ensureTableFor name dataType =
$"CREATE TABLE IF NOT EXISTS %s{name} (data %s{dataType} NOT NULL)"
/// Split a schema and table name
let private splitSchemaAndTable (tableName: string) =
let parts = tableName.Split '.'
if Array.length parts = 1 then "", tableName else parts[0], parts[1]
/// SQL statement to create an index on one or more fields in a JSON document
[<CompiledName "EnsureIndexOn">]
let ensureIndexOn tableName indexName (fields: string seq) dialect =
let _, tbl = splitSchemaAndTable tableName
let jsonFields =
fields
|> Seq.map (fun it ->
let parts = it.Split ' '
let fieldName = if Array.length parts = 1 then it else parts[0]
let direction = if Array.length parts < 2 then "" else $" {parts[1]}"
$"({Field.NameToPath fieldName dialect}){direction}")
|> String.concat ", "
$"CREATE INDEX IF NOT EXISTS idx_{tbl}_%s{indexName} ON {tableName} ({jsonFields})"
/// SQL statement to create a key index for a document table
[<CompiledName "EnsureKey">]
let ensureKey tableName dialect =
(ensureIndexOn tableName "key" [ Configuration.idField () ] dialect).Replace("INDEX", "UNIQUE INDEX")
/// Query to insert a document
[<CompiledName "Insert">]
let insert tableName =
$"INSERT INTO %s{tableName} VALUES (@data)"
/// Query to save a document, inserting it if it does not exist and updating it if it does (AKA "upsert")
[<CompiledName "Save">]
let save tableName =
sprintf
"INSERT INTO %s VALUES (@data) ON CONFLICT ((data->>'%s')) DO UPDATE SET data = EXCLUDED.data"
tableName (Configuration.idField ())
/// Query to count documents in a table (no WHERE clause)
[<CompiledName "Count">]
let count tableName =
$"SELECT COUNT(*) AS it FROM %s{tableName}"
/// Query to check for document existence in a table
[<CompiledName "Exists">]
let exists tableName where =
$"SELECT EXISTS (SELECT 1 FROM %s{tableName} WHERE %s{where}) AS it"
/// Query to select documents from a table (no WHERE clause)
[<CompiledName "Find">]
let find tableName =
$"SELECT data FROM %s{tableName}"
/// Query to update a document (no WHERE clause)
[<CompiledName "Update">]
let update tableName =
$"UPDATE %s{tableName} SET data = @data"
/// Query to delete documents from a table (no WHERE clause)
[<CompiledName "Delete">]
let delete tableName =
$"DELETE FROM %s{tableName}"
/// Create a SELECT clause to retrieve the document data from the given table
[<CompiledName "SelectFromTable">]
[<System.Obsolete "Use Find instead">]
let selectFromTable tableName =
find tableName
/// Create an ORDER BY clause for the given fields
[<CompiledName "OrderBy">]
let orderBy fields dialect =
if Seq.isEmpty fields then ""
else
fields
|> Seq.map (fun it ->
if it.Name.Contains ' ' then
let parts = it.Name.Split ' '
{ it with Name = parts[0] }, Some $" {parts[1]}"
else it, None)
|> Seq.map (fun (field, direction) ->
match dialect, field.Name.StartsWith "n:" with
| PostgreSQL, true -> $"({ { field with Name = field.Name[2..] }.Path PostgreSQL})::numeric"
| SQLite, true -> { field with Name = field.Name[2..] }.Path SQLite
| _, _ -> field.Path dialect
|> function path -> path + defaultArg direction "")
|> String.concat ", "
|> function it -> $" ORDER BY {it}"