diff --git a/src/ContractImplementations.NHibernate/ContractImplementations.NHibernate.csproj b/src/ContractImplementations.NHibernate/ContractImplementations.NHibernate.csproj new file mode 100644 index 0000000..77c8624 --- /dev/null +++ b/src/ContractImplementations.NHibernate/ContractImplementations.NHibernate.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ContractImplementations.NHibernate/EntitySet.cs b/src/ContractImplementations.NHibernate/EntitySet.cs new file mode 100644 index 0000000..c6212d6 --- /dev/null +++ b/src/ContractImplementations.NHibernate/EntitySet.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; +using NHibernate; +using NHibernate.Criterion; +using NHNonUniqueResultException = NHibernate.NonUniqueResultException; +using NonUniqueResultException = IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions.NonUniqueResultException; + +namespace IOKode.OpinionatedFramework.ContractImplementations.NHibernate; + +public class EntitySet : IEntitySet where T : Entity +{ + private readonly ISession _session; + + public EntitySet(ISession session) + { + _session = session; + } + + public async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) + { + try + { + return await _session.LoadAsync(id, cancellationToken); + } + catch (ObjectNotFoundException ex) + { + throw new EntityNotFoundException(id, ex); + } + } + + public async Task GetByIdOrDefaultAsync(object id, CancellationToken cancellationToken = default) + { + return await _session.GetAsync(id, cancellationToken); + } + + public async Task SingleAsync(Filter? filter, CancellationToken cancellationToken = default) + { + var criteria = _session.CreateCriteria(); + ApplyFilter(criteria, filter); + + try + { + var result = await criteria.UniqueResultAsync(cancellationToken); + if (result == null) + { + throw new EmptyResultException(); + } + + return result; + } + catch(NHNonUniqueResultException ex) + { + throw new NonUniqueResultException(ex); + } + } + + public async Task SingleOrDefaultAsync(Filter? filter, CancellationToken cancellationToken = default) + { + var criteria = _session.CreateCriteria(); + ApplyFilter(criteria, filter); + + try + { + return await criteria.UniqueResultAsync(cancellationToken); + } + catch(NHNonUniqueResultException ex) + { + throw new NonUniqueResultException(ex); + } + } + + public async Task FirstAsync(Filter? filter, CancellationToken cancellationToken = default) + { + var criteria = _session.CreateCriteria(); + ApplyFilter(criteria, filter); + criteria.SetMaxResults(1); + + var list = await criteria.ListAsync(cancellationToken); + if (list.Count == 0) + { + throw new EmptyResultException(); + } + + return list[0]; + } + + public async Task FirstOrDefaultAsync(Filter? filter, CancellationToken cancellationToken = default) + { + var criteria = _session.CreateCriteria(); + ApplyFilter(criteria, filter); + criteria.SetMaxResults(1); + + var list = await criteria.ListAsync(cancellationToken); + return list.Count == 0 ? null : list[0]; + } + + public async Task> ManyAsync(Filter? filter, CancellationToken cancellationToken = default) + { + var criteria = _session.CreateCriteria(); + ApplyFilter(criteria, filter); + + var list = await criteria.ListAsync(cancellationToken); + return (IReadOnlyCollection)list; + } + + private void ApplyFilter(ICriteria criteria, Filter? filter) + { + if (filter == null) + { + return; + } + + var criterion = BuildCriterion(filter); + criteria.Add(criterion); + } + + private ICriterion BuildCriterion(Filter filter) + { + return filter switch + { + EqualsFilter eq => Restrictions.Eq(eq.FieldName, eq.Value), + LikeFilter like => Restrictions.Like(like.FieldName, like.Pattern, MatchMode.Anywhere), + InFilter inFilter => Restrictions.In(inFilter.FieldName, inFilter.Values), + BetweenFilter betweenFilter => Restrictions.Between(betweenFilter.FieldName, betweenFilter.Low, betweenFilter.High), + GreaterThanFilter greaterThanFilter => Restrictions.Gt(greaterThanFilter.FieldName, greaterThanFilter.Value), + LessThanFilter lessThanFilter => Restrictions.Lt(lessThanFilter.FieldName, lessThanFilter.Value), + AndFilter andFilter => BuildJunction(andFilter.Filters, isAnd: true), + OrFilter orFilter => BuildJunction(orFilter.Filters, isAnd: false), + NotFilter notFilter => Restrictions.Not(BuildCriterion(notFilter.Filter)), + NotEqualsFilter notEqualsFilter => Restrictions.Not(Restrictions.Eq(notEqualsFilter.FieldName, notEqualsFilter.Value)), + _ => throw new NotSupportedException($"Filter type '{filter.GetType().Name}' is not supported.") + }; + } + + private Junction BuildJunction(Filter[] conditions, bool isAnd) + { + Junction junction = isAnd ? Restrictions.Conjunction() : Restrictions.Disjunction(); + foreach (var cond in conditions) + { + junction.Add(BuildCriterion(cond)); + } + + return junction; + } +} \ No newline at end of file diff --git a/src/ContractImplementations.NHibernate/UnitOfWork.cs b/src/ContractImplementations.NHibernate/UnitOfWork.cs new file mode 100644 index 0000000..2ca29c9 --- /dev/null +++ b/src/ContractImplementations.NHibernate/UnitOfWork.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork.Exceptions; +using NHibernate; + +namespace IOKode.OpinionatedFramework.ContractImplementations.NHibernate; + +public class UnitOfWork : IUnitOfWork, IAsyncDisposable +{ + private readonly Dictionary repositories = new(); + private readonly ISession session; + private ITransaction? transaction; + private bool isRollbacked; + + public UnitOfWork(ISessionFactory sessionFactory) + { + this.session = sessionFactory.OpenSession(); + } + + public bool IsRolledBack => this.isRollbacked; + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + ThrowsIfRolledBack(); + + this.transaction = this.session.BeginTransaction(); + return Task.CompletedTask; + } + + public async Task CommitTransactionAsync(CancellationToken cancellationToken = default) + { + ThrowsIfRolledBack(); + + if (this.transaction is null) + { + throw new InvalidOperationException("No transaction is active."); + } + + await this.transaction.CommitAsync(cancellationToken); + this.transaction.Dispose(); + this.transaction = null; + } + + public async Task RollbackTransactionAsync(CancellationToken cancellationToken = default) + { + ThrowsIfRolledBack(); + + if (this.transaction is null) + { + throw new InvalidOperationException("No transaction is active."); + } + + await this.transaction.RollbackAsync(cancellationToken); + this.transaction.Dispose(); + this.transaction = null; + this.session.Clear(); + await DisposeAsync(); + this.isRollbacked = true; + } + + public bool IsTransactionActive + { + get + { + ThrowsIfRolledBack(); + return this.transaction is {IsActive: true}; + } + } + + public async Task AddAsync(T entity, CancellationToken cancellationToken = default) where T : Entity + { + ThrowsIfRolledBack(); + await this.session.PersistAsync(entity, cancellationToken); + } + + public Task IsTrackedAsync(T entity, CancellationToken cancellationToken = default) where T : Entity + { + ThrowsIfRolledBack(); + return Task.FromResult(session.Contains(entity)); + } + + public async Task StopTrackingAsync(T entity, CancellationToken cancellationToken = default) where T : Entity + { + ThrowsIfRolledBack(); + await this.session.EvictAsync(entity, cancellationToken); + } + + public async Task HasChangesAsync(CancellationToken cancellationToken) + { + ThrowsIfRolledBack(); + return await this.session.IsDirtyAsync(cancellationToken); + } + + public IEntitySet GetEntitySet() where T : Entity + { + ThrowsIfRolledBack(); + return new EntitySet(this.session); + } + + public async Task> RawProjection(string query, IList? parameters = null, CancellationToken cancellationToken = default) + { + ThrowsIfRolledBack(); + + if (parameters != null && !parameters.Any()) + { + parameters = null!; + } + + var transaction = GetTransaction(); + return (await this.session.Connection.QueryAsync(query, parameters, transaction)).ToArray(); + } + + public Repository GetRepository(Type repositoryType) + { + ThrowsIfRolledBack(); + IUnitOfWork.EnsureTypeIsRepository(repositoryType); + + if (this.repositories.TryGetValue(repositoryType, out var repo)) + { + return repo; + } + + // Create an instance of the repository. This assumes the repository has + // a parameterless constructor or a constructor we can access non-publicly. + repo = (Repository)Activator.CreateInstance(repositoryType, nonPublic: true)!; + + // Use reflection to set the UnitOfWork property. + // The property is defined on the Repository base class, so we reflect on typeof(Repository). + // The compiler generates a backing field named `k__BackingField` for auto-properties. + var field = typeof(Repository).GetField("unitOfWork", BindingFlags.Instance | BindingFlags.NonPublic); + + if (field is null) + { + throw new UnreachableException("Could not find the 'unitOfWork' field."); + } + + // Set the field value to the current IUnitOfWork instance + field.SetValue(repo, this); + + this.repositories[repositoryType] = repo; + return repo; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + ThrowsIfRolledBack(); + bool isTransaction = IsTransactionActive; + + if (!isTransaction) + { + await BeginTransactionAsync(cancellationToken); + } + + await this.session.FlushAsync(cancellationToken); + + if (!isTransaction) + { + await CommitTransactionAsync(cancellationToken); + } + } + + public async ValueTask DisposeAsync() + { + if (this.IsTransactionActive) + { + await this.transaction!.RollbackAsync(); + } + this.transaction?.Dispose(); + this.session.Dispose(); + } + + private void ThrowsIfRolledBack() + { + if (IsRolledBack) + { + throw new UnitOfWorkRolledBackException(); + } + } + + private IDbTransaction GetTransaction() + { + using(var command = this.session.Connection.CreateCommand()) + { + this.session.Transaction.Enlist(command); + return command.Transaction; + } + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/AggregateRoot.cs b/src/Foundation/Persistence/AggregateRoot.cs deleted file mode 100644 index d1610fb..0000000 --- a/src/Foundation/Persistence/AggregateRoot.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using IOKode.OpinionatedFramework.Events; - -namespace IOKode.OpinionatedFramework.Persistence; - -public abstract class AggregateRoot -{ - private readonly List _events = new(); - - protected void AddEvent(Event @event) - { - _events.Add(@event); - } - - public IEnumerable Events => _events; -} \ No newline at end of file diff --git a/src/Foundation/Persistence/IRepository.cs b/src/Foundation/Persistence/IRepository.cs deleted file mode 100644 index 9b3b256..0000000 --- a/src/Foundation/Persistence/IRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace IOKode.OpinionatedFramework.Persistence; - -/// -/// Marker interface for repositories. -/// Used in method to check at compile-time that -/// a type is a repository. -/// -public interface IRepository -{ -} \ No newline at end of file diff --git a/src/Foundation/Persistence/IUnitOfWork.cs b/src/Foundation/Persistence/IUnitOfWork.cs deleted file mode 100644 index 4596bc2..0000000 --- a/src/Foundation/Persistence/IUnitOfWork.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using IOKode.OpinionatedFramework.Ensuring; - -namespace IOKode.OpinionatedFramework.Persistence; - -public interface IUnitOfWork -{ - /// - /// Gets a repository instance associated to this unit of work instance. - /// - /// The type of the repository. - /// - public TRepository GetRepository() where TRepository : IRepository - { - var repository = (TRepository) GetRepository(typeof(TRepository)); - return repository; - } - - /// - /// Gets a repository instance associated to this unit of work instance. - /// - /// The type of the repository. - /// - /// Type is not repository. - public IRepository GetRepository(Type repositoryType); - - /// - /// Persist tracked changes into the persistent storage. - /// - /// A cancellation token. - /// - public Task SaveChangesAsync(CancellationToken cancellationToken); - - /// - /// Verifies that the provided type implements . - /// - /// The type to check for implementation. - /// Thrown when the provided '' does not implement . - protected void EnsureTypeIsRepository(Type attemptedRepositoryType) - { - Ensure.Type.IsAssignableTo(attemptedRepositoryType, typeof(IRepository)) - .ElseThrowsIllegalArgument($"The provided type must be a type that implements {nameof(IRepository)}.", - nameof(attemptedRepositoryType)); - } -} \ No newline at end of file diff --git a/src/Foundation/Persistence/IUnitOfWorkFactory.cs b/src/Foundation/Persistence/IUnitOfWorkFactory.cs deleted file mode 100644 index 1c48c9d..0000000 --- a/src/Foundation/Persistence/IUnitOfWorkFactory.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace IOKode.OpinionatedFramework.Persistence; - -public interface IUnitOfWorkFactory -{ - public IUnitOfWork Create(); -} \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Exceptions/EmptyResultException.cs b/src/Foundation/Persistence/QueryBuilder/Exceptions/EmptyResultException.cs new file mode 100644 index 0000000..fa31496 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Exceptions/EmptyResultException.cs @@ -0,0 +1,16 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions; + +public class EmptyResultException : EntitySetException +{ + private const string ExceptionMessage = "The query returned no results when at least one was expected."; + + public EmptyResultException() : base(ExceptionMessage) + { + } + + public EmptyResultException(Exception inner) : base(ExceptionMessage, inner) + { + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Exceptions/EntityNotFoundException.cs b/src/Foundation/Persistence/QueryBuilder/Exceptions/EntityNotFoundException.cs new file mode 100644 index 0000000..5af9099 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Exceptions/EntityNotFoundException.cs @@ -0,0 +1,19 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions; + +public class EntityNotFoundException : EntitySetException +{ + public object AttemptedId { get; } + private const string ExceptionMessage = "No entity was found with the specified ID."; + + public EntityNotFoundException(object attemptedId) : base(ExceptionMessage) + { + AttemptedId = attemptedId; + } + + public EntityNotFoundException(object attemptedId, Exception inner) : base(ExceptionMessage, inner) + { + AttemptedId = attemptedId; + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Exceptions/EntitySetException.cs b/src/Foundation/Persistence/QueryBuilder/Exceptions/EntitySetException.cs new file mode 100644 index 0000000..741228f --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Exceptions/EntitySetException.cs @@ -0,0 +1,14 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions; + +public class EntitySetException : Exception +{ + public EntitySetException(string message) : base(message) + { + } + + public EntitySetException(string message, Exception inner) : base(message, inner) + { + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Exceptions/NonUniqueResultException.cs b/src/Foundation/Persistence/QueryBuilder/Exceptions/NonUniqueResultException.cs new file mode 100644 index 0000000..f6e1bdd --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Exceptions/NonUniqueResultException.cs @@ -0,0 +1,16 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions; + +public class NonUniqueResultException : EntitySetException +{ + private const string ExceptionMessage = "The query returned more than one result when exactly one was expected."; + + public NonUniqueResultException() : base(ExceptionMessage) + { + } + + public NonUniqueResultException(Exception inner) : base(ExceptionMessage, inner) + { + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/AndFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/AndFilter.cs new file mode 100644 index 0000000..b000246 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/AndFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record AndFilter(params Filter[] Filters) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/BetweenFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/BetweenFilter.cs new file mode 100644 index 0000000..4d52a76 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/BetweenFilter.cs @@ -0,0 +1,5 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record BetweenFilter(string FieldName, IComparable Low, IComparable High) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/EqualsFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/EqualsFilter.cs new file mode 100644 index 0000000..4c41793 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/EqualsFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record EqualsFilter(string FieldName, object? Value) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/Filter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/Filter.cs new file mode 100644 index 0000000..4cdbc3f --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/Filter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public abstract record Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/GreaterThanFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/GreaterThanFilter.cs new file mode 100644 index 0000000..7499201 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/GreaterThanFilter.cs @@ -0,0 +1,5 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record GreaterThanFilter(string FieldName, IComparable Value) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/InFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/InFilter.cs new file mode 100644 index 0000000..d0c1fee --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/InFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record InFilter(string FieldName, params object[] Values) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/LessThanFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/LessThanFilter.cs new file mode 100644 index 0000000..d26e3de --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/LessThanFilter.cs @@ -0,0 +1,5 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record LessThanFilter(string FieldName, IComparable Value) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/LikeFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/LikeFilter.cs new file mode 100644 index 0000000..072c61d --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/LikeFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record LikeFilter(string FieldName, string Pattern) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/NotEqualsFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/NotEqualsFilter.cs new file mode 100644 index 0000000..6e86bf3 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/NotEqualsFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record NotEqualsFilter(string FieldName, object? Value) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/NotFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/NotFilter.cs new file mode 100644 index 0000000..8791510 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/NotFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record NotFilter(Filter Filter) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Filters/OrFilter.cs b/src/Foundation/Persistence/QueryBuilder/Filters/OrFilter.cs new file mode 100644 index 0000000..2b76d40 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Filters/OrFilter.cs @@ -0,0 +1,3 @@ +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +public record OrFilter(params Filter[] Filters) : Filter; \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/IEntitySet.cs b/src/Foundation/Persistence/QueryBuilder/IEntitySet.cs new file mode 100644 index 0000000..dae32fe --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/IEntitySet.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; +using Filter = IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters.Filter; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder; + +public interface IEntitySet where T : Entity +{ + Task GetByIdAsync(object id, CancellationToken cancellationToken = default); + Task GetByIdOrDefaultAsync(object id, CancellationToken cancellationToken = default); + Task SingleAsync(Filter? filter = null, CancellationToken cancellationToken = default); + Task SingleOrDefaultAsync(Filter? filter = null, CancellationToken cancellationToken = default); + Task FirstAsync(Filter? filter = null, CancellationToken cancellationToken = default); + Task FirstOrDefaultAsync(Filter? filter = null, CancellationToken cancellationToken = default); + Task> ManyAsync(Filter? filter = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Foundation/Persistence/QueryBuilder/Specification.cs b/src/Foundation/Persistence/QueryBuilder/Specification.cs new file mode 100644 index 0000000..fbc74b6 --- /dev/null +++ b/src/Foundation/Persistence/QueryBuilder/Specification.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; + +namespace IOKode.OpinionatedFramework.Persistence.QueryBuilder; + +public abstract class Specification +{ + private readonly List filters = new(); + + protected void AddFilter(Filter filter) => this.filters.Add(filter); + + /// + /// Converts this specification into a filter. + /// + /// The filter. + /// Thrown when no filters are defined in this specification. + public Filter ToFilter() + { + // If there is only one filter, return it directly; + // if multiple, combine them with AND logic by default. + return this.filters.Count switch + { + 0 => throw new InvalidOperationException("No filters defined in this specification."), + 1 => this.filters[0], + _ => new AndFilter(this.filters.ToArray()) + }; + } + + public static implicit operator Filter(Specification spec) + { + return spec.ToFilter(); + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/UnitOfWork/Entity.cs b/src/Foundation/Persistence/UnitOfWork/Entity.cs new file mode 100644 index 0000000..34e3212 --- /dev/null +++ b/src/Foundation/Persistence/UnitOfWork/Entity.cs @@ -0,0 +1,6 @@ +namespace IOKode.OpinionatedFramework.Persistence.UnitOfWork; + +public abstract class Entity +{ + +} \ No newline at end of file diff --git a/src/Foundation/Persistence/UnitOfWork/Exceptions/UnitOfWorkException.cs b/src/Foundation/Persistence/UnitOfWork/Exceptions/UnitOfWorkException.cs new file mode 100644 index 0000000..d5857cb --- /dev/null +++ b/src/Foundation/Persistence/UnitOfWork/Exceptions/UnitOfWorkException.cs @@ -0,0 +1,14 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.UnitOfWork.Exceptions; + +public class UnitOfWorkException : Exception +{ + public UnitOfWorkException(string message) : base(message) + { + } + + public UnitOfWorkException(string message, Exception inner) : base(message, inner) + { + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/UnitOfWork/Exceptions/UnitOfWorkRolledBackException.cs b/src/Foundation/Persistence/UnitOfWork/Exceptions/UnitOfWorkRolledBackException.cs new file mode 100644 index 0000000..d86e782 --- /dev/null +++ b/src/Foundation/Persistence/UnitOfWork/Exceptions/UnitOfWorkRolledBackException.cs @@ -0,0 +1,16 @@ +using System; + +namespace IOKode.OpinionatedFramework.Persistence.UnitOfWork.Exceptions; + +public class UnitOfWorkRolledBackException : UnitOfWorkException +{ + private const string ExceptionMessage = "The unit of work has been rolled back and can no longer be used."; + + public UnitOfWorkRolledBackException() : base(ExceptionMessage) + { + } + + public UnitOfWorkRolledBackException(Exception inner) : base(ExceptionMessage, inner) + { + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/UnitOfWork/IUnitOfWork.cs b/src/Foundation/Persistence/UnitOfWork/IUnitOfWork.cs new file mode 100644 index 0000000..9daf042 --- /dev/null +++ b/src/Foundation/Persistence/UnitOfWork/IUnitOfWork.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using IOKode.OpinionatedFramework.Ensuring; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder; + +namespace IOKode.OpinionatedFramework.Persistence.UnitOfWork; + +public interface IUnitOfWork +{ + public bool IsRolledBack { get; } + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default); + + public Task CommitTransactionAsync(CancellationToken cancellationToken = default); + + public Task RollbackTransactionAsync(CancellationToken cancellationToken = default); + + public bool IsTransactionActive { get; } + + public Task AddAsync(T entity, CancellationToken cancellationToken = default) where T : Entity; + + public Task IsTrackedAsync(T entity, CancellationToken cancellationToken = default) where T : Entity; + + public Task StopTrackingAsync(T entity, CancellationToken cancellationToken = default) where T : Entity; + + public Task HasChangesAsync(CancellationToken cancellationToken); + + /// + /// Gets an entity set associated to this unit of work instance. + /// + /// The type of the entity. + /// The entity set. + public IEntitySet GetEntitySet() where T : Entity; + + public Task> RawProjection(string query, IList? parameters = null, CancellationToken cancellationToken = default); + + /// + /// Gets a repository instance associated to this unit of work instance. + /// + /// The type of the repository. + /// + public TRepository GetRepository() where TRepository : Repository + { + var repository = (TRepository)GetRepository(typeof(TRepository)); + return repository; + } + + /// + /// Gets a repository instance associated to this unit of work instance. + /// + /// The type of the repository. + /// + /// Type is not repository. + public Repository GetRepository(Type repositoryType); + + /// + /// Persist tracked changes into the persistent storage. + /// + /// A cancellation token. + /// + public Task SaveChangesAsync(CancellationToken cancellationToken); + + /// + /// Verifies that the provided type implements . + /// + /// The type to check for implementation. + /// Thrown when the provided '' does not implement . + protected static void EnsureTypeIsRepository(Type attemptedRepositoryType) + { + Ensure.Type.IsAssignableTo(attemptedRepositoryType, typeof(Repository)) + .ElseThrowsIllegalArgument($"The provided type must be a subtype of {nameof(Repository)}.", nameof(attemptedRepositoryType)); + } +} \ No newline at end of file diff --git a/src/Foundation/Persistence/UnitOfWork/IUnitOfWorkFactory.cs b/src/Foundation/Persistence/UnitOfWork/IUnitOfWorkFactory.cs new file mode 100644 index 0000000..f1cc626 --- /dev/null +++ b/src/Foundation/Persistence/UnitOfWork/IUnitOfWorkFactory.cs @@ -0,0 +1,9 @@ +using IOKode.OpinionatedFramework.Facades; + +namespace IOKode.OpinionatedFramework.Persistence.UnitOfWork; + +[AddToFacade("Uow")] +public interface IUnitOfWorkFactory +{ + public IUnitOfWork Create(); +} \ No newline at end of file diff --git a/src/Foundation/Persistence/UnitOfWork/Repository.cs b/src/Foundation/Persistence/UnitOfWork/Repository.cs new file mode 100644 index 0000000..8209c68 --- /dev/null +++ b/src/Foundation/Persistence/UnitOfWork/Repository.cs @@ -0,0 +1,21 @@ +using IOKode.OpinionatedFramework.Persistence.QueryBuilder; + +namespace IOKode.OpinionatedFramework.Persistence.UnitOfWork; + +public abstract class Repository +{ + private IUnitOfWork unitOfWork = null!; + + /// + /// The unit of work that created this repository. + /// + /// + /// This is set by the contract implementation when the repository is created. + /// + protected IUnitOfWork UnitOfWork => this.unitOfWork; + + protected IEntitySet GetEntitySet() where T : Entity + { + return UnitOfWork.GetEntitySet(); + } +} \ No newline at end of file diff --git a/src/IOKode.OpinionatedFramework.sln b/src/IOKode.OpinionatedFramework.sln index a73bfe7..273d4f5 100644 --- a/src/IOKode.OpinionatedFramework.sln +++ b/src/IOKode.OpinionatedFramework.sln @@ -71,6 +71,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.TaskRunJobs", "Tests. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.Helpers", "Tests.Helpers\Tests.Helpers.csproj", "{8E3B899D-50FA-4166-BD78-582E8E2BB16B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContractImplementations.NHibernate", "ContractImplementations.NHibernate\ContractImplementations.NHibernate.csproj", "{2D5F7A2E-0304-48B4-B01F-0AFF38566552}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.NHibernate", "Tests.NHibernate\Tests.NHibernate.csproj", "{1126989E-9E38-4573-8592-7A8C8E688022}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -197,6 +201,14 @@ Global {8E3B899D-50FA-4166-BD78-582E8E2BB16B}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E3B899D-50FA-4166-BD78-582E8E2BB16B}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E3B899D-50FA-4166-BD78-582E8E2BB16B}.Release|Any CPU.Build.0 = Release|Any CPU + {2D5F7A2E-0304-48B4-B01F-0AFF38566552}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D5F7A2E-0304-48B4-B01F-0AFF38566552}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D5F7A2E-0304-48B4-B01F-0AFF38566552}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D5F7A2E-0304-48B4-B01F-0AFF38566552}.Release|Any CPU.Build.0 = Release|Any CPU + {1126989E-9E38-4573-8592-7A8C8E688022}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1126989E-9E38-4573-8592-7A8C8E688022}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1126989E-9E38-4573-8592-7A8C8E688022}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1126989E-9E38-4573-8592-7A8C8E688022}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -228,5 +240,7 @@ Global {9F7DB6F3-1AD9-4B31-9D02-1AD49224A4A6} = {C33ED8EA-76EF-4985-9055-12D9B668E210} {1F0291CD-0C3D-414F-B7D4-8062A8D62F41} = {756C3C1A-72CD-4CCE-BD8D-977F2980D1B6} {8E3B899D-50FA-4166-BD78-582E8E2BB16B} = {756C3C1A-72CD-4CCE-BD8D-977F2980D1B6} + {2D5F7A2E-0304-48B4-B01F-0AFF38566552} = {C33ED8EA-76EF-4985-9055-12D9B668E210} + {1126989E-9E38-4573-8592-7A8C8E688022} = {756C3C1A-72CD-4CCE-BD8D-977F2980D1B6} EndGlobalSection EndGlobal diff --git a/src/Tests.NHibernate/EntitySetTests.cs b/src/Tests.NHibernate/EntitySetTests.cs new file mode 100644 index 0000000..9df8bd1 --- /dev/null +++ b/src/Tests.NHibernate/EntitySetTests.cs @@ -0,0 +1,259 @@ +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using IOKode.OpinionatedFramework.ContractImplementations.NHibernate; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder.Exceptions; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; +using Xunit; +using Xunit.Abstractions; + +namespace IOKode.OpinionatedFramework.Tests.NHibernate; + +public class EntitySetTests(ITestOutputHelper output) : NHibernateTestsBase(output) +{ + [Fact] + public async Task GetById_Success() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Marta', 'marta@example.com', false);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('3', 'Javier', 'javier@example.com', false);"); + var repository = unitOfWork.GetRepository(); + + // Act and Assert + var user1 = await repository.GetByIdAsync("1", default); + var user2 = await repository.GetByIdAsync("2", default); + var user3 = await repository.GetByIdOrDefaultAsync("3", default); + var userNull = await repository.GetByIdOrDefaultAsync("5", default); + + Assert.Equal("Ivan", user1.Username); + Assert.Equal("ivan@example.com", user1.EmailAddress); + Assert.True(user1.IsActive); + Assert.Equal("Marta", user2.Username); + Assert.Equal("marta@example.com", user2.EmailAddress); + Assert.False(user2.IsActive); + Assert.Equal("Javier", user3.Username); + Assert.Equal("javier@example.com", user3.EmailAddress); + Assert.False(user3.IsActive); + Assert.Null(userNull); + await Assert.ThrowsAsync(async () => + { + await repository.GetByIdAsync("4", default); + }); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task Single_Success() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + var repository = unitOfWork.GetRepository(); + + // Act + var user = await repository.GetByUsernameAsync("Ivan", default); + + // Assert + Assert.Equal("Ivan", user.Username); + Assert.Equal("ivan@example.com", user.EmailAddress); + Assert.True(user.IsActive); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task Single_Fail() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Ivan', 'ivan@example.com', true);"); + var repository = unitOfWork.GetRepository(); + + // Act and Assert + await Assert.ThrowsAsync(async () => { await repository.GetByUsernameAsync("Ivan", default); }); + await Assert.ThrowsAsync(async () => { await repository.GetByUsernameAsync("Marta", default); }); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task SingleOrDefault_Success() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + + // Act + var repository = unitOfWork.GetRepository(); + var user = await repository.GetByEmailAddressOrDefaultAsync("ivan@example.com", default); + var userNull = await repository.GetByEmailAddressOrDefaultAsync("ivan@example.net", default); + + // Assert + Assert.Equal("Ivan", user.Username); + Assert.Equal("ivan@example.com", user.EmailAddress); + Assert.True(user.IsActive); + Assert.Null(userNull); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task SingleOrDefault_Fail() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Ivan', 'ivan@example.com', true);"); + var repository = unitOfWork.GetRepository(); + + // Act and Assert + var user = await repository.GetByEmailAddressOrDefaultAsync("ivan@example.net", default); + Assert.Null(user); + + await Assert.ThrowsAsync(async () => { await repository.GetByEmailAddressOrDefaultAsync("ivan@example.com", default); }); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task First_Success() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Marta', 'marta@example.com', true);"); + var repository = unitOfWork.GetRepository(); + + // Act + var user = await repository.GetFirstActiveAsync(default); + + // Assert + Assert.Equal("Ivan", user.Username); + Assert.Equal("ivan@example.com", user.EmailAddress); + Assert.True(user.IsActive); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task First_Fail() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', false);"); + var repository = unitOfWork.GetRepository(); + + // Act and Assert + await Assert.ThrowsAsync(async () => { await repository.GetFirstActiveAsync(default); }); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task FirstOrDefault_Success() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Ivan', 'ivan2@example.com', false);"); + var repository = unitOfWork.GetRepository(); + + // Act + var user = await repository.GetFirstByNameOrDefaultAsync("Ivan", default); + var userNull = await repository.GetFirstByNameOrDefaultAsync("Marta", default); + + // Assert + Assert.Equal("Ivan", user!.Username); + Assert.Equal("ivan@example.com", user.EmailAddress); + Assert.True(user.IsActive); + Assert.Null(userNull); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task Many_Success() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Marta', 'marta@example.com', false);"); + var repository = unitOfWork.GetRepository(); + + // Act + var users = await repository.GetMultipleByNameAsync(["Ivan", "Marta"], default); + var usersEmpty = await repository.GetMultipleByNameAsync(["Javier"], default); + + // Assert + Assert.Equal(2, users.Count); + Assert.Equal("Ivan", users.ToArray()[0].Username); + Assert.Equal("Marta", users.ToArray()[1].Username); + Assert.Empty(usersEmpty); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task GetAll() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Marta', 'marta@example.com', false);"); + var repository = unitOfWork.GetRepository(); + + // Act + var users = await repository.GetAllAsync(default); + + // Assert + Assert.Equal(2, users.Count); + Assert.Equal("Ivan", users.ToArray()[0].Username); + Assert.Equal("Marta", users.ToArray()[1].Username); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } +} \ No newline at end of file diff --git a/src/Tests.NHibernate/FilterTests.cs b/src/Tests.NHibernate/FilterTests.cs new file mode 100644 index 0000000..8bd6472 --- /dev/null +++ b/src/Tests.NHibernate/FilterTests.cs @@ -0,0 +1,284 @@ +using System.Threading.Tasks; +using Dapper; +using IOKode.OpinionatedFramework.ContractImplementations.NHibernate; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; +using Xunit; +using Xunit.Abstractions; +using NotFilter = IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters.NotFilter; + +namespace IOKode.OpinionatedFramework.Tests.NHibernate; + +public class FilterTests(ITestOutputHelper output) : NHibernateTestsBase(output) +{ + private async Task InsertUsers() + { + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('2', 'Marta', 'marta@example.com', false);"); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('3', 'Javier', 'javier@example.com', false);"); + } + + [Fact] + public async Task EqualsFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new EqualsFilter("emailAddress", "marta@example.com"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, user => Assert.Equal("Marta", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task NotEqualsFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new NotEqualsFilter("username", "Ivan"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Marta", user.Username), + user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task InFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new InFilter("username", "Javier", "Marta"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Marta", user.Username), + user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task LikeFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new LikeFilter("username", "%v%"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Ivan", user.Username), + user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task BetweenFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new BetweenFilter("username", "Ana", "Javier"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Ivan", user.Username), + user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task GreaterThanFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new GreaterThanFilter("username", "Ivan"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Marta", user.Username), + user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task LessThanFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new LessThanFilter("username", "Marta"); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Ivan", user.Username), + user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task AndFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new AndFilter( + new EqualsFilter("isActive", true), + new LessThanFilter("username", "Marta")); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, user => Assert.Equal("Ivan", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task OrFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter = new OrFilter( + new LikeFilter("username", "%n"), + new EqualsFilter("emailAddress", "marta@example.com")); + + // Act + var users = await entitySet.ManyAsync(filter, default); + + // Assert + Assert.Collection(users, + user => Assert.Equal("Ivan", user.Username), + user => Assert.Equal("Marta", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task NotFilter() + { + // Arrange + var sessionFactory = _configuration.BuildSessionFactory(); + IUnitOfWork unitOfWork = new UnitOfWork(sessionFactory); + var entitySet = unitOfWork.GetEntitySet(); + + await CreateUsersTableQueryAsync(); + await InsertUsers(); + + var filter1 = new NotFilter( + new AndFilter( + new EqualsFilter("isActive", true), + new LessThanFilter("username", "Marta") + ) + ); + + var filter2 = new NotFilter( + new OrFilter( + new LikeFilter("username", "%n"), + new EqualsFilter("emailAddress", "marta@example.com") + ) + ); + + // Act + var users1 = await entitySet.ManyAsync(filter1, default); + var users2 = await entitySet.ManyAsync(filter2, default); + + // Assert + Assert.Collection(users1, + user => Assert.Equal("Marta", user.Username), + user => Assert.Equal("Javier", user.Username)); + Assert.Collection(users2, user => Assert.Equal("Javier", user.Username)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } +} \ No newline at end of file diff --git a/src/Tests.NHibernate/NHibernateTestsBase.cs b/src/Tests.NHibernate/NHibernateTestsBase.cs new file mode 100644 index 0000000..8c3257d --- /dev/null +++ b/src/Tests.NHibernate/NHibernateTestsBase.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapper; +using Docker.DotNet; +using Docker.DotNet.Models; +using IOKode.OpinionatedFramework.Utilities; +using Npgsql; +using Xunit; +using Xunit.Abstractions; + +namespace IOKode.OpinionatedFramework.Tests.NHibernate; + +public abstract class NHibernateTestsBase(ITestOutputHelper output) : IAsyncLifetime +{ + protected string _containerId = null!; + protected DockerClient _docker = null!; + protected NpgsqlConnection _npgsqlClient = null!; + protected global::NHibernate.Cfg.Configuration _configuration = null!; + + public async Task InitializeAsync() + { + _docker = new DockerClientConfiguration().CreateClient(); + await pullPostgresImage(); + await runPostgresContainer(); + await waitUntilPostgresServerIsReady(); + + string dbConnectionString = "Server=localhost; Database=testdb; User Id=iokode; Password=secret;"; + + _configuration = new global::NHibernate.Cfg.Configuration(); + _configuration.Properties.Add("connection.connection_string", dbConnectionString); + _configuration.Properties.Add("dialect", "NHibernate.Dialect.PostgreSQL83Dialect"); + _configuration.AddXmlFile("user.hbm.xml"); + + _npgsqlClient = new NpgsqlConnection(dbConnectionString); + await _npgsqlClient.OpenAsync(); + + async Task waitUntilPostgresServerIsReady() + { + bool postgresServerIsReady = await PollingUtility.WaitUntilTrueAsync(async () => + { + var containerInspect = await _docker.Containers.InspectContainerAsync(_containerId); + bool containerIsReady = containerInspect.State.Running; + if (!containerIsReady) + { + return false; + } + + try + { + var dbConnectionString = "Server=localhost; Database=testdb; User Id=iokode; Password=secret;"; + var client = new NpgsqlConnection(dbConnectionString); + await client.OpenAsync(); + await client.QuerySingleAsync("SELECT 1"); + await client.CloseAsync(); + + return true; + } + catch (Exception) + { + return false; + } + }, timeout: 30_000, pollingInterval: 1_000); + + if (!postgresServerIsReady) + { + output.WriteLine("Failed to start Postgres server within the allowed time (30s)."); + } + } + + async Task runPostgresContainer() + { + var container = await _docker.Containers.CreateContainerAsync(new CreateContainerParameters() + { + Image = "postgres", + HostConfig = new HostConfig + { + PortBindings = new Dictionary> + { + { "5432/tcp", [new PortBinding { HostPort = "5432" }] }, + } + }, + Env = [ + "POSTGRES_PASSWORD=secret", + "POSTGRES_USER=iokode", + "POSTGRES_DB=testdb" + ], + Name = "oftest_nhibernate_postgres" + }); + + _containerId = container.ID; + await _docker.Containers.StartContainerAsync(_containerId, new ContainerStartParameters()); + } + + async Task pullPostgresImage() + { + await _docker.Images.CreateImageAsync(new ImagesCreateParameters + { + FromImage = "postgres", + Tag = "latest" + }, null, new Progress(message => { output.WriteLine(message.Status); })); + } + + } + + public async Task DisposeAsync() + { + await _npgsqlClient.CloseAsync(); + await _docker.Containers.StopContainerAsync(_containerId, new ContainerStopParameters()); + await _docker.Containers.RemoveContainerAsync(_containerId, new ContainerRemoveParameters()); + _docker.Dispose(); + } + + protected async Task CreateUsersTableQueryAsync() + { + await _npgsqlClient.ExecuteAsync("CREATE TABLE IF NOT EXISTS Users (id TEXT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL, is_active BOOLEAN NOT NULL);"); + } + + protected async Task DropUsersTableQueryAsync() + { + await _npgsqlClient.ExecuteAsync("DROP TABLE Users;"); + } +} \ No newline at end of file diff --git a/src/Tests.NHibernate/Tests.NHibernate.csproj b/src/Tests.NHibernate/Tests.NHibernate.csproj new file mode 100644 index 0000000..2d1f90c --- /dev/null +++ b/src/Tests.NHibernate/Tests.NHibernate.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/src/Tests.NHibernate/UnitOfWorkTests.cs b/src/Tests.NHibernate/UnitOfWorkTests.cs new file mode 100644 index 0000000..1476968 --- /dev/null +++ b/src/Tests.NHibernate/UnitOfWorkTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using IOKode.OpinionatedFramework.ContractImplementations.NHibernate; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork.Exceptions; +using Xunit; +using Xunit.Abstractions; + +namespace IOKode.OpinionatedFramework.Tests.NHibernate; + +public class UnitOfWorkTests(ITestOutputHelper output) : NHibernateTestsBase(output) +{ + [Fact] + public async Task IdentityMap_SameInstance() + { + // Arrange + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + var repository = unitOfWork.GetRepository(); + + // Act + var user1 = await repository.GetByUsernameAsync("Ivan"); + var user2 = await repository.GetByEmailAddressOrDefaultAsync("ivan@example.com"); + + // Assert + Assert.Same(user1, user2); + + // Arrange post assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task EnsureQueriedEntitiesAreTracked() + { + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + var repository = unitOfWork.GetRepository(); + + var user = await repository.GetByUsernameAsync("Ivan"); + Assert.True(await unitOfWork.IsTrackedAsync(user)); + + user.Username = "Marta"; + await unitOfWork.SaveChangesAsync(default); + + var queriedUser = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)>("SELECT * FROM Users;"); + + Assert.Equal("Marta", queriedUser.Item2); + } + + [Fact] + public async Task Add() + { + // Arrange + await CreateUsersTableQueryAsync(); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + var repository = unitOfWork.GetRepository(); + + // Act & Assert + Assert.False(await unitOfWork.HasChangesAsync(default)); + + var user = new User {Username = "Ivan", EmailAddress = "ivan@example.com", IsActive = true}; + await repository.AddAsync(user, default); + + Assert.True(await unitOfWork.HasChangesAsync(default)); + Assert.True(await unitOfWork.IsTrackedAsync(user, default)); + + var shouldNull = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)?>("SELECT * FROM Users;"); + Assert.Null(shouldNull); + + await unitOfWork.SaveChangesAsync(default); + var shouldSaved = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)>("SELECT * FROM Users;"); + Assert.Equal("Ivan", shouldSaved.Item2); + await Assert.ThrowsAsync(async () => { await repository.AddAsync(user, default); }); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task Transaction() + { + // Arrange + await CreateUsersTableQueryAsync(); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + + Assert.False(unitOfWork.IsTransactionActive); + + await unitOfWork.BeginTransactionAsync(); + Assert.True(unitOfWork.IsTransactionActive); + var repository = unitOfWork.GetRepository(); + + // Act & Assert + Assert.False(await unitOfWork.HasChangesAsync(default)); + + var user = new User {Username = "Ivan", EmailAddress = "ivan@example.com", IsActive = true}; + await repository.AddAsync(user, default); + + Assert.True(await unitOfWork.HasChangesAsync(default)); + Assert.True(await unitOfWork.IsTrackedAsync(user, default)); + + var shouldNull = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)?>("SELECT * FROM Users;"); + Assert.Null(shouldNull); + + await unitOfWork.SaveChangesAsync(default); + Assert.False(await unitOfWork.HasChangesAsync(default)); + var shouldNullBecauseUncommitTsx = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)?>("SELECT * FROM Users;"); + Assert.Null(shouldNullBecauseUncommitTsx); + + await unitOfWork.CommitTransactionAsync(); + Assert.False(unitOfWork.IsTransactionActive); + var shouldSaved = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)?>("SELECT * FROM Users;"); + Assert.Equal("Ivan", shouldSaved!.Value.Item2); + + Assert.True(await unitOfWork.IsTrackedAsync(user, default)); + user.EmailAddress = "ivan2@example.com"; + await unitOfWork.SaveChangesAsync(default); // Outside tsx + var shouldChanged = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)?>("SELECT * FROM Users;"); + Assert.Equal("ivan2@example.com", shouldChanged!.Value.Item3); + + await Assert.ThrowsAsync(async () => await unitOfWork.CommitTransactionAsync()); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task Rollback() + { + // Arrange + await CreateUsersTableQueryAsync(); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + + var repository = unitOfWork.GetRepository(); + + var user = new User {Username = "Ivan", EmailAddress = "ivan@example.com", IsActive = true}; + await repository.AddAsync(user, default); + await unitOfWork.SaveChangesAsync(default); + + await unitOfWork.BeginTransactionAsync(); + user.EmailAddress = "ivan2@example.com"; + await unitOfWork.SaveChangesAsync(default); + await unitOfWork.RollbackTransactionAsync(); + var userSaved = await _npgsqlClient.QuerySingleOrDefaultAsync<(string, string, string, bool)>("SELECT * FROM Users;"); + + await Assert.ThrowsAsync(async () => { await unitOfWork.HasChangesAsync(default); }); + Assert.Equal("ivan2@example.com", user.EmailAddress); + Assert.Equal("ivan@example.com", userSaved.Item3); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task StopTracking() + { + // Arrange + await CreateUsersTableQueryAsync(); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + + var repository = unitOfWork.GetRepository(); + var user = new User {Username = "Ivan", EmailAddress = "ivan@example.com", IsActive = true}; + + // Act and Assert + await repository.AddAsync(user, default); + Assert.True(await unitOfWork.IsTrackedAsync(user, default)); + await unitOfWork.StopTrackingAsync(user, default); + Assert.False(await unitOfWork.IsTrackedAsync(user, default)); + + // Arrange post Assert + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task TransactionsRawProjections() + { + // Arrange + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + IUnitOfWork unitOfWork = new UnitOfWork(_configuration.BuildSessionFactory()); + + // Act + var user = await unitOfWork.GetEntitySet().FirstAsync(); + await unitOfWork.BeginTransactionAsync(); + + user.Username = "Marta"; + await unitOfWork.SaveChangesAsync(default); + + string shouldBeMarta = (await unitOfWork.RawProjection("select name from Users;")).First(); + string shouldBeIvan = (await _npgsqlClient.QueryAsync("select name from Users;")).First(); + + // Assert + Assert.Equal("Marta", shouldBeMarta); + Assert.Equal("Ivan", shouldBeIvan); + + // Arrange post Assert + await unitOfWork.RollbackTransactionAsync(); + await DropUsersTableQueryAsync(); + } + + [Fact] + public async Task MultipleUnitOfWorks() + { + // todo Discuss the behaviour of having more than one UoW + // todo and reasons to have more than one UoW. + + // Arrange + await CreateUsersTableQueryAsync(); + await _npgsqlClient.ExecuteAsync("INSERT INTO Users (id, name, email, is_active) VALUES ('1', 'Ivan', 'ivan@example.com', true);"); + + IUnitOfWork unitOfWork1 = new UnitOfWork(_configuration.BuildSessionFactory()); + IUnitOfWork unitOfWork2 = new UnitOfWork(_configuration.BuildSessionFactory()); + var repository1 = unitOfWork1.GetRepository(); + var repository2 = unitOfWork2.GetRepository(); + + // Act + var user1 = await repository1.GetByIdAsync("1", default); + var user2 = await repository2.GetByIdAsync("1", default); + + // Assert + Assert.NotSame(user1, user2); + // Assert.True(await unitOfWork1.IsTrackedAsync(user1, default)); + // Assert.False(await unitOfWork2.IsTrackedAsync(user1, default)); + // Assert.False(await unitOfWork2.IsTrackedAsync(user2)); + // Assert.True(await unitOfWork1.IsTrackedAsync(user2, default)); + // + // Arrange post Assert + await DropUsersTableQueryAsync(); + } +} \ No newline at end of file diff --git a/src/Tests.NHibernate/User.cs b/src/Tests.NHibernate/User.cs new file mode 100644 index 0000000..132be8c --- /dev/null +++ b/src/Tests.NHibernate/User.cs @@ -0,0 +1,43 @@ +using IOKode.OpinionatedFramework.Ensuring; +using IOKode.OpinionatedFramework.Ensuring.Ensurers; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; + +namespace IOKode.OpinionatedFramework.Tests.NHibernate; + +public class User : Entity +{ + private string id; + private string username; + private string emailAddress; + private bool isActive; + + public User() + { + } + + public required string Username + { + get => this.username; + set + { + Ensure.String.Alphanumeric(value, AlphaOptions.Default).ElseThrowsIllegalArgument(nameof(value), "Invalid username."); + this.username = value; + } + } + + public required string EmailAddress + { + get => this.emailAddress; + set + { + Ensure.String.Email(value).ElseThrowsIllegalArgument(nameof(value), "Invalid email address."); + this.emailAddress = value; + } + } + + public required bool IsActive + { + get => this.isActive; + set => this.isActive = value; + } +} \ No newline at end of file diff --git a/src/Tests.NHibernate/UserRepository.cs b/src/Tests.NHibernate/UserRepository.cs new file mode 100644 index 0000000..905b42c --- /dev/null +++ b/src/Tests.NHibernate/UserRepository.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder; +using IOKode.OpinionatedFramework.Persistence.QueryBuilder.Filters; +using IOKode.OpinionatedFramework.Persistence.UnitOfWork; + +namespace IOKode.OpinionatedFramework.Tests.NHibernate; + +public class UserRepository : Repository +{ + public async Task GetByIdAsync(string id, CancellationToken cancellationToken = default) + { + return await GetEntitySet().GetByIdAsync(id, cancellationToken); + } + + public async Task GetByIdOrDefaultAsync(string id, CancellationToken cancellationToken = default) + { + return await GetEntitySet().GetByIdOrDefaultAsync(id, cancellationToken); + } + + public async Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) + { + return await GetEntitySet().SingleAsync(new ByUsernameSpecification(username), cancellationToken); + } + + public async Task GetByEmailAddressOrDefaultAsync(string emailAddress, CancellationToken cancellationToken = default) + { + return await GetEntitySet().SingleOrDefaultAsync(new EqualsFilter("emailAddress", emailAddress), cancellationToken); + } + + public async Task GetFirstActiveAsync(CancellationToken cancellationToken = default) + { + return await GetEntitySet().FirstAsync(new EqualsFilter("isActive", true), cancellationToken); + } + + public async Task GetFirstByNameOrDefaultAsync(string username, CancellationToken cancellationToken = default) + { + return await GetEntitySet().FirstOrDefaultAsync(new EqualsFilter("username", username), cancellationToken); + } + + public async Task> GetMultipleByNameAsync(ICollection usernames, CancellationToken cancellationToken = default) + { + return await GetEntitySet().ManyAsync(new InFilter("username", usernames.ToArray()), cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await GetEntitySet().ManyAsync(cancellationToken: cancellationToken); + } + + public async Task AddAsync(User user, CancellationToken cancellationToken = default) + { + var repeatedUsernames = await UnitOfWork.RawProjection("select name from Users where name = ?;", [user.Username], cancellationToken); + if (repeatedUsernames.Any()) + { + throw new ArgumentException("User already exists."); + } + await UnitOfWork.AddAsync(user, cancellationToken); + } +} + +public class ByUsernameSpecification : Specification +{ + public ByUsernameSpecification(string username) + { + this.AddFilter(new EqualsFilter("username", username)); + this.AddFilter(new EqualsFilter("isActive", true)); + } +} \ No newline at end of file diff --git a/src/Tests.NHibernate/user.hbm.xml b/src/Tests.NHibernate/user.hbm.xml new file mode 100644 index 0000000..6155b9d --- /dev/null +++ b/src/Tests.NHibernate/user.hbm.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file