Skip to content

Commit e727413

Browse files
HaikAsatryanclaude
andcommitted
Release v4.1.0: cleanup and AggregateQueryModel fix
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. Bumped Gridify.EntityFramework from 2.17.2 to 2.19.0. Added CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6afc825 commit e727413

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)