Skip to content

Commit 90252c5

Browse files
authored
Merge pull request #58 from PandaTechAM/development
column distinct values polishing.
2 parents 92990f8 + 9b53514 commit 90252c5

File tree

13 files changed

+556
-66
lines changed

13 files changed

+556
-66
lines changed

src/GridifyExtensions/Extensions/QueryableExtensions.cs

Lines changed: 119 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using GridifyExtensions.Enums;
44
using GridifyExtensions.Models;
55
using Microsoft.EntityFrameworkCore;
6+
using Microsoft.EntityFrameworkCore.Infrastructure;
7+
using Microsoft.Extensions.DependencyInjection;
68

79
namespace GridifyExtensions.Extensions;
810

@@ -236,56 +238,40 @@ public static async Task<PagedResponse<object>> ColumnDistinctValuesAsync<TEntit
236238

237239
if (!mapper.IsEncrypted(model.PropertyName))
238240
{
239-
var baseQuery = query
240-
.ApplyFiltering(gridifyModel, mapper)
241-
.ApplySelect(model.PropertyName, mapper);
241+
var selectedNonEncrypted = query
242+
.ApplyFiltering(gridifyModel, mapper)
243+
.ApplySelect(model.PropertyName, mapper)
244+
.Distinct();
242245

243-
var filterEmpty = string.IsNullOrWhiteSpace(gridifyModel.Filter);
244-
var hasNull = false;
245-
var take = model.PageSize;
246-
247-
if (filterEmpty)
246+
var term = ExtractStarContainsTerm(model.Filter, model.PropertyName);
247+
if (!string.IsNullOrEmpty(term) && IsStringColumn(query, mapper, model.PropertyName))
248248
{
249-
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
250-
hasNull = await baseQuery.AnyAsync(x => x == null, cancellationToken);
251-
if (hasNull && take > 0)
252-
{
253-
take -= 1;
254-
}
249+
var termLower = term.ToLower();
250+
251+
var projected = query
252+
.ApplyFiltering(gridifyModel, mapper)
253+
.Select(StringSelector(query, mapper, model.PropertyName))
254+
.Distinct();
255+
256+
var data = await projected
257+
.OrderBy(x => x == null ? 0 : 1)
258+
.ThenBy(x => x != null && x.ToLower() == termLower ? 0 : 1)
259+
.ThenBy(x => x == null ? int.MaxValue : x.Length)
260+
.ThenBy(x => x)
261+
.Take(model.PageSize)
262+
.ToListAsync(cancellationToken);
263+
264+
return new CursoredResponse<object?>(data.Cast<object?>()
265+
.ToList(),
266+
model.PageSize);
255267
}
256268

257-
// smart ordering for string values ---
258-
var orderedQuery = baseQuery.Distinct();
259-
260-
if (typeof(string).IsAssignableFrom(orderedQuery.ElementType))
261-
{
262-
var stringQuery = (IQueryable<string?>)orderedQuery;
263-
264-
orderedQuery = stringQuery
265-
.OrderBy(x => x == null ? int.MaxValue : x.Length) // shorter first
266-
.ThenBy(x => x)!; // then lexicographic
267-
}
268-
else
269-
{
270-
orderedQuery = orderedQuery.OrderBy(x => x);
271-
}
272-
273-
var result = await orderedQuery
274-
.Take(take)
275-
.ToListAsync(cancellationToken);
276-
277-
if (!filterEmpty || !hasNull)
278-
{
279-
return new CursoredResponse<object?>(result!, model.PageSize);
280-
}
269+
var data2 = await selectedNonEncrypted
270+
.OrderBy(x => (object?)x == null ? 0 : 1)
271+
.Take(model.PageSize)
272+
.ToListAsync(cancellationToken);
281273

282-
if (result.Count > 0 && ReferenceEquals(result[^1], null))
283-
{
284-
result.RemoveAt(result.Count - 1);
285-
}
286-
287-
result.Insert(0, null!);
288-
return new CursoredResponse<object?>(result!, model.PageSize);
274+
return new CursoredResponse<object?>(data2!, model.PageSize);
289275
}
290276

