From 68ad8742563a26a55a8ab17ddf99e08c2781411b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 6 Jan 2024 15:51:48 -0500 Subject: [PATCH] v3 RC1 (#1) This encompasses: - New behavior for SQLite - Migrated behavior for PostrgeSQL (from BitBadger.Npgsql.FSharp.Documents) - New "byField" behavior for PostgreSQL - A unification of C# and F# centric implementations --- README.md | 12 + .../.idea/.gitignore | 13 + .../.idea.BitBadger.Documents/.idea/.name | 1 + .../.idea/indexLayout.xml | 10 + .../.idea.BitBadger.Documents/.idea/vcs.xml | 7 + src/BitBadger.Documents.sln | 46 + src/Common/BitBadger.Documents.Common.fsproj | 18 + src/Common/Library.fs | 221 ++++ src/Common/README.md | 17 + src/Directory.Build.props | 21 + .../BitBadger.Documents.Postgres.fsproj | 23 + src/Postgres/Extensions.fs | 333 ++++++ src/Postgres/Library.fs | 770 ++++++++++++++ src/Postgres/README.md | 101 ++ src/Sqlite/BitBadger.Documents.Sqlite.fsproj | 32 + src/Sqlite/Extensions.fs | 215 ++++ src/Sqlite/Library.fs | 539 ++++++++++ src/Sqlite/README.md | 101 ++ .../BitBadger.Documents.Tests.CSharp.csproj | 19 + src/Tests.CSharp/CommonCSharpTests.cs | 238 +++++ .../PostgresCSharpExtensionTests.cs | 807 +++++++++++++++ src/Tests.CSharp/PostgresCSharpTests.cs | 970 ++++++++++++++++++ src/Tests.CSharp/PostgresDb.cs | 150 +++ .../SqliteCSharpExtensionTests.cs | 518 ++++++++++ src/Tests.CSharp/SqliteCSharpTests.cs | 596 +++++++++++ src/Tests.CSharp/SqliteDb.cs | 59 ++ src/Tests.CSharp/Types.cs | 15 + src/Tests/BitBadger.Documents.Tests.fsproj | 28 + src/Tests/CommonTests.fs | 159 +++ src/Tests/PostgresExtensionTests.fs | 700 +++++++++++++ src/Tests/PostgresTests.fs | 816 +++++++++++++++ src/Tests/Program.fs | 19 + src/Tests/SqliteExtensionTests.fs | 471 +++++++++ src/Tests/SqliteTests.fs | 536 ++++++++++ src/Tests/Types.fs | 23 + src/icon.png | Bin 0 -> 25176 bytes 36 files changed, 8604 insertions(+) create mode 100644 README.md create mode 100644 src/.idea/.idea.BitBadger.Documents/.idea/.gitignore create mode 100644 src/.idea/.idea.BitBadger.Documents/.idea/.name create mode 100644 src/.idea/.idea.BitBadger.Documents/.idea/indexLayout.xml create mode 100644 src/.idea/.idea.BitBadger.Documents/.idea/vcs.xml create mode 100644 src/BitBadger.Documents.sln create mode 100644 src/Common/BitBadger.Documents.Common.fsproj create mode 100644 src/Common/Library.fs create mode 100644 src/Common/README.md create mode 100644 src/Directory.Build.props create mode 100644 src/Postgres/BitBadger.Documents.Postgres.fsproj create mode 100644 src/Postgres/Extensions.fs create mode 100644 src/Postgres/Library.fs create mode 100644 src/Postgres/README.md create mode 100644 src/Sqlite/BitBadger.Documents.Sqlite.fsproj create mode 100644 src/Sqlite/Extensions.fs create mode 100644 src/Sqlite/Library.fs create mode 100644 src/Sqlite/README.md create mode 100644 src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj create mode 100644 src/Tests.CSharp/CommonCSharpTests.cs create mode 100644 src/Tests.CSharp/PostgresCSharpExtensionTests.cs create mode 100644 src/Tests.CSharp/PostgresCSharpTests.cs create mode 100644 src/Tests.CSharp/PostgresDb.cs create mode 100644 src/Tests.CSharp/SqliteCSharpExtensionTests.cs create mode 100644 src/Tests.CSharp/SqliteCSharpTests.cs create mode 100644 src/Tests.CSharp/SqliteDb.cs create mode 100644 src/Tests.CSharp/Types.cs create mode 100644 src/Tests/BitBadger.Documents.Tests.fsproj create mode 100644 src/Tests/CommonTests.fs create mode 100644 src/Tests/PostgresExtensionTests.fs create mode 100644 src/Tests/PostgresTests.fs create mode 100644 src/Tests/Program.fs create mode 100644 src/Tests/SqliteExtensionTests.fs create mode 100644 src/Tests/SqliteTests.fs create mode 100644 src/Tests/Types.fs create mode 100644 src/icon.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..109625d --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# BitBadger.Documents + +This library provides a lightweight document storage implementation backed by either PostgreSQL or SQLite. Both of these databases have great support for storing, retrieving, and manipulating JSON fields; this library leverages that, and provides a straightforward way to store documents. + +## NuGet Packages +| PostgreSQL | SQLite | +|------------|--------| +|[![Nuget](https://img.shields.io/nuget/v/BitBadger.Documents.Postgres?style=plastic)](https://www.nuget.org/packages/BitBadger.Documents.Postgres/)|[![Nuget](https://img.shields.io/nuget/v/BitBadger.Documents.Sqlite?style=plastic)](https://www.nuget.org/packages/BitBadger.Documents.Sqlite/)| + +## More Information + +See [the project site](https://bitbadger.solutions/open-source/relational-documents/) for a full description and documentation. diff --git a/src/.idea/.idea.BitBadger.Documents/.idea/.gitignore b/src/.idea/.idea.BitBadger.Documents/.idea/.gitignore new file mode 100644 index 0000000..0b2d7ee --- /dev/null +++ b/src/.idea/.idea.BitBadger.Documents/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/projectSettingsUpdater.xml +/contentModel.xml +/modules.xml +/.idea.BitBadger.Documents.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/.idea/.idea.BitBadger.Documents/.idea/.name b/src/.idea/.idea.BitBadger.Documents/.idea/.name new file mode 100644 index 0000000..218932d --- /dev/null +++ b/src/.idea/.idea.BitBadger.Documents/.idea/.name @@ -0,0 +1 @@ +BitBadger.Documents \ No newline at end of file diff --git a/src/.idea/.idea.BitBadger.Documents/.idea/indexLayout.xml b/src/.idea/.idea.BitBadger.Documents/.idea/indexLayout.xml new file mode 100644 index 0000000..c88ded7 --- /dev/null +++ b/src/.idea/.idea.BitBadger.Documents/.idea/indexLayout.xml @@ -0,0 +1,10 @@ + + + + + ../../BitBadger.Documents + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.BitBadger.Documents/.idea/vcs.xml b/src/.idea/.idea.BitBadger.Documents/.idea/vcs.xml new file mode 100644 index 0000000..62bd7a0 --- /dev/null +++ b/src/.idea/.idea.BitBadger.Documents/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/BitBadger.Documents.sln b/src/BitBadger.Documents.sln new file mode 100644 index 0000000..c6f850d --- /dev/null +++ b/src/BitBadger.Documents.sln @@ -0,0 +1,46 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BitBadger.Documents.Common", "Common\BitBadger.Documents.Common.fsproj", "{E52D624A-2A1F-4D38-82B6-115907D9CB1A}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BitBadger.Documents.Tests", "Tests\BitBadger.Documents.Tests.fsproj", "{45D5D503-9FD0-4ADB-ABC6-C8FDAD82B848}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BitBadger.Documents.Sqlite", "Sqlite\BitBadger.Documents.Sqlite.fsproj", "{B8A82483-1E72-46D2-B29A-1C371AC5DD20}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitBadger.Documents.Tests.CSharp", "Tests.CSharp\BitBadger.Documents.Tests.CSharp.csproj", "{AB58418C-7F90-467E-8F67-F4E0AD9D8875}" +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 + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E52D624A-2A1F-4D38-82B6-115907D9CB1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E52D624A-2A1F-4D38-82B6-115907D9CB1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E52D624A-2A1F-4D38-82B6-115907D9CB1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E52D624A-2A1F-4D38-82B6-115907D9CB1A}.Release|Any CPU.Build.0 = Release|Any CPU + {45D5D503-9FD0-4ADB-ABC6-C8FDAD82B848}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45D5D503-9FD0-4ADB-ABC6-C8FDAD82B848}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45D5D503-9FD0-4ADB-ABC6-C8FDAD82B848}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45D5D503-9FD0-4ADB-ABC6-C8FDAD82B848}.Release|Any CPU.Build.0 = Release|Any CPU + {B8A82483-1E72-46D2-B29A-1C371AC5DD20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8A82483-1E72-46D2-B29A-1C371AC5DD20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8A82483-1E72-46D2-B29A-1C371AC5DD20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8A82483-1E72-46D2-B29A-1C371AC5DD20}.Release|Any CPU.Build.0 = Release|Any CPU + {AB58418C-7F90-467E-8F67-F4E0AD9D8875}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB58418C-7F90-467E-8F67-F4E0AD9D8875}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB58418C-7F90-467E-8F67-F4E0AD9D8875}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB58418C-7F90-467E-8F67-F4E0AD9D8875}.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/Common/BitBadger.Documents.Common.fsproj b/src/Common/BitBadger.Documents.Common.fsproj new file mode 100644 index 0000000..2c3f063 --- /dev/null +++ b/src/Common/BitBadger.Documents.Common.fsproj @@ -0,0 +1,18 @@ + + + + Initial release (RC 1) + JSON Document SQL + + + + + + + + + + + + + diff --git a/src/Common/Library.fs b/src/Common/Library.fs new file mode 100644 index 0000000..2f45c2b --- /dev/null +++ b/src/Common/Library.fs @@ -0,0 +1,221 @@ +namespace BitBadger.Documents + +/// The types of logical operations available for JSON fields +[] +type Op = + /// Equals (=) + | EQ + /// Greater Than (>) + | GT + /// Greater Than or Equal To (>=) + | GE + /// Less Than (<) + | LT + /// Less Than or Equal To (<=) + | LE + /// Not Equal to (<>) + | NE + /// Exists (IS NOT NULL) + | EX + /// Does Not Exist (IS NULL) + | NEX + + override this.ToString() = + match this with + | EQ -> "=" + | GT -> ">" + | GE -> ">=" + | LT -> "<" + | LE -> "<=" + | NE -> "<>" + | EX -> "IS NOT NULL" + | NEX -> "IS NULL" + + +/// The required document serialization implementation +type IDocumentSerializer = + + /// Serialize an object to a JSON string + abstract Serialize<'T> : 'T -> string + + /// Deserialize a JSON string into an object + abstract Deserialize<'T> : string -> 'T + + +/// Document serializer defaults +module DocumentSerializer = + + open System.Text.Json + open System.Text.Json.Serialization + + /// The default JSON serializer options to use with the stock serializer + let private jsonDefaultOpts = + let o = JsonSerializerOptions() + o.Converters.Add(JsonFSharpConverter()) + o + + /// The default JSON serializer + [] + let ``default`` = + { new IDocumentSerializer with + member _.Serialize<'T>(it: 'T) : string = + JsonSerializer.Serialize(it, jsonDefaultOpts) + member _.Deserialize<'T>(it: string) : 'T = + JsonSerializer.Deserialize<'T>(it, jsonDefaultOpts) + } + + +/// Configuration for document handling +[] +module Configuration = + + /// The serializer to use for document manipulation + let mutable private serializerValue = DocumentSerializer.``default`` + + /// Register a serializer to use for translating documents to domain types + [] + let useSerializer ser = + serializerValue <- ser + + /// Retrieve the currently configured serializer + [] + let serializer () = + serializerValue + + /// The serialized name of the ID field for documents + let mutable idFieldValue = "Id" + + /// Specify the name of the ID field for documents + [] + let useIdField it = + idFieldValue <- it + + /// Retrieve the currently configured ID field for documents + [] + let idField () = + idFieldValue + + +/// 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 a comparison on a field in a JSON document + [] + let whereByField fieldName op paramName = + let theRest = + match op with + | EX | NEX -> string op + | _ -> $"{op} %s{paramName}" + $"data ->> '%s{fieldName}' {theRest}" + + /// Create a WHERE clause fragment to implement an ID-based query + [] + let whereById paramName = + whereByField (Configuration.idField ()) EQ paramName + + /// Queries to define tables and indexes + module Definition = + + /// SQL statement to create a document table + [] + let ensureTableFor name dataType = + $"CREATE TABLE IF NOT EXISTS %s{name} (data %s{dataType} NOT NULL)" + + /// Split a schema and table name + let private splitSchemaAndTable (tableName: string) = + let parts = tableName.Split '.' + if Array.length parts = 1 then "", tableName else parts[0], parts[1] + + /// SQL statement to create an index on one or more fields in a JSON document + [] + let ensureIndexOn tableName indexName (fields: string seq) = + let _, tbl = splitSchemaAndTable tableName + let jsonFields = + fields + |> Seq.map (fun it -> + let parts = it.Split ' ' + let fieldName = if Array.length parts = 1 then it else parts[0] + let direction = if Array.length parts < 2 then "" else $" {parts[1]}" + $"(data ->> '{fieldName}'){direction}") + |> String.concat ", " + $"CREATE INDEX IF NOT EXISTS idx_{tbl}_%s{indexName} ON {tableName} ({jsonFields})" + + /// SQL statement to create a key index for a document table + [] + let ensureKey tableName = + (ensureIndexOn tableName "key" [ Configuration.idField () ]).Replace("INDEX", "UNIQUE INDEX") + + /// Query to insert a document + [] + let insert tableName = + $"INSERT INTO %s{tableName} VALUES (@data)" + + /// Query to save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + let save tableName = + sprintf + "INSERT INTO %s VALUES (@data) ON CONFLICT ((data ->> '%s')) DO UPDATE SET data = EXCLUDED.data" + tableName (Configuration.idField ()) + + /// Query to update a document + [] + let update tableName = + $"""UPDATE %s{tableName} SET data = @data WHERE {whereById "@id"}""" + + /// 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 text comparison on a JSON field + [] + let byField tableName fieldName op = + $"""SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereByField fieldName op "@field"}""" + + /// 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 comparison on a JSON field + [] + let byField tableName fieldName op = + $"""SELECT EXISTS (SELECT 1 FROM %s{tableName} WHERE {whereByField fieldName op "@field"}) 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 comparison on a JSON field + [] + let byField tableName fieldName op = + $"""{selectFromTable tableName} WHERE {whereByField fieldName op "@field"}""" + + /// 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 comparison on a JSON field + [] + let byField tableName fieldName op = + $"""DELETE FROM %s{tableName} WHERE {whereByField fieldName op "@field"}""" diff --git a/src/Common/README.md b/src/Common/README.md new file mode 100644 index 0000000..7047424 --- /dev/null +++ b/src/Common/README.md @@ -0,0 +1,17 @@ +# BitBadger.Documents.Common + +This package provides common definitions and functionality for `BitBadger.Documents` implementations. These libraries provide a document storage view over relational databases, while also providing convenience functions for relational usage as well. This enables a hybrid approach to data storage, allowing the user to use documents where they make sense, while streamlining traditional ADO.NET functionality where relational data is required. +- `BitBadger.Documents.Postgres` ([NuGet](https://www.nuget.org/packages/BitBadger.Documents.Postgres/)) provides a PostgreSQL implementation. +- `BitBadger.Documents.Sqlite` ([NuGet](https://www.nuget.org/packages/BitBadger.Documents.Sqlite/)) provides a SQLite implementation + +## Features + +- Select, insert, update, save (upsert), delete, count, and check existence of documents, and create tables and indexes for these documents +- Addresses documents via ID and via comparison on any field (for PostgreSQL, also via equality on any property by using JSON containment, or via condition on any property using JSON Path queries) +- Accesses documents as your domain models (POCOs) +- Uses `Task`-based async for all data access functions +- Uses building blocks for more complex queries + +## Getting Started + +Install the library of your choice and follow its README; also, the [project site](https://bitbadger.solutions/open-source/relational-documents/) has complete documentation. diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..d587c48 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,21 @@ + + + net6.0;net7.0;net8.0 + embedded + false + 3.0.0.0 + 3.0.0.0 + 3.0.0 + rc-1 + danieljsummers + Bit Badger Solutions + README.md + icon.png + https://bitbadger.solutions/open-source/relational-documents/ + false + https://github.com/bit-badger/BitBadger.Documents + Git + MIT License + MIT + + diff --git a/src/Postgres/BitBadger.Documents.Postgres.fsproj b/src/Postgres/BitBadger.Documents.Postgres.fsproj new file mode 100644 index 0000000..7a7af5b --- /dev/null +++ b/src/Postgres/BitBadger.Documents.Postgres.fsproj @@ -0,0 +1,23 @@ + + + + Initial release; migrated from BitBadger.Npgsql.Documents, with field and extension additions (RC 1) + JSON Document PostgreSQL Npgsql + + + + + + + + + + + + + + + + + + diff --git a/src/Postgres/Extensions.fs b/src/Postgres/Extensions.fs new file mode 100644 index 0000000..4602341 --- /dev/null +++ b/src/Postgres/Extensions.fs @@ -0,0 +1,333 @@ +namespace BitBadger.Documents.Postgres + +open Npgsql +open Npgsql.FSharp + +/// F# Extensions for the NpgsqlConnection type +[] +module Extensions = + + type NpgsqlConnection with + + /// Execute a query that returns a list of results + member conn.customList<'TDoc> query parameters (mapFunc: RowReader -> 'TDoc) = + WithProps.Custom.list<'TDoc> query parameters mapFunc (Sql.existingConnection conn) + + /// Execute a query that returns one or no results; returns None if not found + member conn.customSingle<'TDoc> query parameters (mapFunc: RowReader -> 'TDoc) = + WithProps.Custom.single<'TDoc> query parameters mapFunc (Sql.existingConnection conn) + + /// Execute a query that returns no results + member conn.customNonQuery query parameters = + WithProps.Custom.nonQuery query parameters (Sql.existingConnection conn) + + /// Execute a query that returns a scalar value + member conn.customScalar<'T when 'T: struct> query parameters (mapFunc: RowReader -> 'T) = + WithProps.Custom.scalar query parameters mapFunc (Sql.existingConnection conn) + + /// Create a document table + member conn.ensureTable name = + WithProps.Definition.ensureTable name (Sql.existingConnection conn) + + /// Create an index on documents in the specified table + member conn.ensureDocumentIndex name idxType = + WithProps.Definition.ensureDocumentIndex name idxType (Sql.existingConnection conn) + + /// Create an index on field(s) within documents in the specified table + member conn.ensureFieldIndex tableName indexName fields = + WithProps.Definition.ensureFieldIndex tableName indexName fields (Sql.existingConnection conn) + + /// Insert a new document + member conn.insert<'TDoc> tableName (document: 'TDoc) = + WithProps.Document.insert<'TDoc> tableName document (Sql.existingConnection conn) + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + member conn.save<'TDoc> tableName (document: 'TDoc) = + WithProps.Document.save<'TDoc> tableName document (Sql.existingConnection conn) + + /// Count all documents in a table + member conn.countAll tableName = + WithProps.Count.all tableName (Sql.existingConnection conn) + + /// Count matching documents using a JSON field comparison query (->> =) + member conn.countByField tableName fieldName op (value: obj) = + WithProps.Count.byField tableName fieldName op value (Sql.existingConnection conn) + + /// Count matching documents using a JSON containment query (@>) + member conn.countByContains tableName criteria = + WithProps.Count.byContains tableName criteria (Sql.existingConnection conn) + + /// Count matching documents using a JSON Path match query (@?) + member conn.countByJsonPath tableName jsonPath = + WithProps.Count.byJsonPath tableName jsonPath (Sql.existingConnection conn) + + /// Determine if a document exists for the given ID + member conn.existsById tableName docId = + WithProps.Exists.byId tableName docId (Sql.existingConnection conn) + + /// Determine if documents exist using a JSON field comparison query (->> =) + member conn.existsByField tableName fieldName op (value: obj) = + WithProps.Exists.byField tableName fieldName op value (Sql.existingConnection conn) + + /// Determine if documents exist using a JSON containment query (@>) + member conn.existsByContains tableName criteria = + WithProps.Exists.byContains tableName criteria (Sql.existingConnection conn) + + /// Determine if documents exist using a JSON Path match query (@?) + member conn.existsByJsonPath tableName jsonPath = + WithProps.Exists.byJsonPath tableName jsonPath (Sql.existingConnection conn) + + /// Retrieve all documents in the given table + member conn.findAll<'TDoc> tableName = + WithProps.Find.all<'TDoc> tableName (Sql.existingConnection conn) + + /// Retrieve a document by its ID; returns None if not found + member conn.findById<'TKey, 'TDoc> tableName docId = + WithProps.Find.byId<'TKey, 'TDoc> tableName docId (Sql.existingConnection conn) + + /// Retrieve documents matching a JSON field comparison query (->> =) + member conn.findByField<'TDoc> tableName fieldName op (value: obj) = + WithProps.Find.byField<'TDoc> tableName fieldName op value (Sql.existingConnection conn) + + /// Retrieve documents matching a JSON containment query (@>) + member conn.findByContains<'TDoc> tableName (criteria: obj) = + WithProps.Find.byContains<'TDoc> tableName criteria (Sql.existingConnection conn) + + /// Retrieve documents matching a JSON Path match query (@?) + member conn.findByJsonPath<'TDoc> tableName jsonPath = + WithProps.Find.byJsonPath<'TDoc> tableName jsonPath (Sql.existingConnection conn) + + /// Retrieve the first document matching a JSON field comparison query (->> =); returns None if not found + member conn.findFirstByField<'TDoc> tableName fieldName op (value: obj) = + WithProps.Find.firstByField<'TDoc> tableName fieldName op value (Sql.existingConnection conn) + + /// Retrieve the first document matching a JSON containment query (@>); returns None if not found + member conn.findFirstByContains<'TDoc> tableName (criteria: obj) = + WithProps.Find.firstByContains<'TDoc> tableName criteria (Sql.existingConnection conn) + + /// Retrieve the first document matching a JSON Path match query (@?); returns None if not found + member conn.findFirstByJsonPath<'TDoc> tableName jsonPath = + WithProps.Find.firstByJsonPath<'TDoc> tableName jsonPath (Sql.existingConnection conn) + + /// Update an entire document by its ID + member conn.updateById tableName (docId: 'TKey) (document: 'TDoc) = + WithProps.Update.byId tableName docId document (Sql.existingConnection conn) + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + member conn.updateByFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) = + WithProps.Update.byFunc tableName idFunc document (Sql.existingConnection conn) + + /// Patch a document by its ID + member conn.patchById tableName (docId: 'TKey) (patch: 'TPatch) = + WithProps.Patch.byId tableName docId patch (Sql.existingConnection conn) + + /// Patch documents using a JSON field comparison query in the WHERE clause (->> =) + member conn.patchByField tableName fieldName op (value: obj) (patch: 'TPatch) = + WithProps.Patch.byField tableName fieldName op value patch (Sql.existingConnection conn) + + /// Patch documents using a JSON containment query in the WHERE clause (@>) + member conn.patchByContains tableName (criteria: 'TCriteria) (patch: 'TPatch) = + WithProps.Patch.byContains tableName criteria patch (Sql.existingConnection conn) + + /// Patch documents using a JSON Path match query in the WHERE clause (@?) + member conn.patchByJsonPath tableName jsonPath (patch: 'TPatch) = + WithProps.Patch.byJsonPath tableName jsonPath patch (Sql.existingConnection conn) + + /// Delete a document by its ID + member conn.deleteById tableName (docId: 'TKey) = + WithProps.Delete.byId tableName docId (Sql.existingConnection conn) + + /// Delete documents by matching a JSON field comparison query (->> =) + member conn.deleteByField tableName fieldName op (value: obj) = + WithProps.Delete.byField tableName fieldName op value (Sql.existingConnection conn) + + /// Delete documents by matching a JSON containment query (@>) + member conn.deleteByContains tableName (criteria: 'TContains) = + WithProps.Delete.byContains tableName criteria (Sql.existingConnection conn) + + /// Delete documents by matching a JSON Path match query (@?) + member conn.deleteByJsonPath tableName path = + WithProps.Delete.byJsonPath tableName path (Sql.existingConnection conn) + + +open System.Runtime.CompilerServices + +/// C# extensions on the NpgsqlConnection type +type NpgsqlConnectionCSharpExtensions = + + /// Execute a query that returns a list of results + [] + static member inline CustomList<'TDoc>(conn, query, parameters, mapFunc: System.Func) = + WithProps.Custom.List<'TDoc>(query, parameters, mapFunc, Sql.existingConnection conn) + + /// Execute a query that returns one or no results; returns None if not found + [] + static member inline CustomSingle<'TDoc when 'TDoc: null>( + conn, query, parameters, mapFunc: System.Func) = + WithProps.Custom.Single<'TDoc>(query, parameters, mapFunc, Sql.existingConnection conn) + + /// Execute a query that returns no results + [] + static member inline CustomNonQuery(conn, query, parameters) = + WithProps.Custom.nonQuery query parameters (Sql.existingConnection conn) + + /// Execute a query that returns a scalar value + [] + static member inline CustomScalar<'T when 'T: struct>( + conn, query, parameters, mapFunc: System.Func) = + WithProps.Custom.Scalar(query, parameters, mapFunc, Sql.existingConnection conn) + + /// Create a document table + [] + static member inline EnsureTable(conn, name) = + WithProps.Definition.ensureTable name (Sql.existingConnection conn) + + /// Create an index on documents in the specified table + [] + static member inline EnsureDocumentIndex(conn, name, idxType) = + WithProps.Definition.ensureDocumentIndex name idxType (Sql.existingConnection conn) + + /// Create an index on field(s) within documents in the specified table + [] + static member inline EnsureFieldIndex(conn, tableName, indexName, fields) = + WithProps.Definition.ensureFieldIndex tableName indexName fields (Sql.existingConnection conn) + + /// Insert a new document + [] + static member inline Insert<'TDoc>(conn, tableName, document: 'TDoc) = + WithProps.Document.insert<'TDoc> tableName document (Sql.existingConnection conn) + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + static member inline Save<'TDoc>(conn, tableName, document: 'TDoc) = + WithProps.Document.save<'TDoc> tableName document (Sql.existingConnection conn) + + /// Count all documents in a table + [] + static member inline CountAll(conn, tableName) = + WithProps.Count.all tableName (Sql.existingConnection conn) + + /// Count matching documents using a JSON field comparison query (->> =) + [] + static member inline CountByField(conn, tableName, fieldName, op, value: obj) = + WithProps.Count.byField tableName fieldName op value (Sql.existingConnection conn) + + /// Count matching documents using a JSON containment query (@>) + [] + static member inline CountByContains(conn, tableName, criteria: 'TCriteria) = + WithProps.Count.byContains tableName criteria (Sql.existingConnection conn) + + /// Count matching documents using a JSON Path match query (@?) + [] + static member inline CountByJsonPath(conn, tableName, jsonPath) = + WithProps.Count.byJsonPath tableName jsonPath (Sql.existingConnection conn) + + /// Determine if a document exists for the given ID + [] + static member inline ExistsById(conn, tableName, docId) = + WithProps.Exists.byId tableName docId (Sql.existingConnection conn) + + /// Determine if documents exist using a JSON field comparison query (->> =) + [] + static member inline ExistsByField(conn, tableName, fieldName, op, value: obj) = + WithProps.Exists.byField tableName fieldName op value (Sql.existingConnection conn) + + /// Determine if documents exist using a JSON containment query (@>) + [] + static member inline ExistsByContains(conn, tableName, criteria: 'TCriteria) = + WithProps.Exists.byContains tableName criteria (Sql.existingConnection conn) + + /// Determine if documents exist using a JSON Path match query (@?) + [] + static member inline ExistsByJsonPath(conn, tableName, jsonPath) = + WithProps.Exists.byJsonPath tableName jsonPath (Sql.existingConnection conn) + + /// Retrieve all documents in the given table + [] + static member inline FindAll<'TDoc>(conn, tableName) = + WithProps.Find.All<'TDoc>(tableName, Sql.existingConnection conn) + + /// Retrieve a document by its ID; returns None if not found + [] + static member inline FindById<'TKey, 'TDoc when 'TDoc: null>(conn, tableName, docId: 'TKey) = + WithProps.Find.ById<'TKey, 'TDoc>(tableName, docId, Sql.existingConnection conn) + + /// Retrieve documents matching a JSON field comparison query (->> =) + [] + static member inline FindByField<'TDoc>(conn, tableName, fieldName, op, value: obj) = + WithProps.Find.ByField<'TDoc>(tableName, fieldName, op, value, Sql.existingConnection conn) + + /// Retrieve documents matching a JSON containment query (@>) + [] + static member inline FindByContains<'TDoc>(conn, tableName, criteria: obj) = + WithProps.Find.ByContains<'TDoc>(tableName, criteria, Sql.existingConnection conn) + + /// Retrieve documents matching a JSON Path match query (@?) + [] + static member inline FindByJsonPath<'TDoc>(conn, tableName, jsonPath) = + WithProps.Find.ByJsonPath<'TDoc>(tableName, jsonPath, Sql.existingConnection conn) + + /// Retrieve the first document matching a JSON field comparison query (->> =); returns None if not found + [] + static member inline FindFirstByField<'TDoc when 'TDoc: null>(conn, tableName, fieldName, op, value: obj) = + WithProps.Find.FirstByField<'TDoc>(tableName, fieldName, op, value, Sql.existingConnection conn) + + /// Retrieve the first document matching a JSON containment query (@>); returns None if not found + [] + static member inline FindFirstByContains<'TDoc when 'TDoc: null>(conn, tableName, criteria: obj) = + WithProps.Find.FirstByContains<'TDoc>(tableName, criteria, Sql.existingConnection conn) + + /// Retrieve the first document matching a JSON Path match query (@?); returns None if not found + [] + static member inline FindFirstByJsonPath<'TDoc when 'TDoc: null>(conn, tableName, jsonPath) = + WithProps.Find.FirstByJsonPath<'TDoc>(tableName, jsonPath, Sql.existingConnection conn) + + /// Update an entire document by its ID + [] + static member inline UpdateById(conn, tableName, docId: 'TKey, document: 'TDoc) = + WithProps.Update.byId tableName docId document (Sql.existingConnection conn) + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + [] + static member inline UpdateByFunc(conn, tableName, idFunc: System.Func<'TDoc, 'TKey>, document: 'TDoc) = + WithProps.Update.ByFunc(tableName, idFunc, document, Sql.existingConnection conn) + + /// Patch a document by its ID + [] + static member inline PatchById(conn, tableName, docId: 'TKey, patch: 'TPatch) = + WithProps.Patch.byId tableName docId patch (Sql.existingConnection conn) + + /// Patch documents using a JSON field comparison query in the WHERE clause (->> =) + [] + static member inline PatchByField(conn, tableName, fieldName, op, value: obj, patch: 'TPatch) = + WithProps.Patch.byField tableName fieldName op value patch (Sql.existingConnection conn) + + /// Patch documents using a JSON containment query in the WHERE clause (@>) + [] + static member inline PatchByContains(conn, tableName, criteria: 'TCriteria, patch: 'TPatch) = + WithProps.Patch.byContains tableName criteria patch (Sql.existingConnection conn) + + /// Patch documents using a JSON Path match query in the WHERE clause (@?) + [] + static member inline PatchByJsonPath(conn, tableName, jsonPath, patch: 'TPatch) = + WithProps.Patch.byJsonPath tableName jsonPath patch (Sql.existingConnection conn) + + /// Delete a document by its ID + [] + static member inline DeleteById(conn, tableName, docId: 'TKey) = + WithProps.Delete.byId tableName docId (Sql.existingConnection conn) + + /// Delete documents by matching a JSON field comparison query (->> =) + [] + static member inline DeleteByField(conn, tableName, fieldName, op, value: obj) = + WithProps.Delete.byField tableName fieldName op value (Sql.existingConnection conn) + + /// Delete documents by matching a JSON containment query (@>) + [] + static member inline DeleteByContains(conn, tableName, criteria: 'TContains) = + WithProps.Delete.byContains tableName criteria (Sql.existingConnection conn) + + /// Delete documents by matching a JSON Path match query (@?) + [] + static member inline DeleteByJsonPath(conn, tableName, path) = + WithProps.Delete.byJsonPath tableName path (Sql.existingConnection conn) diff --git a/src/Postgres/Library.fs b/src/Postgres/Library.fs new file mode 100644 index 0000000..5fcb7cc --- /dev/null +++ b/src/Postgres/Library.fs @@ -0,0 +1,770 @@ +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 + + +open Npgsql + +/// Configuration for document handling +module Configuration = + + /// The data source to use for query execution + let mutable private dataSourceValue : 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 + + /// Execute a task and ignore the result + let internal ignoreTask<'T> (it : System.Threading.Tasks.Task<'T>) = backgroundTask { + let! _ = it + () + } + + +open BitBadger.Documents + +/// Functions for creating parameters +[] +module Parameters = + + /// Create an ID parameter (name "@id", key will be treated as a string) + [] + let idParam (key: 'TKey) = + "@id", Sql.string (string key) + + /// Create a parameter with a JSON value + [] + let jsonParam (name: string) (it: 'TJson) = + name, Sql.jsonb (Configuration.serializer().Serialize it) + + /// Create a JSON field parameter (name "@field") + [] + let fieldParam (value: obj) = + "@field", Sql.parameter (NpgsqlParameter("@field", value)) + + /// An empty parameter sequence + [] + let noParams = + Seq.empty + + +/// Query construction functions +[] +module Query = + + /// Table and index definition queries + module Definition = + + /// SQL statement to create a document table + [] + let ensureTable name = + Query.Definition.ensureTableFor name "JSONB" + + /// SQL statement to create an index on JSON documents in the specified table + [] + let ensureDocumentIndex (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}_document ON {name} USING GIN (data{extraOps})" + + /// 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" + + /// Queries for counting documents + module Count = + + /// 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 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 documents using a JSON containment query (@>) + [] + let byContains tableName = + $"""{Query.selectFromTable tableName} WHERE {whereDataContains "@criteria"}""" + + /// Query to retrieve documents using a JSON Path match (@?) + [] + let byJsonPath tableName = + $"""{Query.selectFromTable tableName} WHERE {whereJsonPathMatches "@path"}""" + + /// Queries to patch (partially update) documents + module Patch = + + /// Query to patch a document by its ID + [] + let byId tableName = + $"""UPDATE %s{tableName} SET data = data || @data WHERE {Query.whereById "@id"}""" + + /// Query to patch documents match a JSON field comparison (->> =) + [] + let byField tableName fieldName op = + $"""UPDATE %s{tableName} SET data = data || @data WHERE {Query.whereByField fieldName op "@field"}""" + + /// Query to patch documents matching a JSON containment query (@>) + [] + let byContains tableName = + $"""UPDATE %s{tableName} SET data = data || @data WHERE {whereDataContains "@criteria"}""" + + /// Query to patch documents matching a JSON containment query (@>) + [] + let byJsonPath tableName = + $"""UPDATE %s{tableName} SET data = data || @data WHERE {whereJsonPathMatches "@path"}""" + + /// Queries to delete documents + module Delete = + + /// 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 + + /// Extract a count from the column "it" + [] + let toCount (row: RowReader) = + row.int "it" + + /// Extract a true/false value from the column "it" + [] + let toExists (row: RowReader) = + row.bool "it" + + +/// Versions of queries that accept SqlProps as the last parameter +module WithProps = + + /// Commands to execute custom SQL queries + [] + module Custom = + + /// Execute a query that returns a list of results + [] + let list<'TDoc> query parameters (mapFunc: RowReader -> 'TDoc) sqlProps = + Sql.query query sqlProps + |> Sql.parameters parameters + |> Sql.executeAsync mapFunc + + /// Execute a query that returns a list of results + let List<'TDoc>(query, parameters, mapFunc: System.Func, sqlProps) = backgroundTask { + let! results = list<'TDoc> query (List.ofSeq parameters) mapFunc.Invoke sqlProps + return ResizeArray results + } + + /// Execute a query that returns one or no results; returns None if not found + [] + let single<'TDoc> query parameters mapFunc sqlProps = backgroundTask { + let! results = list<'TDoc> query parameters mapFunc sqlProps + return FSharp.Collections.List.tryHead results + } + + /// Execute a query that returns one or no results; returns null if not found + let Single<'TDoc when 'TDoc: null>( + query, parameters, mapFunc: System.Func, sqlProps) = backgroundTask { + let! result = single<'TDoc> query (FSharp.Collections.List.ofSeq parameters) mapFunc.Invoke sqlProps + return Option.toObj result + } + + /// Execute a query that returns no results + [] + let nonQuery query parameters sqlProps = + Sql.query query sqlProps + |> Sql.parameters (FSharp.Collections.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 + + /// Execute a query that returns a scalar value + let Scalar<'T when 'T: struct>(query, parameters, mapFunc: System.Func, sqlProps) = + scalar<'T> query (FSharp.Collections.List.ofSeq parameters) mapFunc.Invoke sqlProps + + /// Table and index definition commands + module Definition = + + /// Create a document table + [] + let ensureTable name sqlProps = backgroundTask { + do! Custom.nonQuery (Query.Definition.ensureTable name) [] sqlProps + do! Custom.nonQuery (Query.Definition.ensureKey name) [] sqlProps + } + + /// Create an index on documents in the specified table + [] + let ensureDocumentIndex name idxType sqlProps = + Custom.nonQuery (Query.Definition.ensureDocumentIndex name idxType) [] sqlProps + + /// Create an index on field(s) within documents in the specified table + [] + let ensureFieldIndex tableName indexName fields sqlProps = + Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields) [] sqlProps + + /// Commands to add documents + [] + module Document = + + /// Insert a new document + [] + let insert<'TDoc> tableName (document: 'TDoc) sqlProps = + Custom.nonQuery (Query.insert tableName) [ jsonParam "@data" document ] sqlProps + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + let save<'TDoc> tableName (document: 'TDoc) sqlProps = + Custom.nonQuery (Query.save tableName) [ jsonParam "@data" document ] sqlProps + + /// Commands to count documents + [] + module Count = + + /// Count all documents in a table + [] + let all tableName sqlProps = + Custom.scalar (Query.Count.all tableName) [] toCount sqlProps + + /// Count matching documents using a JSON field comparison (->> =) + [] + let byField tableName fieldName op (value: obj) sqlProps = + Custom.scalar (Query.Count.byField tableName fieldName op) [ fieldParam value ] toCount sqlProps + + /// Count matching documents using a JSON containment query (@>) + [] + let byContains tableName (criteria: 'TContains) sqlProps = + Custom.scalar (Query.Count.byContains tableName) [ jsonParam "@criteria" criteria ] toCount sqlProps + + /// Count matching documents using a JSON Path match query (@?) + [] + let byJsonPath tableName jsonPath sqlProps = + Custom.scalar (Query.Count.byJsonPath tableName) [ "@path", Sql.string jsonPath ] toCount sqlProps + + /// Commands to determine if documents exist + [] + module Exists = + + /// Determine if a document exists for the given ID + [] + let byId tableName (docId: 'TKey) sqlProps = + Custom.scalar (Query.Exists.byId tableName) [ idParam docId ] toExists sqlProps + + /// Determine if a document exists using a JSON field comparison (->> =) + [] + let byField tableName fieldName op (value: obj) sqlProps = + Custom.scalar (Query.Exists.byField tableName fieldName op) [ fieldParam value ] toExists sqlProps + + /// Determine if a document exists using a JSON containment query (@>) + [] + let byContains tableName (criteria: 'TContains) sqlProps = + Custom.scalar (Query.Exists.byContains tableName) [ jsonParam "@criteria" criteria ] toExists sqlProps + + /// Determine if a document exists using a JSON Path match query (@?) + [] + let byJsonPath tableName jsonPath sqlProps = + Custom.scalar (Query.Exists.byJsonPath tableName) [ "@path", Sql.string jsonPath ] toExists sqlProps + + /// Commands to determine if documents exist + [] + module Find = + + /// Retrieve all documents in the given table + [] + let all<'TDoc> tableName sqlProps = + Custom.list<'TDoc> (Query.selectFromTable tableName) [] fromData<'TDoc> sqlProps + + /// Retrieve all documents in the given table + let All<'TDoc>(tableName, sqlProps) = + Custom.List<'TDoc>(Query.selectFromTable tableName, [], fromData<'TDoc>, sqlProps) + + /// Retrieve a document by its ID (returns None if not found) + [] + let byId<'TKey, 'TDoc> tableName (docId: 'TKey) sqlProps = + Custom.single (Query.Find.byId tableName) [ idParam docId ] fromData<'TDoc> sqlProps + + /// Retrieve a document by its ID (returns null if not found) + let ById<'TKey, 'TDoc when 'TDoc: null>(tableName, docId: 'TKey, sqlProps) = + Custom.Single<'TDoc>(Query.Find.byId tableName, [ idParam docId ], fromData<'TDoc>, sqlProps) + + /// Retrieve documents matching a JSON field comparison (->> =) + [] + let byField<'TDoc> tableName fieldName op (value: obj) sqlProps = + Custom.list<'TDoc> (Query.Find.byField tableName fieldName op) [ fieldParam value ] fromData<'TDoc> sqlProps + + /// Retrieve documents matching a JSON field comparison (->> =) + let ByField<'TDoc>(tableName, fieldName, op, value: obj, sqlProps) = + Custom.List<'TDoc>( + Query.Find.byField tableName fieldName op, [ fieldParam value ], fromData<'TDoc>, sqlProps) + + /// Retrieve documents matching a JSON containment query (@>) + [] + let byContains<'TDoc> tableName (criteria: obj) sqlProps = + Custom.list<'TDoc> + (Query.Find.byContains tableName) [ jsonParam "@criteria" criteria ] fromData<'TDoc> sqlProps + + /// Retrieve documents matching a JSON containment query (@>) + let ByContains<'TDoc>(tableName, criteria: obj, sqlProps) = + Custom.List<'TDoc>( + Query.Find.byContains tableName, [ jsonParam "@criteria" criteria ], fromData<'TDoc>, sqlProps) + + /// Retrieve documents matching a JSON Path match query (@?) + [] + let byJsonPath<'TDoc> tableName jsonPath sqlProps = + Custom.list<'TDoc> + (Query.Find.byJsonPath tableName) [ "@path", Sql.string jsonPath ] fromData<'TDoc> sqlProps + + /// Retrieve documents matching a JSON Path match query (@?) + let ByJsonPath<'TDoc>(tableName, jsonPath, sqlProps) = + Custom.List<'TDoc>( + Query.Find.byJsonPath tableName, [ "@path", Sql.string jsonPath ], fromData<'TDoc>, sqlProps) + + /// Retrieve the first document matching a JSON field comparison (->> =); returns None if not found + [] + let firstByField<'TDoc> tableName fieldName op (value: obj) sqlProps = + Custom.single<'TDoc> + $"{Query.Find.byField tableName fieldName op} LIMIT 1" [ fieldParam value ] fromData<'TDoc> sqlProps + + /// Retrieve the first document matching a JSON field comparison (->> =); returns null if not found + let FirstByField<'TDoc when 'TDoc: null>(tableName, fieldName, op, value: obj, sqlProps) = + Custom.Single<'TDoc>( + $"{Query.Find.byField tableName fieldName op} LIMIT 1", [ fieldParam value ], fromData<'TDoc>, sqlProps) + + /// Retrieve the first document matching a JSON containment query (@>); returns None if not found + [] + let firstByContains<'TDoc> tableName (criteria: obj) sqlProps = + Custom.single<'TDoc> + $"{Query.Find.byContains tableName} LIMIT 1" [ jsonParam "@criteria" criteria ] fromData<'TDoc> sqlProps + + /// Retrieve the first document matching a JSON containment query (@>); returns null if not found + let FirstByContains<'TDoc when 'TDoc: null>(tableName, criteria: obj, sqlProps) = + Custom.Single<'TDoc>( + $"{Query.Find.byContains tableName} LIMIT 1", + [ jsonParam "@criteria" criteria ], + fromData<'TDoc>, + sqlProps) + + /// Retrieve the first document matching a JSON Path match query (@?); returns None if not found + [] + let firstByJsonPath<'TDoc> tableName jsonPath sqlProps = + Custom.single<'TDoc> + $"{Query.Find.byJsonPath tableName} LIMIT 1" [ "@path", Sql.string jsonPath ] fromData<'TDoc> sqlProps + + /// Retrieve the first document matching a JSON Path match query (@?); returns null if not found + let FirstByJsonPath<'TDoc when 'TDoc: null>(tableName, jsonPath, sqlProps) = + Custom.Single<'TDoc>( + $"{Query.Find.byJsonPath tableName} LIMIT 1", + [ "@path", Sql.string jsonPath ], + fromData<'TDoc>, + sqlProps) + + /// Commands to update documents + [] + module Update = + + /// Update an entire document by its ID + [] + let byId tableName (docId: 'TKey) (document: 'TDoc) sqlProps = + Custom.nonQuery (Query.update tableName) [ idParam docId; jsonParam "@data" document ] sqlProps + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + [] + let byFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) sqlProps = + byId tableName (idFunc document) document sqlProps + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + let ByFunc(tableName, idFunc: System.Func<'TDoc, 'TKey>, document: 'TDoc, sqlProps) = + byFunc tableName idFunc.Invoke document sqlProps + + /// Commands to patch (partially update) documents + [] + module Patch = + + /// Patch a document by its ID + [] + let byId tableName (docId: 'TKey) (patch: 'TPatch) sqlProps = + Custom.nonQuery (Query.Patch.byId tableName) [ idParam docId; jsonParam "@data" patch ] sqlProps + + /// Patch documents using a JSON field comparison query in the WHERE clause (->> =) + [] + let byField tableName fieldName op (value: obj) (patch: 'TPatch) sqlProps = + Custom.nonQuery + (Query.Patch.byField tableName fieldName op) [ jsonParam "@data" patch; fieldParam value ] sqlProps + + /// Patch documents using a JSON containment query in the WHERE clause (@>) + [] + let byContains tableName (criteria: 'TContains) (patch: 'TPatch) sqlProps = + Custom.nonQuery + (Query.Patch.byContains tableName) [ jsonParam "@data" patch; jsonParam "@criteria" criteria ] sqlProps + + /// Patch documents using a JSON Path match query in the WHERE clause (@?) + [] + let byJsonPath tableName jsonPath (patch: 'TPatch) sqlProps = + Custom.nonQuery + (Query.Patch.byJsonPath tableName) [ jsonParam "@data" patch; "@path", Sql.string jsonPath ] sqlProps + + /// Commands to delete documents + [] + module Delete = + + /// Delete a document by its ID + [] + let byId tableName (docId: 'TKey) sqlProps = + Custom.nonQuery (Query.Delete.byId tableName) [ idParam docId ] sqlProps + + /// Delete documents by matching a JSON field comparison query (->> =) + [] + let byField tableName fieldName op (value: obj) sqlProps = + Custom.nonQuery (Query.Delete.byField tableName fieldName op) [ fieldParam value ] sqlProps + + /// Delete documents by matching a JSON contains query (@>) + [] + let byContains tableName (criteria: 'TCriteria) sqlProps = + Custom.nonQuery (Query.Delete.byContains tableName) [ jsonParam "@criteria" criteria ] sqlProps + + /// Delete documents by matching a JSON Path match query (@?) + [] + let byJsonPath tableName path sqlProps = + Custom.nonQuery (Query.Delete.byJsonPath tableName) [ "@path", Sql.string path ] sqlProps + + +/// Commands to execute custom SQL queries +[] +module Custom = + + /// Execute a query that returns a list of results + [] + let list<'TDoc> query parameters (mapFunc: RowReader -> 'TDoc) = + WithProps.Custom.list<'TDoc> query parameters mapFunc (fromDataSource ()) + + /// Execute a query that returns a list of results + let List<'TDoc>(query, parameters, mapFunc: System.Func) = + WithProps.Custom.List<'TDoc>(query, parameters, mapFunc, fromDataSource ()) + + /// Execute a query that returns one or no results; returns None if not found + [] + let single<'TDoc> query parameters (mapFunc: RowReader -> 'TDoc) = + WithProps.Custom.single<'TDoc> query parameters mapFunc (fromDataSource ()) + + /// Execute a query that returns one or no results; returns null if not found + let Single<'TDoc when 'TDoc: null>(query, parameters, mapFunc: System.Func) = + WithProps.Custom.Single<'TDoc>(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 ()) + + /// Execute a query that returns a scalar value + let Scalar<'T when 'T: struct>(query, parameters, mapFunc: System.Func) = + WithProps.Custom.Scalar<'T>(query, parameters, mapFunc, fromDataSource ()) + + +/// Table and index definition commands +[] +module Definition = + + /// Create a document table + [] + let ensureTable name = + WithProps.Definition.ensureTable name (fromDataSource ()) + + /// Create an index on documents in the specified table + [] + let ensureDocumentIndex name idxType = + WithProps.Definition.ensureDocumentIndex name idxType (fromDataSource ()) + + /// Create an index on field(s) within documents in the specified table + [] + let ensureFieldIndex tableName indexName fields = + WithProps.Definition.ensureFieldIndex tableName indexName fields (fromDataSource ()) + + +/// Document writing functions +[] +module Document = + + /// Insert a new document + [] + let insert<'TDoc> tableName (document: 'TDoc) = + WithProps.Document.insert<'TDoc> tableName document (fromDataSource ()) + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + let save<'TDoc> tableName (document: 'TDoc) = + WithProps.Document.save<'TDoc> 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 field comparison query (->> =) + [] + let byField tableName fieldName op (value: obj) = + WithProps.Count.byField tableName fieldName op value (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 documents exist using a JSON field comparison query (->> =) + [] + let byField tableName fieldName op (value: obj) = + WithProps.Exists.byField tableName fieldName op value (fromDataSource ()) + + /// Determine if documents exist using a JSON containment query (@>) + [] + let byContains tableName criteria = + WithProps.Exists.byContains tableName criteria (fromDataSource ()) + + /// Determine if documents exist 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<'TDoc> tableName = + WithProps.Find.all<'TDoc> tableName (fromDataSource ()) + + /// Retrieve all documents in the given table + let All<'TDoc> tableName = + WithProps.Find.All<'TDoc>(tableName, fromDataSource ()) + + /// Retrieve a document by its ID; returns None if not found + [] + let byId<'TKey, 'TDoc> tableName docId = + WithProps.Find.byId<'TKey, 'TDoc> tableName docId (fromDataSource ()) + + /// Retrieve a document by its ID; returns null if not found + let ById<'TKey, 'TDoc when 'TDoc: null>(tableName, docId: 'TKey) = + WithProps.Find.ById<'TKey, 'TDoc>(tableName, docId, fromDataSource ()) + + /// Retrieve documents matching a JSON field comparison query (->> =) + [] + let byField<'TDoc> tableName fieldName op (value: obj) = + WithProps.Find.byField<'TDoc> tableName fieldName op value (fromDataSource ()) + + /// Retrieve documents matching a JSON field comparison query (->> =) + let ByField<'TDoc>(tableName, fieldName, op, value: obj) = + WithProps.Find.ByField<'TDoc>(tableName, fieldName, op, value, fromDataSource ()) + + /// Retrieve documents matching a JSON containment query (@>) + [] + let byContains<'TDoc> tableName (criteria: obj) = + WithProps.Find.byContains<'TDoc> tableName criteria (fromDataSource ()) + + /// Retrieve documents matching a JSON containment query (@>) + let ByContains<'TDoc>(tableName, criteria: obj) = + WithProps.Find.ByContains<'TDoc>(tableName, criteria, fromDataSource ()) + + /// Retrieve documents matching a JSON Path match query (@?) + [] + let byJsonPath<'TDoc> tableName jsonPath = + WithProps.Find.byJsonPath<'TDoc> tableName jsonPath (fromDataSource ()) + + /// Retrieve documents matching a JSON Path match query (@?) + let ByJsonPath<'TDoc>(tableName, jsonPath) = + WithProps.Find.ByJsonPath<'TDoc>(tableName, jsonPath, fromDataSource ()) + + /// Retrieve the first document matching a JSON field comparison query (->> =); returns None if not found + [] + let firstByField<'TDoc> tableName fieldName op (value: obj) = + WithProps.Find.firstByField<'TDoc> tableName fieldName op value (fromDataSource ()) + + /// Retrieve the first document matching a JSON field comparison query (->> =); returns null if not found + let FirstByField<'TDoc when 'TDoc: null>(tableName, fieldName, op, value: obj) = + WithProps.Find.FirstByField<'TDoc>(tableName, fieldName, op, value, fromDataSource ()) + + /// Retrieve the first document matching a JSON containment query (@>); returns None if not found + [] + let firstByContains<'TDoc> tableName (criteria: obj) = + WithProps.Find.firstByContains<'TDoc> tableName criteria (fromDataSource ()) + + /// Retrieve the first document matching a JSON containment query (@>); returns null if not found + let FirstByContains<'TDoc when 'TDoc: null>(tableName, criteria: obj) = + WithProps.Find.FirstByContains<'TDoc>(tableName, criteria, fromDataSource ()) + + /// Retrieve the first document matching a JSON Path match query (@?); returns None if not found + [] + let firstByJsonPath<'TDoc> tableName jsonPath = + WithProps.Find.firstByJsonPath<'TDoc> tableName jsonPath (fromDataSource ()) + + /// Retrieve the first document matching a JSON Path match query (@?); returns null if not found + let FirstByJsonPath<'TDoc when 'TDoc: null>(tableName, jsonPath) = + WithProps.Find.FirstByJsonPath<'TDoc>(tableName, jsonPath, fromDataSource ()) + + +/// Commands to update documents +[] +module Update = + + /// Update an entire document by its ID + [] + let byId tableName (docId: 'TKey) (document: 'TDoc) = + WithProps.Update.byId tableName docId document (fromDataSource ()) + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + [] + let byFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) = + WithProps.Update.byFunc tableName idFunc document (fromDataSource ()) + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + let ByFunc(tableName, idFunc: System.Func<'TDoc, 'TKey>, document: 'TDoc) = + WithProps.Update.ByFunc(tableName, idFunc, document, fromDataSource ()) + + +/// Commands to patch (partially update) documents +[] +module Patch = + + /// Patch a document by its ID + [] + let byId tableName (docId: 'TKey) (patch: 'TPatch) = + WithProps.Patch.byId tableName docId patch (fromDataSource ()) + + /// Patch documents using a JSON field comparison query in the WHERE clause (->> =) + [] + let byField tableName fieldName op (value: obj) (patch: 'TPatch) = + WithProps.Patch.byField tableName fieldName op value patch (fromDataSource ()) + + /// Patch documents using a JSON containment query in the WHERE clause (@>) + [] + let byContains tableName (criteria: 'TCriteria) (patch: 'TPatch) = + WithProps.Patch.byContains tableName criteria patch (fromDataSource ()) + + /// Patch documents using a JSON Path match query in the WHERE clause (@?) + [] + let byJsonPath tableName jsonPath (patch: 'TPatch) = + WithProps.Patch.byJsonPath tableName jsonPath patch (fromDataSource ()) + + +/// Commands to delete documents +[] +module Delete = + + /// Delete a document by its ID + [] + let byId tableName (docId: 'TKey) = + WithProps.Delete.byId tableName docId (fromDataSource ()) + + /// Delete documents by matching a JSON field comparison query (->> =) + [] + let byField tableName fieldName op (value: obj) = + WithProps.Delete.byField tableName fieldName op value (fromDataSource ()) + + /// Delete documents by matching a JSON containment query (@>) + [] + let byContains tableName (criteria: 'TContains) = + 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 ()) diff --git a/src/Postgres/README.md b/src/Postgres/README.md new file mode 100644 index 0000000..039856b --- /dev/null +++ b/src/Postgres/README.md @@ -0,0 +1,101 @@ +# BitBadger.Documents.Postgres + +This package provides a lightweight document library backed by [PostgreSQL](https://www.postgresql.org). It also provides streamlined functions for traditional ADO.NET functionality where relational data is required. Both C# and F# have first-class implementations. + +## Features + +- Select, insert, update, save (upsert), delete, count, and check existence of documents, and create tables and indexes for these documents +- Address documents via ID, via comparison on any field, via equality on any property (using JSON containment, on a likely indexed field), or via condition on any property (using JSON Path queries) +- Access documents as your domain models (POCOs) +- Use `Task`-based async for all data access functions +- Use building blocks for more complex queries + +## Getting Started + +Once the package is installed, the library needs a data source. Construct an `NpgsqlDataSource` instance, and provide it to the library: + +```csharp +// C# +using BitBadger.Documents.Postgres; + +//... +// Do not use "using" here; the library will handle disposing this instance +var data = new NpgsqlDataSourceBuilder("connection-string").Build(); +Postgres.Configuration.UseDataSource(data); +``` + +```fsharp +// F# +open BitBadger.Documents.Postgres + +// ... +// Do not use "use" here; the library will handle disposing this instance +let dataSource = // same as above .... + +Configuration.useDataSource dataSource +// ... +``` + +By default, the library uses a `System.Text.Json`-based serializer configured to use the `FSharp.SystemTextJson` converter. To provide a different serializer (different options, more converters, etc.), construct it to implement `IDocumentSerializer` and provide it via `Configuration.useSerializer`. If custom serialization makes the serialized Id field not be `Id`, that will also need to be configured. + +## Using + +Retrieve all customers: + +```csharp +// C#; parameter is table name +// Find.All type signature is Func>> +var customers = await Find.All("customer"); +``` + +```fsharp +// F# +// Find.all type signature is string -> Task<'TDoc list> +let! customers = Find.all "customer" +``` + +Select a customer by ID: + +```csharp +// C#; parameters are table name and ID +// Find.ById type signature is Func> +var customer = await Find.ById("customer", "123"); +``` +```fsharp +// F# +// Find.byId type signature is string -> 'TKey -> Task<'TDoc option> +let! customer = Find.byId "customer" "123" +``` +_(keys are treated as strings in the database)_ + +Count customers in Atlanta (using JSON containment): + +```csharp +// C#; parameters are table name and object for containment query +// Count.ByContains type signature is Func +var customerCount = await Count.ByContains("customer", new { City = "Atlanta" }); +``` + +```fsharp +// F# +// Count.byContains type signature is string -> 'TCriteria -> Task +let! customerCount = Count.byContains "customer" {| City = "Atlanta" |} +``` + +Delete customers in Chicago: _(no offense, Second City; just an example...)_ + +```csharp +// C#; parameters are table name and JSON Path expression +// Delete.ByJsonPath type signature is Func +await Delete.ByJsonPath("customer", "$.City ? (@ == \"Chicago\")"); +``` + +```fsharp +// F# +// Delete.byJsonPath type signature is string -> string -> Task +do! Delete.byJsonPath "customer" """$.City ? (@ == "Chicago")""" +``` + +## More Information + +The [project site](https://bitbadger.solutions/open-source/relational-documents/) has full details on how to use this library. diff --git a/src/Sqlite/BitBadger.Documents.Sqlite.fsproj b/src/Sqlite/BitBadger.Documents.Sqlite.fsproj new file mode 100644 index 0000000..864b7a4 --- /dev/null +++ b/src/Sqlite/BitBadger.Documents.Sqlite.fsproj @@ -0,0 +1,32 @@ + + + + Initial release; SQLite document implementation similar to BitBadger.Npgsql.Documents (RC 1) + JSON Document SQLite + + + + + + + + + + + + + + + + <_Parameter1>BitBadger.Documents.Tests + + + <_Parameter1>BitBadger.Documents.Tests.CSharp + + + + + + + + diff --git a/src/Sqlite/Extensions.fs b/src/Sqlite/Extensions.fs new file mode 100644 index 0000000..91304b5 --- /dev/null +++ b/src/Sqlite/Extensions.fs @@ -0,0 +1,215 @@ +namespace BitBadger.Documents.Sqlite + +open Microsoft.Data.Sqlite + +/// F# extensions for the SqliteConnection type +[] +module Extensions = + + type SqliteConnection with + + /// Execute a query that returns a list of results + member conn.customList<'TDoc> query parameters mapFunc = + WithConn.Custom.list<'TDoc> query parameters mapFunc conn + + /// Execute a query that returns one or no results + member conn.customSingle<'TDoc> query parameters mapFunc = + WithConn.Custom.single<'TDoc> query parameters mapFunc conn + + /// Execute a query that does not return a value + member conn.customNonQuery query parameters = + WithConn.Custom.nonQuery query parameters conn + + /// Execute a query that returns a scalar value + member conn.customScalar<'T when 'T: struct> query parameters mapFunc = + WithConn.Custom.scalar<'T> query parameters mapFunc conn + + /// Create a document table + member conn.ensureTable name = + WithConn.Definition.ensureTable name conn + + /// Create an index on a document table + member conn.ensureFieldIndex tableName indexName fields = + WithConn.Definition.ensureFieldIndex tableName indexName fields conn + + /// Insert a new document + member conn.insert<'TDoc> tableName (document: 'TDoc) = + WithConn.insert<'TDoc> tableName document conn + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + member conn.save<'TDoc> tableName (document: 'TDoc) = + WithConn.save tableName document conn + + /// Count all documents in a table + member conn.countAll tableName = + WithConn.Count.all tableName conn + + /// Count matching documents using a comparison on a JSON field + member conn.countByField tableName fieldName op (value: obj) = + WithConn.Count.byField tableName fieldName op value conn + + /// Determine if a document exists for the given ID + member conn.existsById tableName (docId: 'TKey) = + WithConn.Exists.byId tableName docId conn + + /// Determine if a document exists using a comparison on a JSON field + member conn.existsByField tableName fieldName op (value: obj) = + WithConn.Exists.byField tableName fieldName op value conn + + /// Retrieve all documents in the given table + member conn.findAll<'TDoc> tableName = + WithConn.Find.all<'TDoc> tableName conn + + /// Retrieve a document by its ID + member conn.findById<'TKey, 'TDoc> tableName (docId: 'TKey) = + WithConn.Find.byId<'TKey, 'TDoc> tableName docId conn + + /// Retrieve documents via a comparison on a JSON field + member conn.findByField<'TDoc> tableName fieldName op (value: obj) = + WithConn.Find.byField<'TDoc> tableName fieldName op value conn + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + member conn.findFirstByField<'TDoc> tableName fieldName op (value: obj) = + WithConn.Find.firstByField<'TDoc> tableName fieldName op value conn + + /// Update an entire document by its ID + member conn.updateById tableName (docId: 'TKey) (document: 'TDoc) = + WithConn.Update.byId tableName docId document conn + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + member conn.updateByFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) = + WithConn.Update.byFunc tableName idFunc document conn + + /// Patch a document by its ID + member conn.patchById tableName (docId: 'TKey) (patch: 'TPatch) = + WithConn.Patch.byId tableName docId patch conn + + /// Patch documents using a comparison on a JSON field + member conn.patchByField tableName fieldName op (value: obj) (patch: 'TPatch) = + WithConn.Patch.byField tableName fieldName op value patch conn + + /// Delete a document by its ID + member conn.deleteById tableName (docId: 'TKey) = + WithConn.Delete.byId tableName docId conn + + /// Delete documents by matching a comparison on a JSON field + member conn.deleteByField tableName fieldName op (value: obj) = + WithConn.Delete.byField tableName fieldName op value conn + + +open System.Runtime.CompilerServices + +/// C# extensions on the SqliteConnection type +type SqliteConnectionCSharpExtensions = + + /// Execute a query that returns a list of results + [] + static member inline CustomList<'TDoc>(conn, query, parameters, mapFunc: System.Func) = + WithConn.Custom.List<'TDoc>(query, parameters, mapFunc, conn) + + /// Execute a query that returns one or no results + [] + static member inline CustomSingle<'TDoc when 'TDoc: null>( + conn, query, parameters, mapFunc: System.Func) = + WithConn.Custom.Single<'TDoc>(query, parameters, mapFunc, conn) + + /// Execute a query that does not return a value + [] + static member inline CustomNonQuery(conn, query, parameters) = + WithConn.Custom.nonQuery query parameters conn + + /// Execute a query that returns a scalar value + [] + static member inline CustomScalar<'T when 'T: struct>( + conn, query, parameters, mapFunc: System.Func) = + WithConn.Custom.Scalar<'T>(query, parameters, mapFunc, conn) + + /// Create a document table + [] + static member inline EnsureTable(conn, name) = + WithConn.Definition.ensureTable name conn + + /// Create an index on one or more fields in a document table + [] + static member inline EnsureFieldIndex(conn, tableName, indexName, fields) = + WithConn.Definition.ensureFieldIndex tableName indexName fields conn + + /// Insert a new document + [] + static member inline Insert<'TDoc>(conn, tableName, document: 'TDoc) = + WithConn.insert<'TDoc> tableName document conn + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + static member inline Save<'TDoc>(conn, tableName, document: 'TDoc) = + WithConn.save<'TDoc> tableName document conn + + /// Count all documents in a table + [] + static member inline CountAll(conn, tableName) = + WithConn.Count.all tableName conn + + /// Count matching documents using a comparison on a JSON field + [] + static member inline CountByField(conn, tableName, fieldName, op, value: obj) = + WithConn.Count.byField tableName fieldName op value conn + + /// Determine if a document exists for the given ID + [] + static member inline ExistsById<'TKey>(conn, tableName, docId: 'TKey) = + WithConn.Exists.byId tableName docId conn + + /// Determine if a document exists using a comparison on a JSON field + [] + static member inline ExistsByField(conn, tableName, fieldName, op, value: obj) = + WithConn.Exists.byField tableName fieldName op value conn + + /// Retrieve all documents in the given table + [] + static member inline FindAll<'TDoc>(conn, tableName) = + WithConn.Find.All<'TDoc>(tableName, conn) + + /// Retrieve a document by its ID + [] + static member inline FindById<'TKey, 'TDoc when 'TDoc: null>(conn, tableName, docId: 'TKey) = + WithConn.Find.ById<'TKey, 'TDoc>(tableName, docId, conn) + + /// Retrieve documents via a comparison on a JSON field + [] + static member inline FindByField<'TDoc>(conn, tableName, fieldName, op, value) = + WithConn.Find.ByField<'TDoc>(tableName, fieldName, op, value, conn) + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + [] + static member inline FindFirstByField<'TDoc when 'TDoc: null>(conn, tableName, fieldName, op, value: obj) = + WithConn.Find.FirstByField<'TDoc>(tableName, fieldName, op, value, conn) + + /// Update an entire document by its ID + [] + static member inline UpdateById<'TKey, 'TDoc>(conn, tableName, docId: 'TKey, document: 'TDoc) = + WithConn.Update.byId tableName docId document conn + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + [] + static member inline UpdateByFunc<'TKey, 'TDoc>(conn, tableName, idFunc: System.Func<'TDoc, 'TKey>, doc: 'TDoc) = + WithConn.Update.ByFunc(tableName, idFunc, doc, conn) + + /// Patch a document by its ID + [] + static member inline PatchById<'TKey, 'TPatch>(conn, tableName, docId: 'TKey, patch: 'TPatch) = + WithConn.Patch.byId tableName docId patch conn + + /// Patch documents using a comparison on a JSON field + [] + static member inline PatchByField<'TPatch>(conn, tableName, fieldName, op, value: obj, patch: 'TPatch) = + WithConn.Patch.byField tableName fieldName op value patch conn + + /// Delete a document by its ID + [] + static member inline DeleteById<'TKey>(conn, tableName, docId: 'TKey) = + WithConn.Delete.byId tableName docId conn + + /// Delete documents by matching a comparison on a JSON field + [] + static member inline DeleteByField(conn, tableName, fieldName, op, value: obj) = + WithConn.Delete.byField tableName fieldName op value conn diff --git a/src/Sqlite/Library.fs b/src/Sqlite/Library.fs new file mode 100644 index 0000000..6a9c4a2 --- /dev/null +++ b/src/Sqlite/Library.fs @@ -0,0 +1,539 @@ +namespace BitBadger.Documents.Sqlite + +open BitBadger.Documents +open Microsoft.Data.Sqlite + +/// Configuration for document handling +module Configuration = + + /// The connection string to use for query execution + let mutable internal connectionString: string option = None + + /// Register a connection string to use for query execution (enables foreign keys) + [] + let useConnectionString connStr = + let builder = SqliteConnectionStringBuilder(connStr) + builder.ForeignKeys <- Option.toNullable (Some true) + connectionString <- Some (string builder) + + /// Retrieve the currently configured data source + [] + let dbConn () = + match connectionString with + | Some connStr -> + let conn = new SqliteConnection(connStr) + conn.Open() + conn + | None -> invalidOp "Please provide a connection string before attempting data access" + + +/// Query definitions +[] +module Query = + + /// Data definition + module Definition = + + /// SQL statement to create a document table + [] + let ensureTable name = + Query.Definition.ensureTableFor name "TEXT" + + /// Document patching (partial update) queries + module Patch = + + /// Query to patch (partially update) a document by its ID + [] + let byId tableName = + $"""UPDATE %s{tableName} SET data = json_patch(data, json(@data)) WHERE {Query.whereById "@id"}""" + + /// Query to patch (partially update) a document via a comparison on a JSON field + [] + let byField tableName fieldName op = + sprintf + "UPDATE %s SET data = json_patch(data, json(@data)) WHERE %s" + tableName (Query.whereByField fieldName op "@field") + + +/// Parameter handling helpers +[] +module Parameters = + + /// Create an ID parameter (name "@id", key will be treated as a string) + [] + let idParam (key: 'TKey) = + SqliteParameter("@id", string key) + + /// Create a parameter with a JSON value + [] + let jsonParam name (it: 'TJson) = + SqliteParameter(name, Configuration.serializer().Serialize it) + + /// Create a JSON field parameter (name "@field") + [] + let fieldParam (value: obj) = + SqliteParameter("@field", value) + + /// An empty parameter sequence + [] + let noParams = + Seq.empty + + +/// Helper functions for handling results +[] +module Results = + + /// Create a domain item from a document, specifying the field in which the document is found + [] + let fromDocument<'TDoc> field (rdr: SqliteDataReader) : 'TDoc = + Configuration.serializer().Deserialize<'TDoc>(rdr.GetString(rdr.GetOrdinal(field))) + + /// Create a domain item from a document + [] + let fromData<'TDoc> rdr = + fromDocument<'TDoc> "data" rdr + + /// Create a list of items for the results of the given command, using the specified mapping function + [] + let toCustomList<'TDoc> (cmd: SqliteCommand) (mapFunc: SqliteDataReader -> 'TDoc) = backgroundTask { + use! rdr = cmd.ExecuteReaderAsync() + let mutable it = Seq.empty<'TDoc> + while! rdr.ReadAsync() do + it <- Seq.append it (Seq.singleton (mapFunc rdr)) + return List.ofSeq it + } + + /// Extract a count from the first column + [] + let toCount (row: SqliteDataReader) = + row.GetInt64 0 + + /// Extract a true/false value from a count in the first column + [] + let toExists row = + toCount(row) > 0L + + +[] +module internal Helpers = + + /// Execute a non-query command + let internal write (cmd: SqliteCommand) = backgroundTask { + let! _ = cmd.ExecuteNonQueryAsync() + () + } + + +/// Versions of queries that accept a SqliteConnection as the last parameter +module WithConn = + + /// Commands to execute custom SQL queries + [] + module Custom = + + /// Execute a query that returns a list of results + [] + let list<'TDoc> query (parameters: SqliteParameter seq) (mapFunc: SqliteDataReader -> 'TDoc) + (conn: SqliteConnection) = + use cmd = conn.CreateCommand() + cmd.CommandText <- query + cmd.Parameters.AddRange parameters + toCustomList<'TDoc> cmd mapFunc + + /// Execute a query that returns a list of results + let List<'TDoc>(query, parameters, mapFunc: System.Func, conn) = backgroundTask { + let! results = list<'TDoc> query parameters mapFunc.Invoke conn + return ResizeArray<'TDoc> results + } + + /// Execute a query that returns one or no results (returns None if not found) + [] + let single<'TDoc> query parameters (mapFunc: SqliteDataReader -> 'TDoc) conn = backgroundTask { + let! results = list query parameters mapFunc conn + return FSharp.Collections.List.tryHead results + } + + /// Execute a query that returns one or no results (returns null if not found) + let Single<'TDoc when 'TDoc: null>( + query, parameters, mapFunc: System.Func, conn + ) = backgroundTask { + let! result = single<'TDoc> query parameters mapFunc.Invoke conn + return Option.toObj result + } + + /// Execute a query that does not return a value + [] + let nonQuery query (parameters: SqliteParameter seq) (conn: SqliteConnection) = + use cmd = conn.CreateCommand() + cmd.CommandText <- query + cmd.Parameters.AddRange parameters + write cmd + + /// Execute a query that returns a scalar value + [] + let scalar<'T when 'T : struct> query (parameters: SqliteParameter seq) (mapFunc: SqliteDataReader -> 'T) + (conn: SqliteConnection) = backgroundTask { + use cmd = conn.CreateCommand() + cmd.CommandText <- query + cmd.Parameters.AddRange parameters + use! rdr = cmd.ExecuteReaderAsync() + let! isFound = rdr.ReadAsync() + return if isFound then mapFunc rdr else Unchecked.defaultof<'T> + } + + /// Execute a query that returns a scalar value + let Scalar<'T when 'T: struct>(query, parameters, mapFunc: System.Func, conn) = + scalar<'T> query parameters mapFunc.Invoke conn + + /// Functions to create tables and indexes + [] + module Definition = + + /// Create a document table + [] + let ensureTable name conn = backgroundTask { + do! Custom.nonQuery (Query.Definition.ensureTable name) [] conn + do! Custom.nonQuery (Query.Definition.ensureKey name) [] conn + } + + /// Create an index on a document table + [] + let ensureFieldIndex tableName indexName fields conn = + Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields) [] conn + + /// Insert a new document + [] + let insert<'TDoc> tableName (document: 'TDoc) conn = + Custom.nonQuery (Query.insert tableName) [ jsonParam "@data" document ] conn + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + let save<'TDoc> tableName (document: 'TDoc) conn = + Custom.nonQuery (Query.save tableName) [ jsonParam "@data" document ] conn + + /// Commands to count documents + [] + module Count = + + /// Count all documents in a table + [] + let all tableName conn = + Custom.scalar (Query.Count.all tableName) [] toCount conn + + /// Count matching documents using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) conn = + Custom.scalar (Query.Count.byField tableName fieldName op) [ fieldParam value ] toCount conn + + /// Commands to determine if documents exist + [] + module Exists = + + /// Determine if a document exists for the given ID + [] + let byId tableName (docId: 'TKey) conn = + Custom.scalar (Query.Exists.byId tableName) [ idParam docId ] toExists conn + + /// Determine if a document exists using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) conn = + Custom.scalar (Query.Exists.byField tableName fieldName op) [ fieldParam value ] toExists conn + + /// Commands to retrieve documents + [] + module Find = + + /// Retrieve all documents in the given table + [] + let all<'TDoc> tableName conn = + Custom.list<'TDoc> (Query.selectFromTable tableName) [] fromData<'TDoc> conn + + /// Retrieve all documents in the given table + let All<'TDoc>(tableName, conn) = + Custom.List(Query.selectFromTable tableName, [], fromData<'TDoc>, conn) + + /// Retrieve a document by its ID (returns None if not found) + [] + let byId<'TKey, 'TDoc> tableName (docId: 'TKey) conn = + Custom.single<'TDoc> (Query.Find.byId tableName) [ idParam docId ] fromData<'TDoc> conn + + /// Retrieve a document by its ID (returns null if not found) + let ById<'TKey, 'TDoc when 'TDoc: null>(tableName, docId: 'TKey, conn) = + Custom.Single<'TDoc>(Query.Find.byId tableName, [ idParam docId ], fromData<'TDoc>, conn) + + /// Retrieve documents via a comparison on a JSON field + [] + let byField<'TDoc> tableName fieldName op (value: obj) conn = + Custom.list<'TDoc> (Query.Find.byField tableName fieldName op) [ fieldParam value ] fromData<'TDoc> conn + + /// Retrieve documents via a comparison on a JSON field + let ByField<'TDoc>(tableName, fieldName, op, value: obj, conn) = + Custom.List<'TDoc>(Query.Find.byField tableName fieldName op, [ fieldParam value ], fromData<'TDoc>, conn) + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + [] + let firstByField<'TDoc> tableName fieldName op (value: obj) conn = + Custom.single + $"{Query.Find.byField tableName fieldName op} LIMIT 1" [ fieldParam value ] fromData<'TDoc> conn + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + let FirstByField<'TDoc when 'TDoc: null>(tableName, fieldName, op, value: obj, conn) = + Custom.Single( + $"{Query.Find.byField tableName fieldName op} LIMIT 1", [ fieldParam value ], fromData<'TDoc>, conn) + + /// Commands to update documents + [] + module Update = + + /// Update an entire document by its ID + [] + let byId tableName (docId: 'TKey) (document: 'TDoc) conn = + Custom.nonQuery (Query.update tableName) [ idParam docId; jsonParam "@data" document ] conn + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + [] + let byFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) conn = + byId tableName (idFunc document) document conn + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + let ByFunc(tableName, idFunc: System.Func<'TDoc, 'TKey>, document: 'TDoc, conn) = + byFunc tableName idFunc.Invoke document conn + + /// Commands to patch (partially update) documents + [] + module Patch = + + /// Patch a document by its ID + [] + let byId tableName (docId: 'TKey) (patch: 'TPatch) conn = + Custom.nonQuery (Query.Patch.byId tableName) [ idParam docId; jsonParam "@data" patch ] conn + + /// Patch documents using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) (patch: 'TPatch) (conn: SqliteConnection) = + Custom.nonQuery + (Query.Patch.byField tableName fieldName op) [ fieldParam value; jsonParam "@data" patch ] conn + + /// Commands to delete documents + [] + module Delete = + + /// Delete a document by its ID + [] + let byId tableName (docId: 'TKey) conn = + Custom.nonQuery (Query.Delete.byId tableName) [ idParam docId ] conn + + /// Delete documents by matching a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) conn = + Custom.nonQuery (Query.Delete.byField tableName fieldName op) [ fieldParam value ] conn + + +/// Commands to execute custom SQL queries +[] +module Custom = + + /// Execute a query that returns a list of results + [] + let list<'TDoc> query parameters (mapFunc: SqliteDataReader -> 'TDoc) = + use conn = Configuration.dbConn () + WithConn.Custom.list<'TDoc> query parameters mapFunc conn + + /// Execute a query that returns a list of results + let List<'TDoc>(query, parameters, mapFunc: System.Func) = + use conn = Configuration.dbConn () + WithConn.Custom.List<'TDoc>(query, parameters, mapFunc, conn) + + /// Execute a query that returns one or no results (returns None if not found) + [] + let single<'TDoc> query parameters (mapFunc: SqliteDataReader -> 'TDoc) = + use conn = Configuration.dbConn () + WithConn.Custom.single<'TDoc> query parameters mapFunc conn + + /// Execute a query that returns one or no results (returns null if not found) + let Single<'TDoc when 'TDoc: null>(query, parameters, mapFunc: System.Func) = + use conn = Configuration.dbConn () + WithConn.Custom.Single<'TDoc>(query, parameters, mapFunc, conn) + + /// Execute a query that does not return a value + [] + let nonQuery query parameters = + use conn = Configuration.dbConn () + WithConn.Custom.nonQuery query parameters conn + + /// Execute a query that returns a scalar value + [] + let scalar<'T when 'T: struct> query parameters (mapFunc: SqliteDataReader -> 'T) = + use conn = Configuration.dbConn () + WithConn.Custom.scalar<'T> query parameters mapFunc conn + + /// Execute a query that returns a scalar value + let Scalar<'T when 'T: struct>(query, parameters, mapFunc: System.Func) = + use conn = Configuration.dbConn () + WithConn.Custom.Scalar<'T>(query, parameters, mapFunc, conn) + +/// Functions to create tables and indexes +[] +module Definition = + + /// Create a document table + [] + let ensureTable name = + use conn = Configuration.dbConn () + WithConn.Definition.ensureTable name conn + + /// Create an index on a document table + [] + let ensureFieldIndex tableName indexName fields = + use conn = Configuration.dbConn () + WithConn.Definition.ensureFieldIndex tableName indexName fields conn + +/// Document insert/save functions +[] +module Document = + + /// Insert a new document + [] + let insert<'TDoc> tableName (document: 'TDoc) = + use conn = Configuration.dbConn () + WithConn.insert tableName document conn + + /// Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + [] + let save<'TDoc> tableName (document: 'TDoc) = + use conn = Configuration.dbConn () + WithConn.save tableName document conn + +/// Commands to count documents +[] +module Count = + + /// Count all documents in a table + [] + let all tableName = + use conn = Configuration.dbConn () + WithConn.Count.all tableName conn + + /// Count matching documents using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) = + use conn = Configuration.dbConn () + WithConn.Count.byField tableName fieldName op value conn + +/// Commands to determine if documents exist +[] +module Exists = + + /// Determine if a document exists for the given ID + [] + let byId tableName (docId: 'TKey) = + use conn = Configuration.dbConn () + WithConn.Exists.byId tableName docId conn + + /// Determine if a document exists using a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) = + use conn = Configuration.dbConn () + WithConn.Exists.byField tableName fieldName op value conn + +/// Commands to determine if documents exist +[] +module Find = + + /// Retrieve all documents in the given table + [] + let all<'TDoc> tableName = + use conn = Configuration.dbConn () + WithConn.Find.all<'TDoc> tableName conn + + /// Retrieve all documents in the given table + let All<'TDoc> tableName = + use conn = Configuration.dbConn () + WithConn.Find.All<'TDoc>(tableName, conn) + + /// Retrieve a document by its ID (returns None if not found) + [] + let byId<'TKey, 'TDoc> tableName docId = + use conn = Configuration.dbConn () + WithConn.Find.byId<'TKey, 'TDoc> tableName docId conn + + /// Retrieve a document by its ID (returns null if not found) + let ById<'TKey, 'TDoc when 'TDoc: null>(tableName, docId) = + use conn = Configuration.dbConn () + WithConn.Find.ById<'TKey, 'TDoc>(tableName, docId, conn) + + /// Retrieve documents via a comparison on a JSON field + [] + let byField<'TDoc> tableName fieldName op value = + use conn = Configuration.dbConn () + WithConn.Find.byField<'TDoc> tableName fieldName op value conn + + /// Retrieve documents via a comparison on a JSON field + let ByField<'TDoc>(tableName, fieldName, op, value) = + use conn = Configuration.dbConn () + WithConn.Find.ByField<'TDoc>(tableName, fieldName, op, value, conn) + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + [] + let firstByField<'TDoc> tableName fieldName op value = + use conn = Configuration.dbConn () + WithConn.Find.firstByField<'TDoc> tableName fieldName op value conn + + /// Retrieve documents via a comparison on a JSON field, returning only the first result + let FirstByField<'TDoc when 'TDoc: null>(tableName, fieldName, op, value) = + use conn = Configuration.dbConn () + WithConn.Find.FirstByField<'TDoc>(tableName, fieldName, op, value, conn) + +/// Commands to update documents +[] +module Update = + + /// Update an entire document by its ID + [] + let byId tableName (docId: 'TKey) (document: 'TDoc) = + use conn = Configuration.dbConn () + WithConn.Update.byId tableName docId document conn + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + [] + let byFunc tableName (idFunc: 'TDoc -> 'TKey) (document: 'TDoc) = + use conn = Configuration.dbConn () + WithConn.Update.byFunc tableName idFunc document conn + + /// Update an entire document by its ID, using the provided function to obtain the ID from the document + let ByFunc(tableName, idFunc: System.Func<'TDoc, 'TKey>, document: 'TDoc) = + use conn = Configuration.dbConn () + WithConn.Update.ByFunc(tableName, idFunc, document, conn) + +/// Commands to patch (partially update) documents +[] +module Patch = + + /// Patch a document by its ID + [] + let byId tableName (docId: 'TKey) (patch: 'TPatch) = + use conn = Configuration.dbConn () + WithConn.Patch.byId tableName docId patch conn + + /// Patch documents using a comparison on a JSON field in the WHERE clause + [] + let byField tableName fieldName op (value: obj) (patch: 'TPatch) = + use conn = Configuration.dbConn () + WithConn.Patch.byField tableName fieldName op value patch conn + +/// Commands to delete documents +[] +module Delete = + + /// Delete a document by its ID + [] + let byId tableName (docId: 'TKey) = + use conn = Configuration.dbConn () + WithConn.Delete.byId tableName docId conn + + /// Delete documents by matching a comparison on a JSON field + [] + let byField tableName fieldName op (value: obj) = + use conn = Configuration.dbConn () + WithConn.Delete.byField tableName fieldName op value conn diff --git a/src/Sqlite/README.md b/src/Sqlite/README.md new file mode 100644 index 0000000..b79958f --- /dev/null +++ b/src/Sqlite/README.md @@ -0,0 +1,101 @@ +# BitBadger.Documents.Sqlite + +This package provides a lightweight document library backed by [SQLite](https://www.sqlite.org). It also provides streamlined functions for traditional ADO.NET functionality where relational data is required. Both C# and F# have first-class implementations. + +## Features + +- Select, insert, update, save (upsert), delete, count, and check existence of documents, and create tables and indexes for these documents +- Address documents via ID or via comparison on any field +- Access documents as your domain models (POCOs) +- Use `Task`-based async for all data access functions +- Use building blocks for more complex queries + +## Getting Started + +Once the package is installed, the library needs a connection string. Once it has been obtained / constructed, provide it to the library: + +```csharp +// C# +using BitBadger.Documents.Sqlite; + +//... +Sqlite.Configuration.UseConnectionString("connection-string"); + +// A new, open connection to the database can be obtained via +// Sqlite.Configuration.DbConn() +``` + +```fsharp +// F# +open BitBadger.Documents.Sqlite + +// ... +Configuration.useConnectionString "connection-string" + +// A new, open connection to the database can be obtained via +// Configuration.dbConn () +``` + +By default, the library uses a `System.Text.Json`-based serializer configured to use the `FSharp.SystemTextJson` converter. To provide a different serializer (different options, more converters, etc.), construct it to implement `IDocumentSerializer` and provide it via `Configuration.useSerializer`. If custom serialization makes the serialized Id field not be `Id`, that will also need to be configured. + +## Using + +Retrieve all customers: + +```csharp +// C#; parameter is table name +// Find.All type signature is Func>> +var customers = await Find.All("customer"); +``` + +```fsharp +// F# +// Find.all type signature is string -> Task<'TDoc list> +let! customers = Find.all "customer" +``` + +Select a customer by ID: + +```csharp +// C#; parameters are table name and ID +// Find.ById type signature is Func> +var customer = await Find.ById("customer", "123"); +``` +```fsharp +// F# +// Find.byId type signature is string -> 'TKey -> Task<'TDoc option> +let! customer = Find.byId "customer" "123" +``` +_(keys are treated as strings in the database)_ + +Count customers in Atlanta: + +```csharp +// C#; parameters are table name, field, operator, and value +// Count.ByField type signature is Func> +var customerCount = await Count.ByField("customer", "City", Op.EQ, "Atlanta"); +``` + +```fsharp +// F# +// Count.byField type signature is string -> string -> Op -> obj -> Task +let! customerCount = Count.byField "customer" "City" EQ "Atlanta" +``` + +Delete customers in Chicago: _(no offense, Second City; just an example...)_ + +```csharp +// C#; parameters are same as above, except return is void +// Delete.ByField type signature is Func +await Delete.ByField("customer", "City", Op.EQ, "Chicago"); +``` + +```fsharp +// F# +// Delete.byField type signature is string -> string -> Op -> obj -> Task +do! Delete.byField "customer" "City" EQ "Chicago" +``` + +## More Information + +The [project site](https://bitbadger.solutions/open-source/relational-documents/) has full details on how to use this library. diff --git a/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj b/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj new file mode 100644 index 0000000..489d298 --- /dev/null +++ b/src/Tests.CSharp/BitBadger.Documents.Tests.CSharp.csproj @@ -0,0 +1,19 @@ + + + + enable + enable + + + + + + + + + + + + + + diff --git a/src/Tests.CSharp/CommonCSharpTests.cs b/src/Tests.CSharp/CommonCSharpTests.cs new file mode 100644 index 0000000..c75d2ba --- /dev/null +++ b/src/Tests.CSharp/CommonCSharpTests.cs @@ -0,0 +1,238 @@ +using Expecto.CSharp; +using Expecto; + +namespace BitBadger.Documents.Tests.CSharp; + +using static Runner; + +/// +/// A test serializer that returns known values +/// +internal class TestSerializer : IDocumentSerializer +{ + public string Serialize(T it) => "{\"Overridden\":true}"; + public T Deserialize(string it) => default!; +} + +/// +/// C# Tests for common functionality in BitBadger.Documents +/// +public static class CommonCSharpTests +{ + /// + /// Unit tests + /// + [Tests] + public static readonly Test Unit = TestList("Common.C# Unit", new[] + { + TestSequenced( + TestList("Configuration", new[] + { + TestCase("UseSerializer succeeds", () => + { + try + { + Configuration.UseSerializer(new TestSerializer()); + + var serialized = Configuration.Serializer().Serialize(new SubDocument + { + Foo = "howdy", + Bar = "bye" + }); + Expect.equal(serialized, "{\"Overridden\":true}", "Specified serializer was not used"); + + var deserialized = Configuration.Serializer() + .Deserialize("{\"Something\":\"here\"}"); + Expect.isNull(deserialized, "Specified serializer should have returned null"); + } + finally + { + Configuration.UseSerializer(DocumentSerializer.Default); + } + }), + TestCase("Serializer returns configured serializer", () => + { + Expect.isTrue(ReferenceEquals(DocumentSerializer.Default, Configuration.Serializer()), + "Serializer should have been the same"); + }), + TestCase("UseIdField / IdField succeeds", () => + { + try + { + Expect.equal(Configuration.IdField(), "Id", + "The default configured ID field was incorrect"); + Configuration.UseIdField("id"); + Expect.equal(Configuration.IdField(), "id", "UseIdField did not set the ID field"); + } + finally + { + Configuration.UseIdField("Id"); + } + }) + })), + TestList("Op", new[] + { + TestCase("EQ succeeds", () => + { + Expect.equal(Op.EQ.ToString(), "=", "The equals operator was not correct"); + }), + TestCase("GT succeeds", () => + { + Expect.equal(Op.GT.ToString(), ">", "The greater than operator was not correct"); + }), + TestCase("GE succeeds", () => + { + Expect.equal(Op.GE.ToString(), ">=", "The greater than or equal to operator was not correct"); + }), + TestCase("LT succeeds", () => + { + Expect.equal(Op.LT.ToString(), "<", "The less than operator was not correct"); + }), + TestCase("LE succeeds", () => + { + Expect.equal(Op.LE.ToString(), "<=", "The less than or equal to operator was not correct"); + }), + TestCase("NE succeeds", () => + { + Expect.equal(Op.NE.ToString(), "<>", "The not equal to operator was not correct"); + }), + TestCase("EX succeeds", () => + { + Expect.equal(Op.EX.ToString(), "IS NOT NULL", "The \"exists\" operator was not correct"); + }), + TestCase("NEX succeeds", () => + { + Expect.equal(Op.NEX.ToString(), "IS NULL", "The \"not exists\" operator was not correct"); + }) + }), + TestList("Query", new[] + { + TestCase("SelectFromTable succeeds", () => + { + Expect.equal(Query.SelectFromTable("test.table"), "SELECT data FROM test.table", + "SELECT statement not correct"); + }), + TestCase("WhereById succeeds", () => + { + Expect.equal(Query.WhereById("@id"), "data ->> 'Id' = @id", "WHERE clause not correct"); + }), + TestList("WhereByField", new[] + { + TestCase("succeeds when a logical operator is passed", () => + { + Expect.equal(Query.WhereByField("theField", Op.GT, "@test"), "data ->> 'theField' > @test", + "WHERE clause not correct"); + }), + TestCase("succeeds when an existence operator is passed", () => + { + Expect.equal(Query.WhereByField("thatField", Op.NEX, ""), "data ->> 'thatField' IS NULL", + "WHERE clause not correct"); + }) + }), + TestList("Definition", new[] + { + TestCase("EnsureTableFor succeeds", () => + { + Expect.equal(Query.Definition.EnsureTableFor("my.table", "JSONB"), + "CREATE TABLE IF NOT EXISTS my.table (data JSONB NOT NULL)", + "CREATE TABLE statement not constructed correctly"); + }), + TestList("EnsureKey", new[] + { + TestCase("succeeds when a schema is present", () => + { + Expect.equal(Query.Definition.EnsureKey("test.table"), + "CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON test.table ((data ->> 'Id'))", + "CREATE INDEX for key statement with schema not constructed correctly"); + }), + TestCase("succeeds when a schema is not present", () => + { + Expect.equal(Query.Definition.EnsureKey("table"), + "CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON table ((data ->> 'Id'))", + "CREATE INDEX for key statement without schema not constructed correctly"); + }) + }), + TestCase("EnsureIndexOn succeeds for multiple fields and directions", () => + { + Expect.equal( + Query.Definition.EnsureIndexOn("test.table", "gibberish", + new[] { "taco", "guac DESC", "salsa ASC" }), + "CREATE INDEX IF NOT EXISTS idx_table_gibberish ON test.table " + + "((data ->> 'taco'), (data ->> 'guac') DESC, (data ->> 'salsa') ASC)", + "CREATE INDEX for multiple field statement incorrect"); + }) + }), + TestCase("Insert succeeds", () => + { + Expect.equal(Query.Insert("tbl"), "INSERT INTO tbl VALUES (@data)", "INSERT statement not correct"); + }), + TestCase("Save succeeds", () => + { + Expect.equal(Query.Save("tbl"), + $"INSERT INTO tbl VALUES (@data) ON CONFLICT ((data ->> 'Id')) DO UPDATE SET data = EXCLUDED.data", + "INSERT ON CONFLICT UPDATE statement not correct"); + }), + TestCase("Update succeeds", () => + { + Expect.equal(Query.Update("tbl"), "UPDATE tbl SET data = @data WHERE data ->> 'Id' = @id", + "UPDATE full statement not correct"); + }), + TestList("Count", new[] + { + TestCase("All succeeds", () => + { + Expect.equal(Query.Count.All("tbl"), "SELECT COUNT(*) AS it FROM tbl", "Count query not correct"); + }), + TestCase("ByField succeeds", () => + { + Expect.equal(Query.Count.ByField("tbl", "thatField", Op.EQ), + "SELECT COUNT(*) AS it FROM tbl WHERE data ->> 'thatField' = @field", + "JSON field text comparison count query not correct"); + }) + }), + TestList("Exists", new[] + { + TestCase("ById succeeds", () => + { + Expect.equal(Query.Exists.ById("tbl"), + "SELECT EXISTS (SELECT 1 FROM tbl WHERE data ->> 'Id' = @id) AS it", + "ID existence query not correct"); + }), + TestCase("ByField succeeds", () => + { + Expect.equal(Query.Exists.ByField("tbl", "Test", Op.LT), + "SELECT EXISTS (SELECT 1 FROM tbl WHERE data ->> 'Test' < @field) AS it", + "JSON field text comparison exists query not correct"); + }) + }), + TestList("Find", new[] + { + TestCase("ById succeeds", () => + { + Expect.equal(Query.Find.ById("tbl"), "SELECT data FROM tbl WHERE data ->> 'Id' = @id", + "SELECT by ID query not correct"); + }), + TestCase("ByField succeeds", () => + { + Expect.equal(Query.Find.ByField("tbl", "Golf", Op.GE), + "SELECT data FROM tbl WHERE data ->> 'Golf' >= @field", + "SELECT by JSON comparison query not correct"); + }) + }), + TestList("Delete", new[] + { + TestCase("ById succeeds", () => + { + Expect.equal(Query.Delete.ById("tbl"), "DELETE FROM tbl WHERE data ->> 'Id' = @id", + "DELETE by ID query not correct"); + }), + TestCase("ByField succeeds", () => + { + Expect.equal(Query.Delete.ByField("tbl", "gone", Op.NEX), + "DELETE FROM tbl WHERE data ->> 'gone' IS NULL", + "DELETE by JSON comparison query not correct"); + }) + }) + }) + }); +} diff --git a/src/Tests.CSharp/PostgresCSharpExtensionTests.cs b/src/Tests.CSharp/PostgresCSharpExtensionTests.cs new file mode 100644 index 0000000..4f0ee63 --- /dev/null +++ b/src/Tests.CSharp/PostgresCSharpExtensionTests.cs @@ -0,0 +1,807 @@ +using Expecto.CSharp; +using Expecto; +using BitBadger.Documents.Postgres; +using Npgsql; + +namespace BitBadger.Documents.Tests.CSharp; + +using static CommonExtensionsAndTypesForNpgsqlFSharp; +using static Runner; + +/// +/// C# tests for the extensions on the NpgsqlConnection type +/// +public class PostgresCSharpExtensionTests +{ + private static Task LoadDocs() => PostgresCSharpTests.LoadDocs(); + + /// + /// Create a connection to the throwaway database + /// + /// The throwaway database for which a connection should be made + /// An open connection to the throwaway database + private static NpgsqlConnection MkConn(ThrowawayPostgresDb db) + { + var conn = new NpgsqlConnection(db.ConnectionString); + conn.Open(); + return conn; + } + + /// + /// Integration tests for the SQLite extension methods + /// + [Tests] + public static readonly Test Integration = TestList("Postgres.C#.Extensions", new[] + { + TestList("CustomList", new[] + { + TestCase("succeeds when data is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.CustomList(Query.SelectFromTable(PostgresDb.TableName), Parameters.None, + Results.FromData); + Expect.equal(docs.Count, 5, "There should have been 5 documents returned"); + }), + TestCase("succeeds when data is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.CustomList( + $"SELECT data FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath", + new[] { Tuple.Create("@path", Sql.@string("$.NumValue ? (@ > 100)")) }, + Results.FromData); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("CustomSingle", new[] + { + TestCase("succeeds when a row is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.CustomSingle($"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Tuple.Create("@id", Sql.@string("one")) }, Results.FromData); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "one", "The incorrect document was returned"); + }), + TestCase("succeeds when a row is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.CustomSingle($"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Tuple.Create("@id", Sql.@string("eighty")) }, Results.FromData); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("CustomNonQuery", new[] + { + TestCase("succeeds when operating on data", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.CustomNonQuery($"DELETE FROM {PostgresDb.TableName}", Parameters.None); + + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 0, "There should be no documents remaining in the table"); + }), + TestCase("succeeds when no data matches where clause", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.CustomNonQuery($"DELETE FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath", + new[] { Tuple.Create("@path", Sql.@string("$.NumValue ? (@ > 100)")) }); + + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should be 5 documents remaining in the table"); + }) + }), + TestCase("Scalar succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var nbr = await conn.CustomScalar("SELECT 5 AS test_value", Parameters.None, row => row.@int("test_value")); + Expect.equal(nbr, 5, "The query should have returned the number 5"); + }), + TestCase("EnsureTable succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var tableExists = () => conn.CustomScalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'ensured') AS it", Parameters.None, + Results.ToExists); + var keyExists = () => conn.CustomScalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_key') AS it", Parameters.None, + Results.ToExists); + + var exists = await tableExists(); + var alsoExists = await keyExists(); + Expect.isFalse(exists, "The table should not exist already"); + Expect.isFalse(alsoExists, "The key index should not exist already"); + + await conn.EnsureTable("ensured"); + exists = await tableExists(); + alsoExists = await keyExists(); + Expect.isTrue(exists, "The table should now exist"); + Expect.isTrue(alsoExists, "The key index should now exist"); + }), + TestCase("EnsureDocumentIndex succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var indexExists = () => conn.CustomScalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_document') AS it", Parameters.None, + Results.ToExists); + + var exists = await indexExists(); + Expect.isFalse(exists, "The index should not exist already"); + + await conn.EnsureTable("ensured"); + await conn.EnsureDocumentIndex("ensured", DocumentIndex.Optimized); + exists = await indexExists(); + Expect.isTrue(exists, "The index should now exist"); + }), + TestCase("EnsureFieldIndex succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var indexExists = () => conn.CustomScalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_test') AS it", Parameters.None, + Results.ToExists); + + var exists = await indexExists(); + Expect.isFalse(exists, "The index should not exist already"); + + await conn.EnsureTable("ensured"); + await conn.EnsureFieldIndex("ensured", "test", new[] { "Id", "Category" }); + exists = await indexExists(); + Expect.isTrue(exists, "The index should now exist"); + }), + TestList("Insert", new[] + { + TestCase("succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should be no documents in the table"); + + await conn.Insert(PostgresDb.TableName, + new JsonDocument { Id = "turkey", Sub = new() { Foo = "gobble", Bar = "gobble" } }); + var after = await conn.FindAll(PostgresDb.TableName); + Expect.equal(after.Count, 1, "There should have been one document inserted"); + }), + TestCase("fails for duplicate key", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await conn.Insert(PostgresDb.TableName, new JsonDocument { Id = "test" }); + try + { + await conn.Insert(PostgresDb.TableName, new JsonDocument { Id = "test" }); + Expect.isTrue(false, "An exception should have been raised for duplicate document ID insert"); + } + catch (Exception) + { + // This is what should have happened + } + }) + }), + TestList("save", new[] + { + TestCase("succeeds when a document is inserted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should be no documents in the table"); + + await conn.Save(PostgresDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + var after = await conn.FindAll(PostgresDb.TableName); + Expect.equal(after.Count, 1, "There should have been one document inserted"); + }), + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await conn.Insert(PostgresDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + + var before = await conn.FindById(PostgresDb.TableName, "test"); + Expect.isNotNull(before, "There should have been a document returned"); + Expect.equal(before.Id, "test", "The document is not correct"); + + await conn.Save(PostgresDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "c", Bar = "d" } }); + var after = await conn.FindById(PostgresDb.TableName, "test"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Sub!.Foo, "c", "The updated document is not correct"); + }) + }), + TestCase("CountAll succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var theCount = await conn.CountAll(PostgresDb.TableName); + Expect.equal(theCount, 5, "There should have been 5 matching documents"); + }), + TestCase("CountByField succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var theCount = await conn.CountByField(PostgresDb.TableName, "Value", Op.EQ, "purple"); + Expect.equal(theCount, 2, "There should have been 2 matching documents"); + }), + TestCase("CountByContains succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var theCount = await conn.CountByContains(PostgresDb.TableName, new { Value = "purple" }); + Expect.equal(theCount, 2, "There should have been 2 matching documents"); + }), + TestCase("CountByJsonPath succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var theCount = await conn.CountByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 5)"); + Expect.equal(theCount, 3, "There should have been 3 matching documents"); + }), + TestList("ExistsById", new[] + { + TestCase("succeeds when a document exists", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsById(PostgresDb.TableName, "three"); + Expect.isTrue(exists, "There should have been an existing document"); + }), + TestCase("succeeds when a document does not exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsById(PostgresDb.TableName, "seven"); + Expect.isFalse(exists, "There should not have been an existing document"); + }) + }), + TestList("ExistsByField", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsByField(PostgresDb.TableName, "Sub", Op.EX, ""); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when documents do not exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsByField(PostgresDb.TableName, "NumValue", Op.EQ, "six"); + Expect.isFalse(exists, "There should not have been existing documents"); + }) + }), + TestList("ExistsByContains", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsByContains(PostgresDb.TableName, new { NumValue = 10 }); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when no matching documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsByContains(PostgresDb.TableName, new { Nothing = "none" }); + Expect.isFalse(exists, "There should not have been any existing documents"); + }) + }), + TestList("ExistsByJsonPath", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsByJsonPath(PostgresDb.TableName, "$.Sub.Foo ? (@ == \"green\")"); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when no matching documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var exists = await conn.ExistsByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 1000)"); + Expect.isFalse(exists, "There should not have been any existing documents"); + }) + }), + TestList("FindAll", new[] + { + TestCase("succeeds when there is data", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + + await conn.Insert(PostgresDb.TableName, new JsonDocument { Id = "one" }); + await conn.Insert(PostgresDb.TableName, new JsonDocument { Id = "three" }); + await conn.Insert(PostgresDb.TableName, new JsonDocument { Id = "five" }); + + var results = await conn.FindAll(PostgresDb.TableName); + Expect.equal(results.Count, 3, "There should have been 3 documents returned"); + }), + TestCase("succeeds when there is no data", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var results = await conn.FindAll(PostgresDb.TableName); + Expect.isEmpty(results, "There should have been no documents returned"); + }) + }), + TestList("FindById", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindById(PostgresDb.TableName, "two"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindById(PostgresDb.TableName, "three hundred eighty-seven"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("FindByField", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.FindByField(PostgresDb.TableName, "Value", Op.EQ, "another"); + Expect.equal(docs.Count, 1, "There should have been one document returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.FindByField(PostgresDb.TableName, "Value", Op.EQ, "mauve"); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("FindByContains", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.FindByContains(PostgresDb.TableName, + new { Sub = new { Foo = "green" } }); + Expect.equal(docs.Count, 2, "There should have been two documents returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.FindByContains(PostgresDb.TableName, new { Value = "mauve" }); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("FindByJsonPath", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.FindByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ < 15)"); + Expect.equal(docs.Count, 3, "There should have been 3 documents returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var docs = await conn.FindByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ < 0)"); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("FindFirstByField", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByField(PostgresDb.TableName, "Value", Op.EQ, "another"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByField(PostgresDb.TableName, "Value", Op.EQ, "purple"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "five", "four" }, doc.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByField(PostgresDb.TableName, "Value", Op.EQ, "absent"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("FindFirstByContains", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByContains(PostgresDb.TableName, new { Value = "another" }); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByContains(PostgresDb.TableName, + new { Sub = new { Foo = "green" } }); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "two", "four" }, doc.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByContains(PostgresDb.TableName, new { Value = "absent" }); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("FindFirstByJsonPath", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByJsonPath(PostgresDb.TableName, + "$.Value ? (@ == \"FIRST!\")"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "one", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByJsonPath(PostgresDb.TableName, + "$.Sub.Foo ? (@ == \"green\")"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "two", "four" }, doc.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + var doc = await conn.FindFirstByJsonPath(PostgresDb.TableName, "$.Id ? (@ == \"nope\")"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("UpdateById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.UpdateById(PostgresDb.TableName, "one", + new JsonDocument { Id = "one", Sub = new() { Foo = "blue", Bar = "red" } }); + var after = await conn.FindById(PostgresDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is not correct (ID)"); + Expect.equal(after.Value, "", "The updated document is not correct (Value)"); + Expect.equal(after.NumValue, 0, "The updated document is not correct (NumValue)"); + Expect.isNotNull(after.Sub, "The updated document should have had a sub-document"); + Expect.equal(after.Sub!.Foo, "blue", "The updated document is not correct (Sub.Foo)"); + Expect.equal(after.Sub.Bar, "red", "The updated document is not correct (Sub.Bar)"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.UpdateById(PostgresDb.TableName, "test", + new JsonDocument { Id = "x", Sub = new() { Foo = "blue", Bar = "red" } }); + }) + }), + TestList("UpdateByFunc", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.UpdateByFunc(PostgresDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + var after = await conn.FindById(PostgresDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is not correct (ID)"); + Expect.equal(after.Value, "le un", "The updated document is not correct (Value)"); + Expect.equal(after.NumValue, 1, "The updated document is not correct (NumValue)"); + Expect.isNull(after.Sub, "The updated document should not have had a sub-document"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.UpdateByFunc(PostgresDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + }) + }), + TestList("PatchById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.PatchById(PostgresDb.TableName, "one", new { NumValue = 44 }); + var after = await conn.FindById(PostgresDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.NumValue, 44, "The updated document is not correct"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.PatchById(PostgresDb.TableName, "test", new { Foo = "green" }); + }) + }), + TestList("PatchByField", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.PatchByField(PostgresDb.TableName, "Value", Op.EQ, "purple", new { NumValue = 77 }); + var after = await conn.CountByField(PostgresDb.TableName, "NumValue", Op.EQ, "77"); + Expect.equal(after, 2, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.PatchByField(PostgresDb.TableName, "Value", Op.EQ, "burgundy", new { Foo = "green" }); + }) + }), + TestList("PatchByContains", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.PatchByContains(PostgresDb.TableName, new { Value = "purple" }, new { NumValue = 77 }); + var after = await conn.CountByContains(PostgresDb.TableName, new { NumValue = 77 }); + Expect.equal(after, 2, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.PatchByContains(PostgresDb.TableName, new { Value = "burgundy" }, new { Foo = "green" }); + }) + }), + TestList("PatchByJsonPath", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.PatchByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 10)", new { NumValue = 1000 }); + var after = await conn.CountByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 999)"); + Expect.equal(after, 2, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + var before = await conn.CountAll(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.PatchByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ < 0)", new { Foo = "green" }); + }) + }), + TestList("DeleteById", new[] + { + TestCase("succeeds when a document is deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteById(PostgresDb.TableName, "four"); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 4, "There should have been 4 documents remaining"); + }), + TestCase("succeeds when a document is not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteById(PostgresDb.TableName, "thirty"); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + TestList("DeleteByField", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteByField(PostgresDb.TableName, "Value", Op.NE, "purple"); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 2, "There should have been 2 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteByField(PostgresDb.TableName, "Value", Op.EQ, "crimson"); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + TestList("DeleteByContains", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteByContains(PostgresDb.TableName, new { Value = "purple" }); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 3, "There should have been 3 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteByContains(PostgresDb.TableName, new { Value = "crimson" }); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + TestList("DeleteByJsonPath", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteByJsonPath(PostgresDb.TableName, "$.Sub.Foo ? (@ == \"green\")"); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 3, "There should have been 3 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await using var conn = MkConn(db); + await LoadDocs(); + + await conn.DeleteByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 100)"); + var remaining = await conn.CountAll(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + }); +} diff --git a/src/Tests.CSharp/PostgresCSharpTests.cs b/src/Tests.CSharp/PostgresCSharpTests.cs new file mode 100644 index 0000000..a4713c7 --- /dev/null +++ b/src/Tests.CSharp/PostgresCSharpTests.cs @@ -0,0 +1,970 @@ +using Expecto.CSharp; +using Expecto; +using BitBadger.Documents.Postgres; +using ThrowawayDb.Postgres; + +namespace BitBadger.Documents.Tests.CSharp; + +using static CommonExtensionsAndTypesForNpgsqlFSharp; +using static Runner; + +/// +/// C# tests for the PostgreSQL implementation of BitBadger.Documents +/// +public class PostgresCSharpTests +{ + /// + /// Tests which do not hit the database + /// + private static readonly Test Unit = TestList("Unit", new[] + { + TestList("Parameters", new[] + { + TestCase("Id succeeds", () => + { + var it = Parameters.Id(88); + Expect.equal(it.Item1, "@id", "ID parameter not constructed correctly"); + Expect.equal(it.Item2, Sql.@string("88"), "ID parameter value incorrect"); + }), + TestCase("Json succeeds", () => + { + var it = Parameters.Json("@test", new { Something = "good" }); + Expect.equal(it.Item1, "@test", "JSON parameter not constructed correctly"); + Expect.equal(it.Item2, Sql.jsonb("{\"Something\":\"good\"}"), "JSON parameter value incorrect"); + }), + TestCase("Field succeeds", () => + { + var it = Parameters.Field(242); + Expect.equal(it.Item1, "@field", "Field parameter not constructed correctly"); + Expect.isTrue(it.Item2.IsParameter, "Field parameter value incorrect"); + }), + TestCase("None succeeds", () => + { + Expect.isEmpty(Parameters.None, "The no-params sequence should be empty"); + }) + }), + TestList("Query", new[] + { + TestList("Definition", new[] + { + TestCase("EnsureTable succeeds", () => + { + Expect.equal(Postgres.Query.Definition.EnsureTable(PostgresDb.TableName), + $"CREATE TABLE IF NOT EXISTS {PostgresDb.TableName} (data JSONB NOT NULL)", + "CREATE TABLE statement not constructed correctly"); + }), + TestCase("EnsureDocumentIndex succeeds for full index", () => + { + Expect.equal(Postgres.Query.Definition.EnsureDocumentIndex("schema.tbl", DocumentIndex.Full), + "CREATE INDEX IF NOT EXISTS idx_tbl_document ON schema.tbl USING GIN (data)", + "CREATE INDEX statement not constructed correctly"); + }), + TestCase("EnsureDocumentIndex succeeds for JSONB Path Ops index", () => + { + Expect.equal( + Postgres.Query.Definition.EnsureDocumentIndex(PostgresDb.TableName, DocumentIndex.Optimized), + string.Format( + "CREATE INDEX IF NOT EXISTS idx_{0}_document ON {0} USING GIN (data jsonb_path_ops)", + PostgresDb.TableName), + "CREATE INDEX statement not constructed correctly"); + }) + }), + TestCase("WhereDataContains succeeds", () => + { + Expect.equal(Postgres.Query.WhereDataContains("@test"), "data @> @test", + "WHERE clause not correct"); + }), + TestCase("WhereJsonPathMatches succeeds", () => + { + Expect.equal(Postgres.Query.WhereJsonPathMatches("@path"), "data @? @path::jsonpath", + "WHERE clause not correct"); + }), + TestList("Count", new[] + { + TestCase("ByContains succeeds", () => + { + Expect.equal(Postgres.Query.Count.ByContains(PostgresDb.TableName), + $"SELECT COUNT(*) AS it FROM {PostgresDb.TableName} WHERE data @> @criteria", + "JSON containment count query not correct"); + }), + TestCase("ByJsonPath succeeds", () => + { + Expect.equal(Postgres.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", new[] + { + TestCase("ByContains succeeds", () => + { + Expect.equal(Postgres.Query.Exists.ByContains(PostgresDb.TableName), + $"SELECT EXISTS (SELECT 1 FROM {PostgresDb.TableName} WHERE data @> @criteria) AS it", + "JSON containment exists query not correct"); + }), + TestCase("byJsonPath succeeds", () => + { + Expect.equal(Postgres.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", new[] + { + TestCase("byContains succeeds", () => + { + Expect.equal(Postgres.Query.Find.ByContains(PostgresDb.TableName), + $"SELECT data FROM {PostgresDb.TableName} WHERE data @> @criteria", + "SELECT by JSON containment query not correct"); + }), + TestCase("byJsonPath succeeds", () => + { + Expect.equal(Postgres.Query.Find.ByJsonPath(PostgresDb.TableName), + $"SELECT data FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath", + "SELECT by JSON Path match query not correct"); + }) + }), + TestList("Patch", new[] + { + TestCase("ById succeeds", () => + { + Expect.equal(Postgres.Query.Patch.ById(PostgresDb.TableName), + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data ->> 'Id' = @id", + "UPDATE partial by ID statement not correct"); + }), + TestCase("ByField succeeds", () => + { + Expect.equal(Postgres.Query.Patch.ByField(PostgresDb.TableName, "Snail", Op.LT), + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data ->> 'Snail' < @field", + "UPDATE partial by ID statement not correct"); + }), + TestCase("ByContains succeeds", () => + { + Expect.equal(Postgres.Query.Patch.ByContains(PostgresDb.TableName), + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data @> @criteria", + "UPDATE partial by JSON containment statement not correct"); + }), + TestCase("ByJsonPath succeeds", () => + { + Expect.equal(Postgres.Query.Patch.ByJsonPath(PostgresDb.TableName), + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data @? @path::jsonpath", + "UPDATE partial by JSON Path statement not correct"); + }) + }), + TestList("Delete", new[] + { + TestCase("byContains succeeds", () => + { + Expect.equal(Postgres.Query.Delete.ByContains(PostgresDb.TableName), + $"DELETE FROM {PostgresDb.TableName} WHERE data @> @criteria", + "DELETE by JSON containment query not correct"); + }), + TestCase("byJsonPath succeeds", () => + { + Expect.equal(Postgres.Query.Delete.ByJsonPath(PostgresDb.TableName), + $"DELETE FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath", + "DELETE by JSON Path match query not correct"); + }) + }) + }) + }); + + private static readonly List TestDocuments = new() + { + new() { Id = "one", Value = "FIRST!", NumValue = 0 }, + new() { Id = "two", Value = "another", NumValue = 10, Sub = new() { Foo = "green", Bar = "blue" } }, + new() { Id = "three", Value = "", NumValue = 4 }, + new() { Id = "four", Value = "purple", NumValue = 17, Sub = new() { Foo = "green", Bar = "red" } }, + new() { Id = "five", Value = "purple", NumValue = 18 } + }; + + /// + /// Add the test documents to the database + /// + internal static async Task LoadDocs() + { + foreach (var doc in TestDocuments) await Document.Insert(SqliteDb.TableName, doc); + } + + /// + /// Integration tests for the PostgreSQL library + /// + private static readonly Test Integration = TestList("Integration", new[] + { + TestList("Configuration", new[] + { + TestCase("UseDataSource disposes existing source", () => + { + using var db1 = ThrowawayDatabase.Create(PostgresDb.ConnStr.Value); + var source = PostgresDb.MkDataSource(db1.ConnectionString); + Postgres.Configuration.UseDataSource(source); + + using var db2 = ThrowawayDatabase.Create(PostgresDb.ConnStr.Value); + Postgres.Configuration.UseDataSource(PostgresDb.MkDataSource(db2.ConnectionString)); + try + { + _ = source.OpenConnection(); + Expect.isTrue(false, "Data source should have been disposed"); + } + catch (Exception) + { + // This is what should have happened + } + }), + TestCase("DataSource returns configured data source", () => + { + using var db = ThrowawayDatabase.Create(PostgresDb.ConnStr.Value); + var source = PostgresDb.MkDataSource(db.ConnectionString); + Postgres.Configuration.UseDataSource(source); + + Expect.isTrue(ReferenceEquals(source, Postgres.Configuration.DataSource()), + "Data source should have been the same"); + }) + }), + TestList("Custom", new[] + { + TestList("List", new[] + { + TestCase("succeeds when data is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Custom.List(Query.SelectFromTable(PostgresDb.TableName), Parameters.None, + Results.FromData); + Expect.equal(docs.Count, 5, "There should have been 5 documents returned"); + }), + TestCase("succeeds when data is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Custom.List( + $"SELECT data FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath", + new[] { Tuple.Create("@path", Sql.@string("$.NumValue ? (@ > 100)")) }, + Results.FromData); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("Single", new[] + { + TestCase("succeeds when a row is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Custom.Single($"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Tuple.Create("@id", Sql.@string("one")) }, Results.FromData); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "one", "The incorrect document was returned"); + }), + TestCase("succeeds when a row is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Custom.Single($"SELECT data FROM {PostgresDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Tuple.Create("@id", Sql.@string("eighty")) }, Results.FromData); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("NonQuery", new[] + { + TestCase("succeeds when operating on data", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Custom.NonQuery($"DELETE FROM {PostgresDb.TableName}", Parameters.None); + + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 0, "There should be no documents remaining in the table"); + }), + TestCase("succeeds when no data matches where clause", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Custom.NonQuery($"DELETE FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath", + new[] { Tuple.Create("@path", Sql.@string("$.NumValue ? (@ > 100)")) }); + + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should be 5 documents remaining in the table"); + }) + }), + TestCase("Scalar succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + + var nbr = await Custom.Scalar("SELECT 5 AS test_value", Parameters.None, row => row.@int("test_value")); + Expect.equal(nbr, 5, "The query should have returned the number 5"); + }) + }), + TestList("Definition", new[] + { + TestCase("EnsureTable succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + var tableExists = () => Custom.Scalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'ensured') AS it", Parameters.None, + Results.ToExists); + var keyExists = () => Custom.Scalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_key') AS it", Parameters.None, + Results.ToExists); + + var exists = await tableExists(); + var alsoExists = await keyExists(); + Expect.isFalse(exists, "The table should not exist already"); + Expect.isFalse(alsoExists, "The key index should not exist already"); + + await Definition.EnsureTable("ensured"); + exists = await tableExists(); + alsoExists = await keyExists(); + Expect.isTrue(exists, "The table should now exist"); + Expect.isTrue(alsoExists, "The key index should now exist"); + }), + TestCase("EnsureDocumentIndex succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + var indexExists = () => Custom.Scalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_document') AS it", + Parameters.None, Results.ToExists); + + var exists = await indexExists(); + Expect.isFalse(exists, "The index should not exist already"); + + await Definition.EnsureTable("ensured"); + await Definition.EnsureDocumentIndex("ensured", DocumentIndex.Optimized); + exists = await indexExists(); + Expect.isTrue(exists, "The index should now exist"); + }), + TestCase("EnsureFieldIndex succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + var indexExists = () => Custom.Scalar( + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_test') AS it", Parameters.None, + Results.ToExists); + + var exists = await indexExists(); + Expect.isFalse(exists, "The index should not exist already"); + + await Definition.EnsureTable("ensured"); + await Definition.EnsureFieldIndex("ensured", "test", new[] { "Id", "Category" }); + exists = await indexExists(); + Expect.isTrue(exists, "The index should now exist"); + }) + }), + TestList("Document", new[] + { + TestList("Insert", new[] + { + TestCase("succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should be no documents in the table"); + + await Document.Insert(PostgresDb.TableName, + new JsonDocument { Id = "turkey", Sub = new() { Foo = "gobble", Bar = "gobble" } }); + var after = await Count.All(PostgresDb.TableName); + Expect.equal(after, 1, "There should have been one document inserted"); + }), + TestCase("fails for duplicate key", async () => + { + await using var db = PostgresDb.BuildDb(); + await Document.Insert(PostgresDb.TableName, new JsonDocument { Id = "test" }); + try + { + await Document.Insert(PostgresDb.TableName, new JsonDocument { Id = "test" }); + Expect.isTrue(false, "An exception should have been raised for duplicate document ID insert"); + } + catch (Exception) + { + // This is what should have happened + } + }) + }), + TestList("Save", new[] + { + TestCase("succeeds when a document is inserted", async () => + { + await using var db = PostgresDb.BuildDb(); + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should be no documents in the table"); + + await Document.Save(PostgresDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + var after = await Count.All(PostgresDb.TableName); + Expect.equal(after, 1, "There should have been one document inserted"); + }), + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await Document.Insert(PostgresDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + + var before = await Find.ById(PostgresDb.TableName, "test"); + Expect.isNotNull(before, "There should have been a document returned"); + Expect.equal(before.Id, "test", "The document is not correct"); + + before.Sub = new() { Foo = "c", Bar = "d" }; + await Document.Save(PostgresDb.TableName, before); + var after = await Find.ById(PostgresDb.TableName, "test"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "test", "The document is not correct"); + Expect.equal(after.Sub!.Foo, "c", "The updated document is not correct"); + }) + }) + }), + TestList("Count", new[] + { + TestCase("All succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var theCount = await Count.All(PostgresDb.TableName); + Expect.equal(theCount, 5, "There should have been 5 matching documents"); + }), + TestCase("ByField succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var theCount = await Count.ByField(PostgresDb.TableName, "Value", Op.EQ, "purple"); + Expect.equal(theCount, 2, "There should have been 2 matching documents"); + }), + TestCase("ByContains succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var theCount = await Count.ByContains(PostgresDb.TableName, new { Value = "purple" }); + Expect.equal(theCount, 2, "There should have been 2 matching documents"); + }), + TestCase("ByJsonPath succeeds", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var theCount = await Count.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 5)"); + Expect.equal(theCount, 3, "There should have been 3 matching documents"); + }) + }), + TestList("Exists", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document exists", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ById(PostgresDb.TableName, "three"); + Expect.isTrue(exists, "There should have been an existing document"); + }), + TestCase("succeeds when a document does not exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ById(PostgresDb.TableName, "seven"); + Expect.isFalse(exists, "There should not have been an existing document"); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByField(PostgresDb.TableName, "Sub", Op.NEX, ""); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when documents do not exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByField(PostgresDb.TableName, "NumValue", Op.EQ, "six"); + Expect.isFalse(exists, "There should not have been existing documents"); + }) + }), + TestList("ByContains", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByContains(PostgresDb.TableName, new { NumValue = 10 }); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when no matching documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByContains(PostgresDb.TableName, new { Nothing = "none" }); + Expect.isFalse(exists, "There should not have been any existing documents"); + }) + }), + TestList("ByJsonPath", new[] { + TestCase("succeeds when documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByJsonPath(PostgresDb.TableName, "$.Sub.Foo ? (@ == \"green\")"); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when no matching documents exist", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 1000)"); + Expect.isFalse(exists, "There should not have been any existing documents"); + }) + }) + }), + TestList("Find", new[] + { + TestList("All", new[] + { + TestCase("succeeds when there is data", async () => + { + await using var db = PostgresDb.BuildDb(); + + await Document.Insert(PostgresDb.TableName, new SubDocument { Foo = "one", Bar = "two" }); + await Document.Insert(PostgresDb.TableName, new SubDocument { Foo = "three", Bar = "four" }); + await Document.Insert(PostgresDb.TableName, new SubDocument { Foo = "five", Bar = "six" }); + + var results = await Find.All(PostgresDb.TableName); + Expect.equal(results.Count, 3, "There should have been 3 documents returned"); + }), + TestCase("succeeds when there is no data", async () => + { + await using var db = PostgresDb.BuildDb(); + var results = await Find.All(PostgresDb.TableName); + Expect.isEmpty(results, "There should have been no documents returned"); + }) + }), + TestList("ById", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.ById(PostgresDb.TableName, "two"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.ById(PostgresDb.TableName, "three hundred eighty-seven"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByField(PostgresDb.TableName, "Value", Op.EQ, "another"); + Expect.equal(docs.Count, 1, "There should have been one document returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByField(PostgresDb.TableName, "Value", Op.EQ, "mauve"); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("ByContains", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByContains(PostgresDb.TableName, + new { Sub = new { Foo = "green" } }); + Expect.equal(docs.Count, 2, "There should have been two documents returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByContains(PostgresDb.TableName, new { Value = "mauve" }); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("ByJsonPath", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ < 15)"); + Expect.equal(docs.Count, 3, "There should have been 3 documents returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ < 0)"); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("FirstByField", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByField(PostgresDb.TableName, "Value", Op.EQ, "another"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByField(PostgresDb.TableName, "Value", Op.EQ, "purple"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "five", "four" }, doc.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByField(PostgresDb.TableName, "Value", Op.EQ, "absent"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("FirstByContains", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByContains(PostgresDb.TableName, new { Value = "another" }); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByContains(PostgresDb.TableName, + new { Sub = new { Foo = "green" } }); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "two", "four" }, doc.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByContains(PostgresDb.TableName, new { Value = "absent" }); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("FirstByJsonPath", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByJsonPath(PostgresDb.TableName, + "$.Value ? (@ == \"FIRST!\")"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc.Id, "one", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByJsonPath(PostgresDb.TableName, + "$.Sub.Foo ? (@ == \"green\")"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "two", "four" }, doc.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByJsonPath(PostgresDb.TableName, "$.Id ? (@ == \"nope\")"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }) + }), + TestList("Update", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Update.ById(PostgresDb.TableName, "one", + new JsonDocument { Id = "one", Sub = new() { Foo = "blue", Bar = "red" } }); + var after = await Find.ById(PostgresDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is not correct (ID)"); + Expect.equal(after.Value, "", "The updated document is not correct (Value)"); + Expect.equal(after.NumValue, 0, "The updated document is not correct (NumValue)"); + Expect.isNotNull(after.Sub, "The updated document should have had a sub-document"); + Expect.equal(after.Sub!.Foo, "blue", "The updated document is not correct (Sub.Foo)"); + Expect.equal(after.Sub.Bar, "red", "The updated document is not correct (Sub.Bar)"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await Update.ById(PostgresDb.TableName, "test", + new JsonDocument { Id = "x", Sub = new() { Foo = "blue", Bar = "red" } }); + }) + }), + TestList("ByFunc", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Update.ByFunc(PostgresDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + var after = await Find.ById(PostgresDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is not correct (ID)"); + Expect.equal(after.Value, "le un", "The updated document is not correct (Value)"); + Expect.equal(after.NumValue, 1, "The updated document is not correct (NumValue)"); + Expect.isNull(after.Sub, "The updated document should not have had a sub-document"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await Update.ByFunc(PostgresDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + }) + }) + }), + TestList("Patch", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Patch.ById(PostgresDb.TableName, "one", new { NumValue = 44 }); + var after = await Find.ById(PostgresDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.NumValue, 44, "The updated document is not correct"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await Patch.ById(PostgresDb.TableName, "test", new { Foo = "green" }); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Patch.ByField(PostgresDb.TableName, "Value", Op.EQ, "purple", new { NumValue = 77 }); + var after = await Count.ByField(PostgresDb.TableName, "NumValue", Op.EQ, "77"); + Expect.equal(after, 2, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await Patch.ByField(PostgresDb.TableName, "Value", Op.EQ, "burgundy", new { Foo = "green" }); + }) + }), + TestList("ByContains", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Patch.ByContains(PostgresDb.TableName, new { Value = "purple" }, new { NumValue = 77 }); + var after = await Count.ByContains(PostgresDb.TableName, new { NumValue = 77 }); + Expect.equal(after, 2, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await Patch.ByContains(PostgresDb.TableName, new { Value = "burgundy" }, new { Foo = "green" }); + }) + }), + TestList("ByJsonPath", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Patch.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 10)", new { NumValue = 1000 }); + var after = await Count.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 999)"); + Expect.equal(after, 2, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = PostgresDb.BuildDb(); + + var before = await Count.All(PostgresDb.TableName); + Expect.equal(before, 0, "There should have been no documents returned"); + + // This not raising an exception is the test + await Patch.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ < 0)", new { Foo = "green" }); + }) + }) + }), + TestList("Delete", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document is deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ById(PostgresDb.TableName, "four"); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 4, "There should have been 4 documents remaining"); + }), + TestCase("succeeds when a document is not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ById(PostgresDb.TableName, "thirty"); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ByField(PostgresDb.TableName, "Value", Op.EQ, "purple"); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 3, "There should have been 3 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ByField(PostgresDb.TableName, "Value", Op.EQ, "crimson"); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + TestList("ByContains", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ByContains(PostgresDb.TableName, new { Value = "purple" }); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 3, "There should have been 3 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ByContains(PostgresDb.TableName, new { Value = "crimson" }); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }), + TestList("ByJsonPath", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ByJsonPath(PostgresDb.TableName, "$.Sub.Foo ? (@ == \"green\")"); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 3, "There should have been 3 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = PostgresDb.BuildDb(); + await LoadDocs(); + + await Delete.ByJsonPath(PostgresDb.TableName, "$.NumValue ? (@ > 100)"); + var remaining = await Count.All(PostgresDb.TableName); + Expect.equal(remaining, 5, "There should have been 5 documents remaining"); + }) + }) + }) + }); + + /// + /// All Postgres C# tests + /// + [Tests] + public static readonly Test All = TestList("Postgres.C#", new[] { Unit, TestSequenced(Integration) }); +} diff --git a/src/Tests.CSharp/PostgresDb.cs b/src/Tests.CSharp/PostgresDb.cs new file mode 100644 index 0000000..f695a04 --- /dev/null +++ b/src/Tests.CSharp/PostgresDb.cs @@ -0,0 +1,150 @@ +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; + + /// + /// The connection string for the throwaway database + /// + public string ConnectionString => _db.ConnectionString; + + /// + /// Constructor + /// + /// The throwaway database which this instance will wrap + 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(Postgres.Query.Definition.EnsureTable(TableName), sqlProps)); + Sql.executeNonQuery(Sql.query(Query.Definition.EnsureKey(TableName), sqlProps)); + + Postgres.Configuration.UseDataSource(MkDataSource(database.ConnectionString)); + + return new ThrowawayPostgresDb(database); + } +} diff --git a/src/Tests.CSharp/SqliteCSharpExtensionTests.cs b/src/Tests.CSharp/SqliteCSharpExtensionTests.cs new file mode 100644 index 0000000..cc5fb5f --- /dev/null +++ b/src/Tests.CSharp/SqliteCSharpExtensionTests.cs @@ -0,0 +1,518 @@ +using Expecto.CSharp; +using Expecto; +using Microsoft.Data.Sqlite; +using BitBadger.Documents.Sqlite; + +namespace BitBadger.Documents.Tests.CSharp; + +using static Runner; + +/// +/// C# tests for the extensions on the SqliteConnection class +/// +public static class SqliteCSharpExtensionTests +{ + private static Task LoadDocs() => SqliteCSharpTests.LoadDocs(); + + /// + /// Integration tests for the SQLite extension methods + /// + [Tests] + public static readonly Test Integration = TestList("Sqlite.C#.Extensions", new[] + { + TestList("CustomSingle", new[] + { + TestCase("succeeds when a row is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.CustomSingle($"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Parameters.Id("one") }, Results.FromData); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc!.Id, "one", "The incorrect document was returned"); + }), + TestCase("succeeds when a row is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.CustomSingle($"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Parameters.Id("eighty") }, Results.FromData); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("CustomList", new[] + { + TestCase("succeeds when data is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var docs = await conn.CustomList(Query.SelectFromTable(SqliteDb.TableName), Parameters.None, + Results.FromData); + Expect.equal(docs.Count, 5, "There should have been 5 documents returned"); + }), + TestCase("succeeds when data is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var docs = await conn.CustomList( + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value", + new[] { new SqliteParameter("@value", 100) }, Results.FromData); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("CustomNonQuery", new[] + { + TestCase("succeeds when operating on data", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.CustomNonQuery($"DELETE FROM {SqliteDb.TableName}", Parameters.None); + + var remaining = await conn.CountAll(SqliteDb.TableName); + Expect.equal(remaining, 0L, "There should be no documents remaining in the table"); + }), + TestCase("succeeds when no data matches where clause", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.CustomNonQuery($"DELETE FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value", + new[] { new SqliteParameter("@value", 100) }); + + var remaining = await conn.CountAll(SqliteDb.TableName); + Expect.equal(remaining, 5L, "There should be 5 documents remaining in the table"); + }) + }), + TestCase("CustomScalar succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + + var nbr = await conn.CustomScalar("SELECT 5 AS test_value", Parameters.None, rdr => rdr.GetInt32(0)); + Expect.equal(nbr, 5, "The query should have returned the number 5"); + }), + TestCase("EnsureTable succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + + Func> itExists = async name => + await conn.CustomScalar( + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = @name) AS it", + new SqliteParameter[] { new("@name", name) }, Results.ToExists); + + var exists = await itExists("ensured"); + var alsoExists = await itExists("idx_ensured_key"); + Expect.isFalse(exists, "The table should not exist already"); + Expect.isFalse(alsoExists, "The key index should not exist already"); + + await conn.EnsureTable("ensured"); + + exists = await itExists("ensured"); + alsoExists = await itExists("idx_ensured_key"); + Expect.isTrue(exists, "The table should now exist"); + Expect.isTrue(alsoExists, "The key index should now exist"); + }), + TestCase("EnsureFieldIndex succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var indexExists = () => conn.CustomScalar( + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = 'idx_ensured_test') AS it", + Parameters.None, Results.ToExists); + + var exists = await indexExists(); + Expect.isFalse(exists, "The index should not exist already"); + + await conn.EnsureTable("ensured"); + await conn.EnsureFieldIndex("ensured", "test", new[] { "Id", "Category" }); + exists = await indexExists(); + Expect.isTrue(exists, "The index should now exist"); + }), + TestList("Insert", new[] + { + TestCase("succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var before = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(before, "There should be no documents in the table"); + await conn.Insert(SqliteDb.TableName, + new JsonDocument { Id = "turkey", Sub = new() { Foo = "gobble", Bar = "gobble" } }); + var after = await conn.FindAll(SqliteDb.TableName); + Expect.equal(after.Count, 1, "There should have been one document inserted"); + }), + TestCase("fails for duplicate key", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await conn.Insert(SqliteDb.TableName, new JsonDocument { Id = "test" }); + try + { + await Document.Insert(SqliteDb.TableName, new JsonDocument { Id = "test" }); + Expect.isTrue(false, "An exception should have been raised for duplicate document ID insert"); + } + catch (Exception) + { + // This is what is supposed to happen + } + }) + }), + TestList("Save", new[] + { + TestCase("succeeds when a document is inserted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var before = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(before, "There should be no documents in the table"); + + await conn.Save(SqliteDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + var after = await conn.FindAll(SqliteDb.TableName); + Expect.equal(after.Count, 1, "There should have been one document inserted"); + }), + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await conn.Insert(SqliteDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + + var before = await conn.FindById(SqliteDb.TableName, "test"); + Expect.isNotNull(before, "There should have been a document returned"); + Expect.equal(before!.Id, "test", "The document is not correct"); + Expect.isNotNull(before.Sub, "There should have been a sub-document"); + Expect.equal(before.Sub!.Foo, "a", "The document is not correct"); + Expect.equal(before.Sub.Bar, "b", "The document is not correct"); + + await conn.Save(SqliteDb.TableName, new JsonDocument { Id = "test" }); + var after = await conn.FindById(SqliteDb.TableName, "test"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after!.Id, "test", "The updated document is not correct"); + Expect.isNull(after.Sub, "There should not have been a sub-document in the updated document"); + }) + }), + TestCase("CountAll succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var theCount = await conn.CountAll(SqliteDb.TableName); + Expect.equal(theCount, 5L, "There should have been 5 matching documents"); + }), + TestCase("CountByField succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var theCount = await conn.CountByField(SqliteDb.TableName, "Value", Op.EQ, "purple"); + Expect.equal(theCount, 2L, "There should have been 2 matching documents"); + }), + TestList("ExistsById", new[] + { + TestCase("succeeds when a document exists", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var exists = await conn.ExistsById(SqliteDb.TableName, "three"); + Expect.isTrue(exists, "There should have been an existing document"); + }), + TestCase("succeeds when a document does not exist", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var exists = await conn.ExistsById(SqliteDb.TableName, "seven"); + Expect.isFalse(exists, "There should not have been an existing document"); + }) + }), + TestList("ExistsByField", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var exists = await conn.ExistsByField(SqliteDb.TableName, "NumValue", Op.GE, 10); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when no matching documents exist", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var exists = await conn.ExistsByField(SqliteDb.TableName, "Nothing", Op.EQ, "none"); + Expect.isFalse(exists, "There should not have been any existing documents"); + }) + }), + TestList("FindAll", new[] + { + TestCase("succeeds when there is data", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + + await conn.Insert(SqliteDb.TableName, new JsonDocument { Id = "one", Value = "two" }); + await conn.Insert(SqliteDb.TableName, new JsonDocument { Id = "three", Value = "four" }); + await conn.Insert(SqliteDb.TableName, new JsonDocument { Id = "five", Value = "six" }); + + var results = await conn.FindAll(SqliteDb.TableName); + Expect.equal(results.Count, 3, "There should have been 3 documents returned"); + }), + TestCase("succeeds when there is no data", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var results = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(results, "There should have been no documents returned"); + }) + }), + TestList("FindById", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.FindById(SqliteDb.TableName, "two"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc!.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.FindById(SqliteDb.TableName, "eighty-seven"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("FindByField", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var docs = await conn.FindByField(SqliteDb.TableName, "NumValue", Op.GT, 15); + Expect.equal(docs.Count, 2, "There should have been two documents returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var docs = await conn.FindByField(SqliteDb.TableName, "Value", Op.EQ, "mauve"); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("FindFirstByField", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.FindFirstByField(SqliteDb.TableName, "Value", Op.EQ, "another"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc!.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.FindFirstByField(SqliteDb.TableName, "Sub.Foo", Op.EQ, "green"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "two", "four" }, doc!.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var doc = await conn.FindFirstByField(SqliteDb.TableName, "Value", Op.EQ, "absent"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("UpdateById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + var testDoc = new JsonDocument { Id = "one", Sub = new() { Foo = "blue", Bar = "red" } }; + await conn.UpdateById(SqliteDb.TableName, "one", testDoc); + var after = await conn.FindById(SqliteDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is not correct"); + Expect.isNotNull(after.Sub, "The updated document should have had a sub-document"); + Expect.equal(after.Sub!.Foo, "blue", "The updated sub-document is not correct"); + Expect.equal(after.Sub.Bar, "red", "The updated sub-document is not correct"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var before = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.UpdateById(SqliteDb.TableName, "test", + new JsonDocument { Id = "x", Sub = new() { Foo = "blue", Bar = "red" } }); + }) + }), + TestList("UpdateByFunc", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.UpdateByFunc(SqliteDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + var after = await conn.FindById(SqliteDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is incorrect"); + Expect.equal(after.Value, "le un", "The updated document is incorrect"); + Expect.equal(after.NumValue, 1, "The updated document is incorrect"); + Expect.isNull(after.Sub, "The updated document should not have a sub-document"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var before = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.UpdateByFunc(SqliteDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + }) + }), + TestList("PatchById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.PatchById(SqliteDb.TableName, "one", new { NumValue = 44 }); + var after = await conn.FindById(SqliteDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after.Id, "one", "The updated document is not correct"); + Expect.equal(after.NumValue, 44, "The updated document is not correct"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var before = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.PatchById(SqliteDb.TableName, "test", new { Foo = "green" }); + }) + }), + TestList("PatchByField", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.PatchByField(SqliteDb.TableName, "Value", Op.EQ, "purple", new { NumValue = 77 }); + var after = await conn.CountByField(SqliteDb.TableName, "NumValue", Op.EQ, 77); + Expect.equal(after, 2L, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + var before = await conn.FindAll(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await conn.PatchByField(SqliteDb.TableName, "Value", Op.EQ, "burgundy", new { Foo = "green" }); + }) + }), + TestList("DeleteById", new[] + { + TestCase("succeeds when a document is deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.DeleteById(SqliteDb.TableName, "four"); + var remaining = await conn.CountAll(SqliteDb.TableName); + Expect.equal(remaining, 4L, "There should have been 4 documents remaining"); + }), + TestCase("succeeds when a document is not deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.DeleteById(SqliteDb.TableName, "thirty"); + var remaining = await conn.CountAll(SqliteDb.TableName); + Expect.equal(remaining, 5L, "There should have been 5 documents remaining"); + }) + }), + TestList("DeleteByField", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.DeleteByField(SqliteDb.TableName, "Value", Op.NE, "purple"); + var remaining = await conn.CountAll(SqliteDb.TableName); + Expect.equal(remaining, 2L, "There should have been 2 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await using var conn = Sqlite.Configuration.DbConn(); + await LoadDocs(); + + await conn.DeleteByField(SqliteDb.TableName, "Value", Op.EQ, "crimson"); + var remaining = await conn.CountAll(SqliteDb.TableName); + Expect.equal(remaining, 5L, "There should have been 5 documents remaining"); + }) + }), + TestCase("Clean up database", () => Sqlite.Configuration.UseConnectionString("data source=:memory:")) + }); +} diff --git a/src/Tests.CSharp/SqliteCSharpTests.cs b/src/Tests.CSharp/SqliteCSharpTests.cs new file mode 100644 index 0000000..f2e0621 --- /dev/null +++ b/src/Tests.CSharp/SqliteCSharpTests.cs @@ -0,0 +1,596 @@ +using Expecto.CSharp; +using Expecto; +using Microsoft.Data.Sqlite; +using Microsoft.FSharp.Core; +using BitBadger.Documents.Sqlite; + +namespace BitBadger.Documents.Tests.CSharp; + +using static Runner; + +/// +/// C# tests for the SQLite implementation of BitBadger.Documents +/// +public static class SqliteCSharpTests +{ + /// + /// Unit tests for the SQLite library + /// + private static readonly Test Unit = TestList("Unit", new[] + { + TestList("Query", new[] + { + TestCase("Definition.EnsureTable succeeds", () => + { + Expect.equal(Sqlite.Query.Definition.EnsureTable("tbl"), + "CREATE TABLE IF NOT EXISTS tbl (data TEXT NOT NULL)", "CREATE TABLE statement not correct"); + }), + TestList("Patch", new[] + { + TestCase("ById succeeds", () => + { + Expect.equal(Sqlite.Query.Patch.ById("tbl"), + "UPDATE tbl SET data = json_patch(data, json(@data)) WHERE data ->> 'Id' = @id", + "UPDATE partial by ID statement not correct"); + }), + TestCase("ByField succeeds", () => + { + Expect.equal(Sqlite.Query.Patch.ByField("tbl", "Part", Op.NE), + "UPDATE tbl SET data = json_patch(data, json(@data)) WHERE data ->> 'Part' <> @field", + "UPDATE partial by JSON comparison query not correct"); + }) + }), + }), + TestList("Parameters", new[] + { + TestCase("Id succeeds", () => + { + var theParam = Parameters.Id(7); + Expect.equal(theParam.ParameterName, "@id", "The parameter name is incorrect"); + Expect.equal(theParam.Value, "7", "The parameter value is incorrect"); + }), + TestCase("Json succeeds", () => + { + var theParam = Parameters.Json("@test", new { Nice = "job" }); + Expect.equal(theParam.ParameterName, "@test", "The parameter name is incorrect"); + Expect.equal(theParam.Value, "{\"Nice\":\"job\"}", "The parameter value is incorrect"); + }), + TestCase("Field succeeds", () => + { + var theParam = Parameters.Field(99); + Expect.equal(theParam.ParameterName, "@field", "The parameter name is incorrect"); + Expect.equal(theParam.Value, 99, "The parameter value is incorrect"); + }), + TestCase("None succeeds", () => + { + Expect.isEmpty(Parameters.None, "The parameter list should have been empty"); + }) + }) + // Results are exhaustively executed in the context of other tests + }); + + private static readonly List TestDocuments = new() + { + new() { Id = "one", Value = "FIRST!", NumValue = 0 }, + new() { Id = "two", Value = "another", NumValue = 10, Sub = new() { Foo = "green", Bar = "blue" } }, + new() { Id = "three", Value = "", NumValue = 4 }, + new() { Id = "four", Value = "purple", NumValue = 17, Sub = new() { Foo = "green", Bar = "red" } }, + new() { Id = "five", Value = "purple", NumValue = 18 } + }; + + /// + /// Add the test documents to the database + /// + internal static async Task LoadDocs() + { + foreach (var doc in TestDocuments) await Document.Insert(SqliteDb.TableName, doc); + } + + private static readonly Test Integration = TestList("Integration", new[] + { + TestCase("Configuration.UseConnectionString succeeds", () => + { + try + { + Sqlite.Configuration.UseConnectionString("Data Source=test.db"); + Expect.equal(Sqlite.Configuration.connectionString, + new FSharpOption("Data Source=test.db;Foreign Keys=True"), "Connection string incorrect"); + } + finally + { + Sqlite.Configuration.UseConnectionString("Data Source=:memory:"); + } + }), + TestList("Custom", new[] + { + TestList("Single", new[] + { + TestCase("succeeds when a row is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Custom.Single($"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Parameters.Id("one") }, Results.FromData); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc!.Id, "one", "The incorrect document was returned"); + }), + TestCase("succeeds when a row is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Custom.Single($"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id", + new[] { Parameters.Id("eighty") }, Results.FromData); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("List", new[] + { + TestCase("succeeds when data is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var docs = await Custom.List(Query.SelectFromTable(SqliteDb.TableName), Parameters.None, + Results.FromData); + Expect.equal(docs.Count, 5, "There should have been 5 documents returned"); + }), + TestCase("succeeds when data is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var docs = await Custom.List( + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value", + new[] { new SqliteParameter("@value", 100) }, Results.FromData); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("NonQuery", new[] + { + TestCase("succeeds when operating on data", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Custom.NonQuery($"DELETE FROM {SqliteDb.TableName}", Parameters.None); + + var remaining = await Count.All(SqliteDb.TableName); + Expect.equal(remaining, 0L, "There should be no documents remaining in the table"); + }), + TestCase("succeeds when no data matches where clause", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Custom.NonQuery($"DELETE FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value", + new[] { new SqliteParameter("@value", 100) }); + + var remaining = await Count.All(SqliteDb.TableName); + Expect.equal(remaining, 5L, "There should be 5 documents remaining in the table"); + }) + }), + TestCase("Scalar succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + + var nbr = await Custom.Scalar("SELECT 5 AS test_value", Parameters.None, rdr => rdr.GetInt32(0)); + Expect.equal(nbr, 5, "The query should have returned the number 5"); + }) + }), + TestList("Definition", new[] + { + TestCase("EnsureTable succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + + var exists = await ItExists("ensured"); + var alsoExists = await ItExists("idx_ensured_key"); + Expect.isFalse(exists, "The table should not exist already"); + Expect.isFalse(alsoExists, "The key index should not exist already"); + + await Definition.EnsureTable("ensured"); + + exists = await ItExists("ensured"); + alsoExists = await ItExists("idx_ensured_key"); + Expect.isTrue(exists, "The table should now exist"); + Expect.isTrue(alsoExists, "The key index should now exist"); + return; + + async ValueTask ItExists(string name) + { + return await Custom.Scalar( + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = @name) AS it", + new SqliteParameter[] { new("@name", name) }, Results.ToExists); + } + }), + TestCase("EnsureFieldIndex succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + var indexExists = () => Custom.Scalar( + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = 'idx_ensured_test') AS it", + Parameters.None, Results.ToExists); + + var exists = await indexExists(); + Expect.isFalse(exists, "The index should not exist already"); + + await Definition.EnsureTable("ensured"); + await Definition.EnsureFieldIndex("ensured", "test", new[] { "Id", "Category" }); + exists = await indexExists(); + Expect.isTrue(exists, "The index should now exist"); + }) + }), + TestList("Document.Insert", new[] + { + TestCase("succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + var before = await Find.All(SqliteDb.TableName); + Expect.isEmpty(before, "There should be no documents in the table"); + await Document.Insert(SqliteDb.TableName, + new JsonDocument { Id = "turkey", Sub = new() { Foo = "gobble", Bar = "gobble" } }); + var after = await Find.All(SqliteDb.TableName); + Expect.equal(after.Count, 1, "There should have been one document inserted"); + }), + TestCase("fails for duplicate key", async () => + { + await using var db = await SqliteDb.BuildDb(); + await Document.Insert(SqliteDb.TableName, new JsonDocument { Id = "test" }); + try + { + await Document.Insert(SqliteDb.TableName, new JsonDocument { Id = "test" }); + Expect.isTrue(false, "An exception should have been raised for duplicate document ID insert"); + } + catch (Exception) + { + // This is what is supposed to happen + } + }) + }), + TestList("Document.Save", new[] + { + TestCase("succeeds when a document is inserted", async () => + { + await using var db = await SqliteDb.BuildDb(); + var before = await Find.All(SqliteDb.TableName); + Expect.isEmpty(before, "There should be no documents in the table"); + + await Document.Save(SqliteDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + var after = await Find.All(SqliteDb.TableName); + Expect.equal(after.Count, 1, "There should have been one document inserted"); + }), + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await Document.Insert(SqliteDb.TableName, + new JsonDocument { Id = "test", Sub = new() { Foo = "a", Bar = "b" } }); + + var before = await Find.ById(SqliteDb.TableName, "test"); + Expect.isNotNull(before, "There should have been a document returned"); + Expect.equal(before!.Id, "test", "The document is not correct"); + Expect.isNotNull(before.Sub, "There should have been a sub-document"); + Expect.equal(before.Sub!.Foo, "a", "The document is not correct"); + Expect.equal(before.Sub.Bar, "b", "The document is not correct"); + + await Document.Save(SqliteDb.TableName, new JsonDocument { Id = "test" }); + var after = await Find.ById(SqliteDb.TableName, "test"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after!.Id, "test", "The updated document is not correct"); + Expect.isNull(after.Sub, "There should not have been a sub-document in the updated document"); + }) + }), + TestList("Count", new[] + { + TestCase("All succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var theCount = await Count.All(SqliteDb.TableName); + Expect.equal(theCount, 5L, "There should have been 5 matching documents"); + }), + TestCase("ByField succeeds", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var theCount = await Count.ByField(SqliteDb.TableName, "Value", Op.EQ, "purple"); + Expect.equal(theCount, 2L, "There should have been 2 matching documents"); + }) + }), + TestList("Exists", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document exists", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ById(SqliteDb.TableName, "three"); + Expect.isTrue(exists, "There should have been an existing document"); + }), + TestCase("succeeds when a document does not exist", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ById(SqliteDb.TableName, "seven"); + Expect.isFalse(exists, "There should not have been an existing document"); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when documents exist", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByField(SqliteDb.TableName, "NumValue", Op.GE, 10); + Expect.isTrue(exists, "There should have been existing documents"); + }), + TestCase("succeeds when no matching documents exist", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var exists = await Exists.ByField(SqliteDb.TableName, "Nothing", Op.EQ, "none"); + Expect.isFalse(exists, "There should not have been any existing documents"); + }) + }) + }), + TestList("Find", new[] + { + TestList("All", new[] + { + TestCase("succeeds when there is data", async () => + { + await using var db = await SqliteDb.BuildDb(); + + await Document.Insert(SqliteDb.TableName, new JsonDocument { Id = "one", Value = "two" }); + await Document.Insert(SqliteDb.TableName, new JsonDocument { Id = "three", Value = "four" }); + await Document.Insert(SqliteDb.TableName, new JsonDocument { Id = "five", Value = "six" }); + + var results = await Find.All(SqliteDb.TableName); + Expect.equal(results.Count, 3, "There should have been 3 documents returned"); + }), + TestCase("succeeds when there is no data", async () => + { + await using var db = await SqliteDb.BuildDb(); + var results = await Find.All(SqliteDb.TableName); + Expect.isEmpty(results, "There should have been no documents returned"); + }) + }), + TestList("ById", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.ById(SqliteDb.TableName, "two"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc!.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.ById(SqliteDb.TableName, "twenty two"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when documents are found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByField(SqliteDb.TableName, "NumValue", Op.GT, 15); + Expect.equal(docs.Count, 2, "There should have been two documents returned"); + }), + TestCase("succeeds when documents are not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var docs = await Find.ByField(SqliteDb.TableName, "Value", Op.EQ, "mauve"); + Expect.isEmpty(docs, "There should have been no documents returned"); + }) + }), + TestList("FirstByField", new[] + { + TestCase("succeeds when a document is found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByField(SqliteDb.TableName, "Value", Op.EQ, "another"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.equal(doc!.Id, "two", "The incorrect document was returned"); + }), + TestCase("succeeds when multiple documents are found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByField(SqliteDb.TableName, "Sub.Foo", Op.EQ, "green"); + Expect.isNotNull(doc, "There should have been a document returned"); + Expect.contains(new[] { "two", "four" }, doc!.Id, "An incorrect document was returned"); + }), + TestCase("succeeds when a document is not found", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var doc = await Find.FirstByField(SqliteDb.TableName, "Value", Op.EQ, "absent"); + Expect.isNull(doc, "There should not have been a document returned"); + }) + }) + }), + TestList("Update", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + var testDoc = new JsonDocument { Id = "one", Sub = new() { Foo = "blue", Bar = "red" } }; + await Update.ById(SqliteDb.TableName, "one", testDoc); + var after = await Find.ById(SqliteDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after!.Id, "one", "The updated document is not correct"); + Expect.isNotNull(after.Sub, "The updated document should have had a sub-document"); + Expect.equal(after.Sub!.Foo, "blue", "The updated sub-document is not correct"); + Expect.equal(after.Sub.Bar, "red", "The updated sub-document is not correct"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + + var before = await Find.All(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await Update.ById(SqliteDb.TableName, "test", + new JsonDocument { Id = "x", Sub = new() { Foo = "blue", Bar = "red" } }); + }) + }), + TestList("ByFunc", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Update.ByFunc(SqliteDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + var after = await Find.ById(SqliteDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after!.Id, "one", "The updated document is incorrect"); + Expect.equal(after.Value, "le un", "The updated document is incorrect"); + Expect.equal(after.NumValue, 1, "The updated document is incorrect"); + Expect.isNull(after.Sub, "The updated document should not have a sub-document"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + + var before = await Find.All(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await Update.ByFunc(SqliteDb.TableName, doc => doc.Id, + new JsonDocument { Id = "one", Value = "le un", NumValue = 1 }); + }) + }), + }), + TestList("Patch", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Patch.ById(SqliteDb.TableName, "one", new { NumValue = 44 }); + var after = await Find.ById(SqliteDb.TableName, "one"); + Expect.isNotNull(after, "There should have been a document returned post-update"); + Expect.equal(after!.Id, "one", "The updated document is not correct"); + Expect.equal(after.NumValue, 44, "The updated document is not correct"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + + var before = await Find.All(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await Patch.ById(SqliteDb.TableName, "test", new { Foo = "green" }); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when a document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Patch.ByField(SqliteDb.TableName, "Value", Op.EQ, "purple", new { NumValue = 77 }); + var after = await Count.ByField(SqliteDb.TableName, "NumValue", Op.EQ, 77); + Expect.equal(after, 2L, "There should have been 2 documents returned"); + }), + TestCase("succeeds when no document is updated", async () => + { + await using var db = await SqliteDb.BuildDb(); + + var before = await Find.All(SqliteDb.TableName); + Expect.isEmpty(before, "There should have been no documents returned"); + + // This not raising an exception is the test + await Patch.ByField(SqliteDb.TableName, "Value", Op.EQ, "burgundy", new { Foo = "green" }); + }) + }) + }), + TestList("Delete", new[] + { + TestList("ById", new[] + { + TestCase("succeeds when a document is deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Delete.ById(SqliteDb.TableName, "four"); + var remaining = await Count.All(SqliteDb.TableName); + Expect.equal(remaining, 4L, "There should have been 4 documents remaining"); + }), + TestCase("succeeds when a document is not deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Delete.ById(SqliteDb.TableName, "thirty"); + var remaining = await Count.All(SqliteDb.TableName); + Expect.equal(remaining, 5L, "There should have been 5 documents remaining"); + }) + }), + TestList("ByField", new[] + { + TestCase("succeeds when documents are deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Delete.ByField(SqliteDb.TableName, "Value", Op.NE, "purple"); + var remaining = await Count.All(SqliteDb.TableName); + Expect.equal(remaining, 2L, "There should have been 2 documents remaining"); + }), + TestCase("succeeds when documents are not deleted", async () => + { + await using var db = await SqliteDb.BuildDb(); + await LoadDocs(); + + await Delete.ByField(SqliteDb.TableName, "Value", Op.EQ, "crimson"); + var remaining = await Count.All(SqliteDb.TableName); + Expect.equal(remaining, 5L, "There should have been 5 documents remaining"); + }) + }) + }), + TestCase("Clean up database", () => Sqlite.Configuration.UseConnectionString("data source=:memory:")) + }); + + /// + /// All tests for SQLite C# functions and methods + /// + [Tests] + public static readonly Test All = TestList("Sqlite.C#", new[] { Unit, TestSequenced(Integration) }); +} diff --git a/src/Tests.CSharp/SqliteDb.cs b/src/Tests.CSharp/SqliteDb.cs new file mode 100644 index 0000000..cf7fe5e --- /dev/null +++ b/src/Tests.CSharp/SqliteDb.cs @@ -0,0 +1,59 @@ +namespace BitBadger.Documents.Tests; + +using System; +using System.IO; +using System.Threading.Tasks; +using Sqlite; + +/// +/// A throwaway SQLite database file, which will be deleted when it goes out of scope +/// +public class ThrowawaySqliteDb : IDisposable, IAsyncDisposable +{ + private readonly string _dbName; + + public ThrowawaySqliteDb(string dbName) + { + _dbName = dbName; + } + + public void Dispose() + { + if (File.Exists(_dbName)) File.Delete(_dbName); + GC.SuppressFinalize(this); + } + + public ValueTask DisposeAsync() + { + if (File.Exists(_dbName)) File.Delete(_dbName); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } +} + +/// +/// Utility functions for dealing with SQLite databases +/// +public static class SqliteDb +{ + /// + /// The table name for the catalog metadata + /// + public const string Catalog = "sqlite_master"; + + /// + /// The name of the table used for testing + /// + public const string TableName = "test_table"; + + /// + /// Create a throwaway database file with the test_table defined + /// + public static async Task BuildDb() + { + var dbName = $"test-db-{Guid.NewGuid():n}.db"; + Configuration.UseConnectionString($"data source={dbName}"); + await Definition.EnsureTable(TableName); + return new ThrowawaySqliteDb(dbName); + } +} diff --git a/src/Tests.CSharp/Types.cs b/src/Tests.CSharp/Types.cs new file mode 100644 index 0000000..5e7f972 --- /dev/null +++ b/src/Tests.CSharp/Types.cs @@ -0,0 +1,15 @@ +namespace BitBadger.Documents.Tests.CSharp; + +public class SubDocument +{ + public string Foo { get; set; } = ""; + public string Bar { get; set; } = ""; +} + +public class JsonDocument +{ + public string Id { get; set; } = ""; + public string Value { get; set; } = ""; + public int NumValue { get; set; } = 0; + public SubDocument? Sub { get; set; } = null; +} diff --git a/src/Tests/BitBadger.Documents.Tests.fsproj b/src/Tests/BitBadger.Documents.Tests.fsproj new file mode 100644 index 0000000..9e753ad --- /dev/null +++ b/src/Tests/BitBadger.Documents.Tests.fsproj @@ -0,0 +1,28 @@ + + + + Exe + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/CommonTests.fs b/src/Tests/CommonTests.fs new file mode 100644 index 0000000..b7fcc4a --- /dev/null +++ b/src/Tests/CommonTests.fs @@ -0,0 +1,159 @@ +module CommonTests + +open BitBadger.Documents +open Expecto + +/// Test table name +let tbl = "test_table" + +/// Tests which do not hit the database +let all = + testList "Common" [ + testList "Op" [ + test "EQ succeeds" { + Expect.equal (string EQ) "=" "The equals operator was not correct" + } + test "GT succeeds" { + Expect.equal (string GT) ">" "The greater than operator was not correct" + } + test "GE succeeds" { + Expect.equal (string GE) ">=" "The greater than or equal to operator was not correct" + } + test "LT succeeds" { + Expect.equal (string LT) "<" "The less than operator was not correct" + } + test "LE succeeds" { + Expect.equal (string LE) "<=" "The less than or equal to operator was not correct" + } + test "NE succeeds" { + Expect.equal (string NE) "<>" "The not equal to operator was not correct" + } + test "EX succeeds" { + Expect.equal (string EX) "IS NOT NULL" """The "exists" operator was not correct""" + } + test "NEX succeeds" { + Expect.equal (string NEX) "IS NULL" """The "not exists" operator was not correct""" + } + ] + testList "Query" [ + test "selectFromTable succeeds" { + Expect.equal (Query.selectFromTable tbl) $"SELECT data FROM {tbl}" "SELECT statement not correct" + } + test "whereById succeeds" { + Expect.equal (Query.whereById "@id") "data ->> 'Id' = @id" "WHERE clause not correct" + } + testList "whereByField" [ + test "succeeds when a logical operator is passed" { + Expect.equal + (Query.whereByField "theField" GT "@test") + "data ->> 'theField' > @test" + "WHERE clause not correct" + } + test "succeeds when an existence operator is passed" { + Expect.equal + (Query.whereByField "thatField" NEX "") + "data ->> 'thatField' IS NULL" + "WHERE clause not correct" + } + ] + testList "Definition" [ + test "ensureTableFor succeeds" { + Expect.equal + (Query.Definition.ensureTableFor "my.table" "JSONB") + "CREATE TABLE IF NOT EXISTS my.table (data JSONB NOT NULL)" + "CREATE TABLE statement not constructed correctly" + } + testList "ensureKey" [ + test "succeeds when a schema is present" { + Expect.equal + (Query.Definition.ensureKey "test.table") + "CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON test.table ((data ->> 'Id'))" + "CREATE INDEX for key statement with schema not constructed correctly" + } + test "succeeds when a schema is not present" { + Expect.equal + (Query.Definition.ensureKey "table") + "CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON table ((data ->> 'Id'))" + "CREATE INDEX for key statement without schema not constructed correctly" + } + ] + test "ensureIndexOn succeeds for multiple fields and directions" { + Expect.equal + (Query.Definition.ensureIndexOn "test.table" "gibberish" [ "taco"; "guac DESC"; "salsa ASC" ]) + ([ "CREATE INDEX IF NOT EXISTS idx_table_gibberish ON test.table " + "((data ->> 'taco'), (data ->> 'guac') DESC, (data ->> 'salsa') ASC)" ] + |> String.concat "") + "CREATE INDEX for multiple field statement incorrect" + } + ] + test "insert succeeds" { + Expect.equal (Query.insert tbl) $"INSERT INTO {tbl} VALUES (@data)" "INSERT statement not correct" + } + test "save succeeds" { + Expect.equal + (Query.save tbl) + $"INSERT INTO {tbl} VALUES (@data) ON CONFLICT ((data ->> 'Id')) DO UPDATE SET data = EXCLUDED.data" + "INSERT ON CONFLICT UPDATE statement not correct" + } + test "update succeeds" { + Expect.equal + (Query.update tbl) + $"UPDATE {tbl} SET data = @data WHERE data ->> 'Id' = @id" + "UPDATE full statement not correct" + } + testList "Count" [ + test "all succeeds" { + Expect.equal (Query.Count.all tbl) $"SELECT COUNT(*) AS it FROM {tbl}" "Count query not correct" + } + test "byField succeeds" { + Expect.equal + (Query.Count.byField tbl "thatField" EQ) + $"SELECT COUNT(*) AS it FROM {tbl} WHERE data ->> 'thatField' = @field" + "JSON field text comparison count query not correct" + } + ] + testList "Exists" [ + test "byId succeeds" { + Expect.equal + (Query.Exists.byId tbl) + $"SELECT EXISTS (SELECT 1 FROM {tbl} WHERE data ->> 'Id' = @id) AS it" + "ID existence query not correct" + } + test "byField succeeds" { + Expect.equal + (Query.Exists.byField tbl "Test" LT) + $"SELECT EXISTS (SELECT 1 FROM {tbl} WHERE data ->> 'Test' < @field) AS it" + "JSON field text comparison exists query not correct" + } + ] + testList "Find" [ + test "byId succeeds" { + Expect.equal + (Query.Find.byId tbl) + $"SELECT data FROM {tbl} WHERE data ->> 'Id' = @id" + "SELECT by ID query not correct" + } + test "byField succeeds" { + Expect.equal + (Query.Find.byField tbl "Golf" GE) + $"SELECT data FROM {tbl} WHERE data ->> 'Golf' >= @field" + "SELECT by JSON comparison query not correct" + } + ] + testList "Delete" [ + test "byId succeeds" { + Expect.equal + (Query.Delete.byId tbl) + $"DELETE FROM {tbl} WHERE data ->> 'Id' = @id" + "DELETE by ID query not correct" + } + test "byField succeeds" { + Expect.equal + (Query.Delete.byField tbl "gone" NEX) + $"DELETE FROM {tbl} WHERE data ->> 'gone' IS NULL" + "DELETE by JSON comparison query not correct" + } + ] + ] + ] + diff --git a/src/Tests/PostgresExtensionTests.fs b/src/Tests/PostgresExtensionTests.fs new file mode 100644 index 0000000..4d215b2 --- /dev/null +++ b/src/Tests/PostgresExtensionTests.fs @@ -0,0 +1,700 @@ +module PostgresExtensionTests + +open BitBadger.Documents +open BitBadger.Documents.Postgres +open BitBadger.Documents.Tests +open Expecto +open Npgsql +open Types + +/// Open a connection to the throwaway database +let private mkConn (db: ThrowawayPostgresDb) = + let conn = new NpgsqlConnection(db.ConnectionString) + conn.Open() + conn + +/// Integration tests for the F# extensions on the NpgsqlConnection data type +let integrationTests = + let loadDocs (conn: NpgsqlConnection) = backgroundTask { + for doc in testDocuments do do! conn.insert PostgresDb.TableName doc + } + testList "Postgres.Extensions" [ + testList "customList" [ + testTask "succeeds when data is found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.customList (Query.selectFromTable PostgresDb.TableName) [] fromData + Expect.equal (List.length docs) 5 "There should have been 5 documents returned" + } + testTask "succeeds when data is not found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! docs = + conn.customList + $"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 "customSingle" [ + testTask "succeeds when a row is found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! doc = + conn.customSingle + $"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() + use conn = mkConn db + do! loadDocs conn + + let! doc = + conn.customSingle + $"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 "customNonQuery" [ + testTask "succeeds when operating on data" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.customNonQuery $"DELETE FROM {PostgresDb.TableName}" [] + + let! remaining = conn.countAll 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() + use conn = mkConn db + do! loadDocs conn + + do! conn.customNonQuery + $"DELETE FROM {PostgresDb.TableName} WHERE data @? @path::jsonpath" + [ "@path", Sql.string "$.NumValue ? (@ > 100)" ] + + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 5 "There should be 5 documents remaining in the table" + } + ] + testTask "scalar succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let! nbr = conn.customScalar "SELECT 5 AS test_value" [] (fun row -> row.int "test_value") + Expect.equal nbr 5 "The query should have returned the number 5" + } + testTask "ensureTable succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let tableExists () = + conn.customScalar "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'ensured') AS it" [] toExists + let keyExists () = + conn.customScalar + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_key') AS it" [] toExists + + 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! conn.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 "ensureDocumentIndex succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let indexExists () = + conn.customScalar + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_document') AS it" [] toExists + + let! exists = indexExists () + Expect.isFalse exists "The index should not exist already" + + do! conn.ensureTable "ensured" + do! conn.ensureDocumentIndex "ensured" Optimized + let! exists' = indexExists () + Expect.isTrue exists' "The index should now exist" + } + testTask "ensureFieldIndex succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let indexExists () = + conn.customScalar + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_test') AS it" [] toExists + + let! exists = indexExists () + Expect.isFalse exists "The index should not exist already" + + do! conn.ensureTable "ensured" + do! conn.ensureFieldIndex "ensured" "test" [ "Id"; "Category" ] + let! exists' = indexExists () + Expect.isTrue exists' "The index should now exist" + } + testList "insert" [ + testTask "succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should be no documents in the table" + + let testDoc = { emptyDoc with Id = "turkey"; Sub = Some { Foo = "gobble"; Bar = "gobble" } } + do! conn.insert PostgresDb.TableName testDoc + let! after = conn.findAll PostgresDb.TableName + Expect.equal after [ testDoc ] "There should have been one document inserted" + } + testTask "fails for duplicate key" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! conn.insert PostgresDb.TableName { emptyDoc with Id = "test" } + Expect.throws + (fun () -> + conn.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() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should be no documents in the table" + + let testDoc = { emptyDoc with Id = "test"; Sub = Some { Foo = "a"; Bar = "b" } } + do! conn.save PostgresDb.TableName testDoc + let! after = conn.findAll PostgresDb.TableName + Expect.equal after [ testDoc ] "There should have been one document inserted" + } + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let testDoc = { emptyDoc with Id = "test"; Sub = Some { Foo = "a"; Bar = "b" } } + do! conn.insert PostgresDb.TableName testDoc + + let! before = conn.findById PostgresDb.TableName "test" + Expect.isSome before "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! conn.save PostgresDb.TableName upd8Doc + let! after = conn.findById PostgresDb.TableName "test" + Expect.isSome after "There should have been a document returned post-update" + Expect.equal after.Value upd8Doc "The updated document is not correct" + } + ] + testTask "countAll succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! theCount = conn.countAll PostgresDb.TableName + Expect.equal theCount 5 "There should have been 5 matching documents" + } + testTask "countByField succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! theCount = conn.countByField PostgresDb.TableName "Value" EQ "purple" + Expect.equal theCount 2 "There should have been 2 matching documents" + } + testTask "countByContains succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! theCount = conn.countByContains PostgresDb.TableName {| Value = "purple" |} + Expect.equal theCount 2 "There should have been 2 matching documents" + } + testTask "countByJsonPath succeeds" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! theCount = conn.countByJsonPath PostgresDb.TableName "$.NumValue ? (@ > 5)" + Expect.equal theCount 3 "There should have been 3 matching documents" + } + testList "existsById" [ + testTask "succeeds when a document exists" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsById 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() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsById PostgresDb.TableName "seven" + Expect.isFalse exists "There should not have been an existing document" + } + ] + testList "existsByField" [ + testTask "succeeds when documents exist" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsByField PostgresDb.TableName "Sub" EX "" + Expect.isTrue exists "There should have been existing documents" + } + testTask "succeeds when documents do not exist" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsByField PostgresDb.TableName "NumValue" EQ "six" + Expect.isFalse exists "There should not have been existing documents" + } + ] + testList "existsByContains" [ + testTask "succeeds when documents exist" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsByContains PostgresDb.TableName {| NumValue = 10 |} + Expect.isTrue exists "There should have been existing documents" + } + testTask "succeeds when no matching documents exist" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsByContains PostgresDb.TableName {| Nothing = "none" |} + Expect.isFalse exists "There should not have been any existing documents" + } + ] + testList "existsByJsonPath" [ + testTask "succeeds when documents exist" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsByJsonPath 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() + use conn = mkConn db + do! loadDocs conn + + let! exists = conn.existsByJsonPath PostgresDb.TableName "$.NumValue ? (@ > 1000)" + Expect.isFalse exists "There should not have been any existing documents" + } + ] + testList "findAll" [ + testTask "succeeds when there is data" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + + do! conn.insert PostgresDb.TableName { Foo = "one"; Bar = "two" } + do! conn.insert PostgresDb.TableName { Foo = "three"; Bar = "four" } + do! conn.insert PostgresDb.TableName { Foo = "five"; Bar = "six" } + + let! results = conn.findAll 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() + use conn = mkConn db + let! results = conn.findAll PostgresDb.TableName + Expect.equal results [] "There should have been no documents returned" + } + ] + testList "findById" [ + testTask "succeeds when a document is found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findById PostgresDb.TableName "two" + Expect.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() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findById PostgresDb.TableName "three hundred eighty-seven" + Expect.isNone doc "There should not have been a document returned" + } + ] + testList "findByField" [ + testTask "succeeds when documents are found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.findByField PostgresDb.TableName "Value" EQ "another" + Expect.equal (List.length docs) 1 "There should have been one document returned" + } + testTask "succeeds when documents are not found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.findByField PostgresDb.TableName "Value" EQ "mauve" + Expect.isEmpty docs "There should have been no documents returned" + } + ] + testList "findByContains" [ + testTask "succeeds when documents are found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.findByContains 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() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.findByContains PostgresDb.TableName {| Value = "mauve" |} + Expect.isEmpty docs "There should have been no documents returned" + } + ] + testList "findByJsonPath" [ + testTask "succeeds when documents are found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.findByJsonPath 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() + use conn = mkConn db + do! loadDocs conn + + let! docs = conn.findByJsonPath PostgresDb.TableName "$.NumValue ? (@ < 0)" + Expect.isEmpty docs "There should have been no documents returned" + } + ] + testList "findFirstByField" [ + testTask "succeeds when a document is found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByField PostgresDb.TableName "Value" EQ "another" + Expect.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() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByField PostgresDb.TableName "Value" EQ "purple" + Expect.isSome doc "There should have been a document returned" + Expect.contains [ "five"; "four" ] doc.Value.Id "An incorrect document was returned" + } + testTask "succeeds when a document is not found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByField PostgresDb.TableName "Value" EQ "absent" + Expect.isNone doc "There should not have been a document returned" + } + ] + testList "findFirstByContains" [ + testTask "succeeds when a document is found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByContains PostgresDb.TableName {| Value = "another" |} + Expect.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() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByContains PostgresDb.TableName {| Sub = {| Foo = "green" |} |} + Expect.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() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByContains PostgresDb.TableName {| Value = "absent" |} + Expect.isNone doc "There should not have been a document returned" + } + ] + testList "findFirstByJsonPath" [ + testTask "succeeds when a document is found" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByJsonPath PostgresDb.TableName """$.Value ? (@ == "FIRST!")""" + Expect.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() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByJsonPath PostgresDb.TableName """$.Sub.Foo ? (@ == "green")""" + Expect.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() + use conn = mkConn db + do! loadDocs conn + + let! doc = conn.findFirstByJsonPath PostgresDb.TableName """$.Id ? (@ == "nope")""" + Expect.isNone doc "There should not have been a document returned" + } + ] + testList "updateById" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + let testDoc = { emptyDoc with Id = "one"; Sub = Some { Foo = "blue"; Bar = "red" } } + do! conn.updateById PostgresDb.TableName "one" testDoc + let! after = conn.findById PostgresDb.TableName "one" + Expect.isSome after "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() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.updateById + PostgresDb.TableName "test" { emptyDoc with Id = "x"; Sub = Some { Foo = "blue"; Bar = "red" } } + } + ] + testList "updateByFunc" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.updateByFunc + PostgresDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + let! after = conn.findById PostgresDb.TableName "one" + Expect.isSome after "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() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.updateByFunc + PostgresDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + } + ] + testList "patchById" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.patchById PostgresDb.TableName "one" {| NumValue = 44 |} + let! after = conn.findById PostgresDb.TableName "one" + Expect.isSome after "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() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.patchById PostgresDb.TableName "test" {| Foo = "green" |} + } + ] + testList "patchByField" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.patchByField PostgresDb.TableName "Value" EQ "purple" {| NumValue = 77 |} + let! after = conn.countByField PostgresDb.TableName "NumValue" EQ "77" + Expect.equal after 2 "There should have been 2 documents returned" + } + testTask "succeeds when no document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.patchByField PostgresDb.TableName "Value" EQ "burgundy" {| Foo = "green" |} + } + ] + testList "patchByContains" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.patchByContains PostgresDb.TableName {| Value = "purple" |} {| NumValue = 77 |} + let! after = conn.countByContains 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() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.patchByContains PostgresDb.TableName {| Value = "burgundy" |} {| Foo = "green" |} + } + ] + testList "patchByJsonPath" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.patchByJsonPath PostgresDb.TableName "$.NumValue ? (@ > 10)" {| NumValue = 1000 |} + let! after = conn.countByJsonPath 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() + use conn = mkConn db + let! before = conn.countAll PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.patchByJsonPath PostgresDb.TableName "$.NumValue ? (@ < 0)" {| Foo = "green" |} + } + ] + testList "deleteById" [ + testTask "succeeds when a document is deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteById PostgresDb.TableName "four" + let! remaining = conn.countAll 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() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteById PostgresDb.TableName "thirty" + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 5 "There should have been 5 documents remaining" + } + ] + testList "deleteByField" [ + testTask "succeeds when documents are deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteByField PostgresDb.TableName "Value" EQ "purple" + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 3 "There should have been 3 documents remaining" + } + testTask "succeeds when documents are not deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteByField PostgresDb.TableName "Value" EQ "crimson" + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 5 "There should have been 5 documents remaining" + } + ] + testList "deleteByContains" [ + testTask "succeeds when documents are deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteByContains PostgresDb.TableName {| Value = "purple" |} + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 3 "There should have been 3 documents remaining" + } + testTask "succeeds when documents are not deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteByContains PostgresDb.TableName {| Value = "crimson" |} + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 5 "There should have been 5 documents remaining" + } + ] + testList "deleteByJsonPath" [ + testTask "succeeds when documents are deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteByJsonPath PostgresDb.TableName """$.Sub.Foo ? (@ == "green")""" + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 3 "There should have been 3 documents remaining" + } + testTask "succeeds when documents are not deleted" { + use db = PostgresDb.BuildDb() + use conn = mkConn db + do! loadDocs conn + + do! conn.deleteByJsonPath PostgresDb.TableName "$.NumValue ? (@ > 100)" + let! remaining = conn.countAll PostgresDb.TableName + Expect.equal remaining 5 "There should have been 5 documents remaining" + } + ] + ] + |> testSequenced diff --git a/src/Tests/PostgresTests.fs b/src/Tests/PostgresTests.fs new file mode 100644 index 0000000..66938f6 --- /dev/null +++ b/src/Tests/PostgresTests.fs @@ -0,0 +1,816 @@ +module PostgresTests + +open Expecto +open BitBadger.Documents +open BitBadger.Documents.Postgres +open BitBadger.Documents.Tests + +/// Tests which do not hit the database +let unitTests = + testList "Unit" [ + testList "Parameters" [ + test "idParam succeeds" { + Expect.equal (idParam 88) ("@id", Sql.string "88") "ID parameter not constructed correctly" + } + test "jsonParam succeeds" { + Expect.equal + (jsonParam "@test" {| Something = "good" |}) + ("@test", Sql.jsonb """{"Something":"good"}""") + "JSON parameter not constructed correctly" + } + test "fieldParam succeeds" { + let it = fieldParam 242 + Expect.equal (fst it) "@field" "Field parameter name not correct" + match snd it with + | SqlValue.Parameter value -> + Expect.equal value.ParameterName "@field" "Parameter name not correct" + Expect.equal value.Value 242 "Parameter value not correct" + | _ -> Expect.isTrue false "The parameter was not a Parameter type" + } + test "noParams succeeds" { + Expect.isEmpty noParams "The no-params sequence should be empty" + } + ] + testList "Query" [ + testList "Definition" [ + test "ensureTable succeeds" { + Expect.equal + (Query.Definition.ensureTable PostgresDb.TableName) + $"CREATE TABLE IF NOT EXISTS {PostgresDb.TableName} (data JSONB NOT NULL)" + "CREATE TABLE statement not constructed correctly" + } + test "ensureDocumentIndex succeeds for full index" { + Expect.equal + (Query.Definition.ensureDocumentIndex "schema.tbl" Full) + "CREATE INDEX IF NOT EXISTS idx_tbl_document ON schema.tbl USING GIN (data)" + "CREATE INDEX statement not constructed correctly" + } + test "ensureDocumentIndex succeeds for JSONB Path Ops index" { + Expect.equal + (Query.Definition.ensureDocumentIndex PostgresDb.TableName Optimized) + (sprintf "CREATE INDEX IF NOT EXISTS idx_%s_document ON %s USING GIN (data jsonb_path_ops)" + PostgresDb.TableName PostgresDb.TableName) + "CREATE INDEX statement not constructed correctly" + } + ] + 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" + } + testList "Count" [ + 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 "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 "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 "Patch" [ + test "byId succeeds" { + Expect.equal + (Query.Patch.byId PostgresDb.TableName) + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data ->> 'Id' = @id" + "UPDATE partial by ID statement not correct" + } + test "byField succeeds" { + Expect.equal + (Query.Patch.byField PostgresDb.TableName "Snail" LT) + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data ->> 'Snail' < @field" + "UPDATE partial by ID statement not correct" + } + test "byContains succeeds" { + Expect.equal + (Query.Patch.byContains PostgresDb.TableName) + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data @> @criteria" + "UPDATE partial by JSON containment statement not correct" + } + test "byJsonPath succeeds" { + Expect.equal + (Query.Patch.byJsonPath PostgresDb.TableName) + $"UPDATE {PostgresDb.TableName} SET data = data || @data WHERE data @? @path::jsonpath" + "UPDATE partial by JSON Path statement not correct" + } + ] + testList "Delete" [ + 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 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 "Custom" [ + 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 "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 "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" + } + ] + testList "Definition" [ + testTask "ensureTable succeeds" { + use db = PostgresDb.BuildDb() + let tableExists () = + Custom.scalar "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'ensured') AS it" [] toExists + let keyExists () = + Custom.scalar + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_key') AS it" [] toExists + + 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 "ensureDocumentIndex succeeds" { + use db = PostgresDb.BuildDb() + let indexExists () = + Custom.scalar + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_document') AS it" + [] + toExists + + let! exists = indexExists () + Expect.isFalse exists "The index should not exist already" + + do! Definition.ensureTable "ensured" + do! Definition.ensureDocumentIndex "ensured" Optimized + let! exists' = indexExists () + Expect.isTrue exists' "The index should now exist" + } + testTask "ensureFieldIndex succeeds" { + use db = PostgresDb.BuildDb() + let indexExists () = + Custom.scalar + "SELECT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_ensured_test') AS it" [] toExists + + let! exists = indexExists () + Expect.isFalse exists "The index should not exist already" + + do! Definition.ensureTable "ensured" + do! Definition.ensureFieldIndex "ensured" "test" [ "Id"; "Category" ] + let! exists' = indexExists () + Expect.isTrue exists' "The index should now exist" + } + ] + 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" + Expect.isSome before "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" + Expect.isSome after "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 "byField succeeds" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + let! theCount = Count.byField PostgresDb.TableName "Value" EQ "purple" + Expect.equal theCount 2 "There should have been 2 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 "byField" [ + testTask "succeeds when documents exist" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + let! exists = Exists.byField PostgresDb.TableName "Sub" EX "" + Expect.isTrue exists "There should have been existing documents" + } + testTask "succeeds when documents do not exist" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + let! exists = Exists.byField PostgresDb.TableName "NumValue" EQ "six" + Expect.isFalse exists "There should not have been existing documents" + } + ] + 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.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.isNone doc "There should not have been a document returned" + } + ] + testList "byField" [ + testTask "succeeds when documents are found" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + let! docs = Find.byField PostgresDb.TableName "Value" EQ "another" + Expect.equal (List.length docs) 1 "There should have been one document returned" + } + testTask "succeeds when documents are not found" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + let! docs = Find.byField PostgresDb.TableName "Value" EQ "mauve" + Expect.isEmpty docs "There should have been no documents 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.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.isEmpty docs "There should have been no documents returned" + } + ] + testList "firstByField" [ + testTask "succeeds when a document is found" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + let! doc = Find.firstByField PostgresDb.TableName "Value" EQ "another" + Expect.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.firstByField PostgresDb.TableName "Value" EQ "purple" + Expect.isSome doc "There should have been a document returned" + Expect.contains [ "five"; "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.firstByField PostgresDb.TableName "Value" EQ "absent" + Expect.isNone doc "There should not have been a document 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.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.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.isNone 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.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.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.isNone doc "There should not have been a document returned" + } + ] + ] + testList "Update" [ + testList "byId" [ + 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.byId PostgresDb.TableName "one" testDoc + let! after = Find.byId PostgresDb.TableName "one" + Expect.isSome after "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 = Count.all PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! Update.byId + PostgresDb.TableName + "test" + { emptyDoc with Id = "x"; Sub = Some { Foo = "blue"; Bar = "red" } } + } + ] + testList "byFunc" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + do! Update.byFunc PostgresDb.TableName (_.Id) + { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + let! after = Find.byId PostgresDb.TableName "one" + Expect.isSome after "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 = Count.all PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! Update.byFunc + PostgresDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + } + ] + ] + testList "Patch" [ + testList "byId" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + do! Patch.byId PostgresDb.TableName "one" {| NumValue = 44 |} + let! after = Find.byId PostgresDb.TableName "one" + Expect.isSome after "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 = Count.all PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! Patch.byId PostgresDb.TableName "test" {| Foo = "green" |} + } + ] + testList "byField" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + do! Patch.byField PostgresDb.TableName "Value" EQ "purple" {| NumValue = 77 |} + let! after = Count.byField PostgresDb.TableName "NumValue" EQ "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 = Count.all PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! Patch.byField PostgresDb.TableName "Value" EQ "burgundy" {| Foo = "green" |} + } + ] + testList "byContains" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + do! Patch.byContains 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 = Count.all PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! Patch.byContains PostgresDb.TableName {| Value = "burgundy" |} {| Foo = "green" |} + } + ] + testList "byJsonPath" [ + testTask "succeeds when a document is updated" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + do! Patch.byJsonPath 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 = Count.all PostgresDb.TableName + Expect.equal before 0 "There should have been no documents returned" + + // This not raising an exception is the test + do! Patch.byJsonPath PostgresDb.TableName "$.NumValue ? (@ < 0)" {| 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 "byField" [ + testTask "succeeds when documents are deleted" { + use db = PostgresDb.BuildDb() + do! loadDocs () + + do! Delete.byField PostgresDb.TableName "Value" EQ "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.byField PostgresDb.TableName "Value" EQ "crimson" + 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" + } + ] + ] + ] + |> testSequenced + + +let all = testList "Postgres" [ unitTests; integrationTests ] diff --git a/src/Tests/Program.fs b/src/Tests/Program.fs new file mode 100644 index 0000000..7b1bdf2 --- /dev/null +++ b/src/Tests/Program.fs @@ -0,0 +1,19 @@ +open Expecto +open BitBadger.Documents.Tests.CSharp + +let allTests = + testList + "BitBadger.Documents" + [ CommonTests.all + CommonCSharpTests.Unit + PostgresTests.all + PostgresCSharpTests.All + PostgresExtensionTests.integrationTests + testSequenced PostgresCSharpExtensionTests.Integration + SqliteTests.all + SqliteCSharpTests.All + SqliteExtensionTests.integrationTests + testSequenced SqliteCSharpExtensionTests.Integration ] + +[] +let main args = runTestsWithCLIArgs [] args allTests diff --git a/src/Tests/SqliteExtensionTests.fs b/src/Tests/SqliteExtensionTests.fs new file mode 100644 index 0000000..4c6e2b5 --- /dev/null +++ b/src/Tests/SqliteExtensionTests.fs @@ -0,0 +1,471 @@ +module SqliteExtensionTests + +open BitBadger.Documents +open BitBadger.Documents.Sqlite +open BitBadger.Documents.Tests +open Expecto +open Microsoft.Data.Sqlite +open Types + +/// Integration tests for the F# extensions on the SqliteConnection data type +let integrationTests = + let loadDocs () = backgroundTask { + for doc in testDocuments do do! insert SqliteDb.TableName doc + } + testList "Sqlite.Extensions" [ + testTask "ensureTable succeeds" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + let itExists (name: string) = + conn.customScalar + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = @name) AS it" + [ SqliteParameter("@name", name) ] + toExists + + let! exists = itExists "ensured" + let! alsoExists = itExists "idx_ensured_key" + Expect.isFalse exists "The table should not exist already" + Expect.isFalse alsoExists "The key index should not exist already" + + do! conn.ensureTable "ensured" + let! exists' = itExists "ensured" + let! alsoExists' = itExists "idx_ensured_key" + Expect.isTrue exists' "The table should now exist" + Expect.isTrue alsoExists' "The key index should now exist" + } + testTask "ensureFieldIndex succeeds" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + let indexExists () = + conn.customScalar + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = 'idx_ensured_test') AS it" + [] + toExists + + let! exists = indexExists () + Expect.isFalse exists "The index should not exist already" + + do! conn.ensureTable "ensured" + do! conn.ensureFieldIndex "ensured" "test" [ "Name"; "Age" ] + let! exists' = indexExists () + Expect.isTrue exists' "The index should now exist" + } + testList "insert" [ + testTask "succeeds" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + let! before = conn.findAll SqliteDb.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! conn.insert SqliteDb.TableName testDoc + let! after = conn.findAll SqliteDb.TableName + Expect.equal after [ testDoc ] "There should have been one document inserted" + } + testTask "fails for duplicate key" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! conn.insert SqliteDb.TableName { emptyDoc with Id = "test" } + Expect.throws + (fun () -> + conn.insert SqliteDb.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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + let! before = conn.findAll SqliteDb.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! conn.save SqliteDb.TableName testDoc + let! after = conn.findAll SqliteDb.TableName + Expect.equal after [ testDoc ] "There should have been one document inserted" + } + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + let testDoc = { emptyDoc with Id = "test"; Sub = Some { Foo = "a"; Bar = "b" } } + do! conn.insert SqliteDb.TableName testDoc + + let! before = conn.findById SqliteDb.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! conn.save SqliteDb.TableName upd8Doc + let! after = conn.findById SqliteDb.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" + } + ] + testTask "countAll succeeds" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! theCount = conn.countAll SqliteDb.TableName + Expect.equal theCount 5L "There should have been 5 matching documents" + } + testTask "countByField succeeds" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! theCount = conn.countByField SqliteDb.TableName "Value" EQ "purple" + Expect.equal theCount 2L "There should have been 2 matching documents" + } + testList "existsById" [ + testTask "succeeds when a document exists" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! exists = conn.existsById SqliteDb.TableName "three" + Expect.isTrue exists "There should have been an existing document" + } + testTask "succeeds when a document does not exist" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! exists = conn.existsById SqliteDb.TableName "seven" + Expect.isFalse exists "There should not have been an existing document" + } + ] + testList "existsByField" [ + testTask "succeeds when documents exist" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! exists = conn.existsByField SqliteDb.TableName "NumValue" EQ 10 + Expect.isTrue exists "There should have been existing documents" + } + testTask "succeeds when no matching documents exist" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! exists = conn.existsByField SqliteDb.TableName "Nothing" EQ "none" + Expect.isFalse exists "There should not have been any existing documents" + } + ] + testList "findAll" [ + testTask "succeeds when there is data" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + + do! insert SqliteDb.TableName { Foo = "one"; Bar = "two" } + do! insert SqliteDb.TableName { Foo = "three"; Bar = "four" } + do! insert SqliteDb.TableName { Foo = "five"; Bar = "six" } + + let! results = conn.findAll SqliteDb.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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + let! results = conn.findAll SqliteDb.TableName + Expect.equal results [] "There should have been no documents returned" + } + ] + testList "findById" [ + testTask "succeeds when a document is found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = conn.findById SqliteDb.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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = conn.findById SqliteDb.TableName "three hundred eighty-seven" + Expect.isFalse (Option.isSome doc) "There should not have been a document returned" + } + ] + testList "findByField" [ + testTask "succeeds when documents are found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! docs = conn.findByField SqliteDb.TableName "Sub.Foo" EQ "green" + Expect.equal (List.length docs) 2 "There should have been two documents returned" + } + testTask "succeeds when documents are not found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! docs = conn.findByField SqliteDb.TableName "Value" EQ "mauve" + Expect.isTrue (List.isEmpty docs) "There should have been no documents returned" + } + ] + testList "findFirstByField" [ + testTask "succeeds when a document is found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = conn.findFirstByField SqliteDb.TableName "Value" EQ "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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = conn.findFirstByField SqliteDb.TableName "Sub.Foo" EQ "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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = conn.findFirstByField SqliteDb.TableName "Value" EQ "absent" + Expect.isFalse (Option.isSome doc) "There should not have been a document returned" + } + ] + testList "updateById" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let testDoc = { emptyDoc with Id = "one"; Sub = Some { Foo = "blue"; Bar = "red" } } + do! conn.updateById SqliteDb.TableName "one" testDoc + let! after = conn.findById SqliteDb.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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + + let! before = conn.findAll SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.updateById + SqliteDb.TableName + "test" + { emptyDoc with Id = "x"; Sub = Some { Foo = "blue"; Bar = "red" } } + } + ] + testList "updateByFunc" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.updateByFunc + SqliteDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + let! after = conn.findById SqliteDb.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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + + let! before = conn.findAll SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.updateByFunc + SqliteDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + } + ] + testList "patchById" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.patchById SqliteDb.TableName "one" {| NumValue = 44 |} + let! after = conn.findById SqliteDb.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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + + let! before = conn.findAll SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.patchById SqliteDb.TableName "test" {| Foo = "green" |} + } + ] + testList "patchByField" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.patchByField SqliteDb.TableName "Value" EQ "purple" {| NumValue = 77 |} + let! after = conn.countByField SqliteDb.TableName "NumValue" EQ 77 + Expect.equal after 2L "There should have been 2 documents returned" + } + testTask "succeeds when no document is updated" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + + let! before = conn.findAll SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! conn.patchByField SqliteDb.TableName "Value" EQ "burgundy" {| Foo = "green" |} + } + ] + testList "deleteById" [ + testTask "succeeds when a document is deleted" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.deleteById SqliteDb.TableName "four" + let! remaining = conn.countAll SqliteDb.TableName + Expect.equal remaining 4L "There should have been 4 documents remaining" + } + testTask "succeeds when a document is not deleted" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.deleteById SqliteDb.TableName "thirty" + let! remaining = conn.countAll SqliteDb.TableName + Expect.equal remaining 5L "There should have been 5 documents remaining" + } + ] + testList "deleteByField" [ + testTask "succeeds when documents are deleted" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.deleteByField SqliteDb.TableName "Value" NE "purple" + let! remaining = conn.countAll SqliteDb.TableName + Expect.equal remaining 2L "There should have been 2 documents remaining" + } + testTask "succeeds when documents are not deleted" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.deleteByField SqliteDb.TableName "Value" EQ "crimson" + let! remaining = conn.countAll SqliteDb.TableName + Expect.equal remaining 5L "There should have been 5 documents remaining" + } + ] + testList "customSingle" [ + testTask "succeeds when a row is found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = + conn.customSingle + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id" + [ SqliteParameter("@id", "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 = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! doc = + conn.customSingle + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id" + [ SqliteParameter("@id", "eighty") ] + fromData + Expect.isNone doc "There should not have been a document returned" + } + ] + testList "customList" [ + testTask "succeeds when data is found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! docs = conn.customList (Query.selectFromTable SqliteDb.TableName) [] fromData + Expect.hasCountOf docs 5u (fun _ -> true) "There should have been 5 documents returned" + } + testTask "succeeds when data is not found" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + let! docs = + conn.customList + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value" + [ SqliteParameter("@value", 100) ] + fromData + Expect.isEmpty docs "There should have been no documents returned" + } + ] + testList "customNonQuery" [ + testTask "succeeds when operating on data" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.customNonQuery $"DELETE FROM {SqliteDb.TableName}" [] + + let! remaining = conn.countAll SqliteDb.TableName + Expect.equal remaining 0L "There should be no documents remaining in the table" + } + testTask "succeeds when no data matches where clause" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + do! loadDocs () + + do! conn.customNonQuery + $"DELETE FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value" + [ SqliteParameter("@value", 100) ] + + let! remaining = conn.countAll SqliteDb.TableName + Expect.equal remaining 5L "There should be 5 documents remaining in the table" + } + ] + testTask "customScalar succeeds" { + use! db = SqliteDb.BuildDb() + use conn = Configuration.dbConn () + + let! nbr = conn.customScalar "SELECT 5 AS test_value" [] _.GetInt32(0) + Expect.equal nbr 5 "The query should have returned the number 5" + } + test "clean up database" { + Configuration.useConnectionString "data source=:memory:" + } + ] + |> testSequenced diff --git a/src/Tests/SqliteTests.fs b/src/Tests/SqliteTests.fs new file mode 100644 index 0000000..caaa437 --- /dev/null +++ b/src/Tests/SqliteTests.fs @@ -0,0 +1,536 @@ +module SqliteTests + +open BitBadger.Documents +open BitBadger.Documents.Sqlite +open BitBadger.Documents.Tests +open Expecto +open Microsoft.Data.Sqlite +open Types + +/// Unit tests for the SQLite library +let unitTests = + testList "Unit" [ + testList "Query" [ + test "Definition.ensureTable succeeds" { + Expect.equal + (Query.Definition.ensureTable "tbl") + "CREATE TABLE IF NOT EXISTS tbl (data TEXT NOT NULL)" + "CREATE TABLE statement not correct" + } + testList "Patch" [ + test "byId succeeds" { + Expect.equal + (Query.Patch.byId "tbl") + "UPDATE tbl SET data = json_patch(data, json(@data)) WHERE data ->> 'Id' = @id" + "UPDATE partial by ID statement not correct" + } + test "byField succeeds" { + Expect.equal + (Query.Patch.byField "tbl" "Part" NE) + "UPDATE tbl SET data = json_patch(data, json(@data)) WHERE data ->> 'Part' <> @field" + "UPDATE partial by JSON comparison query not correct" + } + ] + ] + testList "Parameters" [ + test "idParam succeeds" { + let theParam = idParam 7 + Expect.equal theParam.ParameterName "@id" "The parameter name is incorrect" + Expect.equal theParam.Value "7" "The parameter value is incorrect" + } + test "jsonParam succeeds" { + let theParam = jsonParam "@test" {| Nice = "job" |} + Expect.equal theParam.ParameterName "@test" "The parameter name is incorrect" + Expect.equal theParam.Value """{"Nice":"job"}""" "The parameter value is incorrect" + } + test "fieldParam succeeds" { + let theParam = fieldParam 99 + Expect.equal theParam.ParameterName "@field" "The parameter name is incorrect" + Expect.equal theParam.Value 99 "The parameter value is incorrect" + } + test "noParams succeeds" { + Expect.isEmpty noParams "The parameter list should have been empty" + } + ] + // Results are exhaustively executed in the context of other tests + ] + +/// These tests each use a fresh copy of a SQLite database +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 SqliteDb.TableName doc + } + testList "Integration" [ + testList "Configuration" [ + test "useConnectionString / connectionString succeed" { + try + Configuration.useConnectionString "Data Source=test.db" + Expect.equal + Configuration.connectionString + (Some "Data Source=test.db;Foreign Keys=True") + "Connection string incorrect" + finally + Configuration.useConnectionString "Data Source=:memory:" + } + test "useSerializer succeeds" { + try + Configuration.useSerializer + { new IDocumentSerializer with + member _.Serialize<'T>(it: 'T) : string = """{"Overridden":true}""" + member _.Deserialize<'T>(it: string) : 'T = Unchecked.defaultof<'T> + } + + let serialized = Configuration.serializer().Serialize { Foo = "howdy"; Bar = "bye"} + Expect.equal serialized """{"Overridden":true}""" "Specified serializer was not used" + + let deserialized = Configuration.serializer().Deserialize """{"Something":"here"}""" + Expect.isNull deserialized "Specified serializer should have returned null" + finally + Configuration.useSerializer DocumentSerializer.``default`` + } + test "serializer returns configured serializer" { + Expect.isTrue (obj.ReferenceEquals(DocumentSerializer.``default``, Configuration.serializer ())) + "Serializer should have been the same" + } + test "useIdField / idField succeeds" { + Expect.equal (Configuration.idField ()) "Id" "The default configured ID field was incorrect" + Configuration.useIdField "id" + Expect.equal (Configuration.idField ()) "id" "useIdField did not set the ID field" + Configuration.useIdField "Id" + } + ] + testList "Custom" [ + testList "single" [ + testTask "succeeds when a row is found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = + Custom.single + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id" + [ SqliteParameter("@id", "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 = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = + Custom.single + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'Id' = @id" + [ SqliteParameter("@id", "eighty") ] + fromData + Expect.isNone doc "There should not have been a document returned" + } + ] + testList "list" [ + testTask "succeeds when data is found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! docs = Custom.list (Query.selectFromTable SqliteDb.TableName) [] fromData + Expect.hasCountOf docs 5u (fun _ -> true) "There should have been 5 documents returned" + } + testTask "succeeds when data is not found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! docs = + Custom.list + $"SELECT data FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value" + [ SqliteParameter("@value", 100) ] + fromData + Expect.isEmpty docs "There should have been no documents returned" + } + ] + testList "nonQuery" [ + testTask "succeeds when operating on data" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Custom.nonQuery $"DELETE FROM {SqliteDb.TableName}" [] + + let! remaining = Count.all SqliteDb.TableName + Expect.equal remaining 0L "There should be no documents remaining in the table" + } + testTask "succeeds when no data matches where clause" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Custom.nonQuery + $"DELETE FROM {SqliteDb.TableName} WHERE data ->> 'NumValue' > @value" + [ SqliteParameter("@value", 100) ] + + let! remaining = Count.all SqliteDb.TableName + Expect.equal remaining 5L "There should be 5 documents remaining in the table" + } + ] + testTask "scalar succeeds" { + use! db = SqliteDb.BuildDb() + + let! nbr = Custom.scalar "SELECT 5 AS test_value" [] _.GetInt32(0) + Expect.equal nbr 5 "The query should have returned the number 5" + } + ] + testList "Definition" [ + testTask "ensureTable succeeds" { + use! db = SqliteDb.BuildDb() + let itExists (name: string) = + Custom.scalar + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = @name) AS it" + [ SqliteParameter("@name", name) ] + toExists + + let! exists = itExists "ensured" + let! alsoExists = itExists "idx_ensured_key" + 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' = itExists "ensured" + let! alsoExists' = itExists "idx_ensured_key" + Expect.isTrue exists' "The table should now exist" + Expect.isTrue alsoExists' "The key index should now exist" + } + testTask "ensureFieldIndex succeeds" { + use! db = SqliteDb.BuildDb() + let indexExists () = + Custom.scalar + $"SELECT EXISTS (SELECT 1 FROM {SqliteDb.Catalog} WHERE name = 'idx_ensured_test') AS it" + [] + toExists + + let! exists = indexExists () + Expect.isFalse exists "The index should not exist already" + + do! Definition.ensureTable "ensured" + do! Definition.ensureFieldIndex "ensured" "test" [ "Name"; "Age" ] + let! exists' = indexExists () + Expect.isTrue exists' "The index should now exist" + } + ] + testList "insert" [ + testTask "succeeds" { + use! db = SqliteDb.BuildDb() + let! before = Find.all SqliteDb.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 SqliteDb.TableName testDoc + let! after = Find.all SqliteDb.TableName + Expect.equal after [ testDoc ] "There should have been one document inserted" + } + testTask "fails for duplicate key" { + use! db = SqliteDb.BuildDb() + do! insert SqliteDb.TableName { emptyDoc with Id = "test" } + Expect.throws + (fun () -> + insert SqliteDb.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 = SqliteDb.BuildDb() + let! before = Find.all SqliteDb.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 SqliteDb.TableName testDoc + let! after = Find.all SqliteDb.TableName + Expect.equal after [ testDoc ] "There should have been one document inserted" + } + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + let testDoc = { emptyDoc with Id = "test"; Sub = Some { Foo = "a"; Bar = "b" } } + do! insert SqliteDb.TableName testDoc + + let! before = Find.byId SqliteDb.TableName "test" + Expect.isSome before "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 SqliteDb.TableName upd8Doc + let! after = Find.byId SqliteDb.TableName "test" + Expect.isSome after "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 = SqliteDb.BuildDb() + do! loadDocs () + + let! theCount = Count.all SqliteDb.TableName + Expect.equal theCount 5L "There should have been 5 matching documents" + } + testTask "byField succeeds" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! theCount = Count.byField SqliteDb.TableName "Value" EQ "purple" + Expect.equal theCount 2L "There should have been 2 matching documents" + } + ] + testList "Exists" [ + testList "byId" [ + testTask "succeeds when a document exists" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! exists = Exists.byId SqliteDb.TableName "three" + Expect.isTrue exists "There should have been an existing document" + } + testTask "succeeds when a document does not exist" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! exists = Exists.byId SqliteDb.TableName "seven" + Expect.isFalse exists "There should not have been an existing document" + } + ] + testList "byField" [ + testTask "succeeds when documents exist" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! exists = Exists.byField SqliteDb.TableName "NumValue" EQ 10 + Expect.isTrue exists "There should have been existing documents" + } + testTask "succeeds when no matching documents exist" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! exists = Exists.byField SqliteDb.TableName "Nothing" LT "none" + Expect.isFalse exists "There should not have been any existing documents" + } + ] + ] + testList "Find" [ + testList "all" [ + testTask "succeeds when there is data" { + use! db = SqliteDb.BuildDb() + + do! insert SqliteDb.TableName { Foo = "one"; Bar = "two" } + do! insert SqliteDb.TableName { Foo = "three"; Bar = "four" } + do! insert SqliteDb.TableName { Foo = "five"; Bar = "six" } + + let! results = Find.all SqliteDb.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 = SqliteDb.BuildDb() + let! results = Find.all SqliteDb.TableName + Expect.equal results [] "There should have been no documents returned" + } + ] + testList "byId" [ + testTask "succeeds when a document is found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = Find.byId SqliteDb.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 = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = Find.byId SqliteDb.TableName "three hundred eighty-seven" + Expect.isFalse (Option.isSome doc) "There should not have been a document returned" + } + ] + testList "byField" [ + testTask "succeeds when documents are found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! docs = Find.byField SqliteDb.TableName "NumValue" GT 15 + Expect.equal (List.length docs) 2 "There should have been two documents returned" + } + testTask "succeeds when documents are not found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! docs = Find.byField SqliteDb.TableName "NumValue" GT 100 + Expect.isTrue (List.isEmpty docs) "There should have been no documents returned" + } + ] + testList "firstByField" [ + testTask "succeeds when a document is found" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = Find.firstByField SqliteDb.TableName "Value" EQ "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 = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = Find.firstByField SqliteDb.TableName "Sub.Foo" EQ "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 = SqliteDb.BuildDb() + do! loadDocs () + + let! doc = Find.firstByField SqliteDb.TableName "Value" EQ "absent" + Expect.isFalse (Option.isSome doc) "There should not have been a document returned" + } + ] + ] + testList "Update" [ + testList "byId" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + let testDoc = { emptyDoc with Id = "one"; Sub = Some { Foo = "blue"; Bar = "red" } } + do! Update.byId SqliteDb.TableName "one" testDoc + let! after = Find.byId SqliteDb.TableName "one" + Expect.isSome after "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 = SqliteDb.BuildDb() + + let! before = Find.all SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! Update.byId + SqliteDb.TableName + "test" + { emptyDoc with Id = "x"; Sub = Some { Foo = "blue"; Bar = "red" } } + } + ] + testList "byFunc" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Update.byFunc + SqliteDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + let! after = Find.byId SqliteDb.TableName "one" + Expect.isSome after "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 = SqliteDb.BuildDb() + + let! before = Find.all SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! Update.byFunc + SqliteDb.TableName (_.Id) { Id = "one"; Value = "le un"; NumValue = 1; Sub = None } + } + ] + ] + testList "Patch" [ + testList "byId" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Patch.byId SqliteDb.TableName "one" {| NumValue = 44 |} + let! after = Find.byId SqliteDb.TableName "one" + Expect.isSome after "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 = SqliteDb.BuildDb() + + let! before = Find.all SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! Patch.byId SqliteDb.TableName "test" {| Foo = "green" |} + } + ] + testList "byField" [ + testTask "succeeds when a document is updated" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Patch.byField SqliteDb.TableName "Value" EQ "purple" {| NumValue = 77 |} + let! after = Count.byField SqliteDb.TableName "NumValue" EQ 77 + Expect.equal after 2L "There should have been 2 documents returned" + } + testTask "succeeds when no document is updated" { + use! db = SqliteDb.BuildDb() + + let! before = Find.all SqliteDb.TableName + Expect.isEmpty before "There should have been no documents returned" + + // This not raising an exception is the test + do! Patch.byField SqliteDb.TableName "Value" EQ "burgundy" {| Foo = "green" |} + } + ] + ] + testList "Delete" [ + testList "byId" [ + testTask "succeeds when a document is deleted" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Delete.byId SqliteDb.TableName "four" + let! remaining = Count.all SqliteDb.TableName + Expect.equal remaining 4L "There should have been 4 documents remaining" + } + testTask "succeeds when a document is not deleted" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Delete.byId SqliteDb.TableName "thirty" + let! remaining = Count.all SqliteDb.TableName + Expect.equal remaining 5L "There should have been 5 documents remaining" + } + ] + testList "byField" [ + testTask "succeeds when documents are deleted" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Delete.byField SqliteDb.TableName "Value" NE "purple" + let! remaining = Count.all SqliteDb.TableName + Expect.equal remaining 2L "There should have been 2 documents remaining" + } + testTask "succeeds when documents are not deleted" { + use! db = SqliteDb.BuildDb() + do! loadDocs () + + do! Delete.byField SqliteDb.TableName "Value" EQ "crimson" + let! remaining = Count.all SqliteDb.TableName + Expect.equal remaining 5L "There should have been 5 documents remaining" + } + ] + ] + test "clean up database" { + Configuration.useConnectionString "data source=:memory:" + } + ] + |> testSequenced + +let all = testList "Sqlite" [ unitTests; integrationTests ] diff --git a/src/Tests/Types.fs b/src/Tests/Types.fs new file mode 100644 index 0000000..de2abca --- /dev/null +++ b/src/Tests/Types.fs @@ -0,0 +1,23 @@ +module Types + +type SubDocument = + { Foo: string + Bar: string } + +type JsonDocument = + { Id: string + Value: string + NumValue: int + Sub: SubDocument option } + +/// An empty JsonDocument +let emptyDoc = { Id = ""; Value = ""; NumValue = 0; Sub = None } + +/// Documents to use for testing +let testDocuments = [ + { 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 } +] diff --git a/src/icon.png b/src/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..da6be2aecde37d3d614f3314a70bcbf0b1b2182f GIT binary patch literal 25176 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 Vtc#mjbM^oL002ovPDHLkV1f;8thWFF literal 0 HcmV?d00001