Skip to content

Commit a7fa730

Browse files
authored
Merge pull request #67 from PandaTechAM/development
Release v4.1.0: cleanup and AggregateQueryModel fix
2 parents 184a705 + e727413 commit a7fa730

11 files changed

Lines changed: 89 additions & 124 deletions

CLAUDE.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Pandatech.GridifyExtensions - Claude Guide
2+
3+
## What is this?
4+
5+
A .NET NuGet library extending [Gridify](https://github.com/alirezanet/Gridify) with streamlined filtering, ordering,
6+
pagination, aggregation, and column distinct value queries for EF Core. Used by all PandaTech backend projects.
7+
8+
## Architecture
9+
10+
- Single library project: `src/GridifyExtensions/`
11+
- All extension methods live in `QueryableExtensions.cs`
12+
- `FilterMapper<T>` extends Gridify's `GridifyMapper<T>` with default ordering, encrypted columns, and fluent API
13+
- Mapper instances are discovered via reflection at startup (`AddGridify()`) and stored in a `FrozenDictionary`
14+
- `WebApplicationBuilderExtensions.cs` handles registration and Gridify global config
15+
16+
## Key Files
17+
18+
- `Extensions/QueryableExtensions.cs` - core extension methods (filtering, paging, distinct, aggregation)
19+
- `Extensions/WebApplicationBuilderExtensions.cs` - DI registration and mapper discovery
20+
- `Models/FilterMapper.cs` - base mapper class with default ordering and encrypted column tracking
21+
- `Models/GridifyQueryModel.cs` - paged query model (inherits Gridify's GridifyQuery)
22+
- `Models/GridifyCursoredQueryModel.cs` - cursor-based query model (standalone, not inheriting GridifyQuery)
23+
- `Models/AggregateQueryModel.cs` - aggregation model (standalone: Filter + PropertyName + AggregateType)
24+
- `Models/ColumnDistinctValueCursoredQueryModel.cs` - distinct value query (extends GridifyCursoredQueryModel)
25+
- `Operators/FlagOperator.cs` - custom `#hasFlag` bitwise operator
26+
27+
## Code Patterns
28+
29+
- `GridifyQueryModel` inherits from Gridify's `GridifyQuery` and overrides properties for validation
30+
- `GridifyCursoredQueryModel` and `AggregateQueryModel` are standalone (no Gridify base) with internal
31+
`ToGridifyQueryModel()` for interop with Gridify's filtering
32+
- Encrypted columns tracked via `HashSet<string>` in FilterMapper; decryption happens client-side via `Func<byte[], string>`
33+
- String distinct values use smart ordering: nulls first, exact match, length, alphabetical
34+
- `PagedResponse<T>` and `CursoredResponse<T>` are records
35+
36+
## Build
37+
38+
```bash
39+
dotnet build src/GridifyExtensions/GridifyExtensions.csproj
40+
```
41+
42+
Targets: net8.0, net9.0, net10.0

src/GridifyExtensions/Extensions/QueryableExtensions.cs

Lines changed: 12 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Collections;
1+
using System.Collections;
2+
using System.Collections.Frozen;
23
using System.Linq.Expressions;
34
using System.Text.RegularExpressions;
45
using Gridify;
@@ -15,7 +16,7 @@ namespace GridifyExtensions.Extensions;
1516
/// </summary>
1617
public static class QueryableExtensions
1718
{
18-
internal static Dictionary<Type, object> EntityGridifyMapperByType = [];
19+
internal static FrozenDictionary<Type, object> EntityGridifyMapperByType = FrozenDictionary<Type, object>.Empty;
1920

2021
// ---------- Core helpers ----------
2122
private static FilterMapper<TEntity> RequireMapper<TEntity>()
@@ -172,85 +173,6 @@ public static Task<CursoredResponse<TEntity>> FilterOrderAndGetCursoredAsync<TEn
172173
.FilterOrderAndGetCursoredAsync(model, x => x, ct);
173174
}
174175

175-
/// <summary>
176-
/// Get distinct values for a column (obsolete - use cursored version).
177-
/// </summary>
178-
[Obsolete("Use ColumnDistinctValueCursoredQueryModel instead.")]
179-
public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntity>(this IQueryable<TEntity> query,
180-
ColumnDistinctValueQueryModel model,
181-
Func<byte[], string>? decryptor = null,
182-
CancellationToken ct = default)
183-
where TEntity : class
184-
{
185-
var mapper = RequireMapper<TEntity>();
186-
187-
if (!mapper.IsEncrypted(model.PropertyName))
188-
{
189-
var result = await query
190-
.ApplyFiltering(model, mapper)
191-
.ApplySelect(model.PropertyName, mapper)
192-
.Distinct()
193-
.OrderBy(x => x)
194-
.GetPagedAsync(model, ct);
195-
return result;
196-
}
197-
198-
var encryptedQuery = query
199-
.ApplyFiltering(model, mapper)
200-
.ApplySelect(model.PropertyName, mapper);
201-
202-
if (string.IsNullOrWhiteSpace(model.Filter))
203-
{
204-
bool hasNullLike;
205-
try
206-
{
207-
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
208-
hasNullLike = await encryptedQuery.AnyAsync(x => x == null, ct);
209-
}
210-
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
211-
{
212-
hasNullLike = true;
213-
}
214-
215-
return hasNullLike ? new PagedResponse<object>([null!], 1, 1, 1) : new PagedResponse<object>([], 1, 1, 0);
216-
}
217-
218-
var selected = await encryptedQuery.FirstOrDefaultAsync(ct);
219-
switch (selected)
220-
{
221-
case null:
222-
case byte[] { Length: 0 }:
223-
return new PagedResponse<object>([null!], 1, 1, 1);
224-
case byte[] sb:
225-
return decryptor == null
226-
? throw new KeyNotFoundException("Decryptor is required for encrypted properties.")
227-
: new PagedResponse<object>([decryptor(sb)], 1, 1, 1);
228-
}
229-
230-
if (selected is not IEnumerable<byte[]> seq)
231-
{
232-
throw new InvalidCastException("Encrypted selector did not return a byte[] or IEnumerable<byte[]> value.");
233-
}
234-
235-
var ng = ((IEnumerable)seq).GetEnumerator();
236-
using var ng1 = ng as IDisposable;
237-
238-
if (!ng.MoveNext())
239-
{
240-
return new PagedResponse<object>([null!], 1, 1, 1);
241-
}
242-
243-
var firstObj = ng.Current;
244-
if (firstObj is not byte[] first || first.Length == 0)
245-
{
246-
return new PagedResponse<object>([null!], 1, 1, 1);
247-
}
248-
249-
return decryptor == null
250-
? throw new KeyNotFoundException("Decryptor is required for encrypted properties.")
251-
: new PagedResponse<object>([decryptor(first)], 1, 1, 1);
252-
}
253-
254176
/// <summary>
255177
/// Get distinct values for a column with cursor pagination.
256178
/// </summary>
@@ -290,7 +212,7 @@ public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntit
290212
.ToListAsync(ct);
291213

292214
return new CursoredResponse<object?>(data.Cast<object?>()
293-
.ToList(),
215+
.ToList(),
294216
model.PageSize);
295217
}
296218

@@ -369,7 +291,7 @@ public static async Task<object> AggregateAsync<TEntity>(this IQueryable<TEntity
369291
where TEntity : class
370292
{
371293
var mapper = RequireMapper<TEntity>();
372-
var filtered = query.ApplyFiltering(model, mapper)
294+
var filtered = query.ApplyFiltering(model.ToGridifyQueryModel(), mapper)
373295
.ApplySelect(model.PropertyName, mapper);
374296

375297
return model.AggregateType switch
@@ -392,19 +314,19 @@ public static IEnumerable<MappingModel> GetMappings<TEntity>()
392314
var mapper = EntityGridifyMapperByType[typeof(TEntity)] as FilterMapper<TEntity>;
393315

394316
return mapper!.GetCurrentMaps()
395-
.Select(x => new MappingModel
396-
{
397-
Name = x.From,
398-
Type = x.To.Body switch
317+
.Select(x => new MappingModel(
318+
x.From,
319+
x.To.Body switch
399320
{
400321
UnaryExpression ue => ue.Operand.Type.Name,
401322
MethodCallExpression mc => (mc.Arguments.LastOrDefault() as LambdaExpression)?.ReturnType.Name
402323
?? x.To.Body.Type.Name,
403324
_ => x.To.Body.Type.Name
404-
}
405-
});
325+
}));
406326
}
407327

328+
// ---------- Private helpers ----------
329+
408330
private static Expression<Func<TEntity, string?>> EfStringSelector<TEntity>(string propertyName)
409331
where TEntity : class
410332
{
@@ -499,4 +421,4 @@ private static bool IsStringColumn<TEntity>(IQueryable<TEntity> query, FilterMap
499421
return infra.Instance.GetService<ICurrentDbContext>()
500422
?.Context;
501423
}
502-
}
424+
}

src/GridifyExtensions/Extensions/WebApplicationBuilderExtensions.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Reflection;
1+
using System.Collections.Frozen;
2+
using System.Reflection;
23
using Gridify;
34
using GridifyExtensions.Models;
45
using GridifyExtensions.Operators;
@@ -39,6 +40,6 @@ private static void AddGridify(Assembly[] assemblies)
3940
.Select(x =>
4041
new KeyValuePair<Type, object>(x.BaseType!.GetGenericArguments()[0],
4142
Activator.CreateInstance(x)!)))
42-
.ToDictionary(x => x.Key, x => x.Value);
43+
.ToFrozenDictionary(x => x.Key, x => x.Value);
4344
}
44-
}
45+
}

src/GridifyExtensions/GridifyExtensions.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
<PackageIcon>pandatech.png</PackageIcon>
2222
<PackageReadmeFile>README.md</PackageReadmeFile>
2323

24-
<Version>4.0.2</Version>
25-
<PackageReleaseNotes>Fixed aggregation rounding</PackageReleaseNotes>
24+
<Version>4.1.0</Version>
25+
<PackageReleaseNotes>AggregateQueryModel no longer inherits GridifyQueryModel, removing Page/PageSize/OrderBy from OpenAPI spec. Removed obsolete ColumnDistinctValueQueryModel and its ColumnDistinctValuesAsync overload. Renamed convertor to converter in FilterMapper.AddMap. Converted MappingModel to record. Changed mapper store to FrozenDictionary for thread safety.</PackageReleaseNotes>
2626

2727
<!-- Build quality -->
2828
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
@@ -50,7 +50,7 @@
5050
</ItemGroup>
5151

5252
<ItemGroup>
53-
<PackageReference Include="Gridify.EntityFramework" Version="2.17.2" />
53+
<PackageReference Include="Gridify.EntityFramework" Version="2.19.0" />
5454
<PackageReference Include="Pandatech.Analyzers" Version="[2.1.0]" PrivateAssets="all" />
5555
<PackageReference Include="SonarAnalyzer.CSharp" Version="[10.19.0.132793]">
5656
<PrivateAssets>all</PrivateAssets>
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
using GridifyExtensions.Enums;
1+
using GridifyExtensions.Enums;
22

33
namespace GridifyExtensions.Models;
44

5-
public class AggregateQueryModel : GridifyQueryModel
5+
public class AggregateQueryModel
66
{
7+
public string? Filter { get; set; }
78
public required string PropertyName { get; set; }
89
public required AggregateType AggregateType { get; set; }
9-
}
10+
11+
internal GridifyQueryModel ToGridifyQueryModel()
12+
{
13+
return new GridifyQueryModel
14+
{
15+
Page = 1,
16+
PageSize = 1,
17+
OrderBy = null,
18+
Filter = Filter
19+
};
20+
}
21+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
namespace GridifyExtensions.Models;
1+
namespace GridifyExtensions.Models;
22

33
public class ColumnDistinctValueCursoredQueryModel : GridifyCursoredQueryModel
44
{
55
public required string PropertyName { get; set; }
6-
}
6+
}

src/GridifyExtensions/Models/ColumnDistinctValueQueryModel.cs

Lines changed: 0 additions & 6 deletions
This file was deleted.

src/GridifyExtensions/Models/FilterMapper.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public IOrderThenBy AddDefaultOrderByDescending(string column)
5050

5151
public IGridifyMapper<T> AddMap(string from,
5252
Expression<Func<T, object?>> to,
53-
Func<string, object>? convertor = null,
53+
Func<string, object>? converter = null,
5454
bool overrideIfExists = true,
5555
bool isEncrypted = false)
5656
{
@@ -59,13 +59,12 @@ public IGridifyMapper<T> AddMap(string from,
5959
_encryptedColumns.Add(from);
6060
}
6161

62-
63-
return base.AddMap(from, to, convertor, overrideIfExists);
62+
return base.AddMap(from, to, converter, overrideIfExists);
6463
}
6564

6665
public IGridifyMapper<T> AddMap(string from,
6766
Expression<Func<T, int, object?>> to,
68-
Func<string, object>? convertor = null,
67+
Func<string, object>? converter = null,
6968
bool overrideIfExists = true,
7069
bool isEncrypted = false)
7170
{
@@ -74,7 +73,6 @@ public IGridifyMapper<T> AddMap(string from,
7473
_encryptedColumns.Add(from);
7574
}
7675

77-
78-
return base.AddMap(from, to, convertor, overrideIfExists);
76+
return base.AddMap(from, to, converter, overrideIfExists);
7977
}
8078
}
Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
namespace GridifyExtensions.Models;
1+
namespace GridifyExtensions.Models;
22

3-
public class MappingModel
4-
{
5-
public string Name { get; set; } = string.Empty;
6-
public string? Type { get; set; } = string.Empty;
7-
}
3+
public record MappingModel(string Name, string? Type);

test/GridifyExtensions.Demo/GridifyExtensions.Demo.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
12-
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
13-
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
14-
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0"/>
12+
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
13+
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
14+
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
1515
</ItemGroup>
1616

1717
<ItemGroup>

0 commit comments

Comments
 (0)