291277
// Encrypted path
@@ -397,4 +383,92 @@ public static IEnumerable<MappingModel> GetMappings<TEntity>()
397383
}
398384
});
399385
}
386+
387+
388+
private static Expression<Func<TEntity, string?>> EfStringSelector<TEntity>(string propertyName)
389+
where TEntity : class
390+
{
391+
var e = Expression.Parameter(typeof(TEntity), "e");
392+
var body = Expression.Call(
393+
typeof(EF),
394+
nameof(EF.Property),
395+
[
396+
typeof(string)
397+
],
398+
e,
399+
Expression.Constant(propertyName));
400+
401+
return Expression.Lambda<Func<TEntity, string?>>(body, e);
402+
}
403+
404+
private static string? ExtractStarContainsTerm(string? filter, string propertyName)
405+
{
406+
if (string.IsNullOrWhiteSpace(filter)) return null;
407+
408+
var m = System.Text.RegularExpressions.Regex.Match(
409+
filter,
410+
$@"(?i)\b{System.Text.RegularExpressions.Regex.Escape(propertyName)}\s*=\s*\*(?<term>[^;,)]+)");
411+
412+
if (!m.Success) return null;
413+
414+
var term = m.Groups["term"]
415+
.Value
416+
.Trim();
417+
return term.Length == 0 ? null : term;
418+
}
419+
420+
private static bool IsStringColumn<TEntity>(IQueryable<TEntity> query, FilterMapper<TEntity> mapper, string name)
421+
where TEntity : class
422+
{
423+
var db = TryGetDbContext(query);
424+
var et = db?.Model.FindEntityType(typeof(TEntity));
425+
var p = et?.FindProperty(name);
426+
if (p != null)
427+
{
428+
return p.ClrType == typeof(string);
429+
}
430+
431+
var map = mapper.GetCurrentMaps()
432+
.FirstOrDefault(m => m.From == name);
433+
if (map == null)
434+
{
435+
return false;
436+
}
437+
438+
var body = map.To.Body is UnaryExpression { NodeType: ExpressionType.Convert } ue ? ue.Operand : map.To.Body;
439+
return body.Type == typeof(string);
440+
}
441+
442+
private static Expression<Func<TEntity, string?>> StringSelector<TEntity>(IQueryable<TEntity> query,
443+
FilterMapper<TEntity> mapper,
444+
string name)
445+
where TEntity : class
446+
{
447+
var db = TryGetDbContext(query);
448+
var et = db?.Model.FindEntityType(typeof(TEntity));
449+
var p = et?.FindProperty(name);
450+
451+
if (p != null)
452+
{
453+
return EfStringSelector<TEntity>(name);
454+
}
455+
456+
var map = mapper.GetCurrentMaps()
457+
.FirstOrDefault(m => m.From == name)
458+
?? throw new KeyNotFoundException($"No map found for '{name}'.");
459+
460+
var param = map.To.Parameters[0];
461+
var body = map.To.Body is UnaryExpression { NodeType: ExpressionType.Convert } ue ? ue.Operand : map.To.Body;
462+
463+
return body.Type != typeof(string)
464+
? throw new InvalidOperationException($"Map '{name}' must return string. Actual: {body.Type}.")
465+
: Expression.Lambda<Func<TEntity, string?>>(body, param);
466+
}
467+
468+
private static DbContext? TryGetDbContext<TEntity>(IQueryable<TEntity> query)
469+
{
470+
if (query is not IInfrastructure<IServiceProvider> infra) return null;
471+
return infra.Instance.GetService<ICurrentDbContext>()
472+
?.Context;
473+
}
400474
}

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.5</Version>
11+
<Version>2.1.6</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 String Ordering Became More Friendly</PackageReleaseNotes>
17+
<PackageReleaseNotes>ColumnDistinctValue polishing</PackageReleaseNotes>
1818
</PropertyGroup>
1919

