namespace BitBadger.Documents open System.Security.Cryptography /// The types of logical operations available for JSON fields [] 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 /// In (IN) | IN // Array Contains/Exists (PostgreSQL: |? SQLite: EXISTS / json_each / IN) //| AEX /// 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" | IN -> "IN" | EX -> "IS NOT NULL" | NEX -> "IS NULL" /// The dialect in which a command should be rendered [] 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 IN field criterion static member IN name (values: obj seq) = { Name = name; Op = IN; Value = values; 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 [] 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}){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})::numeric" | SQLite -> f.Path SQLite elif field.Name.StartsWith "i:" then let p = { field with Name = field.Name[2..] }.Path dialect match dialect with PostgreSQL -> $"LOWER({p})" | SQLite -> $"{p} COLLATE NOCASE" else field.Path dialect |> function path -> path + defaultArg direction "") |> String.concat ", " |> function it -> $" ORDER BY {it}"