namespace BitBadger.Documents open System.Security.Cryptography /// The types of comparisons available for JSON fields type Comparison = /// Equals (=) | Equal of Value: obj /// Greater Than (>) | Greater of Value: obj /// Greater Than or Equal To (>=) | GreaterOrEqual of Value: obj /// Less Than (<) | Less of Value: obj /// Less Than or Equal To (<=) | LessOrEqual of Value: obj /// Not Equal to (<>) | NotEqual of Value: obj /// Between (BETWEEN) | Between of Min: obj * Max: obj /// In (IN) | In of Values: obj seq /// In Array (PostgreSQL: |?, SQLite: EXISTS / json_each / IN) | InArray of Table: string * Values: obj seq /// Exists (IS NOT NULL) | Exists /// Does Not Exist (IS NULL) | NotExists /// Get the operator SQL for this comparison member this.OpSql = match this with | Equal _ -> "=" | Greater _ -> ">" | GreaterOrEqual _ -> ">=" | Less _ -> "<" | LessOrEqual _ -> "<=" | NotEqual _ -> "<>" | Between _ -> "BETWEEN" | In _ -> "IN" | InArray _ -> "?|" // PostgreSQL only; SQL needs a subquery for this | Exists -> "IS NOT NULL" | NotExists -> "IS NULL" /// The dialect in which a command should be rendered [] type Dialect = | PostgreSQL | SQLite /// The format in which an element of a JSON field should be extracted [] type FieldFormat = /// Use ->> or #>>; extracts a text (PostgreSQL) or SQL (SQLite) value | AsSql /// Use -> or #>; extracts a JSONB (PostgreSQL) or JSON (SQLite) value | AsJson /// Criteria for a field WHERE clause type Field = { /// The name of the field Name: string /// The comparison for the field Comparison: Comparison /// The name of the parameter for this field ParameterName: string option /// The table qualifier for this field Qualifier: string option } with /// Create a comparison against a field static member Where name comparison = { Name = name; Comparison = comparison; ParameterName = None; Qualifier = None } /// Create an equals (=) field criterion static member Equal name (value: obj) = Field.Where name (Equal value) /// Create an equals (=) field criterion (alias) static member EQ name (value: obj) = Field.Equal name value /// Create a greater than (>) field criterion static member Greater name (value: obj) = Field.Where name (Greater value) /// Create a greater than (>) field criterion (alias) static member GT name (value: obj) = Field.Greater name value /// Create a greater than or equal to (>=) field criterion static member GreaterOrEqual name (value: obj) = Field.Where name (GreaterOrEqual value) /// Create a greater than or equal to (>=) field criterion (alias) static member GE name (value: obj) = Field.GreaterOrEqual name value /// Create a less than (<) field criterion static member Less name (value: obj) = Field.Where name (Less value) /// Create a less than (<) field criterion (alias) static member LT name (value: obj) = Field.Less name value /// Create a less than or equal to (<=) field criterion static member LessOrEqual name (value: obj) = Field.Where name (LessOrEqual value) /// Create a less than or equal to (<=) field criterion (alias) static member LE name (value: obj) = Field.LessOrEqual name value /// Create a not equals (<>) field criterion static member NotEqual name (value: obj) = Field.Where name (NotEqual value) /// Create a not equals (<>) field criterion (alias) static member NE name (value: obj) = Field.NotEqual name value /// Create a Between field criterion static member Between name (min: obj) (max: obj) = Field.Where name (Between(min, max)) /// Create a Between field criterion (alias) static member BT name (min: obj) (max: obj) = Field.Between name min max /// Create an In field criterion static member In name (values: obj seq) = Field.Where name (In values) /// Create an In field criterion (alias) static member IN name (values: obj seq) = Field.In name values /// Create an InArray field criterion static member InArray name tableName (values: obj seq) = Field.Where name (InArray(tableName, values)) /// Create an exists (IS NOT NULL) field criterion static member Exists name = Field.Where name Exists /// Create an exists (IS NOT NULL) field criterion (alias) static member EX name = Field.Exists name /// Create a not exists (IS NULL) field criterion static member NotExists name = Field.Where name NotExists /// Create a not exists (IS NULL) field criterion (alias) static member NEX name = Field.NotExists name /// Transform a field name (a.b.c) to a path for the given SQL dialect static member NameToPath (name: string) dialect format = let path = if name.Contains '.' then match dialect with | PostgreSQL -> (match format with AsJson -> "#>" | AsSql -> "#>>") + "'{" + String.concat "," (name.Split '.') + "}'" | SQLite -> let parts = name.Split '.' let last = Array.last parts let final = (match format with AsJson -> "'->'" | AsSql -> "'->>'") + $"{last}'" "->'" + String.concat "'->'" (Array.truncate (Array.length parts - 1) parts) + final else match format with AsJson -> $"->'{name}'" | AsSql -> $"->>'{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 = Field.Where name (Equal "") /// 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 format = (this.Qualifier |> Option.map (fun q -> $"{q}.") |> Option.defaultValue "") + Field.NameToPath this.Name dialect format /// How fields should be matched [] 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 [] 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 then let value = prop.GetValue document :?> int8 value = int8 0 elif prop.PropertyType = typeof then let value = prop.GetValue document :?> int16 value = int16 0 elif prop.PropertyType = typeof then let value = prop.GetValue document :?> int value = 0 elif prop.PropertyType = typeof 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 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 [] 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 [] 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 [] let useSerializer ser = serializerValue <- ser /// Retrieve the currently configured 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 [] let useIdField it = idFieldValue <- it /// Retrieve the currently configured ID field for documents [] 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 [] let useAutoIdStrategy it = autoIdValue <- it /// Retrieve the currently configured automatic ID generation strategy [] let autoIdStrategy () = autoIdValue /// The length of automatically generated random strings let mutable private idStringLengthValue = 16 /// Specify the length of automatically generated random strings [] let useIdStringLength length = idStringLengthValue <- length /// Retrieve the currently configured length of automatically generated random strings [] let idStringLength () = idStringLengthValue /// Query construction functions [] module Query = /// Combine a query (select, update, etc.) and a WHERE clause [] let statementWhere statement where = $"%s{statement} WHERE %s{where}" /// Queries to define tables and indexes module Definition = /// SQL statement to create a document table [] 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 [] 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 AsSql}){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 [] let ensureKey tableName dialect = (ensureIndexOn tableName "key" [ Configuration.idField () ] dialect).Replace("INDEX", "UNIQUE INDEX") /// Query to insert a document [] 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") [] 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) [] let count tableName = $"SELECT COUNT(*) AS it FROM %s{tableName}" /// Query to check for document existence in a table [] 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) [] let find tableName = $"SELECT data FROM %s{tableName}" /// Query to update a document (no WHERE clause) [] let update tableName = $"UPDATE %s{tableName} SET data = @data" /// Query to delete documents from a table (no WHERE clause) [] let delete tableName = $"DELETE FROM %s{tableName}" /// Create a SELECT clause to retrieve the document data from the given table [] [] let selectFromTable tableName = find tableName /// Create an ORDER BY clause for the given fields [] 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 |> Array.skip 1 |> String.concat " "}""" else it, None) |> Seq.map (fun (field, direction) -> if field.Name.StartsWith "n:" then let f = { field with Name = field.Name[2..] } match dialect with | PostgreSQL -> $"({f.Path PostgreSQL AsSql})::numeric" | SQLite -> f.Path SQLite AsSql elif field.Name.StartsWith "i:" then let p = { field with Name = field.Name[2..] }.Path dialect AsSql match dialect with PostgreSQL -> $"LOWER({p})" | SQLite -> $"{p} COLLATE NOCASE" else field.Path dialect AsSql |> function path -> path + defaultArg direction "") |> String.concat ", " |> function it -> $" ORDER BY {it}"