diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index f194f7c..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,70 +0,0 @@
-name: CI
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
-
-jobs:
- build-and-test:
-
- runs-on: ubuntu-latest
-
- strategy:
- matrix:
- dotnet-version: [ "6.0", "7.0", "8.0" ]
- postgres-version: [ "12", "13", "14", "15", "latest" ]
-
- services:
- postgres:
- image: postgres:${{ matrix.postgres-version }}
- env:
- POSTGRES_PASSWORD: postgres
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 5432:5432
-
- steps:
- - uses: actions/checkout@v3
- - name: Setup .NET ${{ matrix.dotnet-version }}.x
- uses: actions/setup-dotnet@v3
- with:
- dotnet-version: ${{ matrix.dotnet-version }}.x
- - name: Restore dependencies
- run: dotnet restore src/BitBadger.Documents.sln
- - name: Build
- run: dotnet build src/BitBadger.Documents.sln --no-restore
- - name: Test ${{ matrix.dotnet-version }} against PostgreSQL ${{ matrix.postgres-version }}
- run: dotnet run --project src/Tests/BitBadger.Documents.Tests.fsproj -f net${{ matrix.dotnet-version }}
- publish:
- runs-on: ubuntu-latest
- needs: build-and-test
- steps:
- - uses: actions/checkout@v3
- - name: Setup .NET
- uses: actions/setup-dotnet@v3
- with:
- dotnet-version: "8.0"
- - name: Package Common Library
- run: dotnet pack src/Common/BitBadger.Documents.Common.fsproj -c Release
- - name: Move Common package
- run: cp src/Common/bin/Release/BitBadger.Documents.Common.*.nupkg .
- - name: Package PostgreSQL Library
- run: dotnet pack src/Postgres/BitBadger.Documents.Postgres.fsproj -c Release
- - name: Move PostgreSQL package
- run: cp src/Postgres/bin/Release/BitBadger.Documents.Postgres.*.nupkg .
- - name: Package SQLite Library
- run: dotnet pack src/Sqlite/BitBadger.Documents.Sqlite.fsproj -c Release
- - name: Move SQLite package
- run: cp src/Sqlite/bin/Release/BitBadger.Documents.Sqlite.*.nupkg .
- - name: Save Packages
- uses: actions/upload-artifact@v3
- with:
- name: packages
- path: |
- *.nupkg
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
+ [