database-repository

erymuzuan's avatarfrom erymuzuan

Custom repository pattern and DataContext implementation for JSON-based storage with computed columns.

0stars🔀0forks📁View on GitHub🕐Updated Jan 11, 2026

When & Why to Use This Skill

This Claude skill provides a sophisticated implementation of the Repository Pattern and DataContext, specifically optimized for hybrid JSON-based storage within SQL databases. It bridges the gap between NoSQL flexibility and Relational performance by utilizing SQL computed columns for indexing, while offering a clean Unit of Work (PersistenceSession) abstraction for transaction management and audit tracking in .NET environments.

Use Cases

  • Architecting flexible data models that require the schema-less benefits of JSON storage without sacrificing SQL Server indexing and query performance.
  • Implementing a standardized Data Access Layer (DAL) in C# projects to ensure a clean separation of concerns between business logic and persistence.
  • Managing complex database transactions and multi-entity updates using a robust Unit of Work pattern to ensure data consistency.
  • Optimizing LINQ-to-SQL query translation for specialized scenarios, such as handling SQL 'IN' clauses and JSON-specific data extraction.
  • Streamlining audit logging and entity state management through a centralized persistence session and base entity classes.
namedatabase-repository
descriptionCustom repository pattern and DataContext implementation for JSON-based storage with computed columns.

Database Repository Pattern

Custom repository pattern with JSON storage, adapted from rx-erp.

Pattern Overview

Component Description
Entity Base class with WebId, audit fields
DataContext Query entry point with IQueryable properties
PersistenceSession Unit of Work for transactions
Repository CRUD operations per entity type

Database Design (JSON with Computed Columns)

CREATE TABLE [MotoRent].[Rental]
(
    [RentalId] INT NOT NULL PRIMARY KEY IDENTITY(1,1),
    -- Computed columns for indexing/querying
    [Status] AS CAST(JSON_VALUE([Json], '$.Status') AS NVARCHAR(50)),
    [RenterId] AS CAST(JSON_VALUE([Json], '$.RenterId') AS INT),
    [MotorbikeId] AS CAST(JSON_VALUE([Json], '$.MotorbikeId') AS INT),
    [ShopId] AS CAST(JSON_VALUE([Json], '$.ShopId') AS INT),
    -- DO NOT use JSON_VALUE function for DATE, DATETIMEOFFSET columns
    [StartDate] AS CAST(JSON_VALUE([Json], '$.StartDate') AS DATE),
    -- USE PERSISTENT COLUMN INSTEAD
    [EndDate] DATE NULL,
    -- USE PERSISTENT COLUMN INSTEAD
    [CheckInTimestamp] DATETIMEOFFSET NULL,
    -- JSON storage
    [Json] NVARCHAR(MAX) NOT NULL,
    -- Audit columns
    [CreatedBy] VARCHAR(50) NOT NULL,
    [ChangedBy] VARCHAR(50) NOT NULL,
    [CreatedTimestamp] DATETIMEOFFSET NOT NULL,
    [ChangedTimestamp] DATETIMEOFFSET NOT NULL
)

CREATE INDEX IX_Rental_ShopId_Status ON [MotoRent].[Rental]([ShopId], [Status])

Data Context Pattern

// RentalDataContext.cs
public partial class RentalDataContext
{
    private QueryProvider QueryProvider { get; }

    public RentalDataContext() : this(ObjectBuilder.GetObject<QueryProvider>()) { }
     /// <summary>
    /// Creates a new query for the specified entity type.
    /// Preferred pattern over using Query properties directly.
    /// </summary>
    public Query<T> CreateQuery<T>() where T : Entity, new()
    {
        return new Query<T>(this.QueryProvider);
    }

    public RentalDataContext(QueryProvider provider)
    {
        this.QueryProvider = provider;
    }

    // Preferred: Use CreateQuery<T> instead of Query properties
    public Query<T> CreateQuery<T>() where T : Entity, new()
    {
        return new Query<T>(this.QueryProvider);
    }

    public async Task<T?> LoadOneAsync<T>(Expression<Func<T, bool>> predicate) where T : Entity
    {
        var query = new Query<T>(this.QueryProvider).Where(predicate);
        var repos = ObjectBuilder.GetObject<IRepository<T>>();
        return await repos.LoadOneAsync(query);
    }

    public async Task<LoadOperation<T>> LoadAsync<T>(IQueryable<T> query,
        int page = 1, int size = 40, bool includeTotalRows = false) where T : Entity
    {
        var repos = ObjectBuilder.GetObject<IRepository<T>>();
        return await repos.LoadAsync(query, page, size, includeTotalRows);
    }

    // Aggregate methods
    public async Task<int> GetCountAsync<T>(IQueryable<T> query) where T : Entity;
    public async Task<bool> ExistAsync<T>(IQueryable<T> query) where T : Entity;
    public async Task<TResult> GetSumAsync<T, TResult>(IQueryable<T> query, Expression<Func<T, TResult>> selector);
    public async Task<TResult> GetMaxAsync<T, TResult>(IQueryable<T> query, Expression<Func<T, TResult>> selector);
    public async Task<TResult> GetMinAsync<T, TResult>(IQueryable<T> query, Expression<Func<T, TResult>> selector);
    public async Task<decimal> GetAverageAsync<T>(IQueryable<T> query, Expression<Func<T, decimal>> selector);
    public async Task<TResult?> GetScalarAsync<T, TResult>(IQueryable<T> query, Expression<Func<T, TResult>> selector);
    public async Task<List<TResult>> GetDistinctAsync<T, TResult>(IQueryable<T> query, Expression<Func<T, TResult>> selector);

    public PersistenceSession OpenSession(string username = "system") => new PersistenceSession(this, username);
}

Unit of Work (PersistenceSession)

public sealed class PersistenceSession : IDisposable
{
    private RentalDataContext? m_context;
    internal ObjectCollection<Entity> AttachedCollection { get; } = [];
    internal ObjectCollection<Entity> DeletedCollection { get; } = [];

    public PersistenceSession(RentalDataContext context) => m_context = context;

    public void Attach<T>(params T[] items) where T : Entity
    {
        if (m_context == null)
            throw new ObjectDisposedException("Session has been completed");
        foreach (var item in items)
        {
            if (string.IsNullOrWhiteSpace(item.WebId))
                item.WebId = Guid.NewGuid().ToString();
            this.AttachedCollection.Add(item);
        }
    }

    public void Delete(params Entity[] entities)
    {
        if (m_context == null)
            throw new ObjectDisposedException("Session has been completed");
        this.DeletedCollection.AddRange(entities);
    }

    public async Task<SubmitOperation> SubmitChanges(string operation = "")
    {
        var so = await m_context!.SubmitChangesAsync(this, operation);
        this.AttachedCollection.Clear();
        this.DeletedCollection.Clear();
        m_context = null;
        return so;
    }

    public void Dispose() => m_context = null;
}

Usage Examples

Loading Data

var context = new RentalDataContext();

// Load single entity
var rental = await context.LoadOneAsync<Rental>(r => r.RentalId == id);

// Load with pagination
var query = context.CreateQuery<Rental>()
    .Where(r => r.ShopId == shopId && r.Status == "Active")
    .OrderByDescending(r => r.StartDate);

var result = await context.LoadAsync(query, page: 1, size: 20, includeTotalRows: true);
var rentals = result.ItemCollection;
var totalCount = result.TotalRows;

Saving Data

// Create new rental
var rental = new Rental
{
    ShopId = shopId,
    RenterId = renterId,
    MotorbikeId = motorbikeId,
    StartDate = DateTimeOffset.Now,
    Status = "Active"
};

using var session = context.OpenSession();
session.Attach(rental);
await session.SubmitChanges("CheckIn");  // Publishes: Rental.Changed.CheckIn

Update Pattern

// Load, modify, save
var rental = await context.LoadOneAsync<Rental>(r => r.RentalId == id);
rental!.Status = "Completed";
rental.ActualEndDate = DateTimeOffset.Now;

using var session = context.OpenSession();
session.Attach(rental);
await session.SubmitChanges("CheckOut");

Delete Pattern

var rental = await context.LoadOneAsync<Rental>(r => r.RentalId == id);

using var session = context.OpenSession();
session.Delete(rental!);
await session.SubmitChanges("Delete");

Aggregate Methods

Use aggregate methods instead of LoadAsync when you only need counts or sums:

var context = new RentalDataContext();

// Count
var activeRentals = await context.GetCountAsync(
    context.Rentals.Where(r => r.ShopId == shopId && r.Status == "Active"));

// Exists check
var hasOverdue = await context.ExistAsync(
    context.Rentals.Where(r => r.ShopId == shopId && r.Status == "Overdue"));

// Sum
var totalRevenue = await context.GetSumAsync(
    context.Payments.Where(p => p.ShopId == shopId),
    p => p.Amount);

// Max/Min
var latestRentalDate = await context.GetMaxAsync(
    context.Rentals.Where(r => r.ShopId == shopId),
    r => r.StartDate);

// Average
var avgDailyRate = await context.GetAverageAsync(
    context.Rentals.Where(r => r.ShopId == shopId),
    r => r.DailyRate);

// Distinct values
var uniqueStatuses = await context.GetDistinctAsync(
    context.Rentals.Where(r => r.ShopId == shopId),
    r => r.Status);

Using CreateQuery (Preferred Pattern)

// Instead of using Query properties:
var query = context.Rentals
    .Where(r => r.ShopId == shopId)
    .OrderByDescending(r => r.StartDate);

// Use CreateQuery<T> for better flexibility:
var query = context.CreateQuery<Rental>()
    .Where(r => r.ShopId == shopId)
    .OrderByDescending(r => r.StartDate);

var result = await context.LoadAsync(query, page: 1, size: 20);

WHERE IN Queries (IsInList)

IMPORTANT: Due to C# 14 expression tree changes, you cannot use .Contains() directly in LINQ Where clauses for SQL IN translation. Use the IsInList extension method instead.

using MotoRent.Domain.Extensions;

// WRONG - Does NOT translate to SQL IN clause:
var rentalIds = new[] { 1, 2, 3 };
var query = context.CreateQuery<Payment>()
    .Where(p => rentalIds.Contains(p.RentalId));  // Does NOT work!

// CORRECT - Use IsInList for SQL IN clause:
var rentalIds = new[] { 1, 2, 3 };
var query = context.CreateQuery<Payment>()
    .Where(p => rentalIds.IsInList(p.RentalId));  // Translates to: WHERE [RentalId] IN (1, 2, 3)

var result = await context.LoadAsync(query, page: 1, size: 100);

The IsInList extension method is defined in MotoRent.Domain.Extensions.CollectionExtension:

namespace MotoRent.Domain.Extensions;

public static class CollectionExtension
{
    /// <summary>
    /// Checks if an item is in a list. This method is recognized by the query provider
    /// and translated to SQL IN clause. Use this instead of List.Contains() in LINQ Where clauses.
    /// </summary>
    public static bool IsInList<T>(this IEnumerable<T> list, T item)
    {
        return list.Contains(item);
    }
}

Note: For in-memory filtering (after data is loaded), you can still use .Contains():

// In-memory filtering - .Contains() is fine here:
var result = await context.LoadAsync(query, page: 1, size: 1000);
var filtered = result.ItemCollection.Where(r => rentalIds.Contains(r.RentalId)).ToList();

Best Practices

Practice Description
Short-lived sessions Open session, do work, dispose
Descriptive operations Use meaningful operation names for messaging
Clone for dialogs Always clone entities before passing to edit dialogs
Batch related changes Attach multiple entities in single session
Use aggregates for stats Use GetCountAsync, GetSumAsync instead of loading all entities
CreateQuery over properties Prefer CreateQuery<T>() over Query properties for flexibility
IsInList for WHERE IN Use ids.IsInList(e.Property) for SQL IN clauses, not .Contains()

Source

  • From: E:\project\work\rx-erp repository pattern