diff --git a/src/BitBadger.Documents.sln b/src/BitBadger.Documents.sln
index 00d5639..9381b10 100644
--- a/src/BitBadger.Documents.sln
+++ b/src/BitBadger.Documents.sln
@@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitBadger.Documents.Tests.C
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BitBadger.Documents.Sqlite.Extensions", "Sqlite.Extensions\BitBadger.Documents.Sqlite.Extensions.fsproj", "{D416A5C8-B746-4FDF-8EC9-9CA0B8DA1384}"
EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BitBadger.Documents.Postgres", "Postgres\BitBadger.Documents.Postgres.fsproj", "{30E73486-9D00-440B-B4AC-5B7AC029AE72}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -48,5 +50,9 @@ Global
{D416A5C8-B746-4FDF-8EC9-9CA0B8DA1384}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D416A5C8-B746-4FDF-8EC9-9CA0B8DA1384}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D416A5C8-B746-4FDF-8EC9-9CA0B8DA1384}.Release|Any CPU.Build.0 = Release|Any CPU
+ {30E73486-9D00-440B-B4AC-5B7AC029AE72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {30E73486-9D00-440B-B4AC-5B7AC029AE72}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {30E73486-9D00-440B-B4AC-5B7AC029AE72}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {30E73486-9D00-440B-B4AC-5B7AC029AE72}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 2d3771a..4c182c4 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,10 +3,10 @@
net6.0;net7.0;net8.0
embedded
false
- 1.0.0.0
- 1.0.0.0
- 1.0.0
- alpha
+ 3.0.0.0
+ 3.0.0.0
+ 3.0.0
+ rc-1
Initial release with F# support
danieljsummers
Bit Badger Solutions
diff --git a/src/Postgres/BitBadger.Documents.Postgres.fsproj b/src/Postgres/BitBadger.Documents.Postgres.fsproj
new file mode 100644
index 0000000..246a808
--- /dev/null
+++ b/src/Postgres/BitBadger.Documents.Postgres.fsproj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Postgres/Library.fs b/src/Postgres/Library.fs
new file mode 100644
index 0000000..ba7c111
--- /dev/null
+++ b/src/Postgres/Library.fs
@@ -0,0 +1,566 @@
+namespace BitBadger.Documents.Postgres
+
+/// 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 JSONPath operations (optimized for @>, @?, @@ operators)
+ | Optimized
+
+
+/// Configuration for document handling
+module Configuration =
+
+ /// The data source to use for query execution
+ let mutable private dataSourceValue : Npgsql.NpgsqlDataSource option = None
+
+ /// Register a data source to use for query execution (disposes the current one if it exists)
+ let useDataSource source =
+ if Option.isSome dataSourceValue then dataSourceValue.Value.Dispose()
+ dataSourceValue <- Some source
+
+ /// Retrieve the currently configured data source
+ 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
+
+ open System.Threading.Tasks
+
+ /// Execute a task and ignore the result
+ let internal ignoreTask<'T> (it : Task<'T>) = backgroundTask {
+ let! _ = it
+ ()
+ }
+
+
+open BitBadger.Documents
+
+/// Data definition
+[]
+module Definition =
+
+ /// SQL statement to create a document table
+ let createTable name =
+ $"CREATE TABLE IF NOT EXISTS %s{name} (data JSONB NOT NULL)"
+
+ /// SQL statement to create a key index for a document table
+ let createKey (name : string) =
+ let tableName = name.Split(".") |> Array.last
+ $"CREATE UNIQUE INDEX IF NOT EXISTS idx_{tableName}_key ON {name} ((data ->> '{Configuration.idField ()}'))"
+
+ /// SQL statement to create an index on documents in the specified table
+ let createIndex (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} ON {name} USING GIN (data{extraOps})"
+
+ /// Definitions that take SqlProps as their last parameter
+ module WithProps =
+
+ /// Create a document table
+ let ensureTable name sqlProps = backgroundTask {
+ do! sqlProps |> Sql.query (createTable name) |> Sql.executeNonQueryAsync |> ignoreTask
+ do! sqlProps |> Sql.query (createKey name) |> Sql.executeNonQueryAsync |> ignoreTask
+ }
+
+ /// Create an index on documents in the specified table
+ let ensureIndex name idxType sqlProps =
+ sqlProps |> Sql.query (createIndex name idxType) |> Sql.executeNonQueryAsync |> ignoreTask
+
+ /// Create a document table
+ let ensureTable name =
+ WithProps.ensureTable name (fromDataSource ())
+
+ let ensureIndex name idxType =
+ WithProps.ensureIndex name idxType (fromDataSource ())
+
+
+/// Query construction functions
+[]
+module Query =
+
+ /// Create a SELECT clause to retrieve the document data from the given table
+ let selectFromTable tableName =
+ $"SELECT data FROM %s{tableName}"
+
+ /// Create a WHERE clause fragment to implement an ID-based query
+ let whereById paramName =
+ $"data ->> '{Configuration.idField ()}' = %s{paramName}"
+
+ /// Create a WHERE clause fragment to implement a @> (JSON contains) condition
+ let whereDataContains paramName =
+ $"data @> %s{paramName}"
+
+ /// Create a WHERE clause fragment to implement a @? (JSON Path match) condition
+ let whereJsonPathMatches paramName =
+ $"data @? %s{paramName}::jsonpath"
+
+ /// Create a JSONB document parameter
+ let jsonbDocParam (it: obj) =
+ Sql.jsonb (Configuration.serializer().Serialize it)
+
+ /// Create ID and data parameters for a query
+ let docParameters<'T> docId (doc: 'T) =
+ [ "@id", Sql.string docId; "@data", jsonbDocParam doc ]
+
+ /// 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 ())
+
+ /// Queries for counting documents
+ module Count =
+
+ /// Query to count all documents in a table
+ let all tableName =
+ $"SELECT COUNT(*) AS it FROM %s{tableName}"
+
+ /// Query to count matching documents using a JSON containment query (@>)
+ let byContains tableName =
+ $"""SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereDataContains "@criteria"}"""
+
+ /// Query to count matching documents using a JSON Path match (@?)
+ let byJsonPath tableName =
+ $"""SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereJsonPathMatches "@path"}"""
+
+ /// Queries for determining document existence
+ module Exists =
+
+ /// Query to determine if a document exists for the given ID
+ let byId tableName =
+ $"""SELECT EXISTS (SELECT 1 FROM %s{tableName} WHERE {whereById "@id"}) AS it"""
+
+ /// Query to determine if documents exist using a JSON containment query (@>)
+ let byContains tableName =
+ $"""SELECT EXISTS (SELECT 1 FROM %s{tableName} WHERE {whereDataContains "@criteria"}) AS it"""
+
+ /// Query to determine if documents exist using a JSON Path match (@?)
+ let byJsonPath tableName =
+ $"""SELECT EXISTS (SELECT 1 FROM %s{tableName} WHERE {whereJsonPathMatches "@path"}) AS it"""
+
+ /// Queries for retrieving documents
+ module Find =
+
+ /// Query to retrieve a document by its ID
+ let byId tableName =
+ $"""{selectFromTable tableName} WHERE {whereById "@id"}"""
+
+ /// Query to retrieve documents using a JSON containment query (@>)
+ let byContains tableName =
+ $"""{selectFromTable tableName} WHERE {whereDataContains "@criteria"}"""
+
+ /// Query to retrieve documents using a JSON Path match (@?)
+ let byJsonPath tableName =
+ $"""{selectFromTable tableName} WHERE {whereJsonPathMatches "@path"}"""
+
+ /// Queries to update documents
+ module Update =
+
+ /// Query to update a document
+ let full tableName =
+ $"""UPDATE %s{tableName} SET data = @data WHERE {whereById "@id"}"""
+
+ /// Query to update a document
+ let partialById tableName =
+ $"""UPDATE %s{tableName} SET data = data || @data WHERE {whereById "@id"}"""
+
+ /// Query to update partial documents matching a JSON containment query (@>)
+ let partialByContains tableName =
+ $"""UPDATE %s{tableName} SET data = data || @data WHERE {whereDataContains "@criteria"}"""
+
+ /// Query to update partial documents matching a JSON containment query (@>)
+ let partialByJsonPath tableName =
+ $"""UPDATE %s{tableName} SET data = data || @data WHERE {whereJsonPathMatches "@path"}"""
+
+ /// Queries to delete documents
+ module Delete =
+
+ /// Query to delete a document by its ID
+ let byId tableName =
+ $"""DELETE FROM %s{tableName} WHERE {whereById "@id"}"""
+
+ /// Query to delete documents using a JSON containment query (@>)
+ let byContains tableName =
+ $"""DELETE FROM %s{tableName} WHERE {whereDataContains "@criteria"}"""
+
+ /// Query to delete documents using a JSON Path match (@?)
+ let byJsonPath tableName =
+ $"""DELETE FROM %s{tableName} WHERE {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
+ let fromDocument<'T> field (row: RowReader) : 'T =
+ Configuration.serializer().Deserialize<'T>(row.string field)
+
+ /// Create a domain item from a document
+ let fromData<'T> row : 'T =
+ fromDocument "data" row
+
+
+/// Versions of queries that accept SqlProps as the last parameter
+module WithProps =
+
+ /// Execute a non-query statement to manipulate a document
+ let private executeNonQuery query (document: 'T) sqlProps =
+ sqlProps
+ |> Sql.query query
+ |> Sql.parameters [ "@data", Query.jsonbDocParam document ]
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Execute a non-query statement to manipulate a document with an ID specified
+ let private executeNonQueryWithId query docId (document: 'T) sqlProps =
+ sqlProps
+ |> Sql.query query
+ |> Sql.parameters (Query.docParameters docId document)
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Insert a new document
+ let insert<'T> tableName (document: 'T) sqlProps =
+ executeNonQuery (Query.insert tableName) document sqlProps
+
+ /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert")
+ let save<'T> tableName (document: 'T) sqlProps =
+ executeNonQuery (Query.save tableName) document sqlProps
+
+ /// Commands to count documents
+ []
+ module Count =
+
+ /// Count all documents in a table
+ let all tableName sqlProps =
+ sqlProps
+ |> Sql.query (Query.Count.all tableName)
+ |> Sql.executeRowAsync (fun row -> row.int "it")
+
+ /// Count matching documents using a JSON containment query (@>)
+ let byContains tableName (criteria: obj) sqlProps =
+ sqlProps
+ |> Sql.query (Query.Count.byContains tableName)
+ |> Sql.parameters [ "@criteria", Query.jsonbDocParam criteria ]
+ |> Sql.executeRowAsync (fun row -> row.int "it")
+
+ /// Count matching documents using a JSON Path match query (@?)
+ let byJsonPath tableName jsonPath sqlProps =
+ sqlProps
+ |> Sql.query (Query.Count.byJsonPath tableName)
+ |> Sql.parameters [ "@path", Sql.string jsonPath ]
+ |> Sql.executeRowAsync (fun row -> row.int "it")
+
+ /// Commands to determine if documents exist
+ []
+ module Exists =
+
+ /// Determine if a document exists for the given ID
+ let byId tableName docId sqlProps =
+ sqlProps
+ |> Sql.query (Query.Exists.byId tableName)
+ |> Sql.parameters [ "@id", Sql.string docId ]
+ |> Sql.executeRowAsync (fun row -> row.bool "it")
+
+ /// Determine if a document exists using a JSON containment query (@>)
+ let byContains tableName (criteria: obj) sqlProps =
+ sqlProps
+ |> Sql.query (Query.Exists.byContains tableName)
+ |> Sql.parameters [ "@criteria", Query.jsonbDocParam criteria ]
+ |> Sql.executeRowAsync (fun row -> row.bool "it")
+
+ /// Determine if a document exists using a JSON Path match query (@?)
+ let byJsonPath tableName jsonPath sqlProps =
+ sqlProps
+ |> Sql.query (Query.Exists.byJsonPath tableName)
+ |> Sql.parameters [ "@path", Sql.string jsonPath ]
+ |> Sql.executeRowAsync (fun row -> row.bool "it")
+
+ /// Commands to determine if documents exist
+ []
+ module Find =
+
+ /// Retrieve all documents in the given table
+ let all<'T> tableName sqlProps =
+ sqlProps
+ |> Sql.query (Query.selectFromTable tableName)
+ |> Sql.executeAsync fromData<'T>
+
+ /// Retrieve a document by its ID
+ let byId<'T> tableName docId sqlProps = backgroundTask {
+ let! results =
+ sqlProps
+ |> Sql.query (Query.Find.byId tableName)
+ |> Sql.parameters [ "@id", Sql.string docId ]
+ |> Sql.executeAsync fromData<'T>
+ return List.tryHead results
+ }
+
+ /// Execute a JSON containment query (@>)
+ let byContains<'T> tableName (criteria: obj) sqlProps =
+ sqlProps
+ |> Sql.query (Query.Find.byContains tableName)
+ |> Sql.parameters [ "@criteria", Query.jsonbDocParam criteria ]
+ |> Sql.executeAsync fromData<'T>
+
+ /// Execute a JSON Path match query (@?)
+ let byJsonPath<'T> tableName jsonPath sqlProps =
+ sqlProps
+ |> Sql.query (Query.Find.byJsonPath tableName)
+ |> Sql.parameters [ "@path", Sql.string jsonPath ]
+ |> Sql.executeAsync fromData<'T>
+
+ /// Execute a JSON containment query (@>), returning only the first result
+ let firstByContains<'T> tableName (criteria: obj) sqlProps = backgroundTask {
+ let! results = byContains<'T> tableName criteria sqlProps
+ return List.tryHead results
+ }
+
+ /// Execute a JSON Path match query (@?), returning only the first result
+ let firstByJsonPath<'T> tableName jsonPath sqlProps = backgroundTask {
+ let! results = byJsonPath<'T> tableName jsonPath sqlProps
+ return List.tryHead results
+ }
+
+ /// Commands to update documents
+ []
+ module Update =
+
+ /// Update an entire document
+ let full<'T> tableName docId (document: 'T) sqlProps =
+ executeNonQueryWithId (Query.Update.full tableName) docId document sqlProps
+
+ /// Update an entire document
+ let fullFunc<'T> tableName (idFunc: 'T -> string) (document: 'T) sqlProps =
+ full tableName (idFunc document) document sqlProps
+
+ /// Update a partial document
+ let partialById tableName docId (partial: obj) sqlProps =
+ executeNonQueryWithId (Query.Update.partialById tableName) docId partial sqlProps
+
+ /// Update partial documents using a JSON containment query in the WHERE clause (@>)
+ let partialByContains tableName (criteria: obj) (partial: obj) sqlProps =
+ sqlProps
+ |> Sql.query (Query.Update.partialByContains tableName)
+ |> Sql.parameters [ "@data", Query.jsonbDocParam partial; "@criteria", Query.jsonbDocParam criteria ]
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Update partial documents using a JSON Path match query in the WHERE clause (@?)
+ let partialByJsonPath tableName jsonPath (partial: obj) sqlProps =
+ sqlProps
+ |> Sql.query (Query.Update.partialByJsonPath tableName)
+ |> Sql.parameters [ "@data", Query.jsonbDocParam partial; "@path", Sql.string jsonPath ]
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Commands to delete documents
+ []
+ module Delete =
+
+ /// Delete a document by its ID
+ let byId tableName docId sqlProps =
+ executeNonQueryWithId (Query.Delete.byId tableName) docId {||} sqlProps
+
+ /// Delete documents by matching a JSON contains query (@>)
+ let byContains tableName (criteria: obj) sqlProps =
+ sqlProps
+ |> Sql.query (Query.Delete.byContains tableName)
+ |> Sql.parameters [ "@criteria", Query.jsonbDocParam criteria ]
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Delete documents by matching a JSON Path match query (@?)
+ let byJsonPath tableName path sqlProps =
+ sqlProps
+ |> Sql.query (Query.Delete.byJsonPath tableName)
+ |> Sql.parameters [ "@path", Sql.string path ]
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Commands to execute custom SQL queries
+ []
+ module Custom =
+
+ /// Execute a query that returns one or no results
+ let single<'T> query parameters (mapFunc: RowReader -> 'T) sqlProps = backgroundTask {
+ let! results =
+ Sql.query query sqlProps
+ |> Sql.parameters parameters
+ |> Sql.executeAsync mapFunc
+ return List.tryHead results
+ }
+
+ /// Execute a query that returns a list of results
+ let list<'T> query parameters (mapFunc: RowReader -> 'T) sqlProps =
+ Sql.query query sqlProps
+ |> Sql.parameters parameters
+ |> Sql.executeAsync mapFunc
+
+ /// Execute a query that returns no results
+ let nonQuery query parameters sqlProps =
+ Sql.query query sqlProps
+ |> Sql.parameters (List.ofSeq parameters)
+ |> Sql.executeNonQueryAsync
+ |> ignoreTask
+
+ /// Execute a query that returns a scalar value
+ let scalar<'T when 'T : struct> query parameters (mapFunc: RowReader -> 'T) sqlProps =
+ Sql.query query sqlProps
+ |> Sql.parameters parameters
+ |> Sql.executeRowAsync mapFunc
+
+
+/// Document writing functions
+[]
+module Document =
+ /// Insert a new document
+ let insert<'T> tableName (document: 'T) =
+ WithProps.insert tableName document (fromDataSource ())
+
+ /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert")
+ let save<'T> tableName (document: 'T) =
+ WithProps.save<'T> tableName document (fromDataSource ())
+
+
+/// Queries to count documents
+[]
+module Count =
+
+ /// Count all documents in a table
+ let all tableName =
+ WithProps.Count.all tableName (fromDataSource ())
+
+ /// Count matching documents using a JSON containment query (@>)
+ let byContains tableName criteria =
+ WithProps.Count.byContains tableName criteria (fromDataSource ())
+
+ /// Count matching documents using a JSON Path match query (@?)
+ let byJsonPath tableName jsonPath =
+ WithProps.Count.byJsonPath tableName jsonPath (fromDataSource ())
+
+
+/// Queries to determine if documents exist
+[]
+module Exists =
+
+ /// Determine if a document exists for the given ID
+ let byId tableName docId =
+ WithProps.Exists.byId tableName docId (fromDataSource ())
+
+ /// Determine if a document exists using a JSON containment query (@>)
+ let byContains tableName criteria =
+ WithProps.Exists.byContains tableName criteria (fromDataSource ())
+
+ /// Determine if a document exists using a JSON Path match query (@?)
+ let byJsonPath tableName jsonPath =
+ WithProps.Exists.byJsonPath tableName jsonPath (fromDataSource ())
+
+
+/// Commands to retrieve documents
+[]
+module Find =
+
+ /// Retrieve all documents in the given table
+ let all<'T> tableName =
+ WithProps.Find.all<'T> tableName (fromDataSource ())
+
+ /// Retrieve a document by its ID
+ let byId<'T> tableName docId =
+ WithProps.Find.byId<'T> tableName docId (fromDataSource ())
+
+ /// Execute a JSON containment query (@>)
+ let byContains<'T> tableName criteria =
+ WithProps.Find.byContains<'T> tableName criteria (fromDataSource ())
+
+ /// Execute a JSON Path match query (@?)
+ let byJsonPath<'T> tableName jsonPath =
+ WithProps.Find.byJsonPath<'T> tableName jsonPath (fromDataSource ())
+
+ /// Execute a JSON containment query (@>), returning only the first result
+ let firstByContains<'T> tableName (criteria: obj) =
+ WithProps.Find.firstByContains<'T> tableName criteria (fromDataSource ())
+
+ /// Execute a JSON Path match query (@?), returning only the first result
+ let firstByJsonPath<'T> tableName jsonPath =
+ WithProps.Find.firstByJsonPath<'T> tableName jsonPath (fromDataSource ())
+
+
+/// Commands to update documents
+[]
+module Update =
+
+ /// Update a full document
+ let full<'T> tableName docId (document: 'T) =
+ WithProps.Update.full<'T> tableName docId document (fromDataSource ())
+
+ /// Update a full document
+ let fullFunc<'T> tableName idFunc (document: 'T) =
+ WithProps.Update.fullFunc<'T> tableName idFunc document (fromDataSource ())
+
+ /// Update a partial document
+ let partialById tableName docId (partial: obj) =
+ WithProps.Update.partialById tableName docId partial (fromDataSource ())
+
+ /// Update partial documents using a JSON containment query in the WHERE clause (@>)
+ let partialByContains tableName (criteria: obj) (partial: obj) =
+ WithProps.Update.partialByContains tableName criteria partial (fromDataSource ())
+
+ /// Update partial documents using a JSON Path match query in the WHERE clause (@?)
+ let partialByJsonPath tableName jsonPath (partial: obj) =
+ WithProps.Update.partialByJsonPath tableName jsonPath partial (fromDataSource ())
+
+
+/// Commands to delete documents
+[]
+module Delete =
+
+ /// Delete a document by its ID
+ let byId tableName docId =
+ WithProps.Delete.byId tableName docId (fromDataSource ())
+
+ /// Delete documents by matching a JSON contains query (@>)
+ let byContains tableName (criteria: obj) =
+ WithProps.Delete.byContains tableName criteria (fromDataSource ())
+
+ /// Delete documents by matching a JSON Path match query (@?)
+ let byJsonPath tableName path =
+ WithProps.Delete.byJsonPath tableName path (fromDataSource ())
+
+
+/// Commands to execute custom SQL queries
+[]
+module Custom =
+
+ /// Execute a query that returns one or no results
+ let single<'T> query parameters (mapFunc: RowReader -> 'T) =
+ WithProps.Custom.single query parameters mapFunc (fromDataSource ())
+
+ /// Execute a query that returns a list of results
+ let list<'T> query parameters (mapFunc: RowReader -> 'T) =
+ WithProps.Custom.list query parameters mapFunc (fromDataSource ())
+
+ /// Execute a query that returns no results
+ let nonQuery query parameters =
+ WithProps.Custom.nonQuery query parameters (fromDataSource ())
+
+ /// Execute a query that returns a scalar value
+ let scalar<'T when 'T: struct> query parameters (mapFunc: RowReader -> 'T) =
+ WithProps.Custom.scalar query parameters mapFunc (fromDataSource ())
diff --git a/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj b/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj
index bb60bcd..dad9a03 100644
--- a/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj
+++ b/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj
@@ -7,12 +7,14 @@
+
+
diff --git a/src/Tests.CSharp/PostgresDb.cs b/src/Tests.CSharp/PostgresDb.cs
new file mode 100644
index 0000000..ce2ec6a
--- /dev/null
+++ b/src/Tests.CSharp/PostgresDb.cs
@@ -0,0 +1,144 @@
+using BitBadger.Documents.Postgres;
+using Npgsql;
+using Npgsql.FSharp;
+using ThrowawayDb.Postgres;
+
+namespace BitBadger.Documents.Tests;
+
+///
+/// A throwaway SQLite database file, which will be deleted when it goes out of scope
+///
+public class ThrowawayPostgresDb : IDisposable, IAsyncDisposable
+{
+ private readonly ThrowawayDatabase _db;
+
+ public string ConnectionString => _db.ConnectionString;
+
+ public ThrowawayPostgresDb(ThrowawayDatabase db)
+ {
+ _db = db;
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _db.Dispose();
+ GC.SuppressFinalize(this);
+ return ValueTask.CompletedTask;
+ }
+}
+
+///
+/// Database helpers for PostgreSQL integration tests
+///
+public static class PostgresDb
+{
+ ///
+ /// The name of the table used for testing
+ ///
+ public const string TableName = "test_table";
+
+ ///
+ /// The host for the database
+ ///
+ private static readonly Lazy DbHost = new(() =>
+ {
+ return Environment.GetEnvironmentVariable("BitBadger.Documents.Postgres.DbHost") switch
+ {
+ null => "localhost",
+ var host when host.Trim() == "" => "localhost",
+ var host => host
+ };
+ });
+
+ ///
+ /// The port for the database
+ ///
+ private static readonly Lazy DbPort = new(() =>
+ {
+ return Environment.GetEnvironmentVariable("BitBadger.Documents.Postgres.DbPort") switch
+ {
+ null => 5432,
+ var port when port.Trim() == "" => 5432,
+ var port => int.Parse(port)
+ };
+ });
+
+ ///
+ /// The database itself
+ ///
+ private static readonly Lazy DbDatabase = new(() =>
+ {
+ return Environment.GetEnvironmentVariable("BitBadger.Documents.Postres.DbDatabase") switch
+ {
+ null => "postgres",
+ var db when db.Trim() == "" => "postgres",
+ var db => db
+ };
+ });
+
+ ///
+ /// The user to use in connecting to the database
+ ///
+ private static readonly Lazy DbUser = new(() =>
+ {
+ return Environment.GetEnvironmentVariable("BitBadger.Documents.Postgres.DbUser") switch
+ {
+ null => "postgres",
+ var user when user.Trim() == "" => "postgres",
+ var user => user
+ };
+ });
+
+ ///
+ /// The password to use for the database
+ ///
+ private static readonly Lazy DbPassword = new(() =>
+ {
+ return Environment.GetEnvironmentVariable("BitBadger.Documents.Postrgres.DbPwd") switch
+ {
+ null => "postgres",
+ var pwd when pwd.Trim() == "" => "postgres",
+ var pwd => pwd
+ };
+ });
+
+ ///
+ /// The overall connection string
+ ///
+ public static readonly Lazy ConnStr = new(() =>
+ Sql.formatConnectionString(
+ Sql.password(DbPassword.Value,
+ Sql.username(DbUser.Value,
+ Sql.database(DbDatabase.Value,
+ Sql.port(DbPort.Value,
+ Sql.host(DbHost.Value)))))));
+
+ ///
+ /// Create a data source using the derived connection string
+ ///
+ public static NpgsqlDataSource MkDataSource(string cStr) =>
+ new NpgsqlDataSourceBuilder(cStr).Build();
+
+ ///
+ /// Build the throwaway database
+ ///
+ public static ThrowawayPostgresDb BuildDb()
+ {
+ var database = ThrowawayDatabase.Create(ConnStr.Value);
+
+ var sqlProps = Sql.connect(database.ConnectionString);
+
+ Sql.executeNonQuery(Sql.query(Definition.createTable(TableName), sqlProps));
+ Sql.executeNonQuery(Sql.query(Definition.createKey(TableName), sqlProps));
+
+ Postgres.Configuration.useDataSource(MkDataSource(database.ConnectionString));
+
+ return new ThrowawayPostgresDb(database);
+ }
+}
diff --git a/src/Tests/BitBadger.Documents.Tests.fsproj b/src/Tests/BitBadger.Documents.Tests.fsproj
index 4792e95..a189c86 100644
--- a/src/Tests/BitBadger.Documents.Tests.fsproj
+++ b/src/Tests/BitBadger.Documents.Tests.fsproj
@@ -7,6 +7,7 @@
+
@@ -18,6 +19,7 @@
+
diff --git a/src/Tests/PostgresTests.fs b/src/Tests/PostgresTests.fs
new file mode 100644
index 0000000..0a7f558
--- /dev/null
+++ b/src/Tests/PostgresTests.fs
@@ -0,0 +1,711 @@
+module PostgresTests
+
+open Expecto
+open BitBadger.Documents.Postgres
+open BitBadger.Documents.Tests
+
+/// Tests which do not hit the database
+let unitTests =
+ testList "Unit" [
+ testList "Definition" [
+ test "createTable succeeds" {
+ Expect.equal (Definition.createTable PostgresDb.TableName)
+ $"CREATE TABLE IF NOT EXISTS {PostgresDb.TableName} (data JSONB NOT NULL)"
+ "CREATE TABLE statement not constructed correctly"
+ }
+ test "createKey succeeds" {
+ Expect.equal (Definition.createKey PostgresDb.TableName)
+ $"CREATE UNIQUE INDEX IF NOT EXISTS idx_{PostgresDb.TableName}_key ON {PostgresDb.TableName} ((data ->> 'Id'))"
+ "CREATE INDEX for key statement not constructed correctly"
+ }
+ test "createIndex succeeds for full index" {
+ Expect.equal (Definition.createIndex "schema.tbl" Full)
+ "CREATE INDEX IF NOT EXISTS idx_tbl ON schema.tbl USING GIN (data)"
+ "CREATE INDEX statement not constructed correctly"
+ }
+ test "createIndex succeeds for JSONB Path Ops index" {
+ Expect.equal (Definition.createIndex PostgresDb.TableName Optimized)
+ $"CREATE INDEX IF NOT EXISTS idx_{PostgresDb.TableName} ON {PostgresDb.TableName} USING GIN (data jsonb_path_ops)"
+ "CREATE INDEX statement not constructed correctly"
+ }
+ ]
+ testList "Query" [
+ test "selectFromTable succeeds" {
+ Expect.equal (Query.selectFromTable PostgresDb.TableName) $"SELECT data FROM {PostgresDb.TableName}"
+ "SELECT statement not correct"
+ }
+ test "whereById succeeds" {
+ Expect.equal (Query.whereById "@id") "data ->> 'Id' = @id" "WHERE clause not correct"
+ }
+ test "whereDataContains succeeds" {
+ Expect.equal (Query.whereDataContains "@test") "data @> @test" "WHERE clause not correct"
+ }
+ test "whereJsonPathMatches succeeds" {
+ Expect.equal (Query.whereJsonPathMatches "@path") "data @? @path::jsonpath" "WHERE clause not correct"
+ }
+ test "jsonbDocParam succeeds" {
+ Expect.equal (Query.jsonbDocParam {| Hello = "There" |}) (Sql.jsonb "{\"Hello\":\"There\"}")
+ "JSONB document not serialized correctly"
+ }
+ test "docParameters succeeds" {
+ let parameters = Query.docParameters "abc123" {| Testing = 456 |}
+ let expected = [
+ "@id", Sql.string "abc123"
+ "@data", Sql.jsonb "{\"Testing\":456}"
+ ]
+ Expect.equal parameters expected "There should have been 2 parameters, one string and one JSONB"
+ }
+ test "insert succeeds" {
+ Expect.equal (Query.insert PostgresDb.TableName) $"INSERT INTO {PostgresDb.TableName} VALUES (@data)"
+ "INSERT statement not correct"
+ }
+ test "save succeeds" {
+ Expect.equal (Query.save PostgresDb.TableName)
+ $"INSERT INTO {PostgresDb.TableName} VALUES (@data) ON CONFLICT ((data ->> 'Id')) DO UPDATE SET data = EXCLUDED.data"
+ "INSERT ON CONFLICT UPDATE statement not correct"
+ }
+ testList "Count" [
+ test "all succeeds" {
+ Expect.equal (Query.Count.all PostgresDb.TableName) $"SELECT COUNT(*) AS it FROM {PostgresDb.TableName}"
+ "Count query not correct"
+ }
+ test "byContains succeeds" {
+ Expect.equal (Query.Count.byContains PostgresDb.TableName)
+ $"SELECT COUNT(*) AS it FROM {PostgresDb.TableName} WHERE data @> @criteria"
+ "JSON containment count query not correct"
+ }
+ test "byJsonPath succeeds" {
+ Expect.equal (Query.Count.byJsonPath PostgresDb.TableName)
+ $"SELECT COUNT(*) AS it FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath"
+ "JSON Path match count query not correct"
+ }
+ ]
+ testList "Exists" [
+ test "byId succeeds" {
+ Expect.equal (Query.Exists.byId PostgresDb.TableName)
+ $"SELECT EXISTS (SELECT 1 FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id) AS it"
+ "ID existence query not correct"
+ }
+ test "byContains succeeds" {
+ Expect.equal (Query.Exists.byContains PostgresDb.TableName)
+ $"SELECT EXISTS (SELECT 1 FROM {PostgresDb.TableName} WHERE data @> @criteria) AS it"
+ "JSON containment exists query not correct"
+ }
+ test "byJsonPath succeeds" {
+ Expect.equal (Query.Exists.byJsonPath PostgresDb.TableName)
+ $"SELECT EXISTS (SELECT 1 FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath) AS it"
+ "JSON Path match existence query not correct"
+ }
+ ]
+ testList "Find" [
+ test "byId succeeds" {
+ Expect.equal (Query.Find.byId PostgresDb.TableName)
+ $"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id" "SELECT by ID query not correct"
+ }
+ test "byContains succeeds" {
+ Expect.equal (Query.Find.byContains PostgresDb.TableName)
+ $"SELECT data FROM {PostgresDb.TableName} WHERE data @> @criteria"
+ "SELECT by JSON containment query not correct"
+ }
+ test "byJsonPath succeeds" {
+ Expect.equal (Query.Find.byJsonPath PostgresDb.TableName)
+ $"SELECT data FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath"
+ "SELECT by JSON Path match query not correct"
+ }
+ ]
+ testList "Update" [
+ test "full succeeds" {
+ Expect.equal (Query.Update.full PostgresDb.TableName)
+ $"UPDATE {PostgresDb.TableName} SET data = @data WHERE data ->> 'Id' = @id"
+ "UPDATE full statement not correct"
+ }
+ test "partialById succeeds" {
+ Expect.equal (Query.Update.partialById PostgresDb.TableName)
+ $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data ->> 'Id' = @id"
+ "UPDATE partial by ID statement not correct"
+ }
+ test "partialByContains succeeds" {
+ Expect.equal (Query.Update.partialByContains PostgresDb.TableName)
+ $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data @> @criteria"
+ "UPDATE partial by JSON containment statement not correct"
+ }
+ test "partialByJsonPath succeeds" {
+ Expect.equal (Query.Update.partialByJsonPath PostgresDb.TableName)
+ $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data @? @path::jsonpath"
+ "UPDATE partial by JSON Path statement not correct"
+ }
+ ]
+ testList "Delete" [
+ test "byId succeeds" {
+ Expect.equal (Query.Delete.byId PostgresDb.TableName) $"DELETE FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id"
+ "DELETE by ID query not correct"
+ }
+ test "byContains succeeds" {
+ Expect.equal (Query.Delete.byContains PostgresDb.TableName)
+ $"DELETE FROM {PostgresDb.TableName} WHERE data @> @criteria"
+ "DELETE by JSON containment query not correct"
+ }
+ test "byJsonPath succeeds" {
+ Expect.equal (Query.Delete.byJsonPath PostgresDb.TableName)
+ $"DELETE FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath"
+ "DELETE by JSON Path match query not correct"
+ }
+ ]
+ ]
+ ]
+
+open Npgsql.FSharp
+open ThrowawayDb.Postgres
+open Types
+
+let isTrue<'T> (_ : 'T) = true
+
+let integrationTests =
+ let documents = [
+ { Id = "one"; Value = "FIRST!"; NumValue = 0; Sub = None }
+ { Id = "two"; Value = "another"; NumValue = 10; Sub = Some { Foo = "green"; Bar = "blue" } }
+ { Id = "three"; Value = ""; NumValue = 4; Sub = None }
+ { Id = "four"; Value = "purple"; NumValue = 17; Sub = Some { Foo = "green"; Bar = "red" } }
+ { Id = "five"; Value = "purple"; NumValue = 18; Sub = None }
+ ]
+ let loadDocs () = backgroundTask {
+ for doc in documents do do! insert PostgresDb.TableName doc
+ }
+ testList "Integration" [
+ testList "Configuration" [
+ test "useDataSource disposes existing source" {
+ use db1 = ThrowawayDatabase.Create PostgresDb.ConnStr.Value
+ let source = PostgresDb.MkDataSource db1.ConnectionString
+ Configuration.useDataSource source
+
+ use db2 = ThrowawayDatabase.Create PostgresDb.ConnStr.Value
+ Configuration.useDataSource (PostgresDb.MkDataSource db2.ConnectionString)
+ Expect.throws (fun () -> source.OpenConnection() |> ignore) "Data source should have been disposed"
+ }
+ test "dataSource returns configured data source" {
+ use db = ThrowawayDatabase.Create PostgresDb.ConnStr.Value
+ let source = PostgresDb.MkDataSource db.ConnectionString
+ Configuration.useDataSource source
+
+ Expect.isTrue (obj.ReferenceEquals(source, Configuration.dataSource ()))
+ "Data source should have been the same"
+ }
+ ]
+ testList "Definition" [
+ testTask "ensureTable succeeds" {
+ use db = PostgresDb.BuildDb()
+ let tableExists () =
+ Sql.connect db.ConnectionString
+ |> Sql.query "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'ensured') AS it"
+ |> Sql.executeRowAsync (fun row -> row.bool "it")
+ let keyExists () =
+ Sql.connect db.ConnectionString
+ |> Sql.query "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_key') AS it"
+ |> Sql.executeRowAsync (fun row -> row.bool "it")
+
+ let! exists = tableExists ()
+ let! alsoExists = keyExists ()
+ Expect.isFalse exists "The table should not exist already"
+ Expect.isFalse alsoExists "The key index should not exist already"
+
+ do! Definition.ensureTable "ensured"
+ let! exists' = tableExists ()
+ let! alsoExists' = keyExists ()
+ Expect.isTrue exists' "The table should now exist"
+ Expect.isTrue alsoExists' "The key index should now exist"
+ }
+ testTask "ensureIndex succeeds" {
+ use db = PostgresDb.BuildDb()
+ let indexExists () =
+ Sql.connect db.ConnectionString
+ |> Sql.query "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured') AS it"
+ |> Sql.executeRowAsync (fun row -> row.bool "it")
+
+ let! exists = indexExists ()
+ Expect.isFalse exists "The index should not exist already"
+
+ do! Definition.ensureTable "ensured"
+ do! Definition.ensureIndex "ensured" Optimized
+ let! exists' = indexExists ()
+ Expect.isTrue exists' "The index should now exist"
+ // TODO: check for GIN(jsonp_path_ops), write test for "full" index that checks for their absence
+ }
+ ]
+ testList "insert" [
+ testTask "succeeds" {
+ use db = PostgresDb.BuildDb()
+ let! before = Find.all PostgresDb.TableName
+ Expect.equal before [] "There should be no documents in the table"
+
+ let testDoc = { emptyDoc with Id = "turkey"; Sub = Some { Foo = "gobble"; Bar = "gobble" } }
+ do! insert PostgresDb.TableName testDoc
+ let! after = Find.all PostgresDb.TableName
+ Expect.equal after [ testDoc ] "There should have been one document inserted"
+ }
+ testTask "fails for duplicate key" {
+ use db = PostgresDb.BuildDb()
+ do! insert PostgresDb.TableName { emptyDoc with Id = "test" }
+ Expect.throws (fun () ->
+ insert PostgresDb.TableName {emptyDoc with Id = "test" } |> Async.AwaitTask |> Async.RunSynchronously)
+ "An exception should have been raised for duplicate document ID insert"
+ }
+ ]
+ testList "save" [
+ testTask "succeeds when a document is inserted" {
+ use db = PostgresDb.BuildDb()
+ let! before = Find.all PostgresDb.TableName
+ Expect.equal before [] "There should be no documents in the table"
+
+ let testDoc = { emptyDoc with Id = "test"; Sub = Some { Foo = "a"; Bar = "b" } }
+ do! save PostgresDb.TableName testDoc
+ let! after = Find.all PostgresDb.TableName
+ Expect.equal after [ testDoc ] "There should have been one document inserted"
+ }
+ testTask "succeeds when a document is updated" {
+ use db = PostgresDb.BuildDb()
+ let testDoc = { emptyDoc with Id = "test"; Sub = Some { Foo = "a"; Bar = "b" } }
+ do! insert PostgresDb.TableName testDoc
+
+ let! before = Find.byId PostgresDb.TableName "test"
+ if Option.isNone before then Expect.isTrue false "There should have been a document returned"
+ Expect.equal before.Value testDoc "The document is not correct"
+
+ let upd8Doc = { testDoc with Sub = Some { Foo = "c"; Bar = "d" } }
+ do! save PostgresDb.TableName upd8Doc
+ let! after = Find.byId PostgresDb.TableName "test"
+ if Option.isNone after then Expect.isTrue false "There should have been a document returned post-update"
+ Expect.equal after.Value upd8Doc "The updated document is not correct"
+ }
+ ]
+ testList "Count" [
+ testTask "all succeeds" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! theCount = Count.all PostgresDb.TableName
+ Expect.equal theCount 5 "There should have been 5 matching documents"
+ }
+ testTask "byContains succeeds" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! theCount = Count.byContains PostgresDb.TableName {| Value = "purple" |}
+ Expect.equal theCount 2 "There should have been 2 matching documents"
+ }
+ testTask "byJsonPath succeeds" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! theCount = Count.byJsonPath PostgresDb.TableName "$.NumValue ? (@ > 5)"
+ Expect.equal theCount 3 "There should have been 3 matching documents"
+ }
+ ]
+ testList "Exists" [
+ testList "byId" [
+ testTask "succeeds when a document exists" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! exists = Exists.byId PostgresDb.TableName "three"
+ Expect.isTrue exists "There should have been an existing document"
+ }
+ testTask "succeeds when a document does not exist" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! exists = Exists.byId PostgresDb.TableName "seven"
+ Expect.isFalse exists "There should not have been an existing document"
+ }
+ ]
+ testList "byContains" [
+ testTask "succeeds when documents exist" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! exists = Exists.byContains PostgresDb.TableName {| NumValue = 10 |}
+ Expect.isTrue exists "There should have been existing documents"
+ }
+ testTask "succeeds when no matching documents exist" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! exists = Exists.byContains PostgresDb.TableName {| Nothing = "none" |}
+ Expect.isFalse exists "There should not have been any existing documents"
+ }
+ ]
+ testList "byJsonPath" [
+ testTask "succeeds when documents exist" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! exists = Exists.byJsonPath PostgresDb.TableName """$.Sub.Foo ? (@ == "green")"""
+ Expect.isTrue exists "There should have been existing documents"
+ }
+ testTask "succeeds when no matching documents exist" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! exists = Exists.byJsonPath PostgresDb.TableName "$.NumValue ? (@ > 1000)"
+ Expect.isFalse exists "There should not have been any existing documents"
+ }
+ ]
+ ]
+ testList "Find" [
+ testList "all" [
+ testTask "succeeds when there is data" {
+ use db = PostgresDb.BuildDb()
+
+ do! insert PostgresDb.TableName { Foo = "one"; Bar = "two" }
+ do! insert PostgresDb.TableName { Foo = "three"; Bar = "four" }
+ do! insert PostgresDb.TableName { Foo = "five"; Bar = "six" }
+
+ let! results = Find.all PostgresDb.TableName
+ let expected = [
+ { Foo = "one"; Bar = "two" }
+ { Foo = "three"; Bar = "four" }
+ { Foo = "five"; Bar = "six" }
+ ]
+ Expect.equal results expected "There should have been 3 documents returned"
+ }
+ testTask "succeeds when there is no data" {
+ use db = PostgresDb.BuildDb()
+ let! results = Find.all PostgresDb.TableName
+ Expect.equal results [] "There should have been no documents returned"
+ }
+ ]
+ testList "byId" [
+ testTask "succeeds when a document is found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.byId PostgresDb.TableName "two"
+ Expect.isTrue (Option.isSome doc) "There should have been a document returned"
+ Expect.equal doc.Value.Id "two" "The incorrect document was returned"
+ }
+ testTask "succeeds when a document is not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.byId PostgresDb.TableName "three hundred eighty-seven"
+ Expect.isFalse (Option.isSome doc) "There should not have been a document returned"
+ }
+ ]
+ testList "byContains" [
+ testTask "succeeds when documents are found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! docs = Find.byContains PostgresDb.TableName {| Sub = {| Foo = "green" |} |}
+ Expect.equal (List.length docs) 2 "There should have been two documents returned"
+ }
+ testTask "succeeds when documents are not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! docs = Find.byContains PostgresDb.TableName {| Value = "mauve" |}
+ Expect.isTrue (List.isEmpty docs) "There should have been no documents returned"
+ }
+ ]
+ testList "byJsonPath" [
+ testTask "succeeds when documents are found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! docs = Find.byJsonPath PostgresDb.TableName "$.NumValue ? (@ < 15)"
+ Expect.equal (List.length docs) 3 "There should have been 3 documents returned"
+ }
+ testTask "succeeds when documents are not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! docs = Find.byJsonPath PostgresDb.TableName "$.NumValue ? (@ < 0)"
+ Expect.isTrue (List.isEmpty docs) "There should have been no documents returned"
+ }
+ ]
+ testList "firstByContains" [
+ testTask "succeeds when a document is found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.firstByContains PostgresDb.TableName {| Value = "another" |}
+ Expect.isTrue (Option.isSome doc) "There should have been a document returned"
+ Expect.equal doc.Value.Id "two" "The incorrect document was returned"
+ }
+ testTask "succeeds when multiple documents are found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.firstByContains PostgresDb.TableName {| Sub = {| Foo = "green" |} |}
+ Expect.isTrue (Option.isSome doc) "There should have been a document returned"
+ Expect.contains [ "two"; "four" ] doc.Value.Id "An incorrect document was returned"
+ }
+ testTask "succeeds when a document is not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.firstByContains PostgresDb.TableName {| Value = "absent" |}
+ Expect.isFalse (Option.isSome doc) "There should not have been a document returned"
+ }
+ ]
+ testList "firstByJsonPath" [
+ testTask "succeeds when a document is found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.firstByJsonPath PostgresDb.TableName """$.Value ? (@ == "FIRST!")"""
+ Expect.isTrue (Option.isSome doc) "There should have been a document returned"
+ Expect.equal doc.Value.Id "one" "The incorrect document was returned"
+ }
+ testTask "succeeds when multiple documents are found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.firstByJsonPath PostgresDb.TableName """$.Sub.Foo ? (@ == "green")"""
+ Expect.isTrue (Option.isSome doc) "There should have been a document returned"
+ Expect.contains [ "two"; "four" ] doc.Value.Id "An incorrect document was returned"
+ }
+ testTask "succeeds when a document is not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc = Find.firstByJsonPath PostgresDb.TableName """$.Id ? (@ == "nope")"""
+ Expect.isFalse (Option.isSome doc) "There should not have been a document returned"
+ }
+ ]
+ ]
+ testList "Update" [
+ testList "full" [
+ testTask "succeeds when a document is updated" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let testDoc = { emptyDoc with Id = "one"; Sub = Some { Foo = "blue"; Bar = "red" } }
+ do! Update.full PostgresDb.TableName "one" testDoc
+ let! after = Find.byId PostgresDb.TableName "one"
+ if Option.isNone after then
+ Expect.isTrue false "There should have been a document returned post-update"
+ Expect.equal after.Value testDoc "The updated document is not correct"
+ }
+ testTask "succeeds when no document is updated" {
+ use db = PostgresDb.BuildDb()
+
+ let! before = Find.all PostgresDb.TableName
+ Expect.hasCountOf before 0u isTrue "There should have been no documents returned"
+
+ // This not raising an exception is the test
+ do! Update.full PostgresDb.TableName "test"
+ { emptyDoc with Id = "x"; Sub = Some { Foo = "blue"; Bar = "red" } }
+ }
+ ]
+ testList "fullFunc" [
+ testTask "succeeds when a document is updated" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Update.fullFunc PostgresDb.TableName (_.Id)
+ { Id = "one"; Value = "le un"; NumValue = 1; Sub = None }
+ let! after = Find.byId PostgresDb.TableName "one"
+ if Option.isNone after then
+ Expect.isTrue false "There should have been a document returned post-update"
+ Expect.equal after.Value { Id = "one"; Value = "le un"; NumValue = 1; Sub = None }
+ "The updated document is not correct"
+ }
+ testTask "succeeds when no document is updated" {
+ use db = PostgresDb.BuildDb()
+
+ let! before = Find.all PostgresDb.TableName
+ Expect.hasCountOf before 0u isTrue "There should have been no documents returned"
+
+ // This not raising an exception is the test
+ do! Update.fullFunc PostgresDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None }
+ }
+ ]
+ testList "partialById" [
+ testTask "succeeds when a document is updated" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Update.partialById PostgresDb.TableName "one" {| NumValue = 44 |}
+ let! after = Find.byId PostgresDb.TableName "one"
+ if Option.isNone after then
+ Expect.isTrue false "There should have been a document returned post-update"
+ Expect.equal after.Value.NumValue 44 "The updated document is not correct"
+ }
+ testTask "succeeds when no document is updated" {
+ use db = PostgresDb.BuildDb()
+
+ let! before = Find.all PostgresDb.TableName
+ Expect.hasCountOf before 0u isTrue "There should have been no documents returned"
+
+ // This not raising an exception is the test
+ do! Update.partialById PostgresDb.TableName "test" {| Foo = "green" |}
+ }
+ ]
+ testList "partialByContains" [
+ testTask "succeeds when a document is updated" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Update.partialByContains PostgresDb.TableName {| Value = "purple" |} {| NumValue = 77 |}
+ let! after = Count.byContains PostgresDb.TableName {| NumValue = 77 |}
+ Expect.equal after 2 "There should have been 2 documents returned"
+ }
+ testTask "succeeds when no document is updated" {
+ use db = PostgresDb.BuildDb()
+
+ let! before = Find.all PostgresDb.TableName
+ Expect.hasCountOf before 0u isTrue "There should have been no documents returned"
+
+ // This not raising an exception is the test
+ do! Update.partialByContains PostgresDb.TableName {| Value = "burgundy" |} {| Foo = "green" |}
+ }
+ ]
+ testList "partialByJsonPath" [
+ testTask "succeeds when a document is updated" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Update.partialByJsonPath PostgresDb.TableName "$.NumValue ? (@ > 10)" {| NumValue = 1000 |}
+ let! after = Count.byJsonPath PostgresDb.TableName "$.NumValue ? (@ > 999)"
+ Expect.equal after 2 "There should have been 2 documents returned"
+ }
+ testTask "succeeds when no document is updated" {
+ use db = PostgresDb.BuildDb()
+
+ let! before = Find.all PostgresDb.TableName
+ Expect.hasCountOf before 0u isTrue "There should have been no documents returned"
+
+ // This not raising an exception is the test
+ do! Update.partialByContains PostgresDb.TableName {| Value = "burgundy" |} {| Foo = "green" |}
+ }
+ ]
+ ]
+ testList "Delete" [
+ testList "byId" [
+ testTask "succeeds when a document is deleted" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Delete.byId PostgresDb.TableName "four"
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 4 "There should have been 4 documents remaining"
+ }
+ testTask "succeeds when a document is not deleted" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Delete.byId PostgresDb.TableName "thirty"
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 5 "There should have been 5 documents remaining"
+ }
+ ]
+ testList "byContains" [
+ testTask "succeeds when documents are deleted" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Delete.byContains PostgresDb.TableName {| Value = "purple" |}
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 3 "There should have been 3 documents remaining"
+ }
+ testTask "succeeds when documents are not deleted" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Delete.byContains PostgresDb.TableName {| Value = "crimson" |}
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 5 "There should have been 5 documents remaining"
+ }
+ ]
+ testList "byJsonPath" [
+ testTask "succeeds when documents are deleted" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Delete.byJsonPath PostgresDb.TableName """$.Sub.Foo ? (@ == "green")"""
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 3 "There should have been 3 documents remaining"
+ }
+ testTask "succeeds when documents are not deleted" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Delete.byJsonPath PostgresDb.TableName "$.NumValue ? (@ > 100)"
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 5 "There should have been 5 documents remaining"
+ }
+ ]
+ ]
+ testList "Custom" [
+ testList "single" [
+ testTask "succeeds when a row is found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc =
+ Custom.single $"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id"
+ [ "@id", Sql.string "one"] fromData
+ Expect.isSome doc "There should have been a document returned"
+ Expect.equal doc.Value.Id "one" "The incorrect document was returned"
+ }
+ testTask "succeeds when a row is not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! doc =
+ Custom.single $"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id"
+ [ "@id", Sql.string "eighty" ] fromData
+ Expect.isNone doc "There should not have been a document returned"
+ }
+ ]
+ testList "list" [
+ testTask "succeeds when data is found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! docs = Custom.list (Query.selectFromTable PostgresDb.TableName) [] fromData
+ Expect.hasCountOf docs 5u isTrue "There should have been 5 documents returned"
+ }
+ testTask "succeeds when data is not found" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ let! docs =
+ Custom.list $"SELECT data FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath"
+ [ "@path", Sql.string "$.NumValue ? (@ > 100)" ] fromData
+ Expect.isEmpty docs "There should have been no documents returned"
+ }
+ ]
+ testList "nonQuery" [
+ testTask "succeeds when operating on data" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Custom.nonQuery $"DELETE FROM {PostgresDb.TableName}" []
+
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 0 "There should be no documents remaining in the table"
+ }
+ testTask "succeeds when no data matches where clause" {
+ use db = PostgresDb.BuildDb()
+ do! loadDocs ()
+
+ do! Custom.nonQuery $"DELETE FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath"
+ [ "@path", Sql.string "$.NumValue ? (@ > 100)" ]
+
+ let! remaining = Count.all PostgresDb.TableName
+ Expect.equal remaining 5 "There should be 5 documents remaining in the table"
+ }
+ ]
+ testTask "scalar succeeds" {
+ use db = PostgresDb.BuildDb()
+
+ let! nbr = Custom.scalar $"SELECT 5 AS test_value" [] (fun row -> row.int "test_value")
+ Expect.equal nbr 5 "The query should have returned the number 5"
+ }
+ ]
+ ]
+ |> testSequenced
+
+
+let all = testList "FSharp.Documents" [ unitTests; integrationTests ]