Skip to content

feat(select): add field projection (Select) support#335

Open
akleinvm wants to merge 16 commits into
alirezanet:masterfrom
akleinvm:feat/select-query
Open

feat(select): add field projection (Select) support#335
akleinvm wants to merge 16 commits into
alirezanet:masterfrom
akleinvm:feat/select-query

Conversation

@akleinvm
Copy link
Copy Markdown

@akleinvm akleinvm commented May 2, 2026

Description

Adds first-class field projection to Gridify. Callers can supply a Select string (e.g., select=name,address.city,orders.amount) alongside Filter, OrderBy, Page, and PageSize, and Gridify projects the queryable to a sequence of dynamically-typed objects containing only the requested fields. Under EF Core this translates to column-pruned SQL.

This replaces the half-implemented ApplySelect stub in GridifyExtensions.cs and addresses the integration gaps that caused PR #62 to be closed (no breaking changes; full integration with Gridify(), GridifyQueryable(), QueryBuilder; verified EF compatibility).

Highlights

  • No breaking API changes. IGridifyQuery / IGridifyFiltering / IGridifyOrdering / IGridifyPagination are unchanged. The new IGridifySelecting interface is additive — GridifyQuery implements it, but direct implementors of IGridifyQuery are unaffected.
  • End-to-end pipeline integration. Two ApplySelect overloads, the projecting GridifySelect / GridifyQueryableSelect pipeline methods, and QueryBuilder.AddSelect + BuildSelect / BuildSelectWithPaging family. QueryBuilder.AddQuery forwards Select automatically when the supplied query also implements IGridifySelecting.
  • EF Core compatibility verified. ToQueryString() assertions confirm column-pruned SELECT output, including a collection-projection (groups.name) test that catches client-side-evaluation regressions.
  • Validation. IsValidSelect mirrors the existing Filter / Order validators (with out List<string> errors overload); the composite IGridifyQuery.IsValid is extended to also cover Select when the query implements IGridifySelecting.
  • Zero new dependencies. Hand-rolled IL emission via System.Reflection.Emit.TypeBuilder, cached per shape signature with double-checked locking.

Type of change

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or that my feature works
  • I have made corresponding changes to the documentation
  • I have commented my code, particularly in hard-to-understand areas
  • New and existing unit tests pass locally with my changes

Limitations / non-goals

  • Aliasing / renaming in v1 (select=fullName:firstName,...) — out of scope.
  • Computed fields, aggregations (count, sum) — out of scope.
  • Multiple levels of collection nesting. v1 supports one collection level (e.g., orders.amount works; orders.items.price does not). Deeper projections throw GridifySelectException with a clear message.
  • Gridify.Elasticsearch is a follow-up (Elasticsearch projects via _source field filtering, which is a parallel design problem).
  • NativeAOT. Issue NativeAOT support #140 remains open; under NativeAOT SelectTypeFactory throws GridifySelectException referencing NativeAOT support #140.

Tests

  • 691 / 691 across all 5 projects.
  • Unit tests cover: tokenizer (valid / invalid / dedup / whitespace), IL emission cache + thread safety, expression-builder shapes (flat / nested / collection / mixed-case / case-sensitivity), ApplySelect end-to-end on IQueryable<T>, IsValidSelect (including the IgnoreNotMappedFields path and structural-error propagation), and the QueryBuilder.AddSelect / BuildSelect* API.
  • EF integration tests assert column-pruned SQL via ToQueryString() for flat, two-field, filter-then-select, and collection-projection cases.

Notes for reviewers

The opening commits introduce the building blocks (tokenizer, factory, expression builder); the later commits wire them into the public API and tests. The two fix(select): commits at the head address findings from a structured pre-PR review — narrowing IgnoreNotMappedFields to a typed GridifySelectFieldNotMappedException subtype so structural errors (multi-level collection, missing-property-on-resolved-type) propagate even with the flag on, refactoring IsValidSelect to use a validator-only path resolver that doesn't pollute the SelectTypeFactory cache, and tightening the validator to catch (Exception) matching the existing IsValid validators' contract.

akleinvm added 14 commits May 2, 2026 20:59
- Honor mapper.Configuration.CaseSensitive in WalkSegment property lookup.
- Drop the direct-property-walk fallback in ResolvePath: when no mapper key
  matches the full path or any prefix, throw GridifySelectException instead of
  silently bypassing the mapper. This restores the IgnoreNotMappedFields
  contract for unmapped fields that happen to exist as real properties on T.
- Add XML documentation comments to ApplySelect overloads.
- Add EF integration test for collection navigation projection (groups.name)
  to verify the expression tree translates to SQL rather than falling back to
  client-side evaluation.
- Add unit test covering the partial-mapper + IgnoreNotMappedFields end-to-end
  path through ApplySelect.
- Introduce GridifySelectFieldNotMappedException : GridifySelectException
  thrown only from the "no mapper key matched" branch of ResolvePath.
