PgDoc 3.1.0

dotnet add package PgDoc --version 3.1.0                
NuGet\Install-Package PgDoc -Version 3.1.0                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PgDoc" Version="3.1.0" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add PgDoc --version 3.1.0                
#r "nuget: PgDoc, 3.1.0"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install PgDoc as a Cake Addin
#addin nuget:?package=PgDoc&version=3.1.0

// Install PgDoc as a Cake Tool
#tool nuget:?package=PgDoc&version=3.1.0                

PgDoc

PgDoc

PgDoc is a library for using PostgreSQL as a JSON document store. It manages the mapping between .NET objects and JSON documents.

Philosophy

The traditional approach when dealing with a relational SQL database is to keep a schema defined in SQL language using relational modelling, and then translate this schema into an object-oriented model in the application (usually using an ORM library). Trying to marry these two very different modelling approaches invariably leads to complex layers of abstraction, often treated as "magic" by the developer.

This leads to undesirable complexity, unclear performance characteristics, hard to troubleshoot bugs and maintainability overhead since two separate models have to be maintained.

PgDoc tries to eliminate altogether the need for object-relational mapping. Instead, entities are defined in the application as simple classes and are stored as-is in the database, via a straightforward JSON serialization and deserialization process.

The main benefits of using a RDBMS system such as PostgreSQL are still retained:

  • Ability to use ACID transactions across multiple entities.
  • Ability to use concurrency control to keep the data globally consistent.
  • Ability to query and index complex JSON structures efficiently thanks to GIN indexes.
  • Decades of maturity.
  • Extensive tooling.
  • A permissive open source license.
  • A wide variety of cloud vendors.

Setup

Before using PgDoc, the database schema must be created:

Concepts

Table schema

PgDoc uses a single document table with just three columns:

  • id (of type uuid) is used as the primary key of the table.
  • body contains the JSON document itself.
  • version is used for optimistic concurrency control. It is normally not directly manipulated by the users of PgDoc, but it is used behind the scenes to guarantee safety when using the read-modify-write pattern.

The IJsonEntity<T> type

In the application code, documents are represented using the IJsonEntity<T> type:

public interface IJsonEntity<out T>
{
    /// <summary>
    /// Gets the unique identifier of the document.
    /// </summary>
    public EntityId Id { get; }

    /// <summary>
    /// Gets the body of the document deserialized into an object, or null if the document does not exist.
    /// </summary>
    public T Entity { get; }

    /// /// <summary>
    /// Gets the current version of the document.
    /// </summary>
    public long Version { get; }
}

The Entity property can be null if the document does not exists. This can be the case for a document that hasn't been created yet, or for a document that has been deleted.

Configuration

This package relies on the Microsoft.Extensions.DependencyInjection stack to manage dependencies.

PgDoc can be configured by using the AddPgDoc method at startup.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddPgDoc("connection_string");

This will register several services in the dependency injection container including the EntityStore class.

In ASP.NET Core applications, it is also recommended to register the InitializeDocumentStore filter globally to ensure the IDocumentStore object is automatically initialized. Without this, the IDocumentStore.Initialize method must be called explicitly to open and configure the underlying database connection.

builder.Services.AddControllers(
    options =>
    {
        options.Filters.Add(typeof(InitializeDocumentStore));
    });

Defining document types

Each document type in the database corresponds to a .NET type that defines its schema. The type is serialized to a JSON document before being stored in the database using the System.Text.Json API.

Here is an example of a document type:

[JsonEntityType(1)]
public record Product
{
    public string Name { get; init; }
    public int Aisle { get; init; }
    public decimal Price { get; init; }
    public int StockQuantity { get; init; }
    public IReadOnlyList<string> Categories { get; init; }
}

Since PgDoc uses a single table to store all documents, which could be of different types, the [JsonEntityType] attribute is used to help differenciate them. The value specified through the attribute is stored as the first 32 bits of the ID of the document.

The document_of_type(int) function will return all documents of the specified type, filtering out all the other documents.

Creating a new document

In order to create a new document, the JsonEntity.Create method is used.

Product product = new Product()
{
    Name = "Vanilla Ice Cream",
    Aisle = 3,
    Price = 9.95m,
    StockQuantity = 140,
    Categories = new List<string>() { "Frozen Foods", "Organic" }
};

IJsonEntity<Product> entity = JsonEntity.Create(product);

The ID of the entity will be automatically generated. It can be retrieved using entity.Id.

The EntityStore class must then be used to commit the new document to the database. It can be obtained via dependency injection.

// Obtained via dependency injection
EntityStore store;

await store.UpdateEntities(entity);

This will serialize the object and store it in the database:

{
  "Name": "Vanilla Ice Cream",
  "Aisle": 3,
  "Price": 9.95,
  "StockQuantity": 140,
  "Categories": [
    "Frozen Foods",
    "Organic"
  ]
}

Retrieving a document by ID

