diff --git a/src/Sqlite/Library.fs b/src/Sqlite/Library.fs index a2d76f5..dd1063d 100644 --- a/src/Sqlite/Library.fs +++ b/src/Sqlite/Library.fs @@ -10,12 +10,14 @@ module Configuration = let mutable internal connectionString: string option = None /// Register a connection string to use for query execution (enables foreign keys) + [] let useConnectionString connStr = let builder = SqliteConnectionStringBuilder(connStr) builder.ForeignKeys <- Option.toNullable (Some true) connectionString <- Some (string builder) /// Retrieve the currently configured data source + [] let dbConn () = match connectionString with | Some connStr -> @@ -32,42 +34,38 @@ module Query = module Definition = /// SQL statement to create a document table + [] let ensureTable name = Query.Definition.ensureTableFor name "TEXT" -/// Execute a non-query command -let internal write (cmd: SqliteCommand) = backgroundTask { - let! _ = cmd.ExecuteNonQueryAsync() - () -} +/// Create an ID parameter (key will be treated as a string) +[] +let idParam (key: 'TKey) = + SqliteParameter("@id", string key) +/// Create a parameter with a JSON value +[] +let jsonParam name (it: 'TJson) = + SqliteParameter(name, Configuration.serializer().Serialize it) -/// Add a parameter to a SQLite command, ignoring the return value (can still be accessed on cmd via indexing) -let addParam (cmd: SqliteCommand) name (value: obj) = - cmd.Parameters.AddWithValue(name, value) |> ignore - -let addIdParam (cmd: SqliteCommand) (key: 'TKey) = - addParam cmd "@id" (string key) - -/// Add a JSON document parameter to a command -let addJsonParam (cmd: SqliteCommand) name (it: 'TJson) = - addParam cmd name (Configuration.serializer().Serialize it) - -/// Add ID (@id) and document (@data) parameters to a command -let addIdAndDocParams cmd (docId: 'TKey) (doc: 'TDoc) = - addIdParam cmd docId - addJsonParam cmd "@data" doc +/// Create a JSON field parameter +[] +let fieldParam (value: obj) = + SqliteParameter("@field", value) /// Create a domain item from a document, specifying the field in which the document is found +[] let fromDocument<'TDoc> field (rdr: SqliteDataReader) : 'TDoc = Configuration.serializer().Deserialize<'TDoc>(rdr.GetString(rdr.GetOrdinal(field))) - + /// Create a domain item from a document +[] 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 +[] let toCustomList<'TDoc> (cmd: SqliteCommand) (mapFunc: SqliteDataReader -> 'TDoc) = backgroundTask { use! rdr = cmd.ExecuteReaderAsync() let mutable it = Seq.empty<'TDoc> @@ -77,194 +75,95 @@ let toCustomList<'TDoc> (cmd: SqliteCommand) (mapFunc: SqliteDataReader -> 'TDoc } /// Create a list of items for the results of the given command +[] let toDocumentList<'TDoc> (cmd: SqliteCommand) = toCustomList<'TDoc> cmd fromData -/// Execute a non-query statement to manipulate a document -let private executeNonQuery query (document: 'T) (conn: SqliteConnection) = - use cmd = conn.CreateCommand() - cmd.CommandText <- query - addJsonParam cmd "@data" document - write cmd - -/// Execute a non-query statement to manipulate a document with an ID specified -let private executeNonQueryWithId query (docId: 'TKey) (document: 'TDoc) (conn: SqliteConnection) = - use cmd = conn.CreateCommand() - cmd.CommandText <- query - addIdAndDocParams cmd docId document - write cmd +/// Execute a non-query command +let internal write (cmd: SqliteCommand) = backgroundTask { + let! _ = cmd.ExecuteNonQueryAsync() + () +} -open System.Threading.Tasks +/// Command creation helper functions +[] +module private Helpers = + + let addParam (cmd: SqliteCommand) it = + cmd.Parameters.Add it |> ignore + + /// Add an ID parameter to a command + let addIdParam (cmd: SqliteCommand) (key: 'TKey) = + addParam cmd (idParam key) + + /// Add a JSON document parameter to a command + let addJsonParam (cmd: SqliteCommand) name (it: 'TJson) = + addParam cmd (jsonParam name it) + + /// Add ID (@id) and document (@data) parameters to a command + let addIdAndDocParams cmd (docId: 'TKey) (doc: 'TDoc) = + addIdParam cmd docId + addJsonParam cmd "@data" doc + + /// Add a parameter to a SQLite command, ignoring the return value (can still be accessed on cmd via indexing) + let addFieldParam (cmd: SqliteCommand) (value: obj) = + addParam cmd (SqliteParameter("@field", value)) + + /// Execute a non-query statement to manipulate a document + let executeNonQuery query (document: 'T) (conn: SqliteConnection) = + use cmd = conn.CreateCommand() + cmd.CommandText <- query + addJsonParam cmd "@data" document + write cmd + + /// Execute a non-query statement to manipulate a document with an ID specified + let executeNonQueryWithId query (docId: 'TKey) (document: 'TDoc) (conn: SqliteConnection) = + use cmd = conn.CreateCommand() + cmd.CommandText <- query + addIdAndDocParams cmd docId document + write cmd + /// Versions of queries that accept a SqliteConnection as the last parameter module WithConn = - /// Functions to create tables and indexes - [] - module Definition = - - /// Create a document table - let ensureTable name (conn: SqliteConnection) = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Definition.ensureTable name - do! write cmd - cmd.CommandText <- Query.Definition.ensureKey name - do! write cmd - } - - /// Create an index on a document table - let ensureIndex tableName indexName fields (conn: SqliteConnection) = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Definition.ensureIndexOn tableName indexName fields - do! write cmd - } - - /// Insert a new document - let insert<'TDoc> tableName (document: 'TDoc) conn = - executeNonQuery (Query.insert tableName) document conn - - /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") - let save<'TDoc> tableName (document: 'TDoc) conn = - executeNonQuery (Query.save tableName) document conn - - /// Commands to count documents - [] - module Count = - - /// Count all documents in a table - let all tableName (conn: SqliteConnection) : Task = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Count.all tableName - let! result = cmd.ExecuteScalarAsync() - return result :?> int64 - } - - /// Count matching documents using a comparison on a JSON field - let byField tableName fieldName op (value: obj) (conn: SqliteConnection) : Task = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Count.byField tableName fieldName op - addParam cmd "@field" value - let! result = cmd.ExecuteScalarAsync() - return result :?> int64 - } - - /// Commands to determine if documents exist - [] - module Exists = - - /// Determine if a document exists for the given ID - let byId tableName (docId: 'TKey) (conn: SqliteConnection) : Task = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Exists.byId tableName - addIdParam cmd docId - let! result = cmd.ExecuteScalarAsync() - return (result :?> int64) > 0 - } - - /// Determine if a document exists using a comparison on a JSON field - let byField tableName fieldName op (value: obj) (conn: SqliteConnection) : Task = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Exists.byField tableName fieldName op - addParam cmd "@field" value - let! result = cmd.ExecuteScalarAsync() - return (result :?> int64) > 0 - } - - /// Commands to retrieve documents - [] - module Find = - - /// Retrieve all documents in the given table - let all<'TDoc> tableName (conn: SqliteConnection) : Task<'TDoc list> = - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.selectFromTable tableName - toDocumentList<'TDoc> cmd - - /// Retrieve a document by its ID - let byId<'TKey, 'TDoc> tableName (docId: 'TKey) (conn: SqliteConnection) : Task<'TDoc option> = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Find.byId tableName - addIdParam cmd docId - let! results = toDocumentList<'TDoc> cmd - return List.tryHead results - } - - /// Retrieve documents via a comparison on a JSON field - let byField<'TDoc> tableName fieldName op (value: obj) (conn: SqliteConnection) : Task<'TDoc list> = - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Find.byField tableName fieldName op - addParam cmd "@field" value - toDocumentList<'TDoc> cmd - - /// Retrieve documents via a comparison on a JSON field, returning only the first result - let firstByField<'TDoc> tableName fieldName op (value: obj) (conn: SqliteConnection) - : Task<'TDoc option> = backgroundTask { - use cmd = conn.CreateCommand() - cmd.CommandText <- $"{Query.Find.byField tableName fieldName op} LIMIT 1" - addParam cmd "@field" value - let! results = toDocumentList<'TDoc> cmd - return List.tryHead results - } - - /// Commands to update documents - [] - module Update = - - /// Update an entire document - let full tableName (docId: 'TKey) (document: 'TDoc) conn = - executeNonQueryWithId (Query.Update.full tableName) docId document conn - - /// Update an entire document - let fullFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) conn = - full tableName (idFunc document) document conn - - /// Update a partial document - let partialById tableName (docId: 'TKey) (partial: 'TPatch) conn = - executeNonQueryWithId (Query.Update.partialById tableName) docId partial conn - - /// Update partial documents using a comparison on a JSON field - let partialByField tableName fieldName op (value: obj) (partial: 'TPatch) (conn: SqliteConnection) = - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Update.partialByField tableName fieldName op - addParam cmd "@field" value - addJsonParam cmd "@data" partial - write cmd - - /// Commands to delete documents - [] - module Delete = - - /// Delete a document by its ID - let byId tableName (docId: 'TKey) conn = - executeNonQueryWithId (Query.Delete.byId tableName) docId {||} conn - - /// Delete documents by matching a comparison on a JSON field - let byField tableName fieldName op (value: obj) (conn: SqliteConnection) = - use cmd = conn.CreateCommand() - cmd.CommandText <- Query.Delete.byField tableName fieldName op - addParam cmd "@field" value - write cmd - /// Commands to execute custom SQL queries [] module Custom = /// Execute a query that returns a list of results + [] let list<'TDoc> query (parameters: SqliteParameter seq) (mapFunc: SqliteDataReader -> 'TDoc) (conn: SqliteConnection) = use cmd = conn.CreateCommand() cmd.CommandText <- query cmd.Parameters.AddRange parameters toCustomList<'TDoc> cmd mapFunc - - /// Execute a query that returns one or no results + + /// Execute a query that returns a list of results + let List<'TDoc>(query, parameters, mapFunc: System.Func, conn) = backgroundTask { + let! results = list<'TDoc> query parameters mapFunc.Invoke conn + return ResizeArray<'TDoc> results + } + + /// Execute a query that returns one or no results (returns None if not found) + [] let single<'TDoc> query parameters (mapFunc: SqliteDataReader -> 'TDoc) conn = backgroundTask { let! results = list query parameters mapFunc conn - return List.tryHead results + return FSharp.Collections.List.tryHead results + } + + /// Execute a query that returns one or no results (returns null if not found) + let Single<'TDoc when 'TDoc: null>( + query, parameters, mapFunc: System.Func, conn + ) = backgroundTask { + let! result = single<'TDoc> query parameters mapFunc.Invoke conn + return Option.toObj result } /// Execute a query that does not return a value + [] let nonQuery query (parameters: SqliteParameter seq) (conn: SqliteConnection) = use cmd = conn.CreateCommand() cmd.CommandText <- query @@ -272,6 +171,7 @@ module WithConn = write cmd /// Execute a query that returns a scalar value + [] let scalar<'T when 'T : struct> query (parameters: SqliteParameter seq) (mapFunc: SqliteDataReader -> 'T) (conn: SqliteConnection) = backgroundTask { use cmd = conn.CreateCommand() @@ -282,6 +182,155 @@ module WithConn = return if isFound then mapFunc rdr else Unchecked.defaultof<'T> } + /// Execute a query that returns a scalar value + let Scalar<'T when 'T: struct>(query, parameters, mapFunc: System.Func, conn) = + scalar<'T> query parameters mapFunc.Invoke conn + + /// Functions to create tables and indexes + [] + module Definition = + + /// Create a document table + [] + let ensureTable name conn = backgroundTask { + do! Custom.nonQuery (Query.Definition.ensureTable name) [] conn + do! Custom.nonQuery (Query.Definition.ensureKey name) [] conn + } + + /// Create an index on a document table + [] + let ensureIndex tableName indexName fields conn = + Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields) [] conn + + /// Insert a new document + [] + let insert<'TDoc> tableName (document: 'TDoc) conn = + Custom.nonQuery (Query.insert tableName) [ jsonParam "@data" document ] conn + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + let save<'TDoc> tableName (document: 'TDoc) conn = + Custom.nonQuery (Query.save tableName) [ jsonParam "@data" document ] conn + + /// Commands to count documents + [] + module Count = + + /// Count all documents in a table + [] + let all tableName conn = + Custom.scalar (Query.Count.all tableName) [] (_.GetInt64(0)) conn + + /// Count matching documents using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) conn = + Custom.scalar (Query.Count.byField tableName fieldName op) [ fieldParam value ] (_.GetInt64(0)) conn + + /// Commands to determine if documents exist + [] + module Exists = + + /// SQLite returns a 0 for not-exists and 1 for exists + let private exists (rdr: SqliteDataReader) = + rdr.GetInt64(0) > 0 + + /// Determine if a document exists for the given ID + [] + let byId tableName (docId: 'TKey) conn = + Custom.scalar (Query.Exists.byId tableName) [ idParam docId ] exists conn + + /// Determine if a document exists using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) conn = + Custom.scalar (Query.Exists.byField tableName fieldName op) [ fieldParam value ] exists conn + + /// Commands to retrieve documents + [] + module Find = + + /// Retrieve all documents in the given table + [] + let all<'TDoc> tableName conn = + Custom.list<'TDoc> (Query.selectFromTable tableName) [] fromData<'TDoc> conn + + /// Retrieve all documents in the given table + let All<'TDoc>(tableName, conn) = + Custom.List(Query.selectFromTable tableName, [], fromData<'TDoc>, conn) + + /// Retrieve a document by its ID (returns None if not found) + [] + let byId<'TKey, 'TDoc> tableName (docId: 'TKey) conn = + Custom.single<'TDoc> (Query.Find.byId tableName) [ idParam docId ] fromData<'TDoc> conn + + /// Retrieve a document by its ID (returns null if not found) + let ById<'TKey, 'TDoc when 'TDoc: null>(tableName, docId: 'TKey, conn) = + Custom.Single<'TDoc>(Query.Find.byId tableName, [ idParam docId ], fromData<'TDoc>, conn) + + /// Retrieve documents via a comparison on a JSON field + [] + let byField<'TDoc> tableName fieldName op (value: obj) conn = + Custom.list<'TDoc> (Query.Find.byField tableName fieldName op) [ fieldParam value ] fromData<'TDoc> conn + + /// Retrieve documents via a comparison on a JSON field + let ByField<'TDoc>(tableName, fieldName, op, value: obj, conn) = + Custom.List<'TDoc>(Query.Find.byField tableName fieldName op, [ fieldParam value ], fromData<'TDoc>, conn) + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + [] + let firstByField<'TDoc> tableName fieldName op (value: obj) conn = + Custom.single + $"{Query.Find.byField tableName fieldName op} LIMIT 1" [ fieldParam value ] fromData<'TDoc> conn + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + let FirstByField<'TDoc when 'TDoc: null>(tableName, fieldName, op, value: obj, conn) = + Custom.Single( + $"{Query.Find.byField tableName fieldName op} LIMIT 1", [ fieldParam value ], fromData<'TDoc>, conn) + + /// Commands to update documents + [] + module Update = + + /// Update an entire document + [] + let full tableName (docId: 'TKey) (document: 'TDoc) conn = + Custom.nonQuery (Query.Update.full tableName) [ idParam docId; jsonParam "@data" document ] conn + + /// Update an entire document + [] + let fullFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) conn = + full tableName (idFunc document) document conn + + /// Update an entire document + let FullFunc(tableName, idFunc: System.Func<'TDoc, 'TKey>, document: 'TDoc, conn) = + fullFunc tableName idFunc.Invoke document conn + + /// Update a partial document + [] + let partialById tableName (docId: 'TKey) (partial: 'TPatch) conn = + Custom.nonQuery (Query.Update.partialById tableName) [ idParam docId; jsonParam "@data" partial ] conn + + /// Update partial documents using a comparison on a JSON field + [] + let partialByField tableName fieldName op (value: obj) (partial: 'TPatch) (conn: SqliteConnection) = + Custom.nonQuery + (Query.Update.partialByField tableName fieldName op) + [ fieldParam value; jsonParam "@data" partial ] + conn + + /// Commands to delete documents + [] + module Delete = + + /// Delete a document by its ID + [] + let byId tableName (docId: 'TKey) conn = + Custom.nonQuery (Query.Delete.byId tableName) [ idParam docId ] conn + + /// Delete documents by matching a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) conn = + Custom.nonQuery (Query.Delete.byField tableName fieldName op) [ fieldParam value ] conn + /// Functions to create tables and indexes [] module Definition =