From e3acd469d487e3c3146e5cc8b0cd80971a3683b2 Mon Sep 17 00:00:00 2001 From: Sultonbek Rakhimov Date: Tue, 4 Apr 2023 18:23:17 +0500 Subject: [PATCH] #7 Created initial data access layer --- .gitignore | 5 +- .../Abstractions/IUserClever.cs | 11 - .../Clevers/Interfaces/IUserClever.cs | 9 + .../Clevers/UserClever.cs | 59 ++- Tigernet.Samples.RestApi/Models/User.cs | 12 +- Tigernet.Samples.RestApi/Program.cs | 2 +- .../Resters/UsersRester.cs | 19 +- src/Tigernet.Hosting/Actions/ResterBase.cs | 2 +- .../Commons/HttpMethodAttribute.cs | 2 +- .../Attributes/HttpMethods/GetterAttribute.cs | 2 +- .../Attributes/HttpMethods/PosterAttribute.cs | 2 +- .../Query/SearchablePropertyAttribute.cs | 7 + .../DataAccess/Clevers/CleverBase.cs | 64 +++ .../DataAccess/Clevers/ICleverBase.cs | 50 +++ .../Extensions/EntityExtensions.cs | 40 ++ .../Extensions/ExpressionExtensions.cs | 19 + .../Extensions/QueryExtensions.cs | 419 ++++++++++++++++++ .../Extensions/QueryGeneratorExtensions.cs | 299 +++++++++++++ .../Extensions/TypeExtensions.cs | 162 +++++++ src/Tigernet.Hosting/Models/Common/IEntity.cs | 6 + .../Models/Common/IQueryableEntity.cs | 11 + .../Models/Query/EntityQueryOptions.cs | 12 + .../Models/Query/FilterOptions.cs | 21 + .../Models/Query/IEntityQueryOptions.cs | 15 + .../Models/Query/IQueryOptions.cs | 30 ++ .../Models/Query/IncludeOptions.cs | 22 + .../Models/Query/PaginationOptions.cs | 32 ++ .../Models/Query/PredicateBuilder.cs | 50 +++ .../Models/Query/QueryFilter.cs | 19 + .../Models/Query/QueryOptions.cs | 23 + .../Models/Query/SearchOptions.cs | 20 + .../Models/Query/SortOptions.cs | 20 + src/Tigernet.Hosting/Tigernet.Hosting.csproj | 7 +- src/Tigernet.Hosting/TigernetHostBuilder.cs | 36 +- 34 files changed, 1458 insertions(+), 51 deletions(-) delete mode 100644 Tigernet.Samples.RestApi/Abstractions/IUserClever.cs create mode 100644 Tigernet.Samples.RestApi/Clevers/Interfaces/IUserClever.cs create mode 100644 src/Tigernet.Hosting/Attributes/Query/SearchablePropertyAttribute.cs create mode 100644 src/Tigernet.Hosting/DataAccess/Clevers/CleverBase.cs create mode 100644 src/Tigernet.Hosting/DataAccess/Clevers/ICleverBase.cs create mode 100644 src/Tigernet.Hosting/Extensions/EntityExtensions.cs create mode 100644 src/Tigernet.Hosting/Extensions/ExpressionExtensions.cs create mode 100644 src/Tigernet.Hosting/Extensions/QueryExtensions.cs create mode 100644 src/Tigernet.Hosting/Extensions/QueryGeneratorExtensions.cs create mode 100644 src/Tigernet.Hosting/Extensions/TypeExtensions.cs create mode 100644 src/Tigernet.Hosting/Models/Common/IEntity.cs create mode 100644 src/Tigernet.Hosting/Models/Common/IQueryableEntity.cs create mode 100644 src/Tigernet.Hosting/Models/Query/EntityQueryOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/FilterOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/IEntityQueryOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/IQueryOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/IncludeOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/PaginationOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/PredicateBuilder.cs create mode 100644 src/Tigernet.Hosting/Models/Query/QueryFilter.cs create mode 100644 src/Tigernet.Hosting/Models/Query/QueryOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/SearchOptions.cs create mode 100644 src/Tigernet.Hosting/Models/Query/SortOptions.cs diff --git a/.gitignore b/.gitignore index 9491a2f..027940e 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# For JetBrains IDE files +*.idea \ No newline at end of file diff --git a/Tigernet.Samples.RestApi/Abstractions/IUserClever.cs b/Tigernet.Samples.RestApi/Abstractions/IUserClever.cs deleted file mode 100644 index 410e56b..0000000 --- a/Tigernet.Samples.RestApi/Abstractions/IUserClever.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Tigernet.Samples.RestApi.Models; - -namespace Tigernet.Samples.RestApi.Abstractions -{ - public interface IUserClever - { - User Get(int id); - IEnumerable GetAll(); - User Add(User user); - } -} diff --git a/Tigernet.Samples.RestApi/Clevers/Interfaces/IUserClever.cs b/Tigernet.Samples.RestApi/Clevers/Interfaces/IUserClever.cs new file mode 100644 index 0000000..8860404 --- /dev/null +++ b/Tigernet.Samples.RestApi/Clevers/Interfaces/IUserClever.cs @@ -0,0 +1,9 @@ +using Tigernet.Hosting.DataAccess.Clevers; +using Tigernet.Samples.RestApi.Models; + +namespace Tigernet.Samples.RestApi.Clevers.Interfaces; + +public interface IUserClever : ICleverBase +{ + User Add(User user); +} \ No newline at end of file diff --git a/Tigernet.Samples.RestApi/Clevers/UserClever.cs b/Tigernet.Samples.RestApi/Clevers/UserClever.cs index bbcf383..34de086 100644 --- a/Tigernet.Samples.RestApi/Clevers/UserClever.cs +++ b/Tigernet.Samples.RestApi/Clevers/UserClever.cs @@ -1,28 +1,53 @@ -using Tigernet.Samples.RestApi.Abstractions; +using Tigernet.Hosting.DataAccess.Clevers; +using Tigernet.Samples.RestApi.Clevers.Interfaces; using Tigernet.Samples.RestApi.Models; namespace Tigernet.Samples.RestApi.Clevers { - public class UserClever : IUserClever + public class UserClever : CleverBase, IUserClever { - private List users = new List + private static List users = new List { - new User { Id = 1, Name = "Mukhammadkarim", Age = 12 }, - new User { Id = 2, Name = "Samandar", Age = 32 }, - new User { Id = 3, Name = "Djakhongir", Age = 35 }, - new User { Id = 4, Name = "Ixtiyor", Age = 56 }, - new User { Id = 5, Name = "Yunusjon", Age = 34 }, - new User { Id = 6, Name = "Sabohat", Age = 23 }, + new User + { + Id = 1, + Name = "Mukhammadkarim", + Age = 12 + }, + new User + { + Id = 2, + Name = "Samandar", + Age = 32 + }, + new User + { + Id = 3, + Name = "Djakhongir", + Age = 35 + }, + new User + { + Id = 4, + Name = "Ixtiyor", + Age = 56 + }, + new User + { + Id = 5, + Name = "Yunusjon", + Age = 34 + }, + new User + { + Id = 6, + Name = "Sabohat", + Age = 23 + }, }; - public User Get(int id) + public UserClever() : base(users.AsQueryable()) { - return GetAll().FirstOrDefault(p => p.Id == id); - } - - public IEnumerable GetAll() - { - return users; } public User Add(User user) @@ -32,4 +57,4 @@ public User Add(User user) return user; } } -} +} \ No newline at end of file diff --git a/Tigernet.Samples.RestApi/Models/User.cs b/Tigernet.Samples.RestApi/Models/User.cs index a2f95cd..9e03c5d 100644 --- a/Tigernet.Samples.RestApi/Models/User.cs +++ b/Tigernet.Samples.RestApi/Models/User.cs @@ -1,9 +1,15 @@ -namespace Tigernet.Samples.RestApi.Models +using Tigernet.Hosting.Attributes.Query; +using Tigernet.Hosting.Models.Common; + +namespace Tigernet.Samples.RestApi.Models { - public class User + public class User : IEntity, IQueryableEntity { public int Id { get; set; } + + [SearchableProperty] public string Name { get; set; } + public int Age { get; set; } } -} +} \ No newline at end of file diff --git a/Tigernet.Samples.RestApi/Program.cs b/Tigernet.Samples.RestApi/Program.cs index 43a6f6a..7b9e45f 100644 --- a/Tigernet.Samples.RestApi/Program.cs +++ b/Tigernet.Samples.RestApi/Program.cs @@ -1,6 +1,6 @@ using Tigernet.Hosting; -using Tigernet.Samples.RestApi.Abstractions; using Tigernet.Samples.RestApi.Clevers; +using Tigernet.Samples.RestApi.Clevers.Interfaces; var builder = new TigernetHostBuilder("http://localhost:5000/"); diff --git a/Tigernet.Samples.RestApi/Resters/UsersRester.cs b/Tigernet.Samples.RestApi/Resters/UsersRester.cs index 68dbced..f3a7df1 100644 --- a/Tigernet.Samples.RestApi/Resters/UsersRester.cs +++ b/Tigernet.Samples.RestApi/Resters/UsersRester.cs @@ -1,8 +1,10 @@ using Tigernet.Hosting.Actions; using Tigernet.Hosting.Attributes.HttpMethods; using Tigernet.Hosting.Attributes.Resters; -using Tigernet.Samples.RestApi.Abstractions; +using Tigernet.Hosting.Models.Query; +using Tigernet.Samples.RestApi.Clevers.Interfaces; using Tigernet.Samples.RestApi.Models; + namespace Tigernet.Samples.RestApi.Resters { [ApiRester] @@ -15,17 +17,17 @@ public UsersRester(IUserClever userClever) this.userClever = userClever; } - - [Getter("/all")] - public object GetAll() + [Poster("/by-filter")] + public async ValueTask GetByFilter(EntityQueryOptions model) { - return Ok(userClever.GetAll()); + return Ok(await userClever.GetAsync(model)); } [Getter] - public object Get() + public async ValueTask Get() { - return Ok(userClever.Get(1)); + var result = await userClever.GetByIdAsync(1); + return Ok(result); } [Poster("/new")] @@ -36,10 +38,9 @@ public object Add() Id = 7, Name = "Ikrom", Age = 28 - }; return Ok(userClever.Add(user)); } } -} +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Actions/ResterBase.cs b/src/Tigernet.Hosting/Actions/ResterBase.cs index 7bf7b7f..95e6d2b 100644 --- a/src/Tigernet.Hosting/Actions/ResterBase.cs +++ b/src/Tigernet.Hosting/Actions/ResterBase.cs @@ -9,4 +9,4 @@ public object Ok(object data) return JsonSerializer.Serialize(data); } } -} +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Attributes/HttpMethods/Commons/HttpMethodAttribute.cs b/src/Tigernet.Hosting/Attributes/HttpMethods/Commons/HttpMethodAttribute.cs index 64a894b..2dd2327 100644 --- a/src/Tigernet.Hosting/Attributes/HttpMethods/Commons/HttpMethodAttribute.cs +++ b/src/Tigernet.Hosting/Attributes/HttpMethods/Commons/HttpMethodAttribute.cs @@ -1,4 +1,4 @@ -namespace Tigernet.Hosting.Attributes.Commons; +namespace Tigernet.Hosting.Attributes.HttpMethods.Commons; /// /// Identifies an action that supports a given set of HTTP methods. diff --git a/src/Tigernet.Hosting/Attributes/HttpMethods/GetterAttribute.cs b/src/Tigernet.Hosting/Attributes/HttpMethods/GetterAttribute.cs index 78449d6..964ab3f 100644 --- a/src/Tigernet.Hosting/Attributes/HttpMethods/GetterAttribute.cs +++ b/src/Tigernet.Hosting/Attributes/HttpMethods/GetterAttribute.cs @@ -1,4 +1,4 @@ -using Tigernet.Hosting.Attributes.Commons; +using Tigernet.Hosting.Attributes.HttpMethods.Commons; namespace Tigernet.Hosting.Attributes.HttpMethods; diff --git a/src/Tigernet.Hosting/Attributes/HttpMethods/PosterAttribute.cs b/src/Tigernet.Hosting/Attributes/HttpMethods/PosterAttribute.cs index 2147abd..6842619 100644 --- a/src/Tigernet.Hosting/Attributes/HttpMethods/PosterAttribute.cs +++ b/src/Tigernet.Hosting/Attributes/HttpMethods/PosterAttribute.cs @@ -1,4 +1,4 @@ -using Tigernet.Hosting.Attributes.Commons; +using Tigernet.Hosting.Attributes.HttpMethods.Commons; namespace Tigernet.Hosting.Attributes.HttpMethods; diff --git a/src/Tigernet.Hosting/Attributes/Query/SearchablePropertyAttribute.cs b/src/Tigernet.Hosting/Attributes/Query/SearchablePropertyAttribute.cs new file mode 100644 index 0000000..2c46e05 --- /dev/null +++ b/src/Tigernet.Hosting/Attributes/Query/SearchablePropertyAttribute.cs @@ -0,0 +1,7 @@ +namespace Tigernet.Hosting.Attributes.Query +{ + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public class SearchablePropertyAttribute : Attribute + { + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/DataAccess/Clevers/CleverBase.cs b/src/Tigernet.Hosting/DataAccess/Clevers/CleverBase.cs new file mode 100644 index 0000000..0e395cb --- /dev/null +++ b/src/Tigernet.Hosting/DataAccess/Clevers/CleverBase.cs @@ -0,0 +1,64 @@ +using System.Linq.Expressions; +using Tigernet.Hosting.Extensions; +using Tigernet.Hosting.Models.Common; +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.DataAccess.Clevers; + +/// +/// Provides base clever implementation +/// +/// Data type +/// Data Id type +public class CleverBase : ICleverBase where TEntity : class, IEntity, IQueryableEntity where TKey : struct +{ + public IQueryable DataSource { init; get; } + + public CleverBase() + { + DataSource = Enumerable.Empty().AsQueryable(); + } + + public CleverBase(IQueryable dataSource) + { + DataSource = dataSource; + } + + public virtual IQueryable Get(Expression> expression) + { + return DataSource.Where(expression); + } + + public virtual ValueTask> GetAsync( + Expression> expression, + CancellationToken cancellationToken = default + ) + { + if (expression is null) + throw new ArgumentException("Query expression cannot be null"); + + return new ValueTask>(DataSource.Where(expression).ToList()); + } + + public virtual ValueTask> GetAsync(IEntityQueryOptions queryOptions, CancellationToken cancellationToken = default) + { + if (queryOptions is null) + throw new ArgumentException("Query options cannot be null"); + + return new ValueTask>(DataSource.Where(x => true).ApplyQuery(queryOptions).ToList()); + } + + public virtual ValueTask GetFirstAsync(IEntityQueryOptions queryOptions, CancellationToken cancellationToken = default) + { + if (queryOptions is null) + throw new ArgumentException("Query options cannot be null"); + + queryOptions.AddPagination(1, 1); + return new ValueTask(DataSource.Where(x => true).ApplyQuery(queryOptions).FirstOrDefault()); + } + + public virtual ValueTask GetByIdAsync(TKey id, CancellationToken cancellationToken = default) + { + return new ValueTask(Task.FromResult(DataSource.FirstOrDefault(x => x.Id.Equals(id)))); + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/DataAccess/Clevers/ICleverBase.cs b/src/Tigernet.Hosting/DataAccess/Clevers/ICleverBase.cs new file mode 100644 index 0000000..f5eda2a --- /dev/null +++ b/src/Tigernet.Hosting/DataAccess/Clevers/ICleverBase.cs @@ -0,0 +1,50 @@ +using System.Linq.Expressions; +using Tigernet.Hosting.Models.Common; +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.DataAccess.Clevers; + +/// +/// Defines base clever interface +/// +public interface ICleverBase where TEntity : class, IEntity, IQueryableEntity where TKey : struct +{ + /// + /// Gets entity set as queryable collection without decryption + /// + /// Predicate expression + /// Queryable collection of entity + IQueryable Get(Expression> expression); + + /// + /// Gets entity set after querying + /// + /// Predicate expression + /// Cancellation token + /// Set of entities + ValueTask> GetAsync(Expression> expression, CancellationToken cancellationToken = default); + + /// + /// Gets a set of entities by filter + /// + /// Complex query options + /// Cancellation token + /// Set of entities after query + ValueTask> GetAsync(IEntityQueryOptions queryOptions, CancellationToken cancellationToken = default); + + /// + /// Gets a set of entities by filter + /// + /// Complex query options + /// Cancellation token + /// Set of entities after query + ValueTask GetFirstAsync(IEntityQueryOptions queryOptions, CancellationToken cancellationToken = default); + + /// + /// Gets entity by Id + /// + /// Id of entity + /// Cancellation token + /// Entity if found, otherwise null + ValueTask GetByIdAsync(TKey id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Extensions/EntityExtensions.cs b/src/Tigernet.Hosting/Extensions/EntityExtensions.cs new file mode 100644 index 0000000..d08f43b --- /dev/null +++ b/src/Tigernet.Hosting/Extensions/EntityExtensions.cs @@ -0,0 +1,40 @@ +using Tigernet.Hosting.Models.Common; + +namespace Tigernet.Hosting.Extensions; + +/// +/// Provides extensions specifically for entities +/// +public static class EntityExtensions +{ + /// + /// Checks whether given type is entity + /// + /// Type to check + /// True if given type is entity, otherwise false + /// if given type is entity; otherwise. + private static bool IsEntity(this Type type) + { + return type.InheritsOrImplements(typeof(IQueryableEntity)); + } + + /// + /// Gets direct child entities from a type + /// + /// Type to get direct child entities + /// Set of direct child entities + /// If type is null + public static IEnumerable GetDirectChildEntities(this Type type) + { + if (type == null) + throw new ArgumentException("Cannot get direct children for null type"); + + if (!type.IsEntity()) + return Enumerable.Empty(); + + // Get children + var result = type.GetProperties().Where(x => x.PropertyType.IsClass && x.PropertyType.IsEntity()).Select(x => x.PropertyType).ToList(); + + return result.Distinct(); + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Extensions/ExpressionExtensions.cs b/src/Tigernet.Hosting/Extensions/ExpressionExtensions.cs new file mode 100644 index 0000000..3690b10 --- /dev/null +++ b/src/Tigernet.Hosting/Extensions/ExpressionExtensions.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; + +namespace Tigernet.Hosting.Extensions; + +/// +/// Extends base and other types +/// +public static class ExpressionExtensions +{ + public static MemberExpression GetMemberExpression(this Expression> keySelector) where TModel : class + { + return keySelector.Body.NodeType switch + { + ExpressionType.Convert => ((UnaryExpression)keySelector.Body).Operand as MemberExpression, + ExpressionType.MemberAccess => keySelector.Body as MemberExpression, + _ => throw new ArgumentException("Not a member access", nameof(keySelector)) + }; + } +}; \ No newline at end of file diff --git a/src/Tigernet.Hosting/Extensions/QueryExtensions.cs b/src/Tigernet.Hosting/Extensions/QueryExtensions.cs new file mode 100644 index 0000000..1828f6d --- /dev/null +++ b/src/Tigernet.Hosting/Extensions/QueryExtensions.cs @@ -0,0 +1,419 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Tigernet.Hosting.Models.Common; +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Extensions; + +public static class QueryExtensions +{ + #region Querying + + /// + /// Applies given query options to simple enumerable source + /// + /// Enumerable source + /// Query options + /// Query source type + /// Enumerable source + public static IEnumerable ApplyQuery(this IEnumerable source, IQueryOptions queryOptions) where TModel : class + { + if (source == null || queryOptions == null) + throw new ArgumentException("Cannot apply query options to null source or null query options"); + + var result = source; + + if (queryOptions.SearchOptions != null) + result = result.ApplySearch(queryOptions.SearchOptions); + + if (queryOptions.FilterOptions != null) + result = result.ApplyFilter(queryOptions.FilterOptions); + + if (queryOptions.SortOptions != null) + result = result.ApplySort(queryOptions.SortOptions); + + result = result.ApplyPagination(queryOptions.PaginationOptions); + + return result; + } + + /// + /// Applies given query options to simple query source + /// + /// Query source + /// Query options + /// Query source type + /// Queryable source + public static IQueryable ApplyQuery(this IQueryable source, IQueryOptions queryOptions) where TModel : class + { + if (source == null || queryOptions == null) + throw new ArgumentException("Cannot apply query options to null source or null query options"); + + var result = source; + + if (queryOptions.SearchOptions != null) + result = result.ApplySearch(queryOptions.SearchOptions); + + if (queryOptions.FilterOptions != null) + result = result.ApplyFilter(queryOptions.FilterOptions); + + if (queryOptions.SortOptions != null) + result = result.ApplySort(queryOptions.SortOptions); + + result = result.ApplyPagination(queryOptions.PaginationOptions); + + return result; + } + + /// + /// Applies given query options to relational query source + /// + /// Query source + /// Query options + /// Query source type + /// Queryable source + public static IQueryable ApplyQuery(this IQueryable source, IEntityQueryOptions queryOptions) + where TEntity : class, IQueryableEntity + { + if (source == null || queryOptions == null) + throw new ArgumentException("Cannot apply query options to null source or null query options"); + + var result = source; + + if (queryOptions.SearchOptions != null) + result = result.ApplySearch(queryOptions.SearchOptions); + + if (queryOptions.FilterOptions != null) + result = result.ApplyFilter(queryOptions.FilterOptions); + + if (queryOptions.SortOptions != null) + result = result.ApplySort(queryOptions.SortOptions); + + result = result.ApplyPagination(queryOptions.PaginationOptions); + + return result; + } + + #endregion + + #region Searching + + /// + /// Creates expression from search options + /// + /// Search options + /// Query source type + /// Queryable source + private static Expression> GetSearchExpression(this SearchOptions searchOptions) where TModel : class + { + if (searchOptions == null) + throw new ArgumentException("Can't create search expressions for null search options"); + + // Get the properties type of entity + var parameter = Expression.Parameter(typeof(TModel)); + var searchableProperties = typeof(TModel).GetSearchableProperties(); + + // Add searchable properties + var predicates = searchableProperties?.Select(x => + { + // Create predicate expression + var member = Expression.PropertyOrField(parameter, x.Name); + + // Create specific expression based on type + var compareMethod = x.PropertyType.GetCompareMethod(true); + var argument = Expression.Constant(searchOptions.Keyword, x.PropertyType); + var methodCaller = Expression.Call(member, compareMethod!, argument); + return Expression.Lambda>(methodCaller, parameter); + }) + .ToList(); + + // Join predicate expressions + var finalExpression = PredicateBuilder.False; + predicates?.ForEach(x => finalExpression = PredicateBuilder.Or(finalExpression, x)); + + return finalExpression; + } + + /// + /// Applies given searching options to query source + /// + /// Query source + /// Search options + /// Query source type + /// Queryable source + public static IEnumerable ApplySearch(this IEnumerable source, SearchOptions searchOptions) where TModel : class + { + if (source == null || searchOptions == null) + throw new ArgumentException("Can't apply search to null source or with null search options"); + + return source.Where(searchOptions.GetSearchExpression().Compile()); + } + + /// + /// Applies given searching options to query source + /// + /// Query source + /// Search options + /// Query source type + /// Queryable source + public static IQueryable ApplySearch(this IQueryable source, SearchOptions searchOptions) where TModel : class + { + if (source == null || searchOptions == null) + throw new ArgumentException("Can't apply search to null source or with null search options"); + + var searchExpressions = searchOptions.GetSearchExpression(); + + // Include direct child entities if they have searchable properties too + if (source is IQueryable entitySource && searchOptions is SearchOptions entitySearchOptions && + searchExpressions is Expression> entitySearchExpressions) + entitySearchExpressions.AddSearchIncludeExpressions(entitySearchOptions, entitySource); + + return source.Where(searchExpressions); + } + + #endregion + + #region Filtering + + /// + /// Creates expression from filter options + /// + /// Filters + /// Query source type + /// Queryable source + public static Expression> GetFilterExpression(this FilterOptions filterOptions) where TModel : class + { + if (filterOptions == null) + throw new ArgumentException("Can't create filter expressions for null search options"); + + // Get the properties type of entity + var parameter = Expression.Parameter(typeof(TModel)); + var properties = typeof(TModel).GetProperties().Where(x => x.PropertyType.IsSimpleType()).ToList(); + + // Convert filters to predicate expressions + var predicates = filterOptions.Filters.Where(x => properties.Any(y => y.Name.ToLower() == x.Key.ToLower())) + .GroupBy(x => x.Key) + .Select(x => + { + // Create multi choice predicates + var predicate = PredicateBuilder.False; + var multiChoicePredicates = x.Select(y => + { + // Create predicate expression + var property = properties.First(z => string.Equals(z.Name, y.Key, StringComparison.CurrentCultureIgnoreCase)); + var member = Expression.PropertyOrField(parameter, y.Key); + + // Create specific expression based on type + var compareMethod = property.PropertyType.GetCompareMethod(); + var expectedType = compareMethod.GetParameters().First(); + + var argument = Expression.Convert(Expression.Constant(y.GetValue(property.PropertyType)), expectedType.ParameterType); + var methodCaller = Expression.Call(member, compareMethod, argument); + return Expression.Lambda>(methodCaller, parameter); + }) + .ToList(); + + multiChoicePredicates.ForEach(y => predicate = PredicateBuilder.Or(predicate, y)); + return predicate; + }) + .ToList(); + + // Join predicate expressions + var finalExpression = PredicateBuilder.True; + predicates.ForEach(x => finalExpression = PredicateBuilder.And(finalExpression, x)); + + return finalExpression; + } + + /// + /// Applies given filter options to query source + /// + /// Query source + /// Filter options + /// Query source type + /// Queryable source + public static IEnumerable ApplyFilter(this IEnumerable source, FilterOptions filterOptions) where TModel : class + { + if (source == null || filterOptions == null) + throw new ArgumentException("Can't apply filter to null source or with null filter options"); + + return source.Where(filterOptions.GetFilterExpression().Compile()); + } + + public static IQueryable ApplyFilter(this IQueryable source, FilterOptions filterOptions) where TModel : class + { + if (source == null || filterOptions == null) + throw new ArgumentException("Can't apply filter to null source or with null filter options"); + + return source.Where(filterOptions.GetFilterExpression()); + } + + #endregion + + #region Sorting + + /// + /// Applies given sorting options to query source + /// + /// Query source + /// Sort options + /// Query source type + /// Queryable source + public static IEnumerable ApplySort(this IEnumerable source, SortOptions sortOptions) where TModel : class + { + if (source == null || sortOptions == null) + throw new ArgumentException("Can't apply sort to null source or with null sort options"); + + // Get the properties type of entity + var parameter = Expression.Parameter(typeof(TModel)); + var properties = typeof(TModel).GetProperties().Where(x => x.PropertyType.IsSimpleType()).ToList(); + + // Apply sorting + var matchingProperty = properties.FirstOrDefault(x => x.Name.ToLower() == sortOptions.SortField.ToLower()); + + if (matchingProperty == null) + return source; + + var memExp = Expression.PropertyOrField(parameter, matchingProperty.Name); + var keySelector = Expression.Lambda>(memExp, true, parameter).Compile(); + + return sortOptions.SortAscending ? source.OrderBy(keySelector) : source.OrderByDescending(keySelector); + } + + /// + /// Applies given sorting options to query source + /// + /// Query source + /// Sort options + /// Query source type + /// Queryable source + public static IQueryable ApplySort(this IQueryable source, SortOptions sortOptions) where TModel : class + { + if (source == null || sortOptions == null) + throw new ArgumentException("Can't apply sort to null source or with null sort options"); + + // Get the properties type of entity + var parameter = Expression.Parameter(typeof(TModel)); + var properties = typeof(TModel).GetProperties().Where(x => x.PropertyType.IsSimpleType()).ToList(); + + // Apply sorting + var matchingProperty = properties.FirstOrDefault(x => x.Name.ToLower() == sortOptions.SortField.ToLower()); + + if (matchingProperty == null) + return source; + + var memExp = Expression.Convert(Expression.PropertyOrField(parameter, matchingProperty.Name), typeof(object)); + var keySelector = Expression.Lambda>(memExp, true, parameter); + return sortOptions.SortAscending ? source.OrderBy(keySelector) : source.OrderByDescending(keySelector); + } + + #endregion + + #region Including + + private static Expression> AddSearchIncludeExpressions( + this Expression> searchExpressions, + SearchOptions searchOptions, + IQueryable source + ) where TEntity : class, IQueryableEntity + { + if (searchOptions == null || source == null) + throw new ArgumentException("Can't create search include expressions to null source or with null search options"); + + var relatedEntitiesProperty = typeof(TEntity).GetDirectChildEntities() + ?.Select(x => new + { + Entity = x, + SearchableProperties = x.GetSearchableProperties() + }); + var matchingRelatedEntities = relatedEntitiesProperty?.Where(x => x.SearchableProperties.Any()).ToList(); + + // Include models + var predicates = matchingRelatedEntities?.Select(x => + { + // Include matching entities + source.Include(x.Entity.Name); + + // Add matching entity predicates + var parameter = Expression.Parameter(typeof(TEntity)); + + // Add searchable properties + return x.SearchableProperties?.Select(y => + { + // Create predicate expression + var entity = Expression.PropertyOrField(parameter, x.Entity.Name); + var entityProperty = Expression.PropertyOrField(entity, y.Name); + + // Create specific expression based on type + var compareMethod = y.PropertyType.GetCompareMethod(true); + var argument = Expression.Constant(searchOptions.Keyword, y.PropertyType); + var methodCaller = Expression.Call(entityProperty, compareMethod!, argument); + return Expression.Lambda>(methodCaller, parameter); + }); + }) + .ToList(); + + // Join predicate expressions + predicates?.ForEach(x => x?.ToList().ForEach(y => searchExpressions = PredicateBuilder.Or(searchExpressions, y))); + return searchExpressions; + } + + /// + /// Applies given include models options to query source + /// + /// Query source + /// Include models options + /// Query source type + /// Queryable source + public static IQueryable ApplyIncluding(this IQueryable source, IncludeOptions includeOptions) + where TEntity : class, IQueryableEntity + { + if (source == null || includeOptions == null) + throw new ArgumentException("Can't apply include to null source or with null include options"); + + // Include models + includeOptions.IncludeModels = includeOptions.IncludeModels.Select(x => x.ToLower()).ToList(); + var includeModels = typeof(TEntity).GetDirectChildEntities() + .Where(x => includeOptions.IncludeModels.Any(y => x.Name.Equals(y, StringComparison.InvariantCultureIgnoreCase))) + .ToList(); + + includeModels.ForEach(x => { source = source.Include(x.Name); }); + return source; + } + + #endregion + + #region Pagination + + /// + /// Applies given sorting options to query source + /// + /// Query source + /// Sort options + /// Query source type + /// Queryable source + public static IEnumerable ApplyPagination(this IEnumerable source, PaginationOptions paginationOptions) + { + if (source == null || paginationOptions == null) + throw new ArgumentException("Can't apply pagination to null source or with null pagination options"); + + return source.Skip((paginationOptions.PageToken - 1) * paginationOptions.PageSize).Take(paginationOptions.PageSize); + } + + /// + /// Applies given sorting options to query source + /// + /// Query source + /// Sort options + /// Query source type + /// Queryable source + public static IQueryable ApplyPagination(this IQueryable source, PaginationOptions paginationOptions) + { + if (source == null || paginationOptions == null) + throw new ArgumentException("Can't apply pagination to null source or with null pagination options"); + + return source.Skip((paginationOptions.PageToken - 1) * paginationOptions.PageSize).Take(paginationOptions.PageSize); + } + + #endregion +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Extensions/QueryGeneratorExtensions.cs b/src/Tigernet.Hosting/Extensions/QueryGeneratorExtensions.cs new file mode 100644 index 0000000..93c784b --- /dev/null +++ b/src/Tigernet.Hosting/Extensions/QueryGeneratorExtensions.cs @@ -0,0 +1,299 @@ +using System.Linq.Expressions; +using Tigernet.Hosting.Models.Common; +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Extensions; + +/// +/// Provides extension methods to create query options +/// +public static class QueryGeneratorExtensions +{ + #region Generating + + /// + /// Generates query for a type + /// + /// Query source type + /// Query source type + /// Created query options + public static IQueryOptions CreateQuery(this Type sourceType) where TModel : class + { + return sourceType != null ? new QueryOptions() : throw new ArgumentException("Can't create query options for null source"); + } + + /// + /// Generates query for a entity type + /// + /// Query source type + /// Query source type + /// Created query options + public static IEntityQueryOptions CreateQuery(this TEntity sourceType) where TEntity : class, IQueryableEntity + { + return sourceType != null + ? new EntityQueryOptions() + : throw new ArgumentException("Can't create entity query options for null entity source"); + } + + #endregion + + #region Adding search + + /// + /// Adds search options to given query options + /// + /// The query options + /// Search keyword + /// Determines whether to include children + /// Query source type + /// Updated query options + /// If given options is null + public static IQueryOptions AddSearch(this IQueryOptions options, string keyword, bool includeChildren = false) + where TModel : class + { + if (options == null || string.IsNullOrWhiteSpace(keyword)) + throw new ArgumentException("Can't add search options to null query options or with empty keyword"); + + options.SearchOptions = new SearchOptions(keyword, includeChildren); + return options; + } + + /// + /// Adds search options to given query options + /// + /// The query options + /// Search keyword + /// Determines whether to include children + /// Query source type + /// Updated query options + /// If given options is null + public static IEntityQueryOptions AddSearch( + this IEntityQueryOptions options, + string keyword, + bool includeChildren = false + ) where TEntity : class, IQueryableEntity + { + if (options == null || string.IsNullOrWhiteSpace(keyword)) + throw new ArgumentException("Can't add search options to null entity query options or with empty keyword"); + + options.SearchOptions = new SearchOptions(keyword, includeChildren); + return options; + } + + #endregion + + #region Adding filter + + /// + /// Adds search options to given query options + /// + /// The query options + /// Model filter property selector + /// Value to filter with + /// Query source type + /// Query options + /// Updated query options + /// If query options, key selector or value is null + public static TOptions AddFilter(this TOptions options, Expression> keySelector, object value) + where TModel : class where TOptions : IQueryOptions + { + if (options == null || keySelector == null) + throw new ArgumentException("Can't add filter to null query options or with null key selector"); + + // Get property name + var memberExpression = keySelector.GetMemberExpression(); + var propertyName = memberExpression?.Member.Name ?? throw new InvalidOperationException(); + + // TODO : Check value to property type + options.FilterOptions = options.FilterOptions ?? new FilterOptions(); + options.FilterOptions.Filters.Add(new QueryFilter(propertyName, value.ToString())); + + return options; + } + + /// + /// Adds search options to given query options + /// + /// The query options + /// Model filter property selector + /// Value to filter with + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IEntityQueryOptions AddFilter( + this IEntityQueryOptions options, + Expression> keySelector, + object value + ) where TEntity : class, IQueryableEntity + { + if (options == null || keySelector == null) + throw new ArgumentException("Can't add filter to null entity query options or with null key selector"); + + // Get property name + var memberExpression = keySelector.GetMemberExpression(); + var propertyName = memberExpression?.Member.Name ?? throw new InvalidOperationException(); + + // TODO : Check value to property type + options.FilterOptions = options.FilterOptions ?? new FilterOptions(); + options.FilterOptions.Filters.Add(new QueryFilter(propertyName, value?.ToString())); + + return options; + } + + #endregion + + #region Adding include + + /// + /// Adds include options to given query options + /// + /// The query options + /// Model filter property selector + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IEntityQueryOptions AddInclude( + this IEntityQueryOptions options, + Expression> keySelector + ) where TEntity : class, IQueryableEntity + { + if (options == null || keySelector == null) + throw new ArgumentException("Can't add include to null entity query options or with null key selector"); + + // Get property name + var memberExpression = keySelector.GetMemberExpression(); + var propertyName = memberExpression?.Member.Name ?? throw new InvalidOperationException(); + + // TODO : Check value to property type + options.IncludeOptions = options.IncludeOptions ?? new IncludeOptions(); + options.IncludeOptions.IncludeModels.Add(propertyName); + + return options; + } + + /// + /// Adds include options to given query options + /// + /// The query options + /// Model filter property selector + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IEntityQueryOptions AddInclude( + this IEntityQueryOptions options, + Expression>> keySelector + ) where TEntity : class, IQueryableEntity + { + if (options == null || keySelector == null) + throw new ArgumentException("Can't add include to null query options or with null key selector"); + + // Get property name + var memberExpression = keySelector.GetMemberExpression(); + var propertyName = memberExpression?.Member.Name ?? throw new InvalidOperationException(); + + // TODO : Check value to property type + options.IncludeOptions = options.IncludeOptions ?? new IncludeOptions(); + options.IncludeOptions.IncludeModels.Add(propertyName); + + return options; + } + + #endregion + + #region Adding sort + + /// + /// Adds sort options to given query options + /// + /// The query options + /// Model sort property selector + /// Value to filter with + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IQueryOptions AddSort( + this IQueryOptions options, + Expression> keySelector, + bool sortAscending + ) where TModel : class + { + if (options == null || keySelector == null) + throw new ArgumentException("Can't add sort to null query options or with null key selector"); + + // Get property name + var memberExpression = keySelector.GetMemberExpression(); + var propertyName = memberExpression?.Member.Name ?? throw new InvalidOperationException(); + + options.SortOptions = new SortOptions(propertyName, sortAscending); + return options; + } + + /// + /// Adds sort options to given query options + /// + /// The query options + /// Model sort property selector + /// Value to filter with + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IEntityQueryOptions AddSort( + this IEntityQueryOptions options, + Expression> keySelector, + bool sortAscending + ) where TEntity : class, IQueryableEntity + { + if (options == null || keySelector == null) + throw new ArgumentException("Can't add sort to null entity query options or with null key selector"); + + // Get property name + var memberExpression = keySelector.GetMemberExpression(); + var propertyName = memberExpression?.Member.Name ?? throw new InvalidOperationException(); + + options.SortOptions = new SortOptions(propertyName, sortAscending); + return options; + } + + #endregion + + #region Adding pagination + + /// + /// Adds pagination options to given query options + /// + /// The query options + /// Determines how many items should be selected + /// Determines which section of items should be returned + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IQueryOptions AddPagination(this IQueryOptions options, int pageSize, int pageToken) where TModel : class + { + if (options == null) + throw new ArgumentException("Can't add pagination to null query options"); + + options.PaginationOptions = new PaginationOptions(pageSize, pageToken); + return options; + } + + /// + /// Adds pagination options to given query options + /// + /// The query options + /// Determines how many items should be selected + /// Determines which section of items should be returned + /// Query source type + /// Updated query options + /// If query options, key selector or value is null + public static IEntityQueryOptions AddPagination(this IEntityQueryOptions options, int pageSize, int pageToken) + where TEntity : class, IQueryableEntity + { + if (options == null) + throw new ArgumentException("Can't add pagination to null entity query options"); + + options.PaginationOptions = new PaginationOptions(pageSize, pageToken); + return options; + } + + #endregion +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Extensions/TypeExtensions.cs b/src/Tigernet.Hosting/Extensions/TypeExtensions.cs new file mode 100644 index 0000000..357711f --- /dev/null +++ b/src/Tigernet.Hosting/Extensions/TypeExtensions.cs @@ -0,0 +1,162 @@ +using System.Linq.Expressions; +using System.Reflection; +using Tigernet.Hosting.Attributes.Query; +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Extensions; + +/// +/// Extensions for type operations +/// +public static class TypeExtensions +{ + /// + /// Determines whether type A is child of type B + /// + /// Type checked as child + /// Type checked as parent + /// Result of the check, true if is child + public static bool InheritsOrImplements(this Type child, Type parent) + { + var par = parent; + return InheritsOrImplementsHalf(child, ref parent) || par.IsAssignableFrom(child); + } + + /// + /// Determines whether type A inherits or implements type B + /// + /// Type checked as derived or implementing type + /// Type checked as parent or interface type + /// Result of the check, true if does inherit or implement + private static bool InheritsOrImplementsHalf(Type child, ref Type parent) + { + parent = ResolveGenericTypeDefinition(parent); + var currentChild = child.IsGenericType ? child.GetGenericTypeDefinition() : child; + while (currentChild != typeof(object)) + { + if (parent == currentChild || HasAnyInterfaces(parent, currentChild)) + return true; + currentChild = currentChild.BaseType is { IsGenericType: true } + ? currentChild.BaseType.GetGenericTypeDefinition() + : currentChild.BaseType; + if (currentChild == null) + return false; + } + + return false; + } + + /// + /// Determines whether type A implements type B as direct interface + /// + /// Type checked as implementing type + /// Type checked as interface type + /// Result of the check, true if does implement + private static bool HasAnyInterfaces(Type parent, Type child) + { + return child.GetInterfaces() + .Any(childInterface => + { + var currentInterface = childInterface.IsGenericType ? childInterface.GetGenericTypeDefinition() : childInterface; + + return currentInterface == parent; + }); + } + + /// + /// Gets generic type definition from a type + /// + /// Type being resolved + /// Generic type + private static Type ResolveGenericTypeDefinition(Type parent) + { + var shouldUseGenericType = !(parent.IsGenericType && parent.GetGenericTypeDefinition() != parent); + if (parent.IsGenericType && shouldUseGenericType) + parent = parent.GetGenericTypeDefinition(); + return parent; + } + + /// + /// Checks if type is simple + /// + /// Type to check + /// True if type is simple, otherwise false + public static bool IsSimpleType(this Type type) + { + return type.IsPrimitive || type == typeof(string) || type == typeof(DateTime) || type == typeof(DateTime?) || type == typeof(bool?); + } + + /// + /// Gets appropriate search method for a type + /// + /// Type in request + /// Determines whether to use search comparing methods + /// Method info of the compare method + /// If type is not primitive + /// If type is null + /// If not method found + public static MethodInfo GetCompareMethod(this Type type, bool searchComparing = false) + { + if (type == null) + throw new ArgumentNullException(); + + if (!type.IsSimpleType()) + throw new ArgumentException("Not a primitive type"); + + var methodName = type == typeof(string) && searchComparing ? "Contains" : "Equals"; + return type.GetMethod(methodName, new[] { type }) ?? throw new InvalidOperationException("Method not found"); + } + + /// + /// Gets value in appropriate type in boxed format + /// + /// Filter value + /// Type in request + /// Boxed filter value in its type + /// If type is not primitive + /// If filter or type is null + /// if no parse method found + public static object GetValue(this QueryFilter filter, Type type) + { + if (filter == null || type == null) + throw new ArgumentNullException(); + + if (!type.IsSimpleType()) + throw new ArgumentException("Not a primitive type"); + + // Return string or parsed value + if (type == typeof(string)) + { + return filter.Value; + } + else + { + // Create specific expression based on type + var parameter = Expression.Parameter(typeof(string)); + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + var parseMethod = underlyingType.GetMethod("Parse", new[] { typeof(string) }) ?? + throw new InvalidOperationException($"Method not found to parse value for type {type.FullName}"); + var argument = Expression.Constant(filter.Value); + var methodCaller = Expression.Call(parseMethod, argument); + var returnConverter = Expression.Convert(methodCaller, typeof(object)); + var function = Expression.Lambda>(returnConverter, parameter).Compile(); + return function.Invoke(filter.Value); + } + } + + public static IEnumerable GetSearchableProperties(this Type type) + { + if (type == null) + throw new ArgumentNullException(); + + return type.GetProperties().Where(x => x.PropertyType.IsSimpleType() && Attribute.IsDefined(x, typeof(SearchablePropertyAttribute))); + } + + public static IEnumerable GetCollectionUnderlyingType(this Type type) + { + if (null == type) + throw new ArgumentNullException(nameof(type)); + + return type.GetGenericArguments().ToList(); + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Common/IEntity.cs b/src/Tigernet.Hosting/Models/Common/IEntity.cs new file mode 100644 index 0000000..f7619b8 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Common/IEntity.cs @@ -0,0 +1,6 @@ +namespace Tigernet.Hosting.Models.Common; + +public interface IEntity +{ + TKey Id { get; set; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Common/IQueryableEntity.cs b/src/Tigernet.Hosting/Models/Common/IQueryableEntity.cs new file mode 100644 index 0000000..5260283 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Common/IQueryableEntity.cs @@ -0,0 +1,11 @@ +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Models.Common +{ + /// + /// Defines queryable entity + /// + public interface IQueryableEntity + { + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/EntityQueryOptions.cs b/src/Tigernet.Hosting/Models/Query/EntityQueryOptions.cs new file mode 100644 index 0000000..f1034fe --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/EntityQueryOptions.cs @@ -0,0 +1,12 @@ +using Tigernet.Hosting.Models.Common; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents queryable entities source query options +/// +/// +public class EntityQueryOptions : QueryOptions, IEntityQueryOptions where TEntity : class, IQueryableEntity +{ + public IncludeOptions? IncludeOptions { get; set; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/FilterOptions.cs b/src/Tigernet.Hosting/Models/Query/FilterOptions.cs new file mode 100644 index 0000000..711e186 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/FilterOptions.cs @@ -0,0 +1,21 @@ +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents filtering options +/// +/// +public class FilterOptions where TModel : class +{ + public FilterOptions() => (Filters) = new List(); + + public FilterOptions(IEnumerable filters) => (Filters) = filters.ToList(); + + public List Filters { get; set; } + + public QueryFilter this[string key] + { + get { return Filters.FirstOrDefault(x => x.Key == key); } + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/IEntityQueryOptions.cs b/src/Tigernet.Hosting/Models/Query/IEntityQueryOptions.cs new file mode 100644 index 0000000..4d81120 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/IEntityQueryOptions.cs @@ -0,0 +1,15 @@ +using Tigernet.Hosting.Models.Common; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Defines properties for queryable entities source query options +/// +/// +public interface IEntityQueryOptions : IQueryOptions where TEntity : class, IQueryableEntity +{ + /// + /// Requested include model options + /// + IncludeOptions? IncludeOptions { get; set; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/IQueryOptions.cs b/src/Tigernet.Hosting/Models/Query/IQueryOptions.cs new file mode 100644 index 0000000..31d4f7f --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/IQueryOptions.cs @@ -0,0 +1,30 @@ +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Defines properties for queryable source query options +/// +/// Query source type +public interface IQueryOptions where TModel : class +{ + /// + /// Query searching options + /// + SearchOptions? SearchOptions { get; set; } + + /// + /// Applied filters for a query + /// + FilterOptions? FilterOptions { get; set; } + + /// + /// Result Sort options + /// + SortOptions? SortOptions { get; set; } + + /// + /// Calculated pagination options + /// + PaginationOptions PaginationOptions { get; set; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/IncludeOptions.cs b/src/Tigernet.Hosting/Models/Query/IncludeOptions.cs new file mode 100644 index 0000000..13b0657 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/IncludeOptions.cs @@ -0,0 +1,22 @@ +using Tigernet.Hosting.Models.Common; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents table including options +/// +/// +public class IncludeOptions where TEntity : class, IQueryableEntity +{ + public IncludeOptions() + { + IncludeModels = new List(); + } + + public IncludeOptions(List includeModels) + { + IncludeModels = includeModels; + } + + public List IncludeModels { get; set; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/PaginationOptions.cs b/src/Tigernet.Hosting/Models/Query/PaginationOptions.cs new file mode 100644 index 0000000..8f10fc8 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/PaginationOptions.cs @@ -0,0 +1,32 @@ +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents pagination options +/// +public class PaginationOptions +{ + private int _pageSize; + private int _pageToken; + + public PaginationOptions(int pageSize, int pageToken) => (PageSize, PageToken) = (pageSize, pageToken); + + /// + /// Current page size + /// + /// If value is invalid + public int PageSize + { + get => _pageSize; + set => _pageSize = value <= 0 ? 20 : value; + } + + /// + /// Current page token + /// + /// If value is invalid + public int PageToken + { + get => _pageToken; + set => _pageToken = value <= 0 ? 1 : value; + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/PredicateBuilder.cs b/src/Tigernet.Hosting/Models/Query/PredicateBuilder.cs new file mode 100644 index 0000000..8cb0dac --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/PredicateBuilder.cs @@ -0,0 +1,50 @@ +using System.Linq.Expressions; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Provides extensions to build predicate expressions +/// +/// Expression source +public static class PredicateBuilder where TModel : class +{ + /// + /// Initial true expression + /// + public static Expression> True = entity => true; + + /// + /// Initial false expression + /// + public static Expression> False = entity => false; + + /// + /// Joins to expression with OR logic + /// + /// Left expression + /// Right expression + /// Joined expression + public static Expression> Or(Expression> left, Expression> right) + { + if (left == null || right == null) + throw new ArgumentException("Can't join null predicate expressions for OR operation"); + + var invokeExpr = Expression.Invoke(right, left.Parameters.Cast()); + return Expression.Lambda>(Expression.OrElse(left.Body, invokeExpr), left.Parameters); + } + + /// + /// Joins to expression with AND logic + /// + /// Left expression + /// Right expression + /// Joined expression + public static Expression> And(Expression> left, Expression> right) + { + if (left == null || right == null) + throw new ArgumentException("Can't join null predicate expressions for AND operation"); + + var invokeExpr = Expression.Invoke(right, left.Parameters.Cast()); + return Expression.Lambda>(Expression.AndAlso(left.Body, invokeExpr), left.Parameters); + } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/QueryFilter.cs b/src/Tigernet.Hosting/Models/Query/QueryFilter.cs new file mode 100644 index 0000000..74b0cd6 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/QueryFilter.cs @@ -0,0 +1,19 @@ +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents queryable source query options +/// +public class QueryFilter +{ + public QueryFilter(string key, string? value) => (Key, Value) = (key, value); + + /// + /// Field key name + /// + public string Key { get; } + + /// + /// Filtering value + /// + public string? Value { get; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/QueryOptions.cs b/src/Tigernet.Hosting/Models/Query/QueryOptions.cs new file mode 100644 index 0000000..c458a28 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/QueryOptions.cs @@ -0,0 +1,23 @@ +using Tigernet.Hosting.Models.Query; + +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents queryable source query options +/// +/// +public class QueryOptions : IQueryOptions where TModel : class +{ + public QueryOptions() + { + PaginationOptions = new PaginationOptions(10, 1); + } + + public SearchOptions? SearchOptions { get; set; } + + public FilterOptions? FilterOptions { get; set; } + + public SortOptions? SortOptions { get; set; } + + public PaginationOptions PaginationOptions { get; set; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/SearchOptions.cs b/src/Tigernet.Hosting/Models/Query/SearchOptions.cs new file mode 100644 index 0000000..5d9826f --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/SearchOptions.cs @@ -0,0 +1,20 @@ +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents search options +/// +public class SearchOptions where TModel : class +{ + public SearchOptions(string keyword, bool includeChildren) => + (Keyword, IncludeChildren) = (keyword ?? throw new ArgumentException(), includeChildren); + + /// + /// Search keyword + /// + public string Keyword { get; } + + /// + /// Determines whether to search from direct children + /// + public bool IncludeChildren { get; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Models/Query/SortOptions.cs b/src/Tigernet.Hosting/Models/Query/SortOptions.cs new file mode 100644 index 0000000..b57d200 --- /dev/null +++ b/src/Tigernet.Hosting/Models/Query/SortOptions.cs @@ -0,0 +1,20 @@ +namespace Tigernet.Hosting.Models.Query; + +/// +/// Represents collection sort options +/// +/// +public class SortOptions +{ + public SortOptions(string sortField, bool sortAscending = true) => (SortField, SortAscending) = (sortField, sortAscending); + + /// + /// Sort field + /// + public string SortField { get; } + + /// + /// Indicates whether to sort ascending + /// + public bool SortAscending { get; } +} \ No newline at end of file diff --git a/src/Tigernet.Hosting/Tigernet.Hosting.csproj b/src/Tigernet.Hosting/Tigernet.Hosting.csproj index 1d79523..71e95fa 100644 --- a/src/Tigernet.Hosting/Tigernet.Hosting.csproj +++ b/src/Tigernet.Hosting/Tigernet.Hosting.csproj @@ -4,11 +4,16 @@ Exe net7.0 enable - disable + enable + + + + + diff --git a/src/Tigernet.Hosting/TigernetHostBuilder.cs b/src/Tigernet.Hosting/TigernetHostBuilder.cs index 86ceb8f..1b2d9c9 100644 --- a/src/Tigernet.Hosting/TigernetHostBuilder.cs +++ b/src/Tigernet.Hosting/TigernetHostBuilder.cs @@ -1,11 +1,15 @@ -using System.Data; +using System.Data; using System.Net; using System.Reflection; using System.Text; -using Tigernet.Hosting.Attributes.Commons; +using System.Text.Json.Serialization; +using Newtonsoft.Json; using Tigernet.Hosting.Attributes.HttpMethods; +using Tigernet.Hosting.Attributes.HttpMethods.Commons; using Tigernet.Hosting.Attributes.Resters; using Tigernet.Hosting.Exceptions; +using JsonConverter = System.Text.Json.Serialization.JsonConverter; +using JsonSerializer = System.Text.Json.JsonSerializer; namespace Tigernet.Hosting { @@ -208,7 +212,7 @@ public void MapResters() rester = Activator.CreateInstance(resterType); } - var args = GetArguments(method, context); + var args = await GetArguments(method, context); var result = method.Invoke(rester, args); if (result is Task task) @@ -226,7 +230,7 @@ public void MapResters() }; } - private object[] GetArguments(MethodInfo method, HttpListenerContext context) + private async ValueTask GetArguments(MethodInfo method, HttpListenerContext context) { var parameters = method.GetParameters(); var args = new object[parameters.Length]; @@ -242,9 +246,33 @@ private object[] GetArguments(MethodInfo method, HttpListenerContext context) { args[i] = null; } + + var content = await GetRequestContentAsync(context.Request, parameterType); + if (content != default) + args[i] = content; } return args; } + + private async ValueTask GetRequestContentAsync(HttpListenerRequest request, Type expectedType) + { + if (request.ContentLength64 == 0) + return null; + + using var reader = new StreamReader(request.InputStream); + var content = await reader.ReadToEndAsync(); + var result = default(object); + try + { + result = JsonConvert.DeserializeObject(content, expectedType); + } + catch (Exception exception) + { + // TODO: Log exception + } + + return result; + } } } \ No newline at end of file