2020
<ItemGroup>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using GridifyExtensions.Demo.Domain;
2+
using GridifyExtensions.Extensions;
3+
using GridifyExtensions.Models;
4+
5+
namespace GridifyExtensions.Demo.Context;
6+
7+
public class EstateMapper : FilterMapper<Estate>
8+
{
9+
public EstateMapper()
10+
{
11+
GenerateMappings();
12+
13+
// Direct scalar
14+
AddMap("Id", x => x.Id);
15+
AddMap("Status", x => x.Status);
16+
AddMap("Sqm", x => x.Sqm);
17+
AddMap("ResidentsQuantity", x => x.ResidentsQuantity);
18+
AddMap("Balance", x => x.Balance);
19+
AddMap("CreatedAt", x => x.CreatedAt, x => x.ToUtcDateTime());
20+
21+
// Strings with null/""/" "
22+
AddMap("Comment", x => x.Comment);
23+
AddMap("NonNullText", x => x.NonNullText);
24+
AddMap("NumberText", x => x.NumberText);
25+
26+
// Join-based scalar (ok)
27+
AddMap("BuildingAddress", x => x.Building.Address);
28+
29+
// N:M example (collection) - STILL NOT SUPPORTED!
30+
AddMap("TagNames", x => x.Tags.Select(t => t.Name));
31+
32+
// ✅ Critical: derived scalar (avoid collection map here)
33+
AddMap("PrimaryOwnerId",
34+
x => x.EstateOwnerAssignments
35+
.Where(a => a.IsPrimary && a.EndDate == null && !a.Deleted)
36+
.Max(a => (long?)a.PartnerId));
37+
38+
AddMap("PrimaryOwnerFullName",
39+
x => x.EstateOwnerAssignments
40+
.Where(a => a.IsPrimary && a.EndDate == null && !a.Deleted)
41+
.Max(a => a.Partner.FullName));
42+
43+
AddDefaultOrderByDescending("Id");
44+
}
45+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using GridifyExtensions.Demo.Domain;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace GridifyExtensions.Demo.Context;
5+
6+
public class PostgresContext(DbContextOptions<PostgresContext> options) : DbContext(options)
7+
{
8+
public DbSet<Estate> Estates => Set<Estate>();
9+
public DbSet<Building> Buildings => Set<Building>();
10+
public DbSet<Partner> Partners => Set<Partner>();
11+
public DbSet<EstateOwnerAssignment> EstateOwnerAssignments => Set<EstateOwnerAssignment>();
12+
public DbSet<Tag> Tags => Set<Tag>();
13+
14+
protected override void OnModelCreating(ModelBuilder b)
15+
{
16+
b.Entity<Partner>(e =>
17+
{
18+
e.Property(x => x.FullName)
19+
.HasMaxLength(200);
20+
});
21+
22+
b.Entity<Building>(e =>
23+
{
24+
e.Property(x => x.Address)
25+
.HasMaxLength(300);
26+
e.HasOne(x => x.Partner)
27+
.WithMany()
28+
.HasForeignKey(x => x.PartnerId);
29+
});
30+
31+
b.Entity<Estate>(e =>
32+
{
33+
e.Property(x => x.Comment)
34+
.HasMaxLength(500);
35+
36+
e.HasOne(x => x.Building)
37+
.WithMany(x => x.Estates)
38+
.HasForeignKey(x => x.BuildingId);
39+
40+
// N:M (skip navigation)
41+
e.HasMany(x => x.Tags)
42+
.WithMany(x => x.Estates)
43+
.UsingEntity(j => j.ToTable("estate_tags"));
44+
});
45+
46+
b.Entity<EstateOwnerAssignment>(e =>
47+
{
48+
e.HasOne(x => x.Estate)
49+
.WithMany(x => x.EstateOwnerAssignments)
50+
.HasForeignKey(x => x.EstateId);
51+
e.HasOne(x => x.Partner)
52+
.WithMany()
53+
.HasForeignKey(x => x.PartnerId);
54+
55+
// Helps the demo reproduce/avoid slow plans:
56+
e.HasIndex(x => new
57+
{
58+
x.EstateId,
59+
x.IsPrimary,
60+
x.EndDate,
61+
x.Deleted
62+
});
63+
});
64+
65+
b.Entity<Tag>(e =>
66+
{
67+
e.Property(x => x.Name)
68+
.HasMaxLength(80);
69+
e.HasIndex(x => x.Name)
70+
.IsUnique();
71+
});
72+
}
73+
}

0 commit comments

Comments
 (0)