From d02ea638bc7c7493a29f3ed97c2d72cc3acda8ea Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 25 Dec 2023 23:59:49 -0500 Subject: [PATCH] Import Postgres F# functions --- src/BitBadger.Documents.sln | 6 + src/Directory.Build.props | 8 +- .../BitBadger.Documents.Postgres.fsproj | 15 + src/Postgres/Library.fs | 566 ++++++++++++++ .../BitBadger.Documents.Tests.CSharp.csproj | 2 + src/Tests.CSharp/PostgresDb.cs | 144 ++++ src/Tests/BitBadger.Documents.Tests.fsproj | 2 + src/Tests/PostgresTests.fs | 711 ++++++++++++++++++ 8 files changed, 1450 insertions(+), 4 deletions(-) create mode 100644 src/Postgres/BitBadger.Documents.Postgres.fsproj create mode 100644 src/Postgres/Library.fs create mode 100644 src/Tests.CSharp/PostgresDb.cs create mode 100644 src/Tests/PostgresTests.fs 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 ]