- Narrow the catch in SelectExpressionBuilder.Build from
  GridifySelectException to the new subtype, so structural errors raised
  during the prefix walk (two-level collection, "not a property of type")
  surface to the caller even when IgnoreNotMappedFields is true. The
  field is mapped — it just isn't projectable.
- Add SelectExpressionBuilder.ValidatePaths: a validator-only resolver
  that tokenizes + walks the mapper without invoking SelectTypeFactory,
  so validating user input no longer emits and caches dynamic types per
  path. Refactor IsValidSelect to call it and to wrap the body in
  catch (Exception), matching the never-throws convention used by
  IsValid for IGridifyFiltering / IGridifyOrdering.
- Tests: cover structural errors propagating under IgnoreNotMappedFields,
  the typed subexception, IsValidSelect's correct true/false outcomes
  under IgnoreNotMappedFields, and the diagnostic-message regression
  ("Field 'X' is not mapped" instead of "Select produced no fields.").
- extensions.md: add ApplySelect, GridifySelect, GridifyQueryableSelect
  sections matching the existing ApplyFiltering / Gridify pattern
  (LINQ-equivalent example, IGridifySelecting overload, cross-link to
  the Selecting section in gridifyQuery.md).
- gridifyQuery.md: fix opening grammar, replace the leftover editorial
  line with a Select forward-reference, add an "If Select is set"
  bullet to the IsValid notes, and add a Selecting section covering
  shape rules (nested / one-level collection), validation via
  IsValidSelect, and the NativeAOT / Elasticsearch limitations.
- queryBuilder.md: add AddSelect / BuildSelect / BuildSelectWithPaging
  rows to the method table and an AddSelect section with a
  BuildSelectWithPaging example.
@what-the-diff
Copy link
Copy Markdown

what-the-diff Bot commented May 2, 2026

PR Summary

  • Updated Documentation

    • The extensions.md document now has additional definitions for ApplySelect, GridifySelect, and GridifyQueryableSelect methods with examples and usage instructions.
    • The gridifyQuery.md document has been updated to explain the new Select property in the GridifyQuery class and provides instructions on how to use its field projection feature. Expanded the validation section for the Select property and added a section on how to specify and validate fields to project, with examples.
    • Changes to queryBuilder.md introduce AddSelect and BuildSelect methods, detailing their use in providing field projections during query building.
  • New Files and Code Implementations

    • Two new files were added to support the selection feature: SelectExpressionBuilder.cs is designed to build select expressions, while SelectShape.cs defines the structure of selected fields.
    • We have introduced the SelectTypeFactory class for creating types based on SelectShape.
    • A new GridifySelectException handles issues related to select operations, supplemented by GridifySelectFieldNotMappedException to manage situations where fields aren't mapped.
    • GridifyExtensions now have improvements for dynamic field selection and overloads to support selection from IGridifySelecting.
  • Modified Code Structure and Interface

    • GridifyQuery now implements the IGridifySelecting interface through a new Select property.
    • A new IGridifySelecting interface has been introduced to standardize select operations in gridify queries.
    • Changes to the IQueryBuilder incorporated methods for selecting fields, including AddSelect, BuildSelect, and BuildSelectWithPaging.
  • Utility and Testing

    • A new utility, SelectTokenizer, has been implemented to parse select strings and validate their syntax.
    • Integration tests for Select have been added to ensure SQL generation is functioning as expected and validate filtering with selection.
    • Unit tests for QueryBuilder verify that it properly handles select operations and produces anticipated results during selection and pagination.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class Select (field projection) support across Gridify’s pipeline (extensions + QueryBuilder), enabling callers to project an IQueryable<T> into runtime-emitted shapes containing only requested fields (including one-level nested objects / collections), with EF Core translating this into column-pruned SQL.

Changes:

  • Introduces Select parsing/validation + projection builder (tokenizer, expression builder, runtime type emission + caching).
  • Wires Select through ApplySelect, GridifySelect / GridifyQueryableSelect, IGridifySelecting + IsValidSelect, and QueryBuilder’s BuildSelect* API.
  • Adds unit + EF integration tests and updates docs for the new selection feature.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Gridify/Syntax/SelectTokenizer.cs Adds select-string tokenization + syntax validation.
src/Gridify/Builder/SelectExpressionBuilder.cs Builds projection expressions (flat/nested/collection) and validator-only path resolution.
src/Gridify/Builder/SelectTypeFactory.cs Emits and caches runtime projection types via Reflection.Emit.
src/Gridify/Builder/SelectShape.cs Defines the resolved select “shape” tree used by the builder/factory.
src/Gridify/Exceptions/GridifySelectException.cs Adds base exception type for select failures.
src/Gridify/Exceptions/GridifySelectFieldNotMappedException.cs Adds typed exception for unmapped select fields.
src/Gridify/IGridifySelecting.cs Introduces additive interface for queries that support Select.
src/Gridify/GridifyQuery.cs Implements IGridifySelecting by adding a Select property.
src/Gridify/GridifyExtensions.cs Replaces stub ApplySelect, adds IsValidSelect, and introduces GridifySelect* pipeline methods.
src/Gridify/IQueryBuilder.cs Adds AddSelect + BuildSelect* APIs to QueryBuilder contract.
src/Gridify/QueryBuilder.cs Implements select forwarding/building/projection + paging variant.
test/Gridify.Tests/SelectTests.cs Unit tests for tokenizer, type factory, expression builder, ApplySelect, pipeline, and validation.
test/Gridify.Tests/QueryBuilderSelectTests.cs Tests QueryBuilder’s new select APIs + forwarding from AddQuery.
test/EntityFrameworkSqlProviderIntegrationTests/SelectSqlTests.cs EF integration tests asserting column-pruned SQL and translatable collection projection.
docs/pages/guide/queryBuilder.md Documents QueryBuilder AddSelect/BuildSelect* usage.
docs/pages/guide/gridifyQuery.md Documents GridifyQuery.Select, validation, limitations, and NativeAOT note.
docs/pages/guide/extensions.md Documents ApplySelect, GridifySelect, and GridifyQueryableSelect.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Gridify/Builder/SelectExpressionBuilder.cs Outdated
Comment thread src/Gridify/Builder/SelectExpressionBuilder.cs Outdated
CaseSensitive controls map-key matching, not CLR property lookup, so
WalkSegment now always reflects with IgnoreCase — nested selects like
"childClass.name" no longer break under case-sensitive mappers when keys
are camelCase but CLR properties are PascalCase.

Resolved segments are also canonicalized (mapper-stored "From" casing
for matched keys, CLR prop.Name for walked suffixes) so case-only
variants share one cached emitted type and produce stable property
names regardless of the user's input casing.
@alirezanet alirezanet added this to the V3 milestone May 11, 2026
Comment thread src/Gridify/Builder/SelectExpressionBuilder.cs Outdated
@alirezanet
Copy link
Copy Markdown
Owner

alirezanet commented May 11, 2026

Thanks for the detailed PR, the overall direction looks good to me however I found one correctness issue that I think is worth fixing before approval. It looks like single-field Select aliases do not always use the mapper expression itself. For example, if a mapper exposes a friendly field name like childName for x.ChildClass.Name, then select=childName currently projects the ChildClass object instead of the Name value. I also saw the same pattern with aliases that map to collection projections.

The likely cause is the allSingleSegment branch in SelectExpressionBuilder.BuildShape: it uses the extracted first segment expression, but for a single mapped alias the expected projection is the resolved mapper body / leaf type.

Could you add a small regression test for a single-segment alias mapped to a nested value, and ideally one for a collection projection alias too?

One smaller API consistency note: since QueryBuilder now has AddSelect, I think QueryBuilder.IsValid() should either validate the select string as well or the docs should explicitly say it only validates filtering/ordering. Right now AddSelect("notMapped") still leaves IsValid() returning true.

Once the alias projection issue is fixed, I think this will be in good shape.

The allSingleSegment branch in SelectExpressionBuilder.BuildShape was
passing every single-segment alias through ExtractFirstSegmentExpr,
which walks inward to find the first member directly on rootParam.
That broke two cases:

  * alias to a nested value (childName -> x.ChildClass.Name) projected
    ChildClass instead of the Name leaf.
  * alias to a collection projection (tags -> x.Items.Select(i => i.Tag))
    was unwrapped back to x.Items, losing the projection.

Bind first.Body / first.LeafType directly for the single-segment
branch. ExtractFirstSegmentExpr is still used for the nested/collection
branch where grouping by the first root segment is required.

Also clarify the IsValid contract (non-breaking): XML docs on
IQueryBuilder.IsValid and AddSelect now state the select string is
not validated and point to IsValidSelect. Refresh the stale summary on
the IGridifyQuery IsValid extension to mention Select. Document the
SelectTypeFactory cache as process-wide and unbounded, recommending
upfront validation when select strings come from untrusted input.
@akleinvm
Copy link
Copy Markdown
Author

Thanks for the careful review! Both addressed in abdd7af.

Alias projection: the allSingleSegment branch now binds first.Body / first.LeafType directly instead of routing through ExtractFirstSegmentExpr (which is still correct, and still used, for the nested/collection grouping branch). Regression tests cover alias→nested value, alias→collection projection, and a mixed case. Added an EF SQL test confirming collection-alias projections still translate to SQL.

QueryBuilder.IsValid(): went with the docs route to keep it non-breaking; IsValid and AddSelect now have XML <remarks> pointing to IsValidSelect, and the stale <summary> on the IGridifyQuery IsValid extension was refreshed. Happy to follow up post-merge with an additive IQueryBuilder.IsValidSelect() if you'd prefer a cleaner path.

Two design notes worth flagging for separate discussion (no action expected here):

  • IGridifyQuery composes filter/order/paging but not IGridifySelecting so consumers do an is IGridifySelecting cast. Worth deciding whether to compose it in.
  • Filter and Select disagree on IgnoreNotMappedFields in their IsValid(out errors) paths; Select honors it, Filter reports anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants