@@ -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}
0 commit comments