From 1c5b6e4389420faf22be6135649e77105c695f17 Mon Sep 17 00:00:00 2001 From: Mihail Date: Tue, 3 Feb 2026 13:22:38 +0100 Subject: [PATCH 1/2] feature/ add "include" Query Parameter --- .../Controllers/ModelsControllerTests.cs | 1 - .../TestData/PropertyAndClassNames.cs | 1 - .../ActionFilters/IncludeQueryFilter.cs | 62 +++++++++++++++++++ Dappi.HeadlessCms/Models/IncludeNode.cs | 14 +++++ .../Generators/ActionsGenerator.cs | 3 + 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs create mode 100644 Dappi.HeadlessCms/Models/IncludeNode.cs diff --git a/Dappi.HeadlessCms.Tests/Controllers/ModelsControllerTests.cs b/Dappi.HeadlessCms.Tests/Controllers/ModelsControllerTests.cs index 93c2cb1..e9448f0 100644 --- a/Dappi.HeadlessCms.Tests/Controllers/ModelsControllerTests.cs +++ b/Dappi.HeadlessCms.Tests/Controllers/ModelsControllerTests.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Net.Http.Json; using System.Text.RegularExpressions; using Dappi.HeadlessCms.Models; diff --git a/Dappi.HeadlessCms.Tests/TestData/PropertyAndClassNames.cs b/Dappi.HeadlessCms.Tests/TestData/PropertyAndClassNames.cs index d3f9a99..92c1f3e 100644 --- a/Dappi.HeadlessCms.Tests/TestData/PropertyAndClassNames.cs +++ b/Dappi.HeadlessCms.Tests/TestData/PropertyAndClassNames.cs @@ -1,5 +1,4 @@ using Dappi.HeadlessCms.Models; -using Xunit; namespace Dappi.HeadlessCms.Tests.TestData { diff --git a/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs b/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs new file mode 100644 index 0000000..beea083 --- /dev/null +++ b/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs @@ -0,0 +1,62 @@ +using Dappi.HeadlessCms.Models; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Dappi.HeadlessCms.ActionFilters +{ + public class IncludeQueryFilter : ActionFilterAttribute + { + private const string IncludeParamsKey = "Includes"; + + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.HttpContext.Request.Query.TryGetValue("include", out var includeValues)) + return; + + var includeTree = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var includeValue in includeValues) + { + if (includeValue is null) + continue; + + var includePaths = includeValue + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var includePath in includePaths) + { + var segments = includePath + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + + if (segments.Length == 0) + continue; + + AddSegmentsRecursive(includeTree, segments, 0); + } + } + + if (includeTree.Count == 0) + { + return; + } + + context.HttpContext.Items[IncludeParamsKey] = includeTree; + } + + private static void AddSegmentsRecursive(IDictionary nodes, IReadOnlyList segments, int index) + { + while (index != segments.Count) + { + var segment = segments[index]; + if (!nodes.TryGetValue(segment, out var current)) + { + current = new IncludeNode(segment); + nodes[segment] = current; + } + + nodes = current.Children; + index++; + } + } + } +} diff --git a/Dappi.HeadlessCms/Models/IncludeNode.cs b/Dappi.HeadlessCms/Models/IncludeNode.cs new file mode 100644 index 0000000..be82f2a --- /dev/null +++ b/Dappi.HeadlessCms/Models/IncludeNode.cs @@ -0,0 +1,14 @@ +namespace Dappi.HeadlessCms.Models +{ + public class IncludeNode + { + public IncludeNode(string name) + { + Name = name; + } + + public string Name { get; } + + public IDictionary Children { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/Dappi.SourceGenerator/Generators/ActionsGenerator.cs b/Dappi.SourceGenerator/Generators/ActionsGenerator.cs index da70be7..d19e728 100644 --- a/Dappi.SourceGenerator/Generators/ActionsGenerator.cs +++ b/Dappi.SourceGenerator/Generators/ActionsGenerator.cs @@ -19,6 +19,7 @@ public static string GenerateGetByIdAction(List crudActions, Source [HttpGet("{id}")] {{PropagateDappiAuthorizationTags(item.AuthorizeAttributes, AuthorizeMethods.Get)}} + [IncludeQueryFilter] public async Task Get{{item.ClassName}}(Guid id, [FromQuery] string? fields = null) { try @@ -59,6 +60,7 @@ public static string GenerateGetAction(List crudActions, SourceMode [HttpGet] {{PropagateDappiAuthorizationTags(item.AuthorizeAttributes, AuthorizeMethods.Get)}} [CollectionFilter] + [IncludeQueryFilter] public async Task Get{{item.ClassName.Pluralize()}}([FromQuery] {{item.ClassName}}Filter? filter, [FromQuery] string? fields = null) { try @@ -115,6 +117,7 @@ public static string GenerateGetAllAction(List crudActions, SourceM [HttpGet("get-all")] {{PropagateDappiAuthorizationTags(item.AuthorizeAttributes, AuthorizeMethods.Get)}} + [IncludeQueryFilter] public async Task GetAll{{item.ClassName.Pluralize()}}() { var query = dbContext.{{item.ClassName.Pluralize()}}.AsNoTracking(); From 4c967857185bdf0525be89820bfa5cdd2934e40a Mon Sep 17 00:00:00 2001 From: Mihail Date: Thu, 5 Feb 2026 10:26:33 +0100 Subject: [PATCH 2/2] extract the include nodes from the HTTP context and compose the query --- .../ActionFilters/IncludeQueryFilter.cs | 44 ++++++++++--------- Dappi.SourceGenerator/CrudGenerator.cs | 30 +++++++++++++ .../Generators/ActionsGenerator.cs | 8 ++-- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs b/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs index beea083..e813be0 100644 --- a/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs +++ b/Dappi.HeadlessCms/ActionFilters/IncludeQueryFilter.cs @@ -5,7 +5,7 @@ namespace Dappi.HeadlessCms.ActionFilters { public class IncludeQueryFilter : ActionFilterAttribute { - private const string IncludeParamsKey = "Includes"; + public const string IncludeParamsKey = "Includes"; public override void OnActionExecuting(ActionExecutingContext context) { @@ -14,25 +14,12 @@ public override void OnActionExecuting(ActionExecutingContext context) var includeTree = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var includeValue in includeValues) + foreach (var segments in from includeValue in includeValues.OfType() select includeValue + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) into includePaths from includePath in includePaths select includePath + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray() into segments where segments.Length != 0 select segments) { - if (includeValue is null) - continue; - - var includePaths = includeValue - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var includePath in includePaths) - { - var segments = includePath - .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToArray(); - - if (segments.Length == 0) - continue; - - AddSegmentsRecursive(includeTree, segments, 0); - } + AddSegments(includeTree, segments, 0); } if (includeTree.Count == 0) @@ -43,11 +30,11 @@ public override void OnActionExecuting(ActionExecutingContext context) context.HttpContext.Items[IncludeParamsKey] = includeTree; } - private static void AddSegmentsRecursive(IDictionary nodes, IReadOnlyList segments, int index) + private static void AddSegments(IDictionary nodes, IReadOnlyList segments, int index) { while (index != segments.Count) { - var segment = segments[index]; + var segment = CapitalizeSegment(segments[index]); if (!nodes.TryGetValue(segment, out var current)) { current = new IncludeNode(segment); @@ -58,5 +45,20 @@ private static void AddSegmentsRecursive(IDictionary nodes, index++; } } + + private static string CapitalizeSegment(string segment) + { + if (string.IsNullOrEmpty(segment)) + { + return segment; + } + + if (segment.Length == 1) + { + return segment.ToUpperInvariant(); + } + + return char.ToUpperInvariant(segment[0]) + segment.Substring(1); + } } } diff --git a/Dappi.SourceGenerator/CrudGenerator.cs b/Dappi.SourceGenerator/CrudGenerator.cs index 8d9e711..da07da4 100644 --- a/Dappi.SourceGenerator/CrudGenerator.cs +++ b/Dappi.SourceGenerator/CrudGenerator.cs @@ -65,6 +65,7 @@ protected override void Execute(SourceProductionContext context, using System.IO; using System.Reflection; using System.Collections; +using System.Collections.Generic; using Dappi.Core.Constants; using System.Globalization; using System.Linq; @@ -136,6 +137,35 @@ private dynamic GetDbSetForType(string typeName) return dbSetProperty?.GetValue(dbContext); }} + + private IQueryable<{item.ClassName}> ApplyDynamicIncludes(IQueryable<{item.ClassName}> query) + {{ + var includeTree = HttpContext.Items[IncludeQueryFilter.IncludeParamsKey] as IDictionary; + if (includeTree is null || includeTree.Count == 0) + {{ + return query; + }} + + foreach (var include in includeTree) + {{ + query = ApplyIncludeRecursively(query, include.Key, include.Value); + }} + + return query; + }} + + private static IQueryable<{item.ClassName}> ApplyIncludeRecursively(IQueryable<{item.ClassName}> query, string path, IncludeNode node) + {{ + query = query.Include(path); + + foreach (var child in node.Children) + {{ + var childPath = string.Concat(path, ""."", child.Key); + query = ApplyIncludeRecursively(query, childPath, child.Value); + }} + + return query; + }} }}"; context.AddSource($"{item.ClassName}Controller.cs", generatedCode); diff --git a/Dappi.SourceGenerator/Generators/ActionsGenerator.cs b/Dappi.SourceGenerator/Generators/ActionsGenerator.cs index d19e728..02ff4bb 100644 --- a/Dappi.SourceGenerator/Generators/ActionsGenerator.cs +++ b/Dappi.SourceGenerator/Generators/ActionsGenerator.cs @@ -27,9 +27,9 @@ public static string GenerateGetByIdAction(List crudActions, Source if (id == Guid.Empty) return BadRequest(); - var query = dbContext.{{item.ClassName.Pluralize()}}.AsNoTracking().AsQueryable(); + var query = dbContext.{{item.ClassName.Pluralize()}}.AsNoTracking().AsQueryable(); - query = query{{includesCode}}; + query = ApplyDynamicIncludes(query); var result = await query .FirstOrDefaultAsync(p => p.Id == id); @@ -67,7 +67,7 @@ public static string GenerateGetAction(List crudActions, SourceMode { var query = dbContext.{{item.ClassName.Pluralize()}}.AsNoTracking().AsQueryable(); - query = query{{includesCode}}; + query = ApplyDynamicIncludes(query); var filters = HttpContext.Items[CollectionFilter.FilterParamsKey] as List; if (filters is not null && filters.Count > 0) @@ -122,7 +122,7 @@ public static string GenerateGetAllAction(List crudActions, SourceM { var query = dbContext.{{item.ClassName.Pluralize()}}.AsNoTracking(); - query = query{{includesCode}}; + query = ApplyDynamicIncludes(query); return Ok(new {items = await query.ToListAsync()}); }