The simplest way to retrieve a document is by using its ID, with the GetEntity<T> method.

// The ID of the document is already known
EntityId id;

IJsonEntity<Product?> entity = await store.GetEntity<Product>(id);

If the document does not exist, this method will return a "shadow" IJsonEntity<T> object which has a null body and a version number of 0. It is possible to update this "shadow" document the same way a normal document can be updated, which will result in the document being effectively created in the database.

Modifying a document

PgDoc relies on the read-modify-write pattern, with mandatory optimistic concurrency control to ensure safe writes.

The entity to modify should first be read from the database, either by using its ID as seen above, or using custom SQL queries as seen in the next section.

Once the entity has been retrieved, it can then be modified by calling the Modify method. This method returns a new copy of the original entity with the same ID and version, but a modified body. The new entity is then used with EntityStore.UpdateEntities to commit the update.

Product modifiedProduct = entity.Entity with
{
    Price = 4.95m
};

IJsonEntity<Product?> modifiedEntity = entity.Modify(modifiedProduct);

await store.UpdateEntities(modifiedEntity);

PgDoc will always make sure no update has been made to the document between the time it was read and the time the update was committed. If a conflicting update has been made during that time, an exception of type UpdateConflictException will be thrown at the moment of commiting the update.

Advanced document queries

The full range of PostgreSQL's JSON operators can be used, along with PostgreSQL's JSON indexing capabilities.

Two objects are required: ISqlDocumentStore and IJsonSerializer. Both of these are automatically registered when AddPgDoc is called.

Reusing the Product record previously defined, the code below demonstrates how to define a method that will search for a specific value inside the Categories array of each document.

public class DocumentQueries
{
    private readonly ISqlDocumentStore _documentStore;
    private readonly IJsonSerializer _serializer;

    public DocumentQueries(ISqlDocumentStore documentStore, IJsonSerializer serializer)
    {
        _documentStore = documentStore;
        _serializer = serializer;
    }

    public async Task<IList<IJsonEntity<Product>>> FindByCategory(string category)
    {
        using NpgsqlCommand command = new NpgsqlCommand($@"
            SELECT id, body, version
            FROM document_of_type({EntityType.GetEntityType<Product>()})
            WHERE (body -> 'Categories') ? @category
        ");

        command.Parameters.AddWithValue("@category", category);

        return await _documentStore.ExecuteQuery(command)
            .Select(_serializer.FromExistingDocument<Product>)
            .ToListAsync();
    }
}

This query can be optimized by defining a GIN index:

CREATE INDEX idx_categories ON document
USING GIN ((body -> 'Categories'))
WHERE ((body IS NOT NULL) AND (get_document_type(id) = 1));

The filtering clause ((body IS NOT NULL) AND (get_document_type(id) = 1)) ensures that the index only covers the documents of the correct type. The value 1 corresponds to the value specified in the [JsonEntityType(1)] attribute.

Batch updates

There is often a need to atomically update multiple documents simulteanously. This can be achieved using the BatchBuilder class.

// Obtained via dependency injection
EntityStore store;

BatchBuilder batchBuilder = store.CreateBatchBuilder();

Product modifedProduct1 = entity1.Entity with
{
    StockQuantity = 20
};

batchBuilder.Modify(entity1.Modify(modifedProduct1));

Product modifedProduct2 = entity2.Entity with
{
    StockQuantity = 30
};

batchBuilder.Modify(entity2.Modify(modifedProduct2));

await batchBuilder.Submit();

When batchBuilder.Submit() is called, both documents will be updated together as part of an ACID transaction. If any of the documents have been modified between the time they were read and the time batchBuilder.Submit() is called, an exception of type UpdateConflictException will be thrown, and none of the changes will be committed to the database.

License

Copyright 2016 Flavien Charlon

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on PgDoc:

Package Downloads
PgDoc.Serialization

PgDoc.Serialization is a library for serializing .NET objects to JSON and store them into a PostgreSQL document store.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
3.1.0 486 6/7/2022
3.0.0 424 6/5/2022
2.1.1 661 6/4/2022
2.1.0 683 6/1/2022
2.0.1 452 5/27/2022
2.0.0 452 5/27/2022
2.0.0-alpha.2 111 5/27/2022
2.0.0-alpha.1 349 10/9/2021
1.2.2 543 10/7/2021
1.1.2 2,101 5/21/2019
1.1.1 770 5/19/2019
1.0.6 865 5/12/2019
1.0.5 1,186 1/31/2019
1.0.4 1,258 1/5/2019
1.0.3 905 12/28/2018
1.0.2 859 12/26/2018
1.0.1 2,051 12/8/2018
1.0.0 1,040 12/8/2018
0.5.0 747 12/8/2018
0.4.2 1,197 7/3/2018
0.4.1 863 7/2/2018
0.4.0 823 6/29/2018
0.3.0 944 4/23/2018
0.2.5 947 3/4/2018