Skip to content

Commit 3b87052

Browse files
authored
Merge pull request #56 from PandaTechAM/development
ColumnDistinctValue Encrypted Column Null Handling
2 parents 4e724d4 + 1099178 commit 3b87052

File tree

2 files changed

+120
-54
lines changed

2 files changed

+120
-54
lines changed

src/GridifyExtensions/Extensions/QueryableExtensions.cs

Lines changed: 118 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,6 @@ public static class QueryableExtensions
1111
internal static Dictionary<Type, object> EntityGridifyMapperByType = [];
1212

1313
// ---------- Core helpers ----------
14-
15-
16-
private static Expression<Func<T, object>> CreateSelector<T>(string propertyName)
17-
{
18-
var p = Expression.Parameter(typeof(T), "x");
19-
var body = Expression.Convert(Expression.Property(p, propertyName), typeof(object));
20-
return Expression.Lambda<Func<T, object>>(body, p);
21-
}
22-
2314
private static FilterMapper<TEntity> RequireMapper<TEntity>()
2415
where TEntity : class
2516
{
@@ -145,11 +136,11 @@ public static Task<CursoredResponse<TEntity>> FilterOrderAndGetCursoredAsync<TEn
145136
query.AsNoTracking()
146137
.FilterOrderAndGetCursoredAsync(model, x => x, cancellationToken);
147138

148-
// ---------- Column Distinct ----------
139+
// ---------- Column Distinct ----------
149140
[Obsolete("Use ColumnDistinctValueCursoredQueryModel instead.")]
150141
public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntity>(this IQueryable<TEntity> query,
151142
ColumnDistinctValueQueryModel model,
152-
Func<byte[], string>? decryptor = default,
143+
Func<byte[], string>? decryptor = null,
153144
CancellationToken cancellationToken = default)
154145
where TEntity : class
155146
{
@@ -166,37 +157,77 @@ public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntit
166157
return result;
167158
}
168159

169-
// Encrypted path (scalar byte[] or IEnumerable<byte[]>), use mapper-driven selection
170160
var encryptedQuery = query
171161
.ApplyFiltering(model, mapper)
172162
.ApplySelect(model.PropertyName, mapper); // IQueryable<object?>
173163

174-
// Keep original behavior: if no filter, return empty for the obsolete API
175164
if (string.IsNullOrWhiteSpace(model.Filter))
176165
{
177-
return new PagedResponse<object>([], 1, 1, 0);
166+
bool hasNullLike;
167+
try
168+
{
169+
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
170+
hasNullLike = await encryptedQuery.AnyAsync(x => x == null, cancellationToken);
171+
}
172+
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
173+
{
174+
// NOTE:
175+
// Some providers cannot translate `Any(x => x == null)` when the projection is a COLLECTION
176+
// (e.g., IEnumerable<byte[]> coming from a nav). We need to decide what to do without
177+
// issuing a second, provider-specific query here.
178+
//
179+
// UX policy: when the frontend opens distinct-values with NO filter on an encrypted column,
180+
// we prefer to SHOW the "null" option rather than hide it due to translation limits.
181+
// Therefore we *assume* null-like exists. If you prefer strictness, set `hasNullLike = false`
182+
hasNullLike = true;
183+
}
184+
185+
return hasNullLike ? new PagedResponse<object>([null!], 1, 1, 1) : new PagedResponse<object>([], 1, 1, 0);
178186
}
179187

180188
var selected = await encryptedQuery.FirstOrDefaultAsync(cancellationToken);
181-
if (selected is null) return new PagedResponse<object>([], 1, 1, 0);
182-
if (decryptor is null) throw new KeyNotFoundException("Decryptor is required for encrypted properties.");
189+
switch (selected)
190+
{
191+
case null:
192+
case byte[]
193+
{
194+
Length: 0
195+
}:
196+
return new PagedResponse<object>([null!], 1, 1, 1);
197+
case byte[] sb:
198+
return decryptor == null
199+
? throw new KeyNotFoundException("Decryptor is required for encrypted properties.")
200+
: new PagedResponse<object>([decryptor(sb)], 1, 1, 1);
201+
}
183202

184-
object? decrypted = selected switch
203+
if (selected is not IEnumerable<byte[]> seq)
185204
{
186-
byte[] b => decryptor(b),
187-
IEnumerable<byte[]> bs => bs.FirstOrDefault() is byte[] fb ? decryptor(fb) : null,
188-
_ => throw new InvalidCastException("Encrypted selector did not return a byte[] or IEnumerable<byte[]> value.")
189-
};
205+
throw new InvalidCastException("Encrypted selector did not return a byte[] or IEnumerable<byte[]> value.");
206+
}
207+
208+
var ng = ((System.Collections.IEnumerable)seq).GetEnumerator();
209+
using var ng1 = ng as IDisposable;
210+
211+
if (!ng.MoveNext())
212+
{
213+
return new PagedResponse<object>([null!], 1, 1, 1);
214+
}
190215

191-
return decrypted is null
192-
? new PagedResponse<object>([], 1, 1, 0)
193-
: new PagedResponse<object>([decrypted], 1, 1, 1);
216+
var firstObj = ng.Current;
217+
if (firstObj is not byte[] first || first.Length == 0)
218+
{
219+
return new PagedResponse<object>([null!], 1, 1, 1);
220+
}
221+
222+
return decryptor == null
223+
? throw new KeyNotFoundException("Decryptor is required for encrypted properties.")
224+
: new PagedResponse<object>([decryptor(first)], 1, 1, 1);
194225
}
195226

196227
public static async Task<CursoredResponse<object?>> ColumnDistinctValuesAsync<TEntity>(
197228
this IQueryable<TEntity> query,
198229
ColumnDistinctValueCursoredQueryModel model,
199-
Func<byte[], string>? decryptor = default,
230+
Func<byte[], string>? decryptor = null,
200231
CancellationToken cancellationToken = default)
201232
where TEntity : class
202233
{
@@ -217,7 +248,10 @@ public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntit
217248
{
218249
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
219250
hasNull = await baseQuery.AnyAsync(x => x == null, cancellationToken);
220-
if (hasNull && take > 0) take -= 1;
251+
if (hasNull && take > 0)
252+
{
253+
take -= 1;
254+
}
221255
}
222256

223257
var result = await baseQuery
@@ -227,55 +261,86 @@ public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntit
227261
.ToListAsync(cancellationToken);
228262

229263
if (!filterEmpty || !hasNull)
264+
{
230265
return new CursoredResponse<object?>(result!, model.PageSize);
266+
}
231267

232268
if (result.Count > 0 && ReferenceEquals(result[^1], null))
269+
{
233270
result.RemoveAt(result.Count - 1);
271+
}
234272

235273
result.Insert(0, null!);
236274
return new CursoredResponse<object?>(result!, model.PageSize);
237275
}
238276

239-
// Encrypted path (scalar byte[] or IEnumerable<byte[]>)
277+
// Encrypted path
240278
var encryptedQuery = query
241279
.ApplyFiltering(gridifyModel, mapper)
242280
.ApplySelect(model.PropertyName, mapper); // IQueryable<object?>
243281

244282
if (string.IsNullOrWhiteSpace(model.Filter))
245283
{
246-
// EF-translatable: only checks if the projection itself is NULL in DB
247-
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
248-
var hasNull = await encryptedQuery.AnyAsync(x => x == null, cancellationToken);
284+
bool hasNullLike;
285+
try
286+
{
287+
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
288+
hasNullLike = await encryptedQuery.AnyAsync(x => x == null, cancellationToken);
289+
}
290+
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
291+
{
292+
// NOTE:
293+
// Some providers cannot translate `Any(x => x == null)` when the projection is a COLLECTION
294+
// (e.g., IEnumerable<byte[]> coming from a nav). We need to decide what to do without
295+
// issuing a second, provider-specific query here.
296+
//
297+
// UX policy: when the frontend opens distinct-values with NO filter on an encrypted column,
298+
// we prefer to SHOW the "null" option rather than hide it due to translation limits.
299+
// Therefore we *assume* null-like exists. If you prefer strictness, set `hasNullLike = false`
300+
hasNullLike = true;
301+
}
249302

250-
return hasNull
303+
return hasNullLike
251304
? new CursoredResponse<object?>([null], model.PageSize)
252305
: new CursoredResponse<object?>([], model.PageSize);
253306
}
254307

255308
var selected = await encryptedQuery.FirstOrDefaultAsync(cancellationToken);
256-
if (selected is null)
309+
switch (selected)
257310
{
258-
return new CursoredResponse<object?>([], model.PageSize);
311+
case null:
312+
case byte[]
313+
{
314+
Length: 0
315+
}:
316+
return new CursoredResponse<object?>([null], model.PageSize);
317+
case byte[] when decryptor == null:
318+
throw new KeyNotFoundException("Decryptor is required for encrypted properties.");
319+
case byte[] sb:
320+
return new CursoredResponse<object?>([decryptor(sb)], model.PageSize);
259321
}
260322

261-
if (decryptor is null)
323+
if (selected is not IEnumerable<byte[]> seq)
262324
{
263-
throw new KeyNotFoundException("Decryptor is required for encrypted properties.");
325+
throw new InvalidCastException("Encrypted selector did not return a byte[] or IEnumerable<byte[]> value.");
264326
}
265327

266-
object? decrypted = selected switch
328+
var ng = ((System.Collections.IEnumerable)seq).GetEnumerator();
329+
using var ng1 = ng as IDisposable;
330+
if (!ng.MoveNext())
267331
{
268-
byte[] b => decryptor(b),
269-
IEnumerable<byte[]> bs => bs.FirstOrDefault() is
270-
{ } fb
271-
? decryptor(fb)
272-
: null,
273-
_ => throw new InvalidCastException("Encrypted selector did not return a byte[] or IEnumerable<byte[]> value.")
274-
};
332+
return new CursoredResponse<object?>([null], model.PageSize);
333+
}
334+
335+
var firstObj = ng.Current;
336+
if (firstObj is not byte[] first || first.Length == 0)
337+
{
338+
return new CursoredResponse<object?>([null], model.PageSize);
339+
}
275340

276-
return decrypted is null
277-
? new CursoredResponse<object?>([], model.PageSize)
278-
: new CursoredResponse<object?>([decrypted], model.PageSize);
341+
return decryptor == null
342+
? throw new KeyNotFoundException("Decryptor is required for encrypted properties.")
343+
: new CursoredResponse<object?>([decryptor(first)], model.PageSize);
279344
}
280345

281346
// ---------- Aggregation ----------
@@ -309,12 +374,13 @@ public static IEnumerable<MappingModel> GetMappings<TEntity>()
309374
.Select(x => new MappingModel
310375
{
311376
Name = x.From,
312-
Type = x.To.Body is UnaryExpression ue
313-
? ue.Operand.Type.Name
314-
: x.To.Body is MethodCallExpression mc
315-
? ((mc.Arguments.LastOrDefault() as LambdaExpression)?.ReturnType?.Name)
316-
?? x.To.Body.Type.Name
317-
: x.To.Body.Type.Name
377+
Type = x.To.Body switch
378+
{
379+
UnaryExpression ue => ue.Operand.Type.Name,
380+
MethodCallExpression mc => (mc.Arguments.LastOrDefault() as LambdaExpression)?.ReturnType.Name
381+
?? x.To.Body.Type.Name,
382+
_ => x.To.Body.Type.Name
383+
}
318384
});
319385
}
320386
}

src/GridifyExtensions/GridifyExtensions.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
<PackageReadmeFile>Readme.md</PackageReadmeFile>
99
<Authors>Pandatech</Authors>
1010
<Copyright>MIT</Copyright>
11-
<Version>2.1.3</Version>
11+
<Version>2.1.4</Version>
1212
<PackageId>Pandatech.GridifyExtensions</PackageId>
1313
<Title>Pandatech.Gridify.Extensions</Title>
1414
<PackageTags>Pandatech, library, Gridify, Pagination, Filters</PackageTags>
1515
<Description>Pandatech.Gridify.Extensions simplifies and extends the functionality of the Gridify NuGet package. It provides additional extension methods and functionality to streamline data filtering and pagination, making it more intuitive and powerful to use in .NET applications. Our enhancements ensure more flexibility, reduce boilerplate code, and improve overall developer productivity when working with Gridify.</Description>
1616
<RepositoryUrl>https://github.com/PandaTechAM/be-lib-gridify-extensions</RepositoryUrl>
17-
<PackageReleaseNotes>ColumnDistinctValue Encryption Handling Upgrade</PackageReleaseNotes>
17+
<PackageReleaseNotes>ColumnDistinctValue Encrypted Column Null Handling</PackageReleaseNotes>
1818
</PropertyGroup>
1919

2020
<ItemGroup>

0 commit comments

Comments
 (0)