diff options
Diffstat (limited to 'lib/VNLib.Plugins.Extensions.Data')
7 files changed, 485 insertions, 261 deletions
diff --git a/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IDataStore.cs b/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IDataStore.cs index 4e2d682..1c8174c 100644 --- a/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IDataStore.cs +++ b/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IDataStore.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Data @@ -23,11 +23,13 @@ */ using System; +using System.Threading; using System.Threading.Tasks; using System.Collections.Generic; using VNLib.Utils; + namespace VNLib.Plugins.Extensions.Data.Abstractions { /// <summary> @@ -40,20 +42,23 @@ namespace VNLib.Plugins.Extensions.Data.Abstractions /// <summary> /// Gets the total number of records in the current store /// </summary> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task that resolves the number of records in the store</returns> - Task<long> GetCountAsync(); + Task<long> GetCountAsync(CancellationToken cancellation = default); /// <summary> /// Gets the number of records that belong to the specified constraint /// </summary> /// <param name="specifier">A specifier to constrain the reults</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>The number of records that belong to the specifier</returns> - Task<long> GetCountAsync(string specifier); + Task<long> GetCountAsync(string specifier, CancellationToken cancellation = default); /// <summary> /// Gets a record from its key /// </summary> /// <param name="key">The key identifying the unique record</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A promise that resolves the record identified by the specified key</returns> - Task<T?> GetSingleAsync(string key); + Task<T?> GetSingleAsync(string key, CancellationToken cancellation = default); /// <summary> /// Gets a record from its key /// </summary> @@ -64,16 +69,18 @@ namespace VNLib.Plugins.Extensions.Data.Abstractions /// Gets a record from the store with a partial model, intended to complete the model /// </summary> /// <param name="record">The partial model used to query the store</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task the resolves the completed data-model</returns> - Task<T?> GetSingleAsync(T record); + Task<T?> GetSingleAsync(T record, CancellationToken cancellation = default); /// <summary> /// Fills a collection with enires retireved from the store using the specifer /// </summary> /// <param name="collection">The collection to add entires to</param> /// <param name="specifier">A specifier argument to constrain results</param> /// <param name="limit">The maximum number of elements to retrieve</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A Task the resolves to the number of items added to the collection</returns> - Task<ERRNO> GetCollectionAsync(ICollection<T> collection, string specifier, int limit); + Task<ERRNO> GetCollectionAsync(ICollection<T> collection, string specifier, int limit, CancellationToken cancellation = default); /// <summary> /// Fills a collection with enires retireved from the store using a variable length specifier /// parameter @@ -87,26 +94,30 @@ namespace VNLib.Plugins.Extensions.Data.Abstractions /// Updates an entry in the store with the specified record /// </summary> /// <param name="record">The record to update</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task the resolves an error code (should evaluate to false on failure, and true on success)</returns> - Task<ERRNO> UpdateAsync(T record); + Task<ERRNO> UpdateAsync(T record, CancellationToken cancellation = default); /// <summary> /// Creates a new entry in the store representing the specified record /// </summary> /// <param name="record">The record to add to the store</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task the resolves an error code (should evaluate to false on failure, and true on success)</returns> - Task<ERRNO> CreateAsync(T record); + Task<ERRNO> CreateAsync(T record, CancellationToken cancellation = default); /// <summary> /// Deletes one or more entrires from the store matching the specified record /// </summary> /// <param name="record">The record to remove from the store</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task the resolves the number of records removed(should evaluate to false on failure, and deleted count on success)</returns> - Task<ERRNO> DeleteAsync(T record); + Task<ERRNO> DeleteAsync(T record, CancellationToken cancellation = default); /// <summary> /// Deletes one or more entires from the store matching the specified unique key /// </summary> /// <param name="key">The unique key that identifies the record</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task the resolves the number of records removed(should evaluate to false on failure, and deleted count on success)</returns> - Task<ERRNO> DeleteAsync(string key); + Task<ERRNO> DeleteAsync(string key, CancellationToken cancellation = default); /// <summary> /// Deletes one or more entires from the store matching the supplied specifiers /// </summary> @@ -117,7 +128,8 @@ namespace VNLib.Plugins.Extensions.Data.Abstractions /// Updates an entry in the store if it exists, or creates a new entry if one does not already exist /// </summary> /// <param name="record">The record to add to the store</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task the resolves the result of the operation</returns> - Task<ERRNO> AddOrUpdateAsync(T record); + Task<ERRNO> AddOrUpdateAsync(T record, CancellationToken cancellation = default); } } diff --git a/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IPaginatedDataStore.cs b/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IPaginatedDataStore.cs index 2ccb5ab..7c271eb 100644 --- a/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IPaginatedDataStore.cs +++ b/lib/VNLib.Plugins.Extensions.Data/src/Abstractions/IPaginatedDataStore.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Data @@ -22,8 +22,9 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using System.Collections.Generic; namespace VNLib.Plugins.Extensions.Data.Abstractions { @@ -40,8 +41,9 @@ namespace VNLib.Plugins.Extensions.Data.Abstractions /// <param name="collection">The collection to add records to</param> /// <param name="page">Pagination page to get records from</param> /// <param name="limit">The maximum number of items to retrieve from the store</param> + /// <param name="cancellation">A cancellation token to cancel the operation</param> /// <returns>A task that resolves the number of items added to the collection</returns> - Task<int> GetPageAsync(ICollection<T> collection, int page, int limit); + Task<int> GetPageAsync(ICollection<T> collection, int page, int limit, CancellationToken cancellation = default); /// <summary> /// Gets a collection of records using a pagination style query with constraint arguments, and adds the records to the collecion /// </summary> diff --git a/lib/VNLib.Plugins.Extensions.Data/src/DbStore.cs b/lib/VNLib.Plugins.Extensions.Data/src/DbStore.cs index 7beda55..761d78f 100644 --- a/lib/VNLib.Plugins.Extensions.Data/src/DbStore.cs +++ b/lib/VNLib.Plugins.Extensions.Data/src/DbStore.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Data @@ -23,9 +23,11 @@ */ using System; -using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Transactions; using System.Threading.Tasks; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; @@ -35,16 +37,18 @@ using VNLib.Plugins.Extensions.Data.Abstractions; namespace VNLib.Plugins.Extensions.Data { + /// <summary> /// Implements basic data-store functionality with abstract query builders /// </summary> /// <typeparam name="T">A <see cref="DbModelBase"/> implemented type</typeparam> - public abstract class DbStore<T> : IDataStore<T>, IPaginatedDataStore<T> where T: class, IDbModel + public abstract partial class DbStore<T> : IDataStore<T>, IPaginatedDataStore<T> where T: class, IDbModel { /// <summary> /// Gets a unique ID for a new record being added to the store /// </summary> public abstract string RecordIdBuilder { get; } + /// <summary> /// Gets a new <see cref="TransactionalDbContext"/> ready for use /// </summary> @@ -58,13 +62,13 @@ namespace VNLib.Plugins.Extensions.Data #region Add Or Update ///<inheritdoc/> - public virtual async Task<ERRNO> AddOrUpdateAsync(T record) + public virtual async Task<ERRNO> AddOrUpdateAsync(T record, CancellationToken cancellation = default) { //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadCommitted, cancellation); + IQueryable<T> query; + if (string.IsNullOrWhiteSpace(record.Id)) { //Get the application @@ -77,8 +81,10 @@ namespace VNLib.Plugins.Extensions.Data where et.Id == record.Id select et); } + //Using single - T? entry = await query.SingleOrDefaultAsync(); + T? entry = await query.SingleOrDefaultAsync(cancellation); + //Check if creted if (entry == null) { @@ -92,394 +98,301 @@ namespace VNLib.Plugins.Extensions.Data else { OnRecordUpdate(record, entry); - } - //Save changes - ERRNO result = await ctx.SaveChangesAsync(); - if (result) - { - //commit transaction if update was successful - await ctx.CommitTransactionAsync(); - } - return result; + } + + return await ctx.SaveAndCloseAsync(cancellation); } + ///<inheritdoc/> - public virtual async Task<ERRNO> UpdateAsync(T record) + public virtual async Task<ERRNO> UpdateAsync(T record, CancellationToken cancellation = default) { //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.Serializable, cancellation); + //Get the application IQueryable<T> query = UpdateQueryBuilder(ctx, record); + //Using single to make sure only one app is in the db (should never be an issue) - T? oldEntry = await query.SingleOrDefaultAsync(); + T? oldEntry = await query.SingleOrDefaultAsync(cancellation); + if (oldEntry == null) { return false; } + //Update the template meta-data OnRecordUpdate(record, oldEntry); + //Only publish update if changes happened if (!ctx.ChangeTracker.HasChanges()) { //commit transaction if no changes need to be made - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); return true; } - //Save changes - ERRNO result = await ctx.SaveChangesAsync(); - if (result) - { - //commit transaction if update was successful - await ctx.CommitTransactionAsync(); - } - return result; + + return await ctx.SaveAndCloseAsync(cancellation); } + ///<inheritdoc/> - public virtual async Task<ERRNO> CreateAsync(T record) + public virtual async Task<ERRNO> CreateAsync(T record, CancellationToken cancellation = default) { //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Create a new template id record.Id = RecordIdBuilder; + //Update the created/last modified time of the record record.Created = record.LastModified = DateTime.UtcNow; + //Add the new template ctx.Add(record); - //save changes - ERRNO result = await ctx.SaveChangesAsync(); - if (result) - { - //Commit transaction - await ctx.CommitTransactionAsync(); - } - return result; + + return await ctx.SaveAndCloseAsync(cancellation); } - - /// <summary> - /// Builds a query that attempts to get a single entry from the - /// store based on the specified record if it does not have a - /// valid <see cref="DbModelBase.Id"/> property - /// </summary> - /// <param name="context">The active context to query</param> - /// <param name="record">The record to search for</param> - /// <returns>A query that yields a single record if it exists in the store</returns> - protected virtual IQueryable<T> AddOrUpdateQueryBuilder(TransactionalDbContext context, T record) - { - //default to get single of the specific record - return GetSingleQueryBuilder(context, record); - } - /// <summary> - /// Builds a query that attempts to get a single entry from the - /// store to update based on the specified record - /// </summary> - /// <param name="context">The active context to query</param> - /// <param name="record">The record to search for</param> - /// <returns>A query that yields a single record to update if it exists in the store</returns> - protected virtual IQueryable<T> UpdateQueryBuilder(TransactionalDbContext context, T record) - { - //default to get single of the specific record - return GetSingleQueryBuilder(context, record); - } - /// <summary> - /// Updates the current record (if found) to the new record before - /// storing the updates. - /// </summary> - /// <param name="newRecord">The new record to capture data from</param> - /// <param name="currentRecord">The current record to be updated</param> - protected abstract void OnRecordUpdate(T newRecord, T currentRecord); + #endregion #region Delete + ///<inheritdoc/> - public virtual async Task<ERRNO> DeleteAsync(string key) + public virtual async Task<ERRNO> DeleteAsync(string key, CancellationToken cancellation = default) { //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.RepeatableRead, cancellation); + //Get the template by its id IQueryable<T> query = (from temp in ctx.Set<T>() where temp.Id == key select temp); - T? record = await query.SingleOrDefaultAsync(); + + T? record = await query.SingleOrDefaultAsync(cancellation); + if (record == null) { return false; } + //Add the new application ctx.Remove(record); - //Save changes - ERRNO result = await ctx.SaveChangesAsync(); - if (result) - { - //Commit transaction - await ctx.CommitTransactionAsync(); - } - return result; + + return await ctx.SaveAndCloseAsync(cancellation); } + ///<inheritdoc/> - public virtual async Task<ERRNO> DeleteAsync(T record) + public virtual async Task<ERRNO> DeleteAsync(T record, CancellationToken cancellation = default) { //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.RepeatableRead, cancellation); + //Get a query for a a single item IQueryable<T> query = GetSingleQueryBuilder(ctx, record); + //Get the entry - T? entry = await query.SingleOrDefaultAsync(); + T? entry = await query.SingleOrDefaultAsync(cancellation); + if (entry == null) { return false; } + //Add the new application ctx.Remove(entry); - //Save changes - ERRNO result = await ctx.SaveChangesAsync(); - if (result) - { - //Commit transaction - await ctx.CommitTransactionAsync(); - } - return result; + + return await ctx.SaveAndCloseAsync(cancellation); } + ///<inheritdoc/> public virtual async Task<ERRNO> DeleteAsync(params string[] specifiers) { //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.RepeatableRead); + //Get the template by its id IQueryable<T> query = DeleteQueryBuilder(ctx, specifiers); + T? entry = await query.SingleOrDefaultAsync(); + if (entry == null) { return false; } + //Add the new application ctx.Remove(entry); - //Save changes - ERRNO result = await ctx.SaveChangesAsync(); - if (result) - { - //Commit transaction - await ctx.CommitTransactionAsync(); - } - return result; - } - - /// <summary> - /// Builds a query that results in a single entry to delete from the - /// constraint arguments - /// </summary> - /// <param name="context">The active context</param> - /// <param name="constraints">A variable length parameter array of query constraints</param> - /// <returns>A query that yields a single record (or no record) to delete</returns> - protected virtual IQueryable<T> DeleteQueryBuilder(TransactionalDbContext context, params string[] constraints) - { - //default use the get-single method, as the implementation is usually identical - return GetSingleQueryBuilder(context, constraints); + + return await ctx.SaveAndCloseAsync(); } + #endregion #region Get Collection + ///<inheritdoc/> - public virtual async Task<ERRNO> GetCollectionAsync(ICollection<T> collection, string specifier, int limit) + public virtual async Task<ERRNO> GetCollectionAsync(ICollection<T> collection, string specifier, int limit, CancellationToken cancellation = default) { int previous = collection.Count; + //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Get the single template by its id - await GetCollectionQueryBuilder(ctx, specifier).Take(limit).Select(static e => e).ForEachAsync(collection.Add); + await GetCollectionQueryBuilder(ctx, specifier) + .Take(limit) + .Select(static e => e) + .ForEachAsync(collection.Add, cancellation); + //close db and transaction - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); + //Return the number of elements add to the collection return collection.Count - previous; } + ///<inheritdoc/> public virtual async Task<ERRNO> GetCollectionAsync(ICollection<T> collection, int limit, params string[] args) { int previous = collection.Count; + //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); - //Get the single template by its id - await GetCollectionQueryBuilder(ctx, args).Take(limit).Select(static e => e).ForEachAsync(collection.Add); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted); + + //Get the single template by the supplied user arguments + await GetCollectionQueryBuilder(ctx, args) + .Take(limit) + .Select(static e => e) + .ForEachAsync(collection.Add); + //close db and transaction await ctx.CommitTransactionAsync(); + //Return the number of elements add to the collection return collection.Count - previous; } - /// <summary> - /// Builds a query to get a count of records constrained by the specifier - /// </summary> - /// <param name="context">The active context to run the query on</param> - /// <param name="specifier">The specifier constrain</param> - /// <returns>A query that can be counted</returns> - protected virtual IQueryable<T> GetCollectionQueryBuilder(TransactionalDbContext context, string specifier) - { - return GetCollectionQueryBuilder(context, new string[] { specifier }); - } - - /// <summary> - /// Builds a query to get a collection of records based on an variable length array of parameters - /// </summary> - /// <param name="context">The active context to run the query on</param> - /// <param name="constraints">An arguments array to constrain the results of the query</param> - /// <returns>A query that returns a collection of records from the store</returns> - protected abstract IQueryable<T> GetCollectionQueryBuilder(TransactionalDbContext context, params string[] constraints); - #endregion #region Get Count + ///<inheritdoc/> - public virtual async Task<long> GetCountAsync() + public virtual async Task<long> GetCountAsync(CancellationToken cancellation = default) { //Open db connection - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Async get the number of records of the given entity type - long count = await ctx.Set<T>().LongCountAsync(); + long count = await ctx.Set<T>().LongCountAsync(cancellation); + //close db and transaction - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); + return count; } + ///<inheritdoc/> - public virtual async Task<long> GetCountAsync(string specifier) + public virtual async Task<long> GetCountAsync(string specifier, CancellationToken cancellation) { - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Async get the number of records of the given entity type - long count = await GetCountQueryBuilder(ctx, specifier).LongCountAsync(); + long count = await GetCountQueryBuilder(ctx, specifier).LongCountAsync(cancellation); + //close db and transaction - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); + return count; } - /// <summary> - /// Builds a query to get a count of records constrained by the specifier - /// </summary> - /// <param name="context">The active context to run the query on</param> - /// <param name="specifier">The specifier constrain</param> - /// <returns>A query that can be counted</returns> - protected virtual IQueryable<T> GetCountQueryBuilder(TransactionalDbContext context, string specifier) - { - //Default use the get collection and just call the count method - return GetCollectionQueryBuilder(context, specifier); - } + #endregion #region Get Single + ///<inheritdoc/> - public virtual async Task<T?> GetSingleAsync(string key) + public virtual async Task<T?> GetSingleAsync(string key, CancellationToken cancellation = default) { //Open db connection - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Get the single template by its id T? record = await (from entry in ctx.Set<T>() where entry.Id == key select entry) - .SingleOrDefaultAsync(); + .SingleOrDefaultAsync(cancellation); + //close db and transaction - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); return record; } + ///<inheritdoc/> - public virtual async Task<T?> GetSingleAsync(T record) + public virtual async Task<T?> GetSingleAsync(T record, CancellationToken cancellation = default) { //Open db connection - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Get the single template by its id - T? entry = await GetSingleQueryBuilder(ctx, record).SingleOrDefaultAsync(); + T? entry = await GetSingleQueryBuilder(ctx, record).SingleOrDefaultAsync(cancellation); + //close db and transaction - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); + return record; } + ///<inheritdoc/> public virtual async Task<T?> GetSingleAsync(params string[] specifiers) { //Open db connection - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted); + //Get the single template by its id T? record = await GetSingleQueryBuilder(ctx, specifiers).SingleOrDefaultAsync(); + //close db and transaction await ctx.CommitTransactionAsync(); + return record; } - /// <summary> - /// Builds a query to get a single record from the variable length parameter arguments - /// </summary> - /// <param name="context">The context to execute query against</param> - /// <param name="constraints">Arguments to constrain the results of the query to a single record</param> - /// <returns>A query that yields a single record</returns> - protected abstract IQueryable<T> GetSingleQueryBuilder(TransactionalDbContext context, params string[] constraints); - /// <summary> - /// <para> - /// Builds a query to get a single record from the specified record. - /// </para> - /// <para> - /// Unless overridden, performs an ID based query for a single entry - /// </para> - /// </summary> - /// <param name="context">The context to execute query against</param> - /// <param name="record">A record to referrence the lookup</param> - /// <returns>A query that yields a single record</returns> - protected virtual IQueryable<T> GetSingleQueryBuilder(TransactionalDbContext context, T record) - { - return from entry in context.Set<T>() - where entry.Id == record.Id - select entry; - } + #endregion #region Get Page + ///<inheritdoc/> - public virtual async Task<int> GetPageAsync(ICollection<T> collection, int page, int limit) + public virtual async Task<int> GetPageAsync(ICollection<T> collection, int page, int limit, CancellationToken cancellation = default) { //Store preivous count int previous = collection.Count; + //Open db connection - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted, cancellation); + //Get a page offset and a limit for the await ctx.Set<T>() .Skip(page * limit) .Take(limit) .Select(static p => p) - .ForEachAsync(collection.Add); + .ForEachAsync(collection.Add, cancellation); //close db and transaction - await ctx.CommitTransactionAsync(); + await ctx.CommitTransactionAsync(cancellation); + //Return the number of records added return collection.Count - previous; } + ///<inheritdoc/> public virtual async Task<int> GetPageAsync(ICollection<T> collection, int page, int limit, params string[] constraints) { //Store preivous count int previous = collection.Count; + //Open new db context - await using TransactionalDbContext ctx = NewContext(); - //Open transaction - await ctx.OpenTransactionAsync(); - //Get the single template by its id + await using TransactionalDbContext ctx = await this.OpenAsync(IsolationLevel.ReadUncommitted); + + //Get a page of records constrained by the given arguments await GetPageQueryBuilder(ctx, constraints) .Skip(page * limit) .Take(limit) @@ -488,20 +401,11 @@ namespace VNLib.Plugins.Extensions.Data //close db and transaction await ctx.CommitTransactionAsync(); + //Return the number of records added return collection.Count - previous; } - /// <summary> - /// Builds a query to get a collection of records based on an variable length array of parameters - /// </summary> - /// <param name="context">The active context to run the query on</param> - /// <param name="constraints">An arguments array to constrain the results of the query</param> - /// <returns>A query that returns a paginated collection of records from the store</returns> - protected virtual IQueryable<T> GetPageQueryBuilder(TransactionalDbContext context, params string[] constraints) - { - //Default to getting the entire collection and just selecting a single page - return GetCollectionQueryBuilder(context, constraints); - } + #endregion } } diff --git a/lib/VNLib.Plugins.Extensions.Data/src/DbStoreHelperExtensions.cs b/lib/VNLib.Plugins.Extensions.Data/src/DbStoreHelperExtensions.cs new file mode 100644 index 0000000..5971307 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Data/src/DbStoreHelperExtensions.cs @@ -0,0 +1,82 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Data +* File: DbStore.cs +* +* DbStore.cs is part of VNLib.Plugins.Extensions.Data which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Data is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Data is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Transactions; +using System.Threading.Tasks; + +using VNLib.Utils; + +namespace VNLib.Plugins.Extensions.Data +{ + internal static class DbStoreHelperExtensions + { + /// <summary> + /// Commits saves changes on the context and commits the transaction if the result + /// of the operation was successful + /// </summary> + /// <param name="ctx"></param> + /// <param name="cancellation">A token to cancel the operation</param> + /// <returns>A task that resolves the result of the operation</returns> + public static async Task<ERRNO> SaveAndCloseAsync(this TransactionalDbContext ctx, CancellationToken cancellation = default) + { + //Save changes + ERRNO result = await ctx.SaveChangesAsync(cancellation); + + if (result) + { + //commit transaction if update was successful + await ctx.CommitTransactionAsync(cancellation); + } + + return result; + } + + /// <summary> + /// Opens a new database connection and begins a transaction with the specified isolation level + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="store"></param> + /// <param name="level">The transaction isolation level</param> + /// <param name="cancellation">A token to cancel the transaction operation</param> + /// <returns></returns> + public static async Task<TransactionalDbContext> OpenAsync<T>(this DbStore<T> store, IsolationLevel level, CancellationToken cancellation = default) + where T : class, IDbModel + { + //Open new db context + TransactionalDbContext ctx = store.NewContext(); + try + { + //Open transaction + await ctx.OpenTransactionAsync(level, cancellation); + return ctx; + } + catch + { + await ctx.DisposeAsync(); + throw; + } + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Data/src/DbStoreQueries.cs b/lib/VNLib.Plugins.Extensions.Data/src/DbStoreQueries.cs new file mode 100644 index 0000000..ff0319e --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Data/src/DbStoreQueries.cs @@ -0,0 +1,153 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Data +* File: DbStoreQueries.cs +* +* DbStoreQueries.cs is part of VNLib.Plugins.Extensions.Data which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Data is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Data is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; + +namespace VNLib.Plugins.Extensions.Data +{ + + public partial class DbStore<T> + { + + /// <summary> + /// Builds a query that attempts to get a single entry from the + /// store based on the specified record if it does not have a + /// valid <see cref="DbModelBase.Id"/> property + /// </summary> + /// <param name="context">The active context to query</param> + /// <param name="record">The record to search for</param> + /// <returns>A query that yields a single record if it exists in the store</returns> + protected virtual IQueryable<T> AddOrUpdateQueryBuilder(TransactionalDbContext context, T record) + { + //default to get single of the specific record + return GetSingleQueryBuilder(context, record); + } + + /// <summary> + /// Builds a query that attempts to get a single entry from the + /// store to update based on the specified record + /// </summary> + /// <param name="context">The active context to query</param> + /// <param name="record">The record to search for</param> + /// <returns>A query that yields a single record to update if it exists in the store</returns> + protected virtual IQueryable<T> UpdateQueryBuilder(TransactionalDbContext context, T record) + { + //default to get single of the specific record + return GetSingleQueryBuilder(context, record); + } + + + /// <summary> + /// Builds a query that results in a single entry to delete from the + /// constraint arguments + /// </summary> + /// <param name="context">The active context</param> + /// <param name="constraints">A variable length parameter array of query constraints</param> + /// <returns>A query that yields a single record (or no record) to delete</returns> + protected virtual IQueryable<T> DeleteQueryBuilder(TransactionalDbContext context, params string[] constraints) + { + //default use the get-single method, as the implementation is usually identical + return GetSingleQueryBuilder(context, constraints); + } + + /// <summary> + /// Builds a query to get a count of records constrained by the specifier + /// </summary> + /// <param name="context">The active context to run the query on</param> + /// <param name="specifier">The specifier constrain</param> + /// <returns>A query that can be counted</returns> + protected virtual IQueryable<T> GetCollectionQueryBuilder(TransactionalDbContext context, string specifier) + { + return GetCollectionQueryBuilder(context, new string[] { specifier }); + } + + + /// <summary> + /// Builds a query to get a count of records constrained by the specifier + /// </summary> + /// <param name="context">The active context to run the query on</param> + /// <param name="specifier">The specifier constrain</param> + /// <returns>A query that can be counted</returns> + protected virtual IQueryable<T> GetCountQueryBuilder(TransactionalDbContext context, string specifier) + { + //Default use the get collection and just call the count method + return GetCollectionQueryBuilder(context, specifier); + } + + /// <summary> + /// <para> + /// Builds a query to get a single record from the specified record. + /// </para> + /// <para> + /// Unless overridden, performs an ID based query for a single entry + /// </para> + /// </summary> + /// <param name="context">The context to execute query against</param> + /// <param name="record">A record to referrence the lookup</param> + /// <returns>A query that yields a single record</returns> + protected virtual IQueryable<T> GetSingleQueryBuilder(TransactionalDbContext context, T record) + { + return from entry in context.Set<T>() + where entry.Id == record.Id + select entry; + } + + /// <summary> + /// Builds a query to get a collection of records based on an variable length array of parameters + /// </summary> + /// <param name="context">The active context to run the query on</param> + /// <param name="constraints">An arguments array to constrain the results of the query</param> + /// <returns>A query that returns a paginated collection of records from the store</returns> + protected virtual IQueryable<T> GetPageQueryBuilder(TransactionalDbContext context, params string[] constraints) + { + //Default to getting the entire collection and just selecting a single page + return GetCollectionQueryBuilder(context, constraints); + } + + /// <summary> + /// Updates the current record (if found) to the new record before + /// storing the updates. + /// </summary> + /// <param name="newRecord">The new record to capture data from</param> + /// <param name="currentRecord">The current record to be updated</param> + protected abstract void OnRecordUpdate(T newRecord, T currentRecord); + + /// <summary> + /// Builds a query to get a single record from the variable length parameter arguments + /// </summary> + /// <param name="context">The context to execute query against</param> + /// <param name="constraints">Arguments to constrain the results of the query to a single record</param> + /// <returns>A query that yields a single record</returns> + protected abstract IQueryable<T> GetSingleQueryBuilder(TransactionalDbContext context, params string[] constraints); + + /// <summary> + /// Builds a query to get a collection of records based on an variable length array of parameters + /// </summary> + /// <param name="context">The active context to run the query on</param> + /// <param name="constraints">An arguments array to constrain the results of the query</param> + /// <returns>A query that returns a collection of records from the store</returns> + protected abstract IQueryable<T> GetCollectionQueryBuilder(TransactionalDbContext context, params string[] constraints); + } +} diff --git a/lib/VNLib.Plugins.Extensions.Data/src/IConcurrentDbContext.cs b/lib/VNLib.Plugins.Extensions.Data/src/IConcurrentDbContext.cs new file mode 100644 index 0000000..330b05a --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Data/src/IConcurrentDbContext.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Data +* File: IConcurrentDbContext.cs +* +* IConcurrentDbContext.cs is part of VNLib.Plugins.Extensions.Data which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Data is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Data is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; +using System.Transactions; + +namespace VNLib.Plugins.Extensions.Data +{ + /// <summary> + /// Represents a database context that can manage concurrency via transactions + /// </summary> + public interface IConcurrentDbContext : ITransactionalDbContext + { + /// <summary> + /// Opens a single transaction on the current context. If a transaction is already open, + /// it is disposed and a new transaction is begun. + /// </summary> + /// <param name="isolationLevel">The isolation level of the transaction</param> + /// <param name="cancellationToken">A token to cancel the operations</param> + Task OpenTransactionAsync(IsolationLevel isolationLevel, CancellationToken cancellationToken = default); + } +}
\ No newline at end of file diff --git a/lib/VNLib.Plugins.Extensions.Data/src/ProtectedEntityExtensions.cs b/lib/VNLib.Plugins.Extensions.Data/src/ProtectedEntityExtensions.cs index ea8d8cb..ec7b4f5 100644 --- a/lib/VNLib.Plugins.Extensions.Data/src/ProtectedEntityExtensions.cs +++ b/lib/VNLib.Plugins.Extensions.Data/src/ProtectedEntityExtensions.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Extensions.Data @@ -24,13 +24,13 @@ using System.Linq; using System.Threading; +using System.Transactions; using System.Threading.Tasks; using System.Collections.Generic; using VNLib.Utils; using VNLib.Plugins.Extensions.Data.Abstractions; - namespace VNLib.Plugins.Extensions.Data { public static class ProtectedEntityExtensions @@ -41,11 +41,13 @@ namespace VNLib.Plugins.Extensions.Data /// <param name="store"></param> /// <param name="record">The record to update</param> /// <param name="userId">The userid of the record owner</param> + /// <param name="cancellation">A token to cancel the operation</param> /// <returns>A task that evaluates to the number of records modified</returns> - public static Task<ERRNO> UpdateUserRecordAsync<TEntity>(this IDataStore<TEntity> store, TEntity record, string userId) where TEntity : class, IDbModel, IUserEntity + public static Task<ERRNO> UpdateUserRecordAsync<TEntity>(this IDataStore<TEntity> store, TEntity record, string userId, CancellationToken cancellation = default) + where TEntity : class, IDbModel, IUserEntity { record.UserId = userId; - return store.UpdateAsync(record); + return store.UpdateAsync(record, cancellation); } /// <summary> @@ -54,11 +56,13 @@ namespace VNLib.Plugins.Extensions.Data /// <param name="store"></param> /// <param name="record">The record to update</param> /// <param name="userId">The userid of the record owner</param> + /// <param name="cancellation">A token to cancel the operation</param> /// <returns>A task that evaluates to the number of records modified</returns> - public static Task<ERRNO> CreateUserRecordAsync<TEntity>(this IDataStore<TEntity> store, TEntity record, string userId) where TEntity : class, IDbModel, IUserEntity + public static Task<ERRNO> CreateUserRecordAsync<TEntity>(this IDataStore<TEntity> store, TEntity record, string userId, CancellationToken cancellation = default) + where TEntity : class, IDbModel, IUserEntity { record.UserId = userId; - return store.CreateAsync(record); + return store.CreateAsync(record, cancellation); } /// <summary> @@ -108,10 +112,33 @@ namespace VNLib.Plugins.Extensions.Data /// <typeparam name="TEntity"></typeparam> /// <param name="store"></param> /// <param name="userId">The unique id of the user to query record count</param> + /// <param name="cancellation">A token to cancel the operation</param> /// <returns>A task that resolves the number of records belonging to the specified user</returns> - public static Task<long> GetUserRecordCountAsync<TEntity>(this IDataStore<TEntity> store, string userId) where TEntity : class, IDbModel, IUserEntity + public static Task<long> GetUserRecordCountAsync<TEntity>(this IDataStore<TEntity> store, string userId, CancellationToken cancellation = default) + where TEntity : class, IDbModel, IUserEntity + { + return store.GetCountAsync(userId, cancellation); + } + + /// <summary> + /// If the current context instance inherits the <see cref="IConcurrentDbContext"/> interface, + /// attempts to open a transaction with the specified isolation level. + /// </summary> + /// <param name="tdb"></param> + /// <param name="isolationLevel">The transaction isolation level</param> + /// <param name="cancellationToken">A token to cancel the operation</param> + /// <returns></returns> + internal static Task OpenTransactionAsync(this ITransactionalDbContext tdb, IsolationLevel isolationLevel, CancellationToken cancellationToken = default) { - return store.GetCountAsync(userId); + if(tdb is IConcurrentDbContext ccdb) + { + return ccdb.OpenTransactionAsync(isolationLevel, cancellationToken); + } + else + { + //Just ignore the isolation level + return tdb.OpenTransactionAsync(cancellationToken); + } } } } |