diff --git a/docs-v4/.gitignore b/docs-v4/.gitignore new file mode 100644 index 0000000..7de471e --- /dev/null +++ b/docs-v4/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +out +*.tsbuildinfo +next-env.d.ts diff --git a/docs-v4/app/docs/[...slug]/page.tsx b/docs-v4/app/docs/[...slug]/page.tsx new file mode 100644 index 0000000..502f4db --- /dev/null +++ b/docs-v4/app/docs/[...slug]/page.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { Sidebar } from '@/components/sidebar' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { TableOfContents } from '@/components/table-of-contents' +import { PagerNav } from '@/components/pager-nav' +import { getAllDocSlugs, readDocSource } from '@/lib/docs' +import { getNeighbors, getGroupTitle } from '@/lib/navigation' + +export const dynamicParams = false + +export function generateStaticParams() { + return getAllDocSlugs().map((slug) => ({ slug })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string[] }> +}): Promise { + const { slug } = await params + const source = readDocSource(slug) + if (!source) return {} + return { + title: source.frontmatter.title, + description: source.frontmatter.description, + } +} + +export default async function DocPage({ params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params + const source = readDocSource(slug) + if (!source) notFound() + + const slugPath = source.modulePath.replace(/\.mdx$/, '') + const { default: Content } = await import(`@/content/docs/${slugPath}.mdx`) + + const groupTitle = source.frontmatter.section ?? getGroupTitle(slugPath) + const { prev, next } = getNeighbors(slugPath) + + return ( +
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+ ) +} diff --git a/docs-v4/app/docs/page.tsx b/docs-v4/app/docs/page.tsx new file mode 100644 index 0000000..3ae2747 --- /dev/null +++ b/docs-v4/app/docs/page.tsx @@ -0,0 +1,134 @@ +import Link from 'next/link' +import { ArrowRight, BookOpen, Braces, RotateCcw } from 'lucide-react' +import { SearchButton } from '@/components/search-button' +import { navigation } from '@/lib/navigation' + +export const metadata = { + title: 'Documentation', + description: 'Learn how to build secure, composable query APIs with FlexQuery.NET.', +} + +const startingPoints = [ + { + icon: BookOpen, + title: 'Build your first query', + description: 'Install the EF Core provider and ship a working query endpoint.', + href: '/docs/getting-started/first-query', + meta: '10 min', + }, + { + icon: Braces, + title: 'Understand the query model', + description: 'See how filters, sorting, projection, paging, and includes fit together.', + href: '/docs/concepts/query-options', + meta: 'Core concept', + }, + { + icon: RotateCcw, + title: 'Migrate from v3', + description: 'Review breaking changes and move an existing integration to v4.', + href: '/docs/migration/v3-to-v4', + meta: 'Migration guide', + }, +] + +export default function DocsIndexPage() { + return ( +
+
+
+

+ FlexQuery.NET v4 +

+

+ Documentation +

+

+ Build secure, composable query endpoints for EF Core and Dapper—from the first + filter to production governance and diagnostics. +

+
+
+

+ Find an API, feature, or error message +

+ +
+
+ +
+
+
+

+ Start here +

+

+ Choose the shortest path for what you need to do today. +

+
+
+ {startingPoints.map((item) => ( + +
+
+
+ +
+
+
+

+ Browse the docs +

+

+ The complete v4 documentation, organized by task and system area. +

+
+
+ {navigation.map((group) => ( +
+

+ {group.title} +

+
    + {group.items.map((item) => ( +
  • + + {item.title} +
  • + ))} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/docs-v4/app/globals.css b/docs-v4/app/globals.css new file mode 100644 index 0000000..2c8fcaa --- /dev/null +++ b/docs-v4/app/globals.css @@ -0,0 +1,211 @@ +@import 'tailwindcss'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --font-sans: 'Segoe UI Variable Text', 'Segoe UI', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'Liberation Mono', monospace; + + --color-brand-50: #eff6ff; + --color-brand-100: #dbeafe; + --color-brand-200: #bfdbfe; + --color-brand-300: #93c5fd; + --color-brand-400: #60a5fa; + --color-brand-500: #3b82f6; + --color-brand-600: #2563eb; + --color-brand-700: #1d4ed8; + --color-brand-800: #1e40af; + --color-brand-900: #1e3a8a; + --color-brand-950: #172554; + + --color-accent-400: #22d3ee; + --color-accent-500: #06b6d4; + --color-accent-600: #0891b2; +} + +:root { + --fq-prose: 48rem; + --fq-wide: 68rem; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 5rem; + scrollbar-gutter: stable; +} + +body { + @apply bg-white font-sans text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100; +} + +::selection { + @apply bg-brand-100 text-brand-950 dark:bg-brand-900 dark:text-brand-50; +} + +@layer base { + :focus-visible { + outline: 2px solid var(--color-brand-500); + outline-offset: 2px; + } +} + +button:not(:disabled), +[role='button'] { + cursor: pointer; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +/* ---- Shiki dual-theme code highlighting ---- */ +html.dark .shiki, +html.dark .shiki span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; +} + +/* ---- Prose styles for MDX content ---- */ +.prose-doc { + @apply text-[15px] leading-7 text-zinc-700 dark:text-zinc-300; +} + +/* One shared grid. Narrative text keeps the --fq-prose measure, while every wide + component - tables, API tables, code, HTTP and JSON examples - inherits exactly the + same --fq-wide content width: same left edge, same right edge, same breakpoints. + They scroll inside their own containers; the page itself never scrolls horizontally. */ +.prose-doc > * { + max-width: var(--fq-prose); +} + +.prose-doc > [data-doc-wide='table'], +.prose-doc > [data-doc-wide='code'] { + width: 100%; + min-width: 0; + max-width: var(--fq-wide); +} + +/* Nested wide content (inside callouts, tabs) matches its container edge to edge too. */ +.prose-doc [data-doc-wide] { + width: 100%; + min-width: 0; +} + +.prose-doc h1 { + @apply mt-2 mb-5 scroll-mt-24 text-[2rem] leading-tight font-bold tracking-[-0.025em] text-zinc-950 dark:text-white; +} + +.prose-doc h2 { + @apply mt-12 mb-4 scroll-mt-24 text-[1.35rem] leading-snug font-semibold tracking-[-0.015em] text-zinc-950 dark:text-white; +} + +.prose-doc h3 { + @apply mt-8 mb-3 scroll-mt-24 text-lg font-semibold tracking-[-0.01em] text-zinc-950 dark:text-white; +} + +.prose-doc h4 { + @apply mt-6 mb-2 scroll-mt-24 text-base font-semibold text-zinc-900 dark:text-white; +} + +.prose-doc h1, +.prose-doc h2, +.prose-doc h3, +.prose-doc h4 { + overflow-wrap: anywhere; +} + +.prose-doc p { + @apply my-4; +} + +.prose-doc > p:first-of-type { + @apply mt-0 mb-7 text-[17px] leading-8 text-zinc-600 dark:text-zinc-300; +} + +.prose-doc a:not([class]) { + @apply font-medium text-brand-700 underline decoration-brand-300 underline-offset-4 transition-colors hover:text-brand-800 hover:decoration-brand-500 dark:text-brand-300 dark:decoration-brand-800 dark:hover:text-brand-200 dark:hover:decoration-brand-500; +} + +.prose-doc ul { + @apply my-4 list-disc space-y-1.5 pl-6; +} + +.prose-doc ol { + @apply my-4 list-decimal space-y-1.5 pl-6; +} + +.prose-doc li::marker { + @apply text-zinc-400 dark:text-zinc-600; +} + +/* Numbered steps (e.g. "How it works") read as a pipeline: mono, accent-colored. */ +.prose-doc ol > li::marker { + @apply font-mono text-[13px] font-medium text-brand-600 dark:text-brand-400; +} + +.prose-doc code:not(pre code) { + @apply rounded-md border border-zinc-200 bg-zinc-100 px-1.5 py-0.5 font-mono text-[13px] font-medium text-brand-700 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-brand-300; + /* Long query strings inside inline code (e.g. in callouts) must wrap on small + screens instead of widening the page. */ + overflow-wrap: anywhere; +} + +/* Table frame: contained scroll only - width comes from the shared [data-doc-wide] system. */ +.prose-doc .doc-table-wrap { + @apply my-6 overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800; +} + +.prose-doc table { + @apply w-full border-collapse text-sm; +} + +/* Small screens: tables never squash into unreadable columns and never widen the + page - the scroll lives inside .doc-table-wrap around the table. */ +@media (max-width: 47.9375rem) { + .prose-doc table { + min-width: 34rem; + } + + .prose-doc table:has(tr > :nth-child(4)) { + min-width: 44rem; + } +} + +.prose-doc th { + @apply border-b border-zinc-200 bg-zinc-50/80 px-4 py-2.5 text-left font-semibold text-zinc-950 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-white; +} + +.prose-doc td { + @apply border-b border-zinc-100 px-4 py-2.5 align-top dark:border-zinc-800/70; + overflow-wrap: anywhere; +} + +.prose-doc tbody tr:last-child td { + @apply border-b-0; +} + +.prose-doc blockquote { + @apply my-5 border-l-2 border-brand-400 pl-4 text-zinc-600 dark:border-brand-700 dark:text-zinc-400; +} + +.prose-doc hr { + @apply my-8 border-zinc-200 dark:border-zinc-800; +} + +.prose-doc strong { + @apply font-semibold text-zinc-900 dark:text-zinc-100; +} diff --git a/docs-v4/app/icon.svg b/docs-v4/app/icon.svg new file mode 100644 index 0000000..40deaa6 --- /dev/null +++ b/docs-v4/app/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + F + diff --git a/docs-v4/app/layout.tsx b/docs-v4/app/layout.tsx new file mode 100644 index 0000000..74bccd6 --- /dev/null +++ b/docs-v4/app/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next' +import { ThemeProvider } from 'next-themes' +import { SiteHeader } from '@/components/site-header' +import { SiteFooter } from '@/components/site-footer' +import { SearchProvider } from '@/components/search-provider' +import { SearchDialog } from '@/components/search-dialog' +import './globals.css' + +export const metadata: Metadata = { + metadataBase: new URL('https://flexquery.net'), + title: { + default: 'FlexQuery.NET — Dynamic querying for .NET APIs', + template: '%s · FlexQuery.NET', + }, + description: + 'Dynamic filtering, sorting, paging, projection, and aggregates for IQueryable in .NET. Secure, server-side, and translated to SQL via expression trees.', +} + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + + + + Skip to content + + +
{children}
+ + +
+
+ + + ) +} diff --git a/docs-v4/app/not-found.tsx b/docs-v4/app/not-found.tsx new file mode 100644 index 0000000..acf620d --- /dev/null +++ b/docs-v4/app/not-found.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link' +import { FileQuestion } from 'lucide-react' + +export default function NotFound() { + return ( +
+ +

Page not found

+

+ The page you are looking for does not exist. +

+ + Back to documentation + +
+ ) +} diff --git a/docs-v4/app/page.tsx b/docs-v4/app/page.tsx new file mode 100644 index 0000000..4225b16 --- /dev/null +++ b/docs-v4/app/page.tsx @@ -0,0 +1,470 @@ +import Link from 'next/link' +import { + ArrowRight, + ArrowDown, + ArrowUpRight, + Check, + X, + Filter, + ArrowDownWideNarrow, + Layers, + GitBranch, + BarChart3, + KeyRound, + ShieldCheck, + Stethoscope, + Database, + FileCode2, + MousePointerClick, + Plug, +} from 'lucide-react' +import { CodeBlock } from '@/components/code-block' + +const capabilityGroups = [ + { + label: 'Query', + description: 'Shape the result set', + items: [ + { + icon: Filter, + title: 'Dynamic Filtering', + description: '18 operators, nested groups, collection filters — translated to SQL expression trees, never client-evaluated.', + href: '/docs/guides/filtering', + }, + { + icon: ArrowDownWideNarrow, + title: 'Multi-column Sorting', + description: 'Colon and space form directions, navigation paths, default sorts, and aggregate sorts.', + href: '/docs/guides/sorting', + }, + { + icon: Layers, + title: 'Server-side Projection', + description: 'Select only what you need — flat, nested, aliased, and DTO-shaped output with enforced result surfaces.', + href: '/docs/guides/projection', + }, + ], + }, + { + label: 'Data', + description: 'Load and shape relationships', + items: [ + { + icon: GitBranch, + title: 'Deep Expand Trees', + description: 'Load navigations with per-branch filter, sort, and take — hydrated via split queries.', + href: '/docs/guides/expand', + }, + { + icon: BarChart3, + title: 'Grouping & Aggregates', + description: 'GROUP BY with sum, count, avg, min, max — and HAVING with a full expression tree.', + href: '/docs/guides/grouping', + }, + { + icon: KeyRound, + title: 'Keyset Pagination', + description: 'Seek predicates instead of OFFSET for large datasets, with opaque, versioned cursors.', + href: '/docs/guides/keyset-pagination', + }, + ], + }, + { + label: 'Control', + description: 'Keep it safe and observable', + items: [ + { + icon: ShieldCheck, + title: 'Security & Governance', + description: 'Allowed/blocked fields, per-operation sets, operator allow-lists, role-based access.', + href: '/docs/security', + }, + { + icon: Stethoscope, + title: 'Diagnostics', + description: 'Pipeline events, timing reports, SQL previews, and copy-paste-ready Dapper SQL logs.', + href: '/docs/diagnostics', + }, + ], + }, +] + +const integrationGroups = [ + { + label: 'Data providers', + items: [ + { icon: Database, name: 'Entity Framework Core', href: '/docs/providers/ef-core' }, + { icon: Database, name: 'Dapper', href: '/docs/providers/dapper' }, + ], + }, + { + label: 'API & platform', + items: [ + { icon: Plug, name: 'ASP.NET Core', href: '/docs/integrations/aspnetcore' }, + { icon: FileCode2, name: 'OpenAPI / Swagger', href: '/docs/integrations/openapi' }, + ], + }, + { + label: 'UI data grids', + items: [ + { icon: MousePointerClick, name: 'AG Grid', href: '/docs/integrations/ag-grid' }, + { icon: MousePointerClick, name: 'Kendo UI', href: '/docs/integrations/kendo' }, + ], + }, +] + +const steps = [ + { + title: 'Install', + body: 'Add the core package and your provider package from NuGet.', + language: 'bash', + code: 'dotnet add package FlexQuery.NET.EntityFrameworkCore', + }, + { + title: 'Configure', + body: 'Set global defaults once at startup - then never think about them again.', + language: 'csharp', + code: 'FlexQueryCore.Configure(options =>\n{\n options.DefaultPageSize = 20;\n options.MaxPageSize = 1000;\n});', + }, + { + title: 'Query', + body: 'Bind FlexQueryParameters and execute. That is the whole endpoint.', + language: 'csharp', + code: 'var result = await db.Customers\n .AsNoTracking()\n .FlexQueryAsync(parameters,\n cancellationToken: ct);', + }, +] + +const pipeline = [ + { step: '01', title: 'HTTP query', detail: '?filter=…&sort=…&page=…' }, + { step: '02', title: 'Parser', detail: 'Query string → query model' }, + { step: '03', title: 'Governance', detail: 'Allow-lists, roles, limits' }, + { step: '04', title: 'Expression tree', detail: 'Typed IQueryable composition' }, + { step: '05', title: 'Provider', detail: 'EF Core or Dapper' }, + { step: '06', title: 'Database', detail: 'Parameterized SQL' }, +] + +function HeroQuery() { + return ( +
+
+
+ + HTTP + + GET + + + + Query syntax +
+
+          
+            GET
+            {' '}
+            /api/customers
+            {'\n    '}
+            ?
+            filter
+            =
+            Status:eq:Active
+            {'\n    '}
+            &
+            sort
+            =
+            LastName:asc
+            {'\n    '}
+            &
+            select
+            =
+            Id,FirstName,Email
+            {'\n    '}
+            &
+            page
+            =
+            1
+            &
+            pageSize
+            =
+            20
+          
+        
+
+

+ query parameters → FlexQuery pipeline → SQL expression tree +

+
+ ) +} + +export default function Home() { + return ( +
+ {/* Hero */} +
+
+
+ v4 — typed DTOs, expand trees, keyset pagination +
+

+ Dynamic querying for{' '} + .NET APIs +

+

+ FlexQuery.NET turns query parameters into secure, server-side expression trees — + filtering, sorting, paging, projection, and aggregates in a single line, with + EF Core or Dapper. +

+
+ + Get started + + + Read the docs + +
+ +
+
+ + {/* Quick start */} +
+
+
+

Up and running in minutes

+

+ Three steps from NuGet to a production-grade query endpoint. +

+
+
+ {steps.map((s, i) => ( +
+
+ + {i + 1} + +

{s.title}

+
+

{s.body}

+
+ + {s.code} + +
+
+ ))} +
+
+
+ + {/* Capabilities, grouped by role in the pipeline */} +
+
+

Everything a query API needs

+

+ One pipeline from query string to SQL — validated, governed, and observable. +

+
+
+ {capabilityGroups.map((group) => ( +
+
+

+ {group.label} +

+ {group.description} +
+
+ {group.items.map((item) => ( + +
+
+

{item.description}

+ + ))} +
+
+ ))} +
+
+ + {/* Why FlexQuery */} +
+
+
+

Why FlexQuery?

+

+ Every list endpoint needs filtering, sorting, paging, projection, and include logic — + plus validation for all of it. FlexQuery centralizes that repetition into one governed pipeline. +

+
+
+
+

+

+
    + {[ + 'Custom filter parsing in every endpoint', + 'Ad-hoc sorting and pagination logic', + 'Projection and include code repeated per feature', + 'Validation re-implemented — or forgotten', + 'Multiple endpoints, or one overloaded query object', + ].map((line) => ( +
  • +
  • + ))} +
+
+
+

+

+
    + {[ + 'One bind and one call per endpoint', + 'Query string → validated expression tree → SQL', + 'Defaults and governance configured once, globally', + 'Identical query behavior on EF Core and Dapper', + ].map((line) => ( +
  • +
  • + ))} +
+
+                {'var result = await db.Customers\n    .FlexQueryAsync(parameters, cancellationToken: ct);'}
+              
+
+
+
+
+ + {/* Architecture */} +
+
+

From query string to SQL

+

+ Nothing is string-matched into LINQ at runtime. Requests become governed, typed expression + trees before they ever reach your database. +

+
+
    + {pipeline.map((node, i) => ( +
  1. +
    +
    + {node.step} + {node.title} +
    +

    {node.detail}

    +
    + {i < pipeline.length - 1 && ( + + )} +
  2. + ))} +
+

+ See the full{' '} + + execution pipeline + {' '} + in the docs. +

+
+ + {/* Providers & integrations */} +
+
+
+

Providers & integrations

+

+ Works with your stack — data access, UI grids, and API documentation. +

+
+
+ {integrationGroups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((p) => ( + + + + {p.name} +
+
+ ))} +
+
+
+ + {/* Bottom CTA */} +
+
+
+

+ Build your first query in five minutes +

+

+ Follow the quick-start guide and ship a dynamic query endpoint today. +

+ + Start building + +
+
+ + {'dotnet add package FlexQuery.NET.EntityFrameworkCore'} + +
+
+
+
+ ) +} diff --git a/docs-v4/components/api-table.tsx b/docs-v4/components/api-table.tsx new file mode 100644 index 0000000..69ec3d6 --- /dev/null +++ b/docs-v4/components/api-table.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +export function ApiTable({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+
+ ) +} diff --git a/docs-v4/components/brand-mark.tsx b/docs-v4/components/brand-mark.tsx new file mode 100644 index 0000000..259efec --- /dev/null +++ b/docs-v4/components/brand-mark.tsx @@ -0,0 +1,12 @@ +export function BrandMark({ compact = false }: { compact?: boolean }) { + return ( + + ) +} diff --git a/docs-v4/components/breadcrumbs.tsx b/docs-v4/components/breadcrumbs.tsx new file mode 100644 index 0000000..296f64f --- /dev/null +++ b/docs-v4/components/breadcrumbs.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link' +import { ChevronRight } from 'lucide-react' + +export function Breadcrumbs({ group, title }: { group: string | null; title: string }) { + return ( + + ) +} diff --git a/docs-v4/components/callout.tsx b/docs-v4/components/callout.tsx new file mode 100644 index 0000000..2569adc --- /dev/null +++ b/docs-v4/components/callout.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react' +import { Info, Lightbulb, TriangleAlert, OctagonX } from 'lucide-react' + +const variants = { + note: { + icon: Info, + wrap: 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-950/40', + iconColor: 'text-blue-500', + title: 'Note', + }, + tip: { + icon: Lightbulb, + wrap: 'border-emerald-200 bg-emerald-50 dark:border-emerald-900/50 dark:bg-emerald-950/40', + iconColor: 'text-emerald-500', + title: 'Tip', + }, + warning: { + icon: TriangleAlert, + wrap: 'border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-950/40', + iconColor: 'text-amber-500', + title: 'Warning', + }, + danger: { + icon: OctagonX, + wrap: 'border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-950/40', + iconColor: 'text-red-500', + title: 'Important', + }, +} as const + +export function Callout({ + variant = 'note', + title, + children, +}: { + variant?: keyof typeof variants + title?: string + children: ReactNode +}) { + const v = variants[variant] + const Icon = v.icon + return ( + + ) +} diff --git a/docs-v4/components/code-block.tsx b/docs-v4/components/code-block.tsx new file mode 100644 index 0000000..4da3023 --- /dev/null +++ b/docs-v4/components/code-block.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useEffect, useState, useRef, type ReactElement } from 'react' +import { Check, Copy, TriangleAlert } from 'lucide-react' + +const languageLabels: Record = { + csharp: 'C#', + cs: 'C#', + js: 'JavaScript', + javascript: 'JavaScript', + ts: 'TypeScript', + typescript: 'TypeScript', + json: 'JSON', + sql: 'SQL', + http: 'HTTP', + bash: 'Shell', + shell: 'Shell', + text: 'Plain text', +} + +export function CodeBlock(props: { + children?: ReactElement + className?: string + style?: React.CSSProperties + 'data-language'?: string +}) { + const { children, className, style, 'data-language': dataLanguage } = props + const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle') + const preRef = useRef(null) + const resetTimerRef = useRef | null>(null) + + const child: any = children?.props ?? {} + const codeClass: string = child.className ?? '' + const languageMatch = /language-(\w+)/.exec(codeClass) + const language = dataLanguage ?? (languageMatch ? languageMatch[1] : null) + + useEffect( + () => () => { + if (resetTimerRef.current) clearTimeout(resetTimerRef.current) + }, + [], + ) + + const copy = async () => { + const text = preRef.current?.textContent ?? '' + try { + await navigator.clipboard.writeText(text) + setCopyState('success') + } catch { + setCopyState('error') + } + if (resetTimerRef.current) clearTimeout(resetTimerRef.current) + resetTimerRef.current = setTimeout(() => setCopyState('idle'), 2000) + } + + return ( +
+
+ + {language ? (languageLabels[language.toLowerCase()] ?? language) : 'Plain text'} + + +
+
+        {children}
+      
+
+ ) +} diff --git a/docs-v4/components/mobile-nav.tsx b/docs-v4/components/mobile-nav.tsx new file mode 100644 index 0000000..9d9057d --- /dev/null +++ b/docs-v4/components/mobile-nav.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Menu, X, ChevronDown } from 'lucide-react' +import { navigation } from '@/lib/navigation' +import { SearchButton } from '@/components/search-button' + +export function MobileNav({ links }: { links: { href: string; label: string }[] }) { + const [open, setOpen] = useState(false) + const pathname = usePathname() + const activeSlug = pathname.startsWith('/docs/') ? pathname.slice('/docs/'.length) : '' + const activeGroup = navigation.find((group) => group.items.some((item) => item.slug === activeSlug)) + const [expanded, setExpanded] = useState(activeGroup?.title ?? null) + const menuButtonRef = useRef(null) + const closeButtonRef = useRef(null) + const panelRef = useRef(null) + + useEffect(() => { + if (activeGroup) setExpanded(activeGroup.title) + }, [activeSlug]) + + useEffect(() => { + if (!open) return + document.body.style.overflow = 'hidden' + closeButtonRef.current?.focus() + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false) + menuButtonRef.current?.focus() + } + } + window.addEventListener('keydown', onKeyDown) + return () => { + document.body.style.overflow = '' + window.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + return ( + <> + + {open && + createPortal( +
{ + if (event.key !== 'Tab') return + const focusable = panelRef.current?.querySelectorAll( + 'button, a[href], [tabindex]:not([tabindex="-1"])', + ) + if (!focusable?.length) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + }} + > + +
+ setOpen(false)} /> +
+ +
, + document.body, + )} + + ) +} diff --git a/docs-v4/components/pager-nav.tsx b/docs-v4/components/pager-nav.tsx new file mode 100644 index 0000000..81e160b --- /dev/null +++ b/docs-v4/components/pager-nav.tsx @@ -0,0 +1,39 @@ +import Link from 'next/link' +import { ArrowLeft, ArrowRight } from 'lucide-react' +import type { NavItem } from '@/lib/navigation' + +export function PagerNav({ prev, next }: { prev: NavItem | null; next: NavItem | null }) { + if (!prev && !next) return null + return ( + + ) +} diff --git a/docs-v4/components/related-links.tsx b/docs-v4/components/related-links.tsx new file mode 100644 index 0000000..4ad0af6 --- /dev/null +++ b/docs-v4/components/related-links.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link' +import { BookOpen, ArrowRight } from 'lucide-react' + +export interface RelatedLink { + title: string + slug: string + description: string +} + +export function RelatedLinks({ links }: { links: RelatedLink[] }) { + return ( +
+

+ + Related documentation +

+
+ {links.map((l) => ( + + + {l.title} + + + {l.description} + + ))} +
+
+ ) +} diff --git a/docs-v4/components/search-button.tsx b/docs-v4/components/search-button.tsx new file mode 100644 index 0000000..509bf74 --- /dev/null +++ b/docs-v4/components/search-button.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Search } from 'lucide-react' +import { useSearch } from '@/components/search-provider' + +export function SearchButton({ + fullWidth = false, + onOpen, +}: { + fullWidth?: boolean + onOpen?: () => void +}) { + const { setOpen } = useSearch() + const [shortcut, setShortcut] = useState('Ctrl K') + + useEffect(() => { + if (/Mac|iPhone|iPad/.test(navigator.platform)) setShortcut('⌘ K') + }, []) + + return ( + + ) +} diff --git a/docs-v4/components/search-dialog.tsx b/docs-v4/components/search-dialog.tsx new file mode 100644 index 0000000..2b69287 --- /dev/null +++ b/docs-v4/components/search-dialog.tsx @@ -0,0 +1,306 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import Fuse from 'fuse.js' +import { ArrowRight, FileText, LoaderCircle, Search, Text } from 'lucide-react' +import { useSearch } from '@/components/search-provider' + +interface SearchEntry { + title: string + description: string + section: string + slug: string + headings: { text: string; id: string; level?: number }[] + body: string +} + +interface SearchResult { + key: string + title: string + pageTitle: string + description: string + section: string + href: string + kind: 'page' | 'heading' + searchText: string +} + +type LoadState = 'idle' | 'loading' | 'ready' | 'error' + +function createSearchResults(entries: SearchEntry[]): SearchResult[] { + return entries.flatMap((entry) => { + const page: SearchResult = { + key: entry.slug, + title: entry.title, + pageTitle: entry.title, + description: entry.description, + section: entry.section || 'Documentation', + href: `/docs/${entry.slug}`, + kind: 'page', + searchText: `${entry.title} ${entry.description} ${entry.body}`, + } + + const headings = entry.headings + .filter((heading) => heading.level !== 1 && heading.text !== entry.title) + .map((heading) => ({ + key: `${entry.slug}#${heading.id}`, + title: heading.text, + pageTitle: entry.title, + description: entry.description, + section: entry.section || 'Documentation', + href: `/docs/${entry.slug}#${heading.id}`, + kind: 'heading', + searchText: heading.text, + })) + + return [page, ...headings] + }) +} + +export function SearchDialog() { + const { open, setOpen } = useSearch() + const [entries, setEntries] = useState([]) + const [loadState, setLoadState] = useState('idle') + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(0) + const inputRef = useRef(null) + const dialogRef = useRef(null) + const restoreFocusRef = useRef(null) + const router = useRouter() + + useEffect(() => { + if (!open || entries.length > 0) return + + const controller = new AbortController() + setLoadState('loading') + fetch('/search-index.json', { signal: controller.signal }) + .then((response) => { + if (!response.ok) throw new Error(`Search index returned ${response.status}`) + return response.json() as Promise + }) + .then((nextEntries) => { + setEntries(nextEntries) + setLoadState('ready') + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') { + setLoadState('idle') + return + } + setLoadState('error') + }) + + return () => controller.abort() + }, [open, entries.length]) + + useEffect(() => { + if (!open) return + restoreFocusRef.current = document.activeElement as HTMLElement | null + setQuery('') + setSelected(0) + document.body.style.overflow = 'hidden' + requestAnimationFrame(() => inputRef.current?.focus()) + + return () => { + document.body.style.overflow = '' + restoreFocusRef.current?.focus() + } + }, [open]) + + const searchable = useMemo(() => createSearchResults(entries), [entries]) + const fuse = useMemo( + () => + new Fuse(searchable, { + keys: ['searchText'], + threshold: 0.3, + ignoreLocation: true, + }), + [searchable], + ) + + const results = useMemo(() => { + if (query.trim().length === 0) { + return searchable.filter((result) => result.kind === 'page').slice(0, 8) + } + return fuse.search(query, { limit: 12 }).map((result) => result.item) + }, [fuse, query, searchable]) + + useEffect(() => { + if (selected >= results.length) setSelected(Math.max(0, results.length - 1)) + }, [results.length, selected]) + + if (!open) return null + + const close = () => setOpen(false) + const go = (href: string) => { + close() + router.push(href) + } + + return ( +
{ + if (event.target === event.currentTarget) close() + }} + > +
{ + if (event.key === 'Escape') { + event.preventDefault() + close() + } else if (event.key === 'Tab') { + const focusable = dialogRef.current?.querySelectorAll( + 'input, button:not([tabindex="-1"]), a[href], [tabindex]:not([tabindex="-1"])', + ) + if (!focusable?.length) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + }} + > +

+ Search documentation +

+
+
+ +
+ {loadState === 'loading' && ( +
+
+ )} + {loadState === 'error' && ( +
+

Search is temporarily unavailable

+

+ Browse the documentation navigation, or close this window and try again. +

+
+ )} + {loadState === 'ready' && results.length === 0 && ( +
+

No results for “{query}”

+

+ Try a feature or API name such as “filter”, “Dapper”, or “QueryOptions”. +

+
+ )} + {loadState === 'ready' && results.length > 0 && ( + <> +
+ {query ? `${results.length} results` : 'Suggested pages'} + ↑↓ to navigate · Enter to open +
+
    + {results.map((result, index) => { + const Icon = result.kind === 'page' ? FileText : Text + return ( +
  • + +
  • + ) + })} +
+ + )} +
+
+
+ ) +} diff --git a/docs-v4/components/search-provider.tsx b/docs-v4/components/search-provider.tsx new file mode 100644 index 0000000..5d9dcab --- /dev/null +++ b/docs-v4/components/search-provider.tsx @@ -0,0 +1,34 @@ +'use client' + +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' + +interface SearchContextValue { + open: boolean + setOpen: (open: boolean) => void +} + +const SearchContext = createContext({ open: false, setOpen: () => {} }) + +export function useSearch() { + return useContext(SearchContext) +} + +export function SearchProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false) + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + setOpen(true) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + const updateOpen = useCallback((nextOpen: boolean) => setOpen(nextOpen), []) + const value = useMemo(() => ({ open, setOpen: updateOpen }), [open, updateOpen]) + + return {children} +} diff --git a/docs-v4/components/sidebar.tsx b/docs-v4/components/sidebar.tsx new file mode 100644 index 0000000..ed65b6a --- /dev/null +++ b/docs-v4/components/sidebar.tsx @@ -0,0 +1,80 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { ChevronDown } from 'lucide-react' +import { navigation } from '@/lib/navigation' + +export function Sidebar() { + const pathname = usePathname() + const activeSlug = pathname.startsWith('/docs/') ? pathname.slice('/docs/'.length) : '' + const activeGroup = navigation.find((group) => group.items.some((item) => item.slug === activeSlug)) + const [openGroups, setOpenGroups] = useState(() => (activeGroup ? [activeGroup.title] : [])) + + useEffect(() => { + if (activeGroup) { + setOpenGroups((current) => + current.includes(activeGroup.title) ? current : [...current, activeGroup.title], + ) + } + // Keep the current page visible in the scrollable sidebar after navigation. + const activeLink = document.querySelector('[data-sidebar-active="true"]') + activeLink?.scrollIntoView({ block: 'nearest' }) + }, [activeSlug]) + + return ( + + ) +} diff --git a/docs-v4/components/site-footer.tsx b/docs-v4/components/site-footer.tsx new file mode 100644 index 0000000..e225ed3 --- /dev/null +++ b/docs-v4/components/site-footer.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link' +import { BrandMark } from '@/components/brand-mark' + +export function SiteFooter() { + return ( +
+
+
+
+ + FlexQuery.NET +
+ +

MIT Licensed · v4.0

+
+
+
+ ) +} diff --git a/docs-v4/components/site-header.tsx b/docs-v4/components/site-header.tsx new file mode 100644 index 0000000..e84c4e1 --- /dev/null +++ b/docs-v4/components/site-header.tsx @@ -0,0 +1,92 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { ThemeToggle } from '@/components/theme-toggle' +import { SearchButton } from '@/components/search-button' +import { MobileNav } from '@/components/mobile-nav' +import { BrandMark } from '@/components/brand-mark' + +const links = [ + { href: '/docs', label: 'Docs' }, + { href: '/docs/guides/filtering', label: 'Guides' }, + { href: '/docs/api-reference', label: 'API Reference' }, + { href: '/docs/migration/v3-to-v4', label: 'Migration' }, +] + +export function SiteHeader() { + const pathname = usePathname() + const isDocs = pathname === '/docs' || pathname.startsWith('/docs/') + + return ( +
+
+ + + + FlexQuery.NET + + + v4 + + + + +
+
+ ) +} diff --git a/docs-v4/components/table-of-contents.tsx b/docs-v4/components/table-of-contents.tsx new file mode 100644 index 0000000..32dcebe --- /dev/null +++ b/docs-v4/components/table-of-contents.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useEffect, useState } from 'react' + +export interface Heading { + text: string + id: string + level: number +} + +export function TableOfContents({ headings }: { headings: Heading[] }) { + const [activeId, setActiveId] = useState('') + + useEffect(() => { + if (headings.length === 0) return + + // The active heading is the last one whose top sits above the activation + // line (~header height + breathing room). rAF-throttled scroll listener: + // stable while scrolling and never jumps back on short final sections. + let frame = 0 + const update = () => { + frame = 0 + const line = 96 + let current = '' + for (const h of headings) { + const el = document.getElementById(h.id) + if (!el) continue + if (el.getBoundingClientRect().top <= line) current = h.id + else break + } + // Near the bottom of the page, always activate the last heading so the + // TOC never leaves the final section unresolved. + if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) { + current = headings[headings.length - 1]?.id ?? current + } + setActiveId(current) + } + const onScroll = () => { + if (frame) return + frame = requestAnimationFrame(update) + } + + update() + window.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('resize', onScroll, { passive: true }) + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onScroll) + } + }, [headings]) + + if (headings.length < 2) return null + + return ( + + ) +} diff --git a/docs-v4/components/tabs.tsx b/docs-v4/components/tabs.tsx new file mode 100644 index 0000000..b46e71c --- /dev/null +++ b/docs-v4/components/tabs.tsx @@ -0,0 +1,74 @@ +'use client' + +import { Children, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' + +export function Tabs({ children }: { children: ReactNode }) { + const items = Children.toArray(children) + const labels = items.map((item: any) => item?.props?.label ?? 'Tab') + const [active, setActive] = useState(0) + const id = useId() + const tabRefs = useRef>([]) + + const selectFromKeyboard = (event: KeyboardEvent, index: number) => { + let next = index + if (event.key === 'ArrowRight') next = (index + 1) % items.length + else if (event.key === 'ArrowLeft') next = (index - 1 + items.length) % items.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = items.length - 1 + else return + + event.preventDefault() + setActive(next) + tabRefs.current[next]?.focus() + } + + return ( +
+
+ {labels.map((label, i) => ( + + ))} +
+ {items.map((item, i) => ( + + ))} +
+ ) +} + +export function Tab({ label, children }: { label: string; children: ReactNode }) { + return
{children}
+} diff --git a/docs-v4/components/theme-toggle.tsx b/docs-v4/components/theme-toggle.tsx new file mode 100644 index 0000000..f7b6b1d --- /dev/null +++ b/docs-v4/components/theme-toggle.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTheme } from 'next-themes' +import { Moon, Sun } from 'lucide-react' + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => setMounted(true), []) + + return ( + + ) +} diff --git a/docs-v4/content/docs/api-reference/index.mdx b/docs-v4/content/docs/api-reference/index.mdx new file mode 100644 index 0000000..002bf22 --- /dev/null +++ b/docs-v4/content/docs/api-reference/index.mdx @@ -0,0 +1,165 @@ +--- +title: API Reference +description: Curated reference of the public FlexQuery.NET v4 surface per package. +section: Resources +--- + +import { ApiTable } from '@/components/api-table' + +# API Reference + +A curated reference of the public developer-facing surface. Internal types are omitted; only +APIs necessary or useful for building on FlexQuery.NET are listed. + +## FlexQuery.NET (core) + +### Entry points + +| Member | Description | +|---|---| +| `FlexQueryCore.Configure(Action?)` | Global options; immutable after first call. | +| `Query.Create()` | Fluent `FluentQueryBuilder` (implicit -> `QueryOptions`). | +| `FlexQueryMapping.Configure(Action)` | Global type-map registry. | + +### Request models (`FlexQuery.NET.Models`) + +| Type | Purpose | +|---|---| +| `FlexQueryParameters` | Query-string binding model (`Filter`, `Sort`, `Select`, `Include`, `Expand`, `GroupBy`, `Having`, `Aggregate`, `Page`, `PageSize`, `IncludeCount`, `Distinct`, `Mode`, `Cursor`, `UseKeysetPagination`). | +| `FlexQueryRequest` | Strongly-typed request model with `ToQueryOptions()`. | +| `QueryOptions` | Parsed options consumed by the pipeline. | +| `QueryResult` | Result envelope (`Data`, `TotalCount`, `ResultCount`, `Page`, `PageSize`, `TotalPages`, `HasNextPage`, `HasPreviousPage`, `Aggregates`, `NextCursorToken`, `ResultShape`). | + +### Sync pipeline (`FlexQuery.NET` extensions) + +| Method | Description | +|---|---| +| `Apply(query, options)` | Applies the full pipeline. | +| `ApplyFilter` / `ApplySort` / `ApplyPaging` / `ApplySelect` | Individual stages. | +| `FlexQuery(query, parameters / options, configure?)` | Synchronous end-to-end execution. | + +### Request conversion + +| Method | Description | +|---|---| +| `FlexQueryParametersExtensions.ToQueryOptions()` / `.ToQueryOptions(QuerySyntax?)` | Convert bound parameters. | +| `FlexQueryRequestExtensions.ToQueryOptions()` | Convert a typed request. | + +### Result helpers (`QueryResultExtensions`) + +| Method | Description | +|---|---| +| `ToProjectedQueryResult(...)` | Re-projects a result into another element type. | +| `ToObjectResult(Async)` | Erases `T` to `object` for polymorphic endpoints. | +| `ToDynamicResult(Async)` | Erases `T` to `dynamic`. | + +### Validation helpers + +| Method | Description | +|---|---| +| `ValidationExtensions.Validate(options, entityType[, execOptions])` | Runs the rule pipeline, returns `ValidationResult`. | +| `ValidationExtensions.ValidateOrThrow(...)` | Runs the pipeline, throws `QueryValidationException`. | +| `options.ValidateSafe(...)` | Non-throwing validation for staged rollouts. | + +### Fluent builders (`FlexQuery.NET.Builders.Fluent`) + +| Type | Members | +|---|---| +| `FluentQueryBuilder` | `Filter(Action)`, `Sort(Action)`, `Select(params string[])`, `Include(params string[])`, `Expand(Action)`, `Mode`, `GroupBy`, `Aggregate(Action)`, `Having(function, field, op, value)`, `Distinct`, `Page`, `UseKeysetPagination`, `DisablePaging`, `Build()`, implicit `QueryOptions` conversion. | +| `FilterGroupBuilder` | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Contains`, `StartsWith`, `EndsWith`, `In`, `NotIn`, `IsNull`, `IsNotNull`, `Between`, `And(g => ...)`, `Or(g => ...)`. | +| `FilterBuilder` / `FilterConditionBuilder` | `Field/And/Or(name)`, terminators `Eq`, `Neq`, `Contains`, `StartsWith`, `EndsWith`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `Between`, `IsNull`, `NotNull`, `Any(b => ...)`, `All(b => ...)`, `Not()`, `AndGroup`, `OrGroup`. | +| `SortBuilder` | `Ascending(field)`, `Descending(field)`. | +| `AggregateBuilder` | `Sum`, `Count`, `Avg`, `Min`, `Max` (field, alias). | +| `ExpandBuilder` | `Path(path, filter?, configureChildren?)`. | + +### Keyset (`FlexQuery.NET`) + +| Method | Description | +|---|---| +| `SeekAfter(query, cursor)` | Keyset predicate on an ordered queryable. | + +### Exceptions (`FlexQuery.NET.Exceptions`) + +`FlexQueryException` (base), `QueryParseException`, `FlexQueryParseException`, +`DslParseException`, `FqlParseException`, `MiniODataParseException`, +`QueryValidationException`, `ParserNotRegisteredException`. + +## FlexQuery.NET.EntityFrameworkCore + +| Member | Description | +|---|---| +| `FlexQueryEFCore.Setup()` | Registers EF Core operator handlers. | +| `FlexQueryEFCore.Configure(Action?)` | Provider options (`UseNoTracking`). | +| `FlexQueryAsync(query, parameters, configure?, ct)` | Execute (dynamic results). | +| `FlexQueryAsync(query, QueryOptions, configure?, ct)` | Execute pre-parsed options. | +| `FlexQueryAsync(query, ...)` | Typed DTO execution (4 overloads). | +| `ApplyExpand(query, options)` | Include pipeline from expand tree. | +| `ToSqlPreview(query)` / `ExplainProjection(query, options)` | SQL and projection inspection. | +| `UseEfCoreOperators(options)` | Registers the `like` handler on a hand-built options object. | + +## FlexQuery.NET.Dapper + +| Member | Description | +|---|---| +| `FlexQueryDapper.Configure(Action?)` | Global Dapper config (`Model`, `CommandTimeout`). | +| `FlexQueryAsync(connection, parameters, configure?, ct)` | Execute (dynamic results). | +| `FlexQueryAsync(connection, IDictionary, configure?, ct)` | Execute from raw query-string values. | +| `FlexQueryAsync(connection, QueryOptions, configure?, ct)` | Execute pre-parsed options. | +| `FlexQueryAsync(connection, ...)` | Typed DTO execution (4 overloads). | +| `ModelBuilder.Entity()` / `ApplyConfiguration()` / `ApplyConfigurationsFromAssembly()` | Model mapping. | +| `EntityTypeBuilder.ToTable / HasKey / Property / Ignore / HasMany...` | Per-entity mapping. | + +## FlexQuery.NET.AspNetCore + +| Member | Description | +|---|---| +| `AddFlexQuerySecurity(this IMvcBuilder)` | Registers `FieldAccessFilter` + result-shape JSON converter. | +| `AddFlexQueryJson(this IMvcBuilder)` | Result-shape JSON converter only. | +| `AddFlexQuery(services, configure?)` | Combined global config registration. | +| `[FieldAccess]` | Per-endpoint governance attribute (11 properties incl. `AllowedIncludes`). | +| `GetFlexQueryExecutionOptions(HttpContext)` | Reads resolved execution options. | + +## FlexQuery.NET.OpenApi + +| Member | Description | +|---|---| +| `AddFlexQueryOpenApi(services)` | Registers schema/operation transformers. | +| `AddFlexQuery(this OpenApiOptions)` | Attaches transformers to the OpenAPI document pipeline. | + +## FlexQuery.NET.Diagnostics + +| Member | Description | +|---|---| +| `IFlexQueryExecutionListener` | Four ValueTask hooks: `QueryParsedAsync`, `QueryTranslatedAsync`, `QueryExecutedAsync`, `QueryMaterializedAsync`. | +| `ConsoleExecutionListener` | Console output listener. | +| `FlexQueryDiagnosticsCollector` | In-memory collector; `BuildReport(provider, translator)`, `Clear()`. | + +## Adapters + +### AgGrid (`FlexQuery.NET.Adapters.AgGrid`) + +`AgGridRequest.ToQueryOptions()`, `JsonElement.ToQueryOptions()`, +`ApplyAgGridRequest(options)`, `ToAgGridServerSideResponse(...)`. + +### Kendo (`FlexQuery.NET.Adapters.Kendo`) + +`KendoRequest.ToQueryOptions()`, `JsonElement.ToQueryOptions()`, +`ApplyKendoRequest(options)`. + +## Parsers + +| Member | Description | +|---|---| +| `Fql.Register()` | Registers the FQL parser (`FlexQuery.NET.Parsers.Fql`). | +| `MiniOData.Register()` | Registers the MiniOData parser (`FlexQuery.NET.Parsers.MiniOData`). | +| `QuerySyntax` | `NativeDsl` / `Fql` / `MiniOData`. | +| `MiniODataRequest` | Typed MiniOData request (`Filter`, `OrderBy`, `Select`, `Expand`, `Top`, `Skip`, `Count`) with `ToQueryOptions()`. | + +## Governance & security + +| Member | Description | +|---|---| +| `QueryGovernanceOptions` | All governance sets (see [Security](/docs/security)). | +| `BaseQueryOptions` | Mapping (`MapField`, `CreateMap`), paging defaults, `QuerySyntax`, `DisablePaging`, `Listener`. | +| `FlexQueryOptions` | Global defaults: `MaxPageSize` (1000), `DefaultPageSize` (20), `IncludeTotalCount`, `StrictFieldValidation`, `MaxFieldDepth` (5), `DefaultQuerySyntax` (NativeDsl), `CreateMap`. | +| `FilterOperators` | Canonical operator constants and normalization. | diff --git a/docs-v4/content/docs/concepts/configuration.mdx b/docs-v4/content/docs/concepts/configuration.mdx new file mode 100644 index 0000000..f18e64b --- /dev/null +++ b/docs-v4/content/docs/concepts/configuration.mdx @@ -0,0 +1,169 @@ +--- +title: Configuration +description: Global options, provider options, and per-request overrides. +section: Core Concepts +--- + +import { Callout } from '@/components/callout' +import { ApiTable } from '@/components/api-table' + +# Configuration + +FlexQuery is configured at three levels, and every level has one job: the more general one +supplies defaults, the more specific one overrides them. Understanding this layering — and +the immutability rule that guards it — is the difference between predictable behavior and +order-of-initialization bugs. + +``` +Global (FlexQueryCore) <- application-wide defaults, set once + |- Provider (EFCore/Dapper) <- provider behavior (no-tracking, model, timeout) + `- Per request <- per-call overrides (governance, limits, syntax) +``` + + + Every Configure method throws InvalidOperationException when + called after a query has already executed. This is deliberate: query execution reads + configuration concurrently, and a mutable global config is a race condition. Always + configure during startup. + + +## Global options + +`FlexQueryCore.Configure` runs once at startup, before any query: + +```csharp +using FlexQuery.NET; + +var builder = WebApplication.CreateBuilder(args); + +FlexQueryCore.Configure(options => +{ + options.DefaultQuerySyntax = QuerySyntax.NativeDsl; + options.DefaultPageSize = 20; + options.MaxPageSize = 1000; + options.IncludeTotalCount = true; + options.StrictFieldValidation = true; + options.MaxFieldDepth = 5; +}); +``` + + + + PropertyTypeDefaultDescription + + + DefaultQuerySyntaxQuerySyntaxNativeDslSyntax used when no per-request syntax is supplied. + DefaultPageSizeint20Page size when the client omits one. + MaxPageSizeint1000Maximum page size a client may request. + IncludeTotalCountbooltrueCompute total counts by default. + StrictFieldValidationbooltrueThrow on unauthorized field access. + MaxFieldDepthint5Maximum nested field-path depth. + + + +### Global type maps + +`FlexQueryOptions.CreateMap` registers application-level entity to DTO maps that every typed +execution reuses: + +```csharp +FlexQueryCore.Configure(options => +{ + options.CreateMap() + .ForMember(dto => dto.CustomerFullName, entity => entity.CustomerName); + + options.CreateMap(); +}); +``` + +Per-query `CreateMap` registrations take precedence over global maps. See +[Typed DTO Projection](/docs/guides/typed-dto-projection). + +## Provider options + +### EF Core + +```csharp +FlexQueryEFCore.Configure(options => +{ + options.UseNoTracking = true; +}); +``` + +`FlexQueryEFCore.Setup()` (no delegate) only registers the EF Core-specific operator +handlers, such as `like`. `UseNoTracking` defaults execution to no-tracking; each call can +override it (`opt.UseNoTracking = false`). + +### Dapper + +Dapper needs a model — there is no DbContext to reflect over: + +```csharp +using FlexQuery.NET.Dapper.Configuration; + +FlexQueryDapper.Configure(options => +{ + options.CommandTimeout = 30; + + options.Model.Entity() + .ToTable("Customers") + .HasKey(c => c.Id) + .HasMany(c => c.Orders) + .HasForeignKey("CustomerId"); +}); +``` + +Relationship configuration can also be grouped per entity in an +`IEntityTypeConfiguration` class and applied with `ApplyConfiguration` / +`ApplyConfigurationsFromAssembly`. Entity types with standard naming and +`[Table]`/`[Column]`/`[Key]` attributes need no explicit configuration at all — +conventions fill in the gaps. + +The SQL dialect is auto-detected from the `DbConnection` type at runtime. See +[Dapper](/docs/providers/dapper) for the full mapping API. + +## Per-request overrides + +Every execution method accepts an optional `Action<...Options>` delegate that wins over +global/provider values: + +```csharp +var result = await db.Customers + .FlexQueryAsync(parameters, opt => + { + opt.MaxPageSize = 50; // tighter ceiling + opt.AllowedFields = ["Id", "FirstName", "Email"]; // endpoint surface + opt.QuerySyntax = QuerySyntax.Fql; // force a syntax + opt.UseNoTracking = false; // opt out of no-tracking (EF) + }, cancellationToken); +``` + +Typical per-request settings: governance sets (see +[Security](/docs/security)), paging limits, query syntax, field mappings, per-query type +maps, and the diagnostics listener. + +## Precedence rules + +| Setting | Global | Provider | Per request | +|---|---|---|---| +| Default query syntax | Yes | — | Yes (overrides global) | +| Page size defaults / limits | Yes | — | Yes (overrides global) | +| Validation strictness & field depth | Yes (baseline) | — | Yes (overrides) | +| Field governance sets (Allowed/Blocked/…) | — | — | Yes (per request) | +| No-tracking behavior | — | Yes (default) | Yes (per call) | +| Dapper model (statics/`FlexQueryDapper.Configure`) | — | Yes | attributes per query | +| Type maps | Yes (global maps via `FlexQueryOptions.CreateMap`) | — | Yes (per-query wins) | + +## Common mistakes + + + Calling Configure inside a controller or on first request throws the + immutability error as soon as any earlier query ran. Configure in + Program.cs/Startup only. + + + + The defaults are convenient, not restrictive. Real endpoints set + AllowedFields, MaxPageSize, and AllowedIncludes per + request — see the [defense-in-depth checklist](/docs/security#defense-in-depth-checklist). + diff --git a/docs-v4/content/docs/concepts/pipeline.mdx b/docs-v4/content/docs/concepts/pipeline.mdx new file mode 100644 index 0000000..9783bee --- /dev/null +++ b/docs-v4/content/docs/concepts/pipeline.mdx @@ -0,0 +1,117 @@ +--- +title: Execution Pipeline +description: How a FlexQuery.NET request flows from query string to executed query. +section: Core Concepts +--- + +import { Callout } from '@/components/callout' + +# Execution Pipeline + +Every FlexQuery call — whether it started as a query string, a fluent build, or an adapter +request — flows through the same five-stage pipeline. Knowing the stages, their order, and +what each one guarantees is what lets you predict result ordering, interpret validation +errors, and place custom logic at the right point. + +## The stages + +``` +Query string / request model / fluent build + | + v ++------------+ 1. Parse - parameters -> QueryOptions (syntax-specific parser) +| Parsers | ++------------+ + | + v ++------------+ 2. Validate - field existence, operators, types, field access, +| Validation | governance limits, expand paths, aggregates ++------------+ + | + v ++------------+ 3. Apply - filter -> grouping/aggregates -> sort -> paging -> +| Builder | projection, composed as expression trees ++------------+ + | + v ++------------+ 4. Translate - provider converts expressions to SQL (EF Core) +| Provider | or generates SQL directly (Dapper) ++------------+ + | + v ++------------+ 5. Execute - query runs server-side; results materialize +| Execution | into QueryResult ++------------+ +``` + +### 1. Parse + +The syntax selected for the request (global default or per-request override) determines +which parser runs. All parsers produce the same canonical `QueryOptions` — the rest of the +pipeline is syntax-agnostic. Grammar failures surface as `QueryParseException` (which +carries the offending parameter name, syntax, received value, and position), with the +syntax-specific parse error as the inner exception — all deriving `FlexQueryException`. + +### 2. Validate + +The rule pipeline checks the parsed options against the entity model and governance +configuration. Validation runs *before* any expression is built, so a rejected request costs +no database work. See [Validation](/docs/guides/validation). + +### 3. Apply + +The builder composes LINQ expressions in a fixed order: + +1. **Filter** (`WHERE`) — narrows rows first; everything downstream operates on fewer rows. +2. **GroupBy / Aggregates / Having** — grouping forms after filtering; `HAVING` prunes + groups before ordering. +3. **Sort** (`ORDER BY`) — orders rows (or groups). +4. **Paging** (`OFFSET/FETCH` or keyset seek predicates) — slices from the ordered set. +5. **Projection** (`SELECT`) — last, so only requested fields materialize. +6. **Total count** — computed on the filtered set, independent of paging and projection. + + + Paging before sorting would produce arbitrary page contents; projecting before filtering + would hide filterable columns. The pipeline encodes SQL semantics, which is why result + ordering and page boundaries are stable. + + +### 4. Translate + +- **EF Core**: the composed expression tree hands off to EF's translation — everything + becomes SQL. Include/expand branches use EF Core filtered includes (`.Include(...)` + expressions with `Where`/`OrderBy`/`Take` inside), so the related-data window is applied + server-side in EF's own generated SQL. +- **Dapper**: FlexQuery generates the SQL itself — select list (surface-aware, type-map + rewritten), WHERE, GROUP BY/HAVING, ORDER BY, and dialect-specific paging. Related data + loads as separate child queries batched by parent keys (split-query style hydration), + and expand `take` becomes a server-side ranked/limited child query. + +### 5. Execute + +The provider executes; results materialize into `QueryResult` with paging metadata, +optional aggregates, and the optional cursor token. Cancellation is observed across the +async overloads (see provider notes for scope). + +## Events + +Each stage emits an event that any `IFlexQueryExecutionListener` can observe: + +| Hook | Fired when | +|---|---| +| `QueryParsedAsync` | Parameters parsed into `QueryOptions`. | +| `QueryTranslatedAsync` | Provider translated the query (SQL available). | +| `QueryExecutedAsync` | Database command completed (includes timing). | +| `QueryMaterializedAsync` | Results materialized into the result shape. | + +Attaching a listener is a one-liner (`opt.Listener = ...`), and +`FlexQueryDiagnosticsCollector` accumulates all four into a report — see +[Diagnostics](/docs/diagnostics). + +## Where client code fits + +- **Before parse** — authentication, rate limiting. +- **Between parse and execute** — governance via the `configure` delegate, tenant scoping + by wrapping the `IQueryable` before FlexQuery sees it. +- **After execute** — serialization (result-shape enforcement), diagnostics, response + shaping. diff --git a/docs-v4/content/docs/concepts/query-options.mdx b/docs-v4/content/docs/concepts/query-options.mdx new file mode 100644 index 0000000..1a07e7e --- /dev/null +++ b/docs-v4/content/docs/concepts/query-options.mdx @@ -0,0 +1,116 @@ +--- +title: Query Options +description: The QueryOptions model - every option the pipeline understands. +section: Core Concepts +--- + +import { ApiTable } from '@/components/api-table' +import { Callout } from '@/components/callout' + +# Query Options + +`QueryOptions` (namespace `FlexQuery.NET.Models`) is the *parsed* form of a request — the +single model every execution method consumes, regardless of where the request came from +(query string, fluent builder, or adapter). Understanding its properties means understanding +everything the pipeline can do. + +## How a request becomes options + +Three equivalent paths, one destination: + +```csharp +// 1. From FlexQueryParameters (query-string binding) +var options = parameters.ToQueryOptions(); // global default syntax +var options2 = parameters.ToQueryOptions(QuerySyntax.Fql); // explicit syntax + +// 2. Built in code (fluent API) +var options4 = Query.Create() + .Filter(f => f.Equal("Status", "Active")) + .Page(1, 20) + .Build(); +``` + +Most endpoints skip explicit construction entirely — `FlexQueryAsync(parameters, ...)` +converts internally. Constructing `QueryOptions` yourself matters when composing: adapter +output, pre-built saved queries, or merging client input with server-side structure. + +## Properties + + + + PropertyTypeDescription + + + FilterFilterGroup?Filter expression tree — conditions, nested groups, logic operators. See Filtering. + SortList<SortNode>Ordered sort specs (field, direction, optional aggregate). See Sorting. + SelectList<SelectNode>?Projection tree — fields, aliases, nested selections. See Projection. + IncludesList<string>?Navigation paths to include with all scalars. See Include. + ExpandList<IncludeNode>?Expansion trees with per-branch filter/sort/take. See Expand. + ProjectionModeProjectionModeOutput shaping: Nested (default), Flat, FlatMixed. + GroupByList<string>?Group key fields. + AggregatesList<Aggregate>Aggregate specs (typed AggregateFunction + field + alias). + HavingHavingNode?Condition tree over aggregate values — functions referenced as FUNCTION:Field:Operator:Value (e.g. sum:Total:gt:100), which must match a declared aggregate. + Distinctbool?Applies Distinct(). + PagingPagingOptionsPage, PageSize (clamped 1–1000), Disabled flag. + IncludeCountbool?Whether the total count is computed. + + + +## The three request models + +| Model | Use when | +|---|---| +| `FlexQueryParameters` | ASP.NET Core `[FromQuery]` binding of raw strings. | +| `FlexQueryRequest` | Strongly-typed request objects (OpenAPI-documented bodies), via `ToQueryOptions()`. | +| `QueryOptions` | Composed server-side, adapter output, saved queries. | + +## Projection modes in detail + +| Mode | Behavior | +|---|---| +| `Nested` | Nested selections produce nested objects — the natural hierarchical shape. | +| `Flat` | Nested collections flatten with `SelectMany` into a leaf-level rowset (SQL-join semantics). | +| `FlatMixed` | Root scalars and nested-collection fields share one output row. | + +## Complete worked example + +Composing client input with server-side constraints: + +```csharp +[HttpGet("api/orders/search")] +public async Task Search( + [FromQuery] FlexQueryParameters clientParams, + CancellationToken cancellationToken) +{ + var options = clientParams.ToQueryOptions(); // client-driven part + + // server-side composition - clients cannot override these + options.GroupBy = ["Status"]; + options.Aggregates.Add(new Aggregate + { + Function = AggregateFunction.Sum, + Field = "TotalAmount", + Alias = "TotalRevenue", + }); + + var result = await db.Orders.FlexQueryAsync(options, cancellationToken); + return Ok(result); +} +``` + +## Common mistakes + + + Options built in code flow through the same validation as parsed requests. Hand-built + options with unknown fields still fail — by design. + + + + QueryOptions is consumed by the pipeline; mutate it before calling + FlexQueryAsync, not concurrently with it. + + +## Related + +- [Query Composition](/docs/guides/query-composition) — building and merging these options in code +- [Query Syntax](/docs/concepts/query-syntax) — the languages that parse into them diff --git a/docs-v4/content/docs/concepts/query-result.mdx b/docs-v4/content/docs/concepts/query-result.mdx new file mode 100644 index 0000000..8368f5f --- /dev/null +++ b/docs-v4/content/docs/concepts/query-result.mdx @@ -0,0 +1,150 @@ +--- +title: Query Result +description: The QueryResult shape returned by every execution method. +section: Core Concepts +--- + +import { ApiTable } from '@/components/api-table' +import { Callout } from '@/components/callout' + +# Query Result + +Every execution method returns `QueryResult` — a uniform envelope that pairs the page of +data with the metadata clients need for pagination UIs, aggregate displays, and cursor-based +navigation. Because the envelope is the same for EF Core and Dapper, dynamic and typed +results, your response contract never changes when the query does. + +## Properties + + + + PropertyTypeDescription + + + DataIReadOnlyList<T>The page of results (entities, projected objects, or DTOs). + TotalCountint?Source rows matching the query before paging — independent of what is returned in Data. Null when counting is disabled. On grouped queries it is the number of underlying rows, not the number of groups. + ResultCountint?The post-shaping row total (groups for grouped queries, distinct rows for distinct) and what TotalPages is computed from. Null on plain queries unless the provider computes it. + PageintCurrent 1-based page number. + PageSizeintEffective page size (after clamping). + TotalPagesintComputed from total count and page size. + HasNextPageboolA next page exists. + HasPreviousPageboolA previous page exists. + AggregatesDictionary<string, Dictionary<string, object>>?Grand totals for ungrouped aggregate queries: field → aggregate key → value. Null otherwise. + NextCursorTokenstring?Cursor for the next keyset page (keyset mode only). + ResultShapeIReadOnlyList<SelectOutputField>?The effective output surface when an explicit select is present. + + + +## ResultShape fields + +Each `SelectOutputField` describes one output column: + +| Field | Meaning | +|---|---| +| `SourceName` | The public/source field the client requested (e.g. `CustomerFullName`). | +| `SourcePropertyName` | The entity property it resolves to. | +| `OutputName` | The response field name — the alias when present, otherwise the source name. | + +When the result-shape JSON converter is registered (`AddFlexQuerySecurity()` / +`AddFlexQueryJson()`), serialization enforces exactly this surface: anything outside it is +stripped from the payload, with aliases applied. + +## Serialization example + +```json +{ + "data": [ /* 20 rows */ ], + "totalCount": 137, + "page": 3, + "pageSize": 20, + "totalPages": 7, + "hasNextPage": true, + "hasPreviousPage": true +} +``` + +(The `resultCount` key is only present when a result-shape count was computed — grouped +or distinct queries.) + +Clients can build complete pagination UIs from this envelope alone: page numbers +(`totalPages`), next/previous buttons (`hasNextPage`/`hasPreviousPage`), and row counts +(`totalCount`). + +## Grouped queries + +Grouped queries produce **one `Data` entry per group**. Each row carries the group key(s) +plus every aggregate under its alias, and the paging metadata is computed over groups: +`totalCount` remains the underlying source-row count, `resultCount` is the number of +groups, and `totalPages`/`hasNextPage` follow from the group count. + +```json +{ + "data": [ + { "status": "Active", "sumTotal": 1250.00, "countOrders": 14 } + ], + "totalCount": 417, + "resultCount": 1, + "page": 1, + "pageSize": 20, + "totalPages": 1, + "hasNextPage": false, + "hasPreviousPage": false +} +``` + +Ungrouped aggregate queries (`?aggregate=...` without `groupBy`) do not produce group rows; +their single-row totals appear in a separate `aggregates` object keyed by the aggregate's +source field (or `"all"`), with inner entries keyed by alias: + +```json +{ + "data": [ ...paged matching records... ], + "aggregates": { "TotalAmount": { "sumRevenue": 1250.00 } }, + "totalCount": 417, + "page": 1, + "pageSize": 20 +} +``` + +See [Grouping & Aggregates](/docs/guides/grouping) for the full semantics. + +## Keyset pagination + +When keyset mode is active, `NextCursorToken` carries the opaque, versioned cursor built +from the sort-key values of the last row on the page. When a page comes back empty, the +token is `null` — the standard end-of-scroll signal. Pass it back as the `cursor` +parameter — see [Keyset Pagination](/docs/guides/keyset-pagination). + +## Complete worked example + +Shaping a stable public response from the envelope: + +```csharp +var result = await db.Customers + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + +return Ok(new +{ + items = result.Data, + pagination = new + { + page = result.Page, + pageSize = result.PageSize, + total = result.TotalCount, + totalPages = result.TotalPages, + }, + nextCursor = result.NextCursorToken, +}); +``` + +## Common mistakes + + + With includeCount=false, TotalCount is null — clients using it + for "N results" UIs must handle that. Only rely on it when counting is enabled. + + + + Aggregates is populated only for grouped queries. On plain queries it is + null — guard before reading. + diff --git a/docs-v4/content/docs/concepts/query-syntax.mdx b/docs-v4/content/docs/concepts/query-syntax.mdx new file mode 100644 index 0000000..c1e6546 --- /dev/null +++ b/docs-v4/content/docs/concepts/query-syntax.mdx @@ -0,0 +1,212 @@ +--- +title: Query Syntax +description: The three query languages - DSL, FQL, and MiniOData - and how to select them. +section: Core Concepts +--- + +import { Callout } from '@/components/callout' + +# Query Syntax + +FlexQuery accepts three query languages on the same endpoint. All three parse into the same +internal `QueryOptions` model, and DSL and FQL expose the full feature set — filtering, +sorting, projection, include/expand, grouping, aggregates, paging — interchangeably. +MiniOData is intentionally a lighter compatibility layer: it covers filter, sort, select, +and relationship loading (its `$expand` maps to plain `include`), while grouping, +aggregates, filtered expansion, and keyset paging remain DSL/FQL features. The syntax is a +client-facing choice, not a server-side fork. + +| Syntax | Enum value | Style | Extra package | +|---|---|---|---| +| Native DSL | `QuerySyntax.NativeDsl` | `filter=Status:eq:Active` | built-in | +| FQL | `QuerySyntax.Fql` | `filter=Status = 'Active'` | `FlexQuery.NET.Parsers.Fql` | +| MiniOData | `QuerySyntax.MiniOData` | `$filter=Status eq 'Active'` | `FlexQuery.NET.Parsers.MiniOData` | + +**Choosing a syntax**: DSL is compact and URL-friendly — the default. FQL reads like SQL and +suits developer-facing tools. MiniOData eases migration from OData consumers. + +## Selecting the syntax + +### Globally + +```csharp +FlexQueryCore.Configure(options => +{ + options.DefaultQuerySyntax = QuerySyntax.Fql; +}); +``` + +### Per request + +```csharp +var result = await db.Customers.FlexQueryAsync( + parameters, + opt => opt.QuerySyntax = QuerySyntax.MiniOData, + cancellationToken: cancellationToken); +``` + +## Registering parsers + +The DSL parser is built in. FQL and MiniOData live in separate packages and must be +registered once at startup: + +```csharp +using FlexQuery.NET.Parsers.Fql; +using FlexQuery.NET.Parsers.MiniOData; + +Fql.Register(); +MiniOData.Register(); +``` + +Registration must happen before any execution; requesting an unregistered syntax throws +`ParserNotRegisteredException`. + +## Parameter map + +DSL and FQL share the same parameter keys; MiniOData uses its `$`-prefixed spellings for +the expressions it supports. + +| Parameter | DSL example | FQL example | MiniOData | +|---|---|---|---| +| Filter | `filter=Status:eq:Active` | `filter=Status = 'Active'` | `$filter=Status eq 'Active'` | +| Sort | `sort=Name:asc,Age:desc` | `sort=Name ASC, Age DESC` | `$orderby=Name asc, Age desc` | +| Select | `select=Id,Name,Orders.Total` | `select=Id,Name` | `$select=Id,Name` | +| Include | `include=Orders` | `include=Orders` | `$expand=Orders` | +| GroupBy | `groupBy=Status` | `groupBy=Status` | — | +| Aggregate | `aggregate=sum:Total:TotalRevenue` | `aggregate=SUM(Total) AS TotalRevenue` | — | +| Having | `having=sum:Total:gt:100` | `having=SUM(Total) > 100` | — | +| Expand (filtered) | `expand=Orders(filter=Status:eq:'Active'; sort=OrderDate:desc; take=5)` | same options shape, FQL expressions inside (`filter`/`sort`/`take`) | — | +| Page / PageSize | `page=1&pageSize=20` | same | `$top` / `$skip` (translated to page/size) | +| Distinct | `distinct=true` | same | — | +| Mode | `mode=flat` | same | — | +| Cursor / keyset | `useKeysetPagination=true&cursor=...` | same | — | + + + The MiniOData parser supports $filter, $orderby, + $select, $top, $skip, $count, and + $expand (flat navigation paths — nested expand options are not supported). + Grouping, aggregates, having, and filtered expansion are DSL/FQL features. A typed + MiniODataRequest model (Filter, OrderBy, Select, Expand, Top, Skip, Count) + with ToQueryOptions() is available for strongly-typed consumers. + + +## DSL filter grammar + +``` +filter = condition ((AND|OR) condition)* +condition = field:operator:value +field = property.path (dot-separated navigation) +value = literal (unquoted single token or 'quoted string') +``` + +- Logical operators: the `AND` / `OR` keywords **and** the symbolic `&` / `|` forms are both + accepted; `AND` has higher precedence than `OR` (the parser builds AND-groups inside + OR-groups). +- Collection operators (`any`, `all`, `count`) target collection navigations: + `Orders:any:Total:gt:100`. +- Values containing spaces or reserved keywords must be quoted: `City:eq:'New York'`. +- The keywords `AND`/`OR` are reserved — an unquoted value that starts with one is rejected + with a hint to quote it (`name:eq:"AND"`). +- Null-check operators take no value: `DeletedAt:isnull`. + +## FQL filter grammar + +``` +filter = condition ((AND|OR) condition)* +condition = field op value | field [NOT] IN (...) | field [NOT] BETWEEN a AND b + | field IS [NOT] NULL | field [NOT] LIKE '%pattern%' +op = = | != | > | >= | < | <= | CONTAINS | STARTSWITH | ENDSWITH +collection = field ANY (…) | field ALL (…) +``` + +FQL is SQL-inspired: values are quoted strings (or numbers/booleans), operators are words or +symbols, and parentheses group expressions: + +``` +filter=Status = 'Active' AND (Age >= 18 OR City = 'Berlin') +``` + +## MiniOData filter grammar + +``` +filter = comparison ((and|or) comparison)* | not (filter) +comparison = field op value + | field [not] in (v1, v2, ...) + | field is null | field is not null +op = eq | ne | gt | ge | lt | le +functions = contains(field,'x') | startswith(field,'x') | endswith(field,'x') +paths = slash-separated: Orders/TotalAmount gt 100 +``` + +``` +$filter=Status eq 'Active' and Age ge 18 +``` + +The parser is deliberately small: flat paths and the operators above. It does not implement +the full OData vocabulary (`$apply`, nested `$expand` options, etc.). + +## Aggregate syntax + +DSL aggregates use the `aggregate` parameter with `function:field[:alias]` triples; FQL uses +`FUNCTION(field) [AS alias]`: + +``` +aggregate=sum:Total:TotalRevenue,count:Id (DSL) +aggregate=SUM(Total) AS TotalRevenue, COUNT(Id) (FQL) +``` + +- Functions: `sum`, `count`, `avg` (or `average`), `min`, `max`. +- Without an explicit alias, the output field is the PascalCase field + function + (`sum:Total` → `TotalSum`). +- Aggregates combine with `groupBy`; `having` references declared aggregates: + `having=sum:Total:gt:100` (DSL) or `having=SUM(Total) > 100` (FQL). + +## Sort syntax + +Both direction spellings are accepted: + +``` +sort=Name:asc,Age:desc (colon form) +sort=Name ASC, Age DESC (space form) +``` + +Aggregate sorts use `function:target:direction` (DSL) or `SUM(Field) DESC` (FQL) — see +[Sorting](/docs/guides/sorting). + +## Complete worked example + +One endpoint, three syntaxes, same result: + +```csharp +[HttpGet("api/customers")] +public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) +{ + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, cancellationToken); + return Ok(result); +} +``` + +```http +GET /api/customers?filter=Status:eq:Active AND Age:gte:18 (DSL) +GET /api/customers?filter=Status:eq:Active & Age:gte:18 (DSL, symbolic) +GET /api/customers?filter=Status = 'Active' AND Age >= 18 (FQL) +GET /api/customers?$filter=Status eq 'Active' and Age ge 18 (MiniOData) +``` + +All four produce identical results. + +## Common mistakes + + + filter=Status = 'Active'&sort=Name:asc mixes FQL filter syntax with DSL + sort syntax. The whole request parses with one syntax — use one language per request. + + + + A global DefaultQuerySyntax = QuerySyntax.Fql without + Fql.Register() throws ParserNotRegisteredException on first use. + diff --git a/docs-v4/content/docs/diagnostics/index.mdx b/docs-v4/content/docs/diagnostics/index.mdx new file mode 100644 index 0000000..5f03b52 --- /dev/null +++ b/docs-v4/content/docs/diagnostics/index.mdx @@ -0,0 +1,142 @@ +--- +title: Diagnostics & Observability +description: Execution listeners, collectors, reports, and SQL inspection. +section: Diagnostics +--- + +import { Callout } from '@/components/callout' + +# Diagnostics & Observability + +Dynamic queries are hard to debug precisely because they are dynamic: the SQL that executed +depends on the request. `FlexQuery.NET.Diagnostics` exposes the pipeline as a stream of +events — parse, translate, execute, materialize — that you can log, collect into a report, +or inspect per stage. When a query misbehaves, the answer is in the events, not in guesses. + +## Execution events + +Implement `IFlexQueryExecutionListener` (namespace `FlexQuery.NET.Execution`) to observe the +four pipeline stages. Every method is a `ValueTask`-returning hook with a default no-op +implementation, so you implement only what you need: + +| Hook | Fired when | Contains | +|---|---|---| +| `QueryParsedAsync(QueryParsedEvent e, CancellationToken ct)` | Parameters parsed into `QueryOptions` | What the client actually asked for | +| `QueryTranslatedAsync(QueryTranslatedEvent e, ct)` | Provider translated the query | Generated SQL / LINQ | +| `QueryExecutedAsync(QueryExecutedEvent e, ct)` | Database command completed | Execution timing and outcome | +| `QueryMaterializedAsync(QueryMaterializedEvent e, ct)` | Results materialized | Result-shape details | + +Attach a listener per request through the execution options: + +```csharp +opt.Listener = myListener; +``` + +## Built-in listeners + +- **`ConsoleExecutionListener`** — writes each stage to the console; ideal for development. +- **`FlexQueryDiagnosticsCollector`** — accumulates all events in memory for programmatic + inspection. + +```csharp +var collector = new FlexQueryDiagnosticsCollector(); +var result = await db.Customers.FlexQueryAsync(parameters, opt => opt.Listener = collector); + +FlexQueryDiagnosticsReport report = + collector.BuildReport(provider: "EF Core", translator: "Sqlite"); + +collector.Clear(); +``` + +`BuildReport` aggregates the collected events into a `FlexQueryDiagnosticsReport` covering +all four stages with durations — useful for request-scoped debug endpoints (the sample +application wraps this in a `DiagnosticsHelper` that attaches a `__diagnostics` object to +responses during development). + + + Attaching diagnostics to every production response leaks schema details. Gate them behind + configuration, an admin role, or a query-string flag. + + +## SQL inspection + +Two provider-specific paths to the actual SQL: + +### EF Core — ToSqlPreview + +```csharp +string sql = query.ToSqlPreview(); // translated SQL, nothing executed +var plan = query.ExplainProjection(options); // projection plan explanation +``` + +`ToSqlPreview` uses EF Core's `ToQueryString()` under the hood and works after dynamic +projections are applied. `ExplainProjection` returns a `ProjectionExplanation`: selected +fields, navigation usage, and optimization notes. + +### Dapper — SQL execution logging + +Every Dapper command logs an Information-level entry right before execution under logger +category `FlexQuery.NET.Dapper`. The entry contains the final SQL formatted for readability, +preceded by a `DECLARE` block embedding the parameter values — copy-paste-ready: + +```sql +DECLARE @p0 BIGINT = 42; + +SELECT [o].[Id], [o].[Total] +FROM [Orders] AS [o] +WHERE [o].[CustomerId] = @p0 +``` + +The logging helper only reads the SQL and parameters already passed to Dapper — it never +mutates or executes anything, and it short-circuits to a no-op when the logger is null or +Information level is disabled. + +## Complete worked example + +A timing endpoint for investigating a slow query: + +```csharp +[HttpGet("api/debug/customers")] +public async Task DebugQuery( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) +{ + var collector = new FlexQueryDiagnosticsCollector(); + var sw = Stopwatch.StartNew(); + + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, opt => opt.Listener = collector, cancellationToken); + + sw.Stop(); + var report = collector.BuildReport(provider: "EF Core", translator: "Sqlite"); + + return Ok(new + { + result.TotalCount, + elapsedMs = sw.Elapsed.TotalMilliseconds, + diagnostics = report, + }); +} +``` + +## SQL formatting + +The SQL that reaches reports and Dapper logs is rendered by a shared formatter +(`FlexQuery.NET.SqlFormatting`) used by both providers — clause-per-line layout and +parameter blocks come from the same component everywhere. It is an implementation detail +rather than a public API; consume the formatted SQL through the listener events, the +collector report, and the logger. + +## Common mistakes + + + A FlexQueryDiagnosticsCollector accumulates events — one instance per + request, and Clear() between uses. A shared instance mixes events from + concurrent requests. + + + + When results look wrong, compare the parsed options (stage 1) against the translated SQL + (stage 2) before suspecting execution — most surprises are translation-visible. + diff --git a/docs-v4/content/docs/getting-started/first-query.mdx b/docs-v4/content/docs/getting-started/first-query.mdx new file mode 100644 index 0000000..8a2354d --- /dev/null +++ b/docs-v4/content/docs/getting-started/first-query.mdx @@ -0,0 +1,187 @@ +--- +title: First Query +description: Build your first FlexQuery.NET endpoint with EF Core in a few minutes. +section: Getting Started +--- + +import { Callout } from '@/components/callout' + +# First Query + +This walkthrough builds a production-shaped ASP.NET Core endpoint that accepts dynamic +query parameters and executes them against Entity Framework Core. It takes about five +minutes, and everything you learn here composes with the rest of the documentation. + +## What you are building + +One endpoint that handles, with no additional code: + +```http +GET /api/customers?filter=Status:eq:Active&sort=LastName:asc&page=1&pageSize=20&select=Id,FirstName,Email +``` + +…as a server-side, validated, SQL-translated query — not in-memory LINQ. + +## 0. Prerequisites + +- .NET 6, 8, or 10 project with EF Core set up and a `Customer` entity on a `DbContext`. +- The packages installed: + +```bash +dotnet add package FlexQuery.NET +dotnet add package FlexQuery.NET.EntityFrameworkCore +``` + +## 1. Configure global options at startup + +Call `FlexQueryCore.Configure` once in `Program.cs`, before any query executes: + +```csharp +using FlexQuery.NET; + +var builder = WebApplication.CreateBuilder(args); + +FlexQueryCore.Configure(options => +{ + options.DefaultPageSize = 20; + options.MaxPageSize = 1000; + options.StrictFieldValidation = true; +}); + +builder.Services.AddControllers(); +``` + +These are defaults, not security — endpoints override them per request below. + +## 2. Create the endpoint + +`FlexQueryParameters` is the model binder for query-string input. Pass it straight to +`FlexQueryAsync`: + +```csharp +using FlexQuery.NET; +using FlexQuery.NET.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +[ApiController] +[Route("api/customers")] +public sealed class CustomersController(AppDbContext db) : ControllerBase +{ + [HttpGet] + public async Task GetCustomers( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) + { + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, opt => + { + opt.AllowedFields = ["Id", "FirstName", "LastName", "Email", "Status"]; + opt.MaxPageSize = 100; + opt.DefaultSortField = "Id"; + }, cancellationToken); + + return Ok(result); + } +} +``` + +What each line buys you: + +- `AsNoTracking()` — read-only queries without change-tracking overhead. +- `AllowedFields` — the only fields clients can filter, sort, or select on. +- `MaxPageSize` — an endpoint-level ceiling (clients cannot exceed it). +- `DefaultSortField` — stable page boundaries even when clients omit `sort`. + +## 3. Query it + +```http +GET /api/customers?filter=Status:eq:Active&sort=LastName:asc&page=1&pageSize=20&select=Id,FirstName,Email +``` + +Response (ASP.NET Core's default camelCase JSON): + +```json +{ + "data": [ + { "id": 3, "firstName": "Ana", "email": "ana@example.com" } + ], + "totalCount": 42, + "page": 1, + "pageSize": 20, + "totalPages": 3, + "hasNextPage": true, + "hasPreviousPage": false +} +``` + +Only the selected fields appear on each row — with an explicit `select`, the result-shape +converter emits exactly the requested surface and nothing else. + +## What just happened + +1. `FlexQueryParameters` bound the query string (`filter`, `sort`, `page`, `pageSize`, + `select`). +2. `FlexQueryAsync` parsed the parameters with the default syntax (`NativeDsl`), validated + every field against `AllowedFields`, and applied the pipeline — filter → sort → paging → + projection — as SQL-translated expression trees. +3. Only selected columns left the database, and only allowed fields could be addressed. + +## Variations + +### Explicit query options + +When the query is composed server-side instead of parsed from the request: + +```csharp +var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync( + new QueryOptions + { + Sort = [new SortNode { Field = "LastName", Descending = false }], + Paging = new PagingOptions { Page = 1, PageSize = 20 }, + }, + cancellationToken: cancellationToken); +``` + +`QueryOptions` lives in `FlexQuery.NET.Models`; `SortNode` and `PagingOptions` as well. + +### Typed DTO result + +Project into your own response type — same endpoint shape, documented contract: + +```csharp +public record CustomerDto(int Id, string Name, string Email); + +var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); +``` + +See [Typed DTO Projection](/docs/guides/typed-dto-projection) for the mapping model +(`CreateMap`, `ForMember`, `ForNavigation`). + +### Alternative query syntax + +Clients can address the same endpoint with FQL or MiniOData once the parser packages are +installed and registered: + +```csharp +Fql.Register(); // FlexQuery.NET.Parsers.Fql +MiniOData.Register(); // FlexQuery.NET.Parsers.MiniOData +``` + +```http +GET /api/customers?filter=Status = 'Active' (FQL) +``` + +See [Query Syntax](/docs/concepts/query-syntax). + +## Next steps + +- [Configuration](/docs/concepts/configuration) — the three configuration levels. +- [Filtering](/docs/guides/filtering) — the full operator reference. +- [Paging](/docs/guides/paging) — offset vs keyset modes. +- [Security & Governance](/docs/security) — locking endpoints down properly. diff --git a/docs-v4/content/docs/getting-started/installation.mdx b/docs-v4/content/docs/getting-started/installation.mdx new file mode 100644 index 0000000..58fae8b --- /dev/null +++ b/docs-v4/content/docs/getting-started/installation.mdx @@ -0,0 +1,82 @@ +--- +title: Installation +description: Add FlexQuery.NET packages to your project. +section: Getting Started +--- + +# Installation + +FlexQuery.NET is distributed as a set of NuGet packages. Install the core package plus the +provider package for your data access technology; optional packages add integrations and +alternative syntaxes. + +## Core package + +```bash +dotnet add package FlexQuery.NET +``` + +Always required. Contains the query engine: parsers, filtering, sorting, paging, +projection, grouping, validation, and the fluent API. + +## Provider package + +Pick exactly one provider package: + +```bash +# Entity Framework Core +dotnet add package FlexQuery.NET.EntityFrameworkCore + +# Dapper +dotnet add package FlexQuery.NET.Dapper +``` + +The provider package supplies the execution pipeline — EF Core translates FlexQuery's +expression trees through the EF stack; Dapper generates and executes SQL directly. See +[EF Core](/docs/providers/ef-core) and [Dapper](/docs/providers/dapper) for provider +specifics. + +## Optional packages + +```bash +# ASP.NET Core security filter ([FieldAccess] attributes) + result-shape JSON +dotnet add package FlexQuery.NET.AspNetCore + +# Execution diagnostics and observability +dotnet add package FlexQuery.NET.Diagnostics + +# OpenAPI/Swagger documentation for FlexQuery endpoints +dotnet add package FlexQuery.NET.OpenApi + +# AG Grid Server-Side Row Model adapter +dotnet add package FlexQuery.NET.Adapters.AgGrid + +# Kendo UI DataSource adapter +dotnet add package FlexQuery.NET.Adapters.Kendo + +# FQL syntax parser (SQL-inspired language) +dotnet add package FlexQuery.NET.Parsers.Fql + +# MiniOData syntax parser (OData-compatible) +dotnet add package FlexQuery.NET.Parsers.MiniOData +``` + +Which optional packages for which scenario: + +| Scenario | Packages | +|---|---| +| Standard ASP.NET Core + EF Core API | Core, EF Core, AspNetCore | +| Swagger-documented API | + OpenApi | +| Grid-driven dashboards | + Adapters.AgGrid or Adapters.Kendo | +| OData/FQL clients | + Parsers.MiniOData / Parsers.Fql | +| Debug/diagnostic tooling | + Diagnostics | + +## Supported frameworks + +All packages target **.NET 6, .NET 8, and .NET 10**, except `FlexQuery.NET.OpenApi`, which +targets **.NET 9 and .NET 10** (it builds on `Microsoft.AspNetCore.OpenApi`). + +## Next steps + +Continue with [First Query](/docs/getting-started/first-query) to wire up your first +FlexQuery endpoint. diff --git a/docs-v4/content/docs/guides/expand.mdx b/docs-v4/content/docs/guides/expand.mdx new file mode 100644 index 0000000..9e5513b --- /dev/null +++ b/docs-v4/content/docs/guides/expand.mdx @@ -0,0 +1,134 @@ +--- +title: Expand +description: Filtered, ordered, size-bounded related data — related rows that carry their own filter, sort, and take. +--- + +# Expand + +`include` loads a whole related collection. `expand` loads a **defined slice** of it: +each branch of the graph can carry its own `filter`, `sort`, and `take` — "every +customer, but only their three most recent delivered orders". This is the pattern behind +dashboard cards, order-history previews, and any UI that shows a bounded slice of +related data without dragging thousands of child rows across the wire. + +Expand replaces the old v3 filtered-includes approach — see +[Migrating from v3](/docs/migration/v3-to-v4). + +## Grammar + +```http +GET /api/customers?include=Orders&expand=Orders(filter=Status:eq:Delivered; sort=OrderDate:desc; take=3) +``` + +An expand entry is a dotted navigation path — at the top level or nested inside another +entry's parentheses — optionally followed by an option block: + +``` +expand-entry: path [ "(" option *( ";"|"," option ) ")" ] +option: "filter=" + | "sort=" + | "take=" + | "(" ... ")" (nested expansion) +``` + +- Options inside a block are separated by `;` **or** `,` (they are trimmed — either + `take=3;sort=Id:desc` and `take=3; sort=Id:desc` parse fine; pick one style and stay + consistent). +- `filter=` holds a filter expression in the request's syntax (a plain + `status:eq:Delivered` for DSL; URL-encode spaces inside expressions). +- `sort=` holds a sort expression (`OrderDate:desc` or `OrderDate DESC`). +- `take=` accepts any integer ≥ 0; `take=0` loads no children at all. +- Paths are validated like every other navigation: they must be navigation properties, + every expand path must also appear in `include`, and a navigation may be expanded + **at most once** per query. + +## Worked example: bounded order history + +Model — `Customer` → `Orders` → (per-branch, filtered and capped) latest delivered +orders: + +```http +GET /api/customers?include=Orders&expand=Orders(filter=Status:eq:Delivered;sort=OrderDate:desc;take=3) +``` + +Response (shape): + +```json +{ + "data": [ + { + "id": 4, + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "city": "London", + "status": "Active", + "salary": 90000, + "createdDate": "2023-05-17T00:00:00Z", + "orders": [ + { "id": 101, "orderNumber": "ORD-101", "totalAmount": 129.90, "orderDate": "2024-05-01T00:00:00Z", "status": "Delivered" }, + { "id": 118, "orderNumber": "ORD-118", "totalAmount": 89.00, "orderDate": "2024-04-12T00:00:00Z", "status": "Delivered" } + ] + } + ], + "totalCount": 42 +} +``` + +Customers without qualifying orders still appear (they just carry `"orders": []`). +Filter/sort/take on the expanded branch never changes the root result set or the root +sort order — the root query and each expansion are separate SQL operations. + +### Deeper nesting + +Expand grandchildren inside the branch — here each customer's three latest delivered +orders, and for those orders the six biggest items: + +```http +GET /api/customers?include=Orders,Orders.OrderItems + &expand=Orders(filter=Status:eq:Delivered;sort=OrderDate:desc;take=3; + OrderItems(take=6;sort=UnitPrice:desc)) +``` + +Child paths inside parentheses are relative to their parent. A deep tree produces one +batched level per depth that has expansion options. + +## Governance + +Expanded branches go through the **same** governance gates as root queries, evaluated +against the related entity type (`type.member` rules like `Orders.Total:gt:100` remain +available to the server): + +- Every expand path (and every nested child path) must satisfy `AllowedIncludes`. +- Branch filter/sort fields must be valid for the related entity's public surface — + DTO-typed surface checks apply exactly like at the root. + +## What expansion cannot do + +- Duplicate expansion of the same path (`Orders(take=1),Orders(take=2)`) — rejected with + `EXPAND_DUPLICATE_PATH`; merge the options into one block. +- Sort/take branches are collection-only; applying them to a single-valued reference + navigation is rejected (`EXPAND_SORT_ON_REFERENCE`). +- Expanding a scalar property or non-navigation member is rejected + (`EXPAND_PATH_NOT_FOUND` / `NAVIGATION_PROPERTY_REQUIRED`). +- Grouped queries cannot combine with include/expand at all + (`GROUPBY_INCLUDE_CONFLICT`). +- Option keys are only `filter`, `sort`, `take`, and nested paths — anything else fails + parse (`Unexpected expand option`). + +## Provider behavior + +- **EF Core**: options are applied inside EF's own filtered-include machinery — the + database trims the children before rows reach memory, so a `take=3` branch loads three + rows per parent, not the whole collection. +- **Dapper**: each expanded level runs as its own batched query restricted to the keys + on the current parent page. `take` becomes a dialect-correct ranked subquery + (`ROW_NUMBER() OVER (PARTITION BY ...)`) so the trimming is also server-side, while a + filter-only branch is folded straight into the child `WHERE`. +- On both providers, expand blocks never trim parent rows: root paging and `totalCount` + describe the full, unexpanded result set. + +## Related + +- [Include](/docs/guides/include) — full, unbounded relation loading +- [Projection](/docs/guides/projection) — shape the expanded output diff --git a/docs-v4/content/docs/guides/filtering.mdx b/docs-v4/content/docs/guides/filtering.mdx new file mode 100644 index 0000000..98e7769 --- /dev/null +++ b/docs-v4/content/docs/guides/filtering.mdx @@ -0,0 +1,149 @@ +--- +title: Filtering +description: Constrain results with the filter DSL — operators, collections, nested paths, and safety. +--- + +# Filtering + +The `filter` parameter narrows which rows are returned. Expressions are parsed into a +validated model and executed server-side — EF Core translates them to SQL, Dapper +generates parameterized SQL, and with LINQ-to-objects they evaluate as expression trees. +The database only ever returns rows that already match. + +## Anatomy of a filter expression + +A single condition is `field:operator:value`: + +```http +/api/customers?filter=Status:eq:Active +``` + +Combine conditions with **`&`** (AND) and **`|`** (OR) — or the equivalent `AND` / `OR` +keywords (case-insensitive). Parentheses group sub-expressions, `!` or `not(...)` +negates, and `AND` binds tighter than `OR`: + +```http +/api/customers?filter=City:eq:Berlin AND (Status:eq:Active OR Status:eq:Premium) +/api/customers?filter=Age:gte:18¬(City:eq:Ankara) +/api/customers?filter=Status:eq:Active AND Salary:gte:50000 AND (City:eq:Berlin OR City:eq:Munich) +``` + +> **Commas are values, not combinators.** Inside a filter, everything from the value +> position until the next operator is taken as one raw value — so a comma ends up inside +> the value. `filter=Status:eq:Active,City:eq:Berlin` matches a status literally equal to +> `"Active,City:eq:Berlin"`, not two conditions. Always combine with `&` / `|`. + +Values containing spaces must be quoted: + +```http +/api/customers?filter=City:eq:'New York' +``` + +Values may otherwise contain colons freely (URLs, `key:value` pairs), and dates parse +from ISO 8601 / invariant formats: + +```http +/api/customers?filter=CreatedDate:gte:2024-01-15 +/api/orders?filter=OrderDate:between:2024-01-01,2024-02-01 +``` + +## Operators + +| Operator | Meaning | Example | +|---|---|---| +| `eq` | equals | `Status:eq:Active` | +| `neq` | not equals | `Status:neq:Cancelled` | +| `gt` / `gte` | greater than (or equal) | `Salary:gte:50000` | +| `lt` / `lte` | less than (or equal) | `Age:lt:30` | +| `contains` | substring | `Email:contains:@example.com` | +| `startswith` | prefix match | `LastName:startswith:Ann` | +| `endswith` | suffix match | `LastName:endswith:son` | +| `like` | SQL wildcard pattern (`%` = zero or more, `_` = one char) | `Name:like:J%h` | +| `in` | value in list (list items separated by `,`) | `Status:in:Active,Pending` | +| `notin` | value not in list | `Status:notin:Cancelled` | +| `between` | inclusive numeric/date range (two values, `,`) | `Salary:between:40000,60000` | +| `isnull` | property is null (no value part) | `DeletedAt:isnull` | +| `isnotnull` | property is not null (no value part) | `Email:isnotnull` | +| `any` | at least one related item matches | see Collections | +| `all` | every related item matches | see Collections | +| `count` | count of related items | see Collections | + +Operator names are case-insensitive (`Status:IN:Active`). Comparison semantics are +type-aware: dates, numbers, GUIDs, and enums are converted to the property type before +comparison — values that cannot convert to the field's type fail validation with a +`TYPE_MISMATCH` / conversion description. + +Exact operator semantics, aliases, type rules, and per-provider behavior: [Operators](/docs/guides/operators). + +## Filtering on collections + +There are two equivalent shapes for collection checks — a flat colon form and a +parenthesized form: + +```http +/api/customers?filter=Orders:any:TotalAmount:gt:100 +/api/customers?filter=Orders.any(TotalAmount:gt:100) +/api/customers?filter=Orders.all(Status:eq:Delivered) +/api/customers?filter=Orders:count:gt:3 +/api/customers?filter=Orders.count(Status:eq:Delivered):gte:2 +``` + +`any`/`all` take an inner condition on the related type; `count` takes (optionally) an +inner condition and a numeric comparison (`:gt:3` etc.). Collection segments inside a +dotted path are checked existence-style (`Any`), so: + +```http +/api/orders?filter=Items.Product.Name:eq:Widget +``` + +matches orders that have at least one item whose product is *Widget*. Direct reference +navigations work the same way (`Address.City:eq:Berlin`). + +> Expanding into navigation properties in a filter lets clients probe for the existence +> of related rows. Treat navigation roots like any other protected field: either +> authorize them through `AllowedIncludes`/`SelectableFields` governance, or keep +> endpoints without such fields and rely on the default governance rejection. A +> governance denial raises the same `QueryValidationException` with code +> `FIELD_ACCESS_DENIED` regardless of the requested value. + +## Provider behavior + +- **EF Core** translates the filter into SQL — all comparisons, wildcards, and + collection checks (`EXISTS`/subqueries) are database-side; values are parameterized. + `in`/`notin` become list parameters, `between` becomes `>=`/`<=`, and `like` maps to + the provider's `LIKE`/`Contains` semantics. +- **Dapper** builds the same SQL server-side (quoted identifiers per dialect, + `p0…` parameters). Unsupported/unknown fields fail before SQL is generated. +- Both providers run the same validation pipeline first, so a bad filter never reaches + the database. + +## FQL and MiniOData spellings + +The same filter tree can be expressed in SQL-like or OData-like syntaxes once the parser +packages are registered by the client's syntax selection (see [Query +Syntax](/docs/concepts/query-syntax) and the examples there). Filter *values* are quoted +literal strings in FQL (`Status = 'Active' AND Age >= 18`), and MiniOData uses its OData +form (`Status eq 'Active' and Age ge 18`, `Orders/TotalAmount gt 100`, +`contains(Email,'@acme')`). + +## Case sensitivity + +`contains`, `startswith`, `endswith`, `eq`, and `in` compare strings case-sensitively in +memory and are collation-sensitive when pushed to SQL (EF Core / Dapper). If you need +portable case-insensitive text search, normalize on the data side — FlexQuery has no +per-request case-insensitivity switch. + +## When a filter is wrong + +- Unknown or unauthorized field → `QueryValidationException` (`FIELD_NOT_FOUND`, + `FIELD_ACCESS_DENIED`) — thrown unless `StrictFieldValidation` has been relaxed for + that endpoint (stripped rather than thrown; see [Security](/docs/security)). +- Unsupported operator for a field (e.g. `gt` on a bool) → `TYPE_MISMATCH` / + `INVALID_OPERATOR`. +- Malformed expression → `QueryParseException` naming the failing parameter + (`filter`), the syntax, what was expected, and the position in the string. + +## Related + +- [Sorting](/docs/guides/sorting) · [Paging](/docs/guides/paging) · + [Query Syntax](/docs/concepts/query-syntax) (FQL / MiniOData equivalents) diff --git a/docs-v4/content/docs/guides/fluent-api.mdx b/docs-v4/content/docs/guides/fluent-api.mdx new file mode 100644 index 0000000..f70faac --- /dev/null +++ b/docs-v4/content/docs/guides/fluent-api.mdx @@ -0,0 +1,115 @@ +--- +title: Fluent API +description: Build QueryOptions in code — typed, composable, and validated like any request. +--- + +# Fluent API + +The fluent builder gives you the full `QueryOptions` model without ever touching query +strings. It is the programmatic twin of the wire grammar: same options, same validation, +same execution methods. Use it for saved queries, server-composed policies, tests, and +data-export jobs — anywhere a client should not be in control. + +```csharp +using FlexQuery.NET.Builders.Fluent; +using FlexQuery.NET.Models; // QueryOptions lives here + +var options = Query.Create() + .Filter(f => f + .Equal("Status", "Active") + .And(g => g.GreaterThan("Salary", 50000).Or(s => s.Contains("City", "ville")))) + .Sort(s => s.Ascending("LastName").Descending("CreatedAt")) + .Select("Id", "FirstName", "LastName") + .Page(1, 20) + .Build(); + +var result = await db.Customers.FlexQueryAsync(options, cancellationToken: ct); +``` + +`Build()` returns `QueryOptions`; `FluentQueryBuilder` also converts implicitly, so you +can drop `Build()` at call sites. Execution is provider code (`FlexQueryAsync` overloads +accept `QueryOptions` directly). + +## Two styles, one filter model + +- `FilterGroupBuilder` (shown above) — method-per-operator: + `Equal / NotEqual / GreaterThan / GreaterThanOrEqual / LessThan / LessThanOrEqual / + Contains / StartsWith / EndsWith / In / NotIn / IsNull / IsNotNull / Between`, + combined with `And(...)` / `Or(...)` groups. +- `FilterBuilder` — field-first chained style: + +```csharp +var filter = new FilterBuilder() + .Field("Status").Eq("Active") + .And("Age").GreaterThan(18) + .Field("Orders").Any(o => o.Field("Total").GreaterThan(100)) + .Build(); +``` + +`Any`/`All` mirror the collection operators, and `Field(...).Not().Eq(...)` negates a +single condition. + +## All builder methods + +Starting from `Query.Create()`: + +| Method | Meaning | Options field | +|---|---|---| +| `.Filter(f => …)` | filter tree | `Filter` | +| `.Sort(s => s.Ascending("X").Descending("Y"))` | ordering (list order = priority) | `Sort` | +| `.Select(params string[])` | projection paths / syntax accepted by the wire select grammar | `Select` | +| `.Include(params string[])` | navigation paths | `Includes` | +| `.Expand(e => e.Path("Orders", f => f.Equal("Status","Delivered"), children => …))` | filtered relation loading | `Expand` | +| `.Mode(ProjectionMode.Flat)` | projection shape (Nested/Flat/FlatMixed) | `ProjectionMode` | +| `.GroupBy(params string[])` | group keys | `GroupBy` | +| `.Aggregate(a => a.Sum("Total").Count("Id", "orders"))` | aggregates (`Sum/Count/Avg/Min/Max`, optional alias) | `Aggregates` | +| `.Having("sum", "Total", "gt", "100")` | one HAVING comparison over a declared aggregate | `Having` | +| `.Distinct(true)` | DISTINCT | `Distinct` | +| `.Page(page, pageSize)` | offset paging | `Paging` | +| `.UseKeysetPagination(pageSize, cursor?)` | keyset paging (sort required downstream) | `IsKeysetMode`/cursor | +| `.DisablePaging()` | return the full result set | `Paging.Disabled` | +| `.Build()` | produce `QueryOptions` | — | + +HAVING can also be composed as a tree (AND/OR groups, FQL-style functions) via the +`HavingNode` types in `FlexQuery.NET.Models.Aggregates` when the single-condition +overload gets too cramped. + +## Composition example: saved queries + policy + +```csharp +public static class Reports +{ + public static QueryOptions RegionalCustomers( + string status, int page) => Query.Create() + .Filter(f => f.Equal("Region", "EMEA").And().Equal("Status", status)) + .Sort(s => s.Ascending("CompanyName").Ascending("Id")) + .Page(page, 50) + .Build(); +} + +// the controller stays thin — wire parameters never enter the picture +var options = Reports.RegionalCustomers("Active", page: 2); +var result = await db.Customers.FlexQueryAsync(options, cancellationToken: ct); +``` + +Because the output is a plain `QueryOptions`, the query composes further: merge +adapter-parsed options (`ApplyAgGridRequest` / `ApplyKendoRequest`), or keep server-side +constraints separate from client-provided ones and combine filters manually. + +## What the builder does *not* bypass + +Validation and governance are properties of execution, not of parsing: + +- A hand-built `QueryOptions` still goes through the full validator against your + `QueryGovernanceOptions` — an unknown field or disallowed operator fails exactly like + a bad query string would. +- Field values are formatted as the DSL formats them (dates as invariant strings, etc.), + so the same type-constraints apply. +- Building options does not register the request as legitimate for a model — you own + what you put inside. + +## Related + +- [Query Options](/docs/concepts/query-options) +- [Query Composition](/docs/guides/query-composition) — the underlying model +- [Filtering](/docs/guides/filtering) · [Paging](/docs/guides/paging) diff --git a/docs-v4/content/docs/guides/grouping.mdx b/docs-v4/content/docs/guides/grouping.mdx new file mode 100644 index 0000000..425c9c4 --- /dev/null +++ b/docs-v4/content/docs/guides/grouping.mdx @@ -0,0 +1,128 @@ +--- +title: Grouping & Aggregates +description: Grouped queries, HAVING filters, and grand totals — SQL aggregation without writing SQL. +--- + +# Grouping & Aggregates + +Reports like revenue per status, average salary per city, or order counts per customer +are aggregation work that FlexQuery can do server-side. Three parameters describe the +shape: `groupBy` defines the grouping, `aggregate` declares the aggregate functions, +and `having` filters the groups. Grand totals come free as a bonus on ungrouped +aggregate queries. + +> Grouped queries are a different row model — no root entity, and therefore **no +> include/expand** in the same request (`GROUPBY_INCLUDE_CONFLICT`), and `select` +> restrictions. Everything on this page holds for both EF Core and Dapper providers. + +## Group by one or more fields + +```http +/api/orders?groupBy=Status +/api/orders?groupBy=Status,Customer.City +``` + +Each `Data` row is one group. Without any `aggregate`, the result is the distinct set of +group keys. Single-key grouping yields `{ status: "Active" }` rows; dotted keys group by +the related value (e.g. `Customer.City`) and surface under the request's projection +naming. + +## Aggregates + +`aggregate` is a comma-separated list of `function:field[:alias]` items. Supported +functions: `sum`, `avg` (`average` is accepted), `min`, `max`, `count` (case-insensitive +function names). `count` works on properties or collection navigations; `*` as a target +is not part of the DSL. + +```http +/api/orders?groupBy=Status&aggregate=count:Id,sum:TotalAmount,avg:TotalAmount&sort=TotalAmountSum:desc +/api/orders?groupBy=Status&aggregate=sum:TotalAmount:revenue,count:Id:orders&sort=revenue:desc +``` + +- With no alias, the output name follows the field+function convention + (`sum:TotalAmount` → `TotalAmountSum`, `count:Id` → `IdCount`); aliases are validated + as identifiers, must be unique within the request, and replace the generated default. +- Group rows carry the keys and every declared aggregate under the alias/default name. + +## HAVING — filtering groups + +`having` conditions pair an aggregate with a comparison. The canonical DSL spelling is +`function:field:operator:value` (e.g. `sum:Total:gt:100`), combined with `AND`, `OR`, and +parentheses; FQL uses the SQL-like `SUM(Total) > 100` form: + +```http +/api/orders?groupBy=Status&aggregate=count:Id,sum:Total:Revenue&having=count:Id:gte:5 AND sum:Total:gt:100 +/api/orders?groupBy=Customer.City&aggregate=avg:Salary:avgSalary&having=avg:Salary:gt:50000&sort=avgSalary:desc +``` + +The rules that keep aggregates meaningful: + +- `having` requires both `groupBy` and at least one matching declared aggregate — + otherwise `HAVING_WITHOUT_GROUPBY` / `HAVING_REQUIRES_GROUPBY`. +- every condition must match a declared aggregate by function **and** field + (case-insensitive); an unknown pairing fails with `AGGREGATE_NOT_DECLARED`. +- operators are comparison-only (`eq ne gt gte lt lte`) with numeric type checks + (`sum`/`avg` targets must be numeric), and `count` conditions compare against a value. +- in grouped queries, `sort` may only order by group keys or aggregate names. + +## Grand totals (ungrouped aggregates) + +Declare aggregates **without** `groupBy` to get single-row totals across the whole +filtered set — alongside the normal paged data, in a separate envelope field: + +```http +/api/orders?aggregate=sum:TotalAmount,count:Id&pageSize=20 +``` + +```json +{ + "data": [ /* the requested 20 orders, normal paging ... */ ], + "totalCount": 612, + "page": 1, + "pageSize": 20, + "totalPages": 31, + "aggregates": { + "TotalAmount": { "sum": 1250.00 }, + "Id": { "count": 612 } + } +} +``` + +The aggregate sub-dictionary keys are the aggregate's alias (or its auto-generated +name); the outer key is the aggregate's source field. Grand-total queries only compute +over the **filtered** rows — the same `filter` applies to data and totals. + +## Complete worked example + +"Active customers who own at least 5 orders priced over 100, shown with order count +and average order value": + +```http +GET /api/customers?filter=Status:eq:Active + &groupBy=Id,FirstName,LastName + &aggregate=count:Orders:orderCount,avg:Orders.Total:avgOrderValue + &having=count:Orders:gte:5 + &sort=orderCount:desc +``` + +What each piece does: the filter narrows customers before grouping; three keys define +group identity (`Id,FirstName,LastName`); each group computes two aggregates under +explicit aliases; `having` drops groups with fewer than 5 matching orders (the count is +over the full navigation, not the filtered page); and the groups themselves are sorted +by the alias. The response `Data` is the group-row list, with normal paging on top — +paging metadata counts **groups**, and `totalCount` reflects the underlying source rows. + +## Provider notes + +- **EF Core**: grouping/aggregation translates to SQL `GROUP BY`/`HAVING`/aggregates — + all computation is done by the database. +- **Dapper**: `GROUP BY`, `HAVING`, key-set paging and ordered aggregate aliases are + generated directly into SQL (with dialect-correct `ORDER BY … NULLS LAST` behavior on + Oracle for grouped sorts). +- Nested aggregates over paths (`max:Orders.Total`) work as long as the property path is + resolvable from the root entity type through the provider's translation. + +## Related + +- [Sorting](/docs/guides/sorting) · [Paging](/docs/guides/paging) · + [Query Result](/docs/concepts/query-result) diff --git a/docs-v4/content/docs/guides/include.mdx b/docs-v4/content/docs/guides/include.mdx new file mode 100644 index 0000000..b449f6d --- /dev/null +++ b/docs-v4/content/docs/guides/include.mdx @@ -0,0 +1,134 @@ +--- +title: Include +description: Load related entities through navigation paths — server-side, bounded, governed. +--- + +import { Callout } from '@/components/callout' + +# Include + +`include` attaches related records to each row: customers **with their orders**, +orders **with their order items and product**. The loading happens server-side (SQL +joins / follow-up queries — never a change-tracking fixup), and the set of navigations a +client may name is entirely up to your governance configuration. + +## Syntax + +One or more comma-separated navigation paths, using dots for depth: + +```http +GET /api/customers?include=Orders +GET /api/customers?include=Orders,Orders.OrderItems +GET /api/orders?include=Customer.Address +``` + +- Duplicates collapse silently — `include=Orders,Orders` loads `Orders` once. +- Every path must resolve to **navigation properties** end to end. A path that walks + into a scalar property is rejected. +- The wire parameter is `include` in the native DSL and FQL; MiniOData clients spell the + same thing as `$expand=Orders` (see [Query Syntax](/docs/concepts/query-syntax)). + +## What the response looks like + +```http +GET /api/customers?include=Orders&pageSize=2 +``` + +```json +{ + "data": [ + { + "id": 12, + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "city": "London", + "status": "Active", + "salary": 90000, + "createdDate": "2023-05-17T00:00:00Z", + "orders": [ + { "id": 441, "customerId": 12, "orderNumber": "ORD-441", + "totalAmount": 210.00, "orderDate": "2024-02-01T00:00:00Z", "status": "Delivered" } + ] + } + ], + "totalCount": 42, + "page": 1, + "pageSize": 2, + "totalPages": 21, + "hasNextPage": true, + "hasPreviousPage": false +} +``` + +Each included navigation appears as a property on the parent row. Two shape rules are +worth knowing: + +- **Only requested branches are sent.** Children of `Orders` (like `OrderItems`) stay + out of the response until you ask for them — including a navigation loads its scalar + fields, not its own children. +- **Parents are never dropped.** A customer with zero orders gets an empty array, and + the root page/row count is unaffected by what you include. + +## Combining with select + +`include` decides *what gets loaded*; `select` decides *what gets returned*: + +```http +GET /api/customers?include=Orders&select=Id,Orders(Id,TotalAmount) +``` + +This is the bandwidth-friendly pair — children load with only the listed fields. The +rule connecting them: selecting *through* a navigation path requires that path in +`include` (otherwise `QueryValidationException`, `NAVIGATION_PROJECTION_REQUIRES_INCLUDE` +— the message even tells you which `include=` value to add). + +## Governance: which paths are includable + +```csharp +opt.AllowedIncludes = ["Orders", "Orders.OrderItems"]; +``` + +- With `AllowedIncludes` set, anything outside the list is rejected with + `INCLUDE_ACCESS_DENIED` (or, in lenient mode, the unauthorized branch is dropped from + the response — a security property, not a convenience). +- With it unset, any valid navigation of the entity type may be included. For + public endpoints, set it explicitly — unbounded `include` trees are the classic + data-exfiltration and cartesian-blow-up vector. +- Governance paths are enforced for includes and expansions alike (see + [Expand](/docs/guides/expand) and [Security](/docs/security)). + +## Provider behavior + +- **EF Core**: includes are composed into the query as filtered `Include`/`ThenInclude` + expressions; EF's own query pipeline emits the SQL (join or follow-up query per + provider behavior) and materializes the graph. Tracked queries would additionally fix + up inverses — FlexQuery runs no-tracking by default. +- **Dapper**: FlexQuery issues one root query plus **batched child queries** per include + level (`SELECT … WHERE CustomerId IN (page keys)`), so the server work stays bounded by + what is on the current page — a single `include=Orders` even collapses to one + streamed join command behind the scenes. +- Grouping queries reject include/expand outright (`GROUPBY_INCLUDE_CONFLICT`) — the + row model has no parent entity to hang a graph on. Pair `groupBy` with `aggregate` + instead. + +## Common mistakes + + + include=Orders loads every related row. A customer with 800 + orders returns 800 child objects. If you need "top 5 recent orders", use + expand with take and sort — or + shape the relation with select. + + + + Bidirectional navigations (Order.Customer with back-reference to + Customer.Orders) will serialize in circles unless the inverse is + [JsonIgnore] — the sample domain does exactly that. + + +## Related + +- [Expand](/docs/guides/expand) — filtered, sorted, size-bounded includes +- [Projection](/docs/guides/projection) — shape what loads into what returns +- [Security & Governance](/docs/security) diff --git a/docs-v4/content/docs/guides/keyset-pagination.mdx b/docs-v4/content/docs/guides/keyset-pagination.mdx new file mode 100644 index 0000000..5d61c1b --- /dev/null +++ b/docs-v4/content/docs/guides/keyset-pagination.mdx @@ -0,0 +1,121 @@ +--- +title: Keyset Pagination +description: Cursor-based paging that stays fast at any scroll depth. +--- + +# Keyset Pagination + +Offset paging asks the database to count and discard rows before yours; deep +`page` values get slower the further you scroll. Keyset (cursor) paging instead +remembers **where the last page ended** and asks for whatever comes after that point — +cost stays flat whether you are on page 1 or page 10,000, which makes it the right mode +for infinite scrolling, mobile feeds, and large exports. + +## The three parameters + +| Wire parameter | Meaning | +|---|---| +| `useKeysetPagination=true` | Requests keyset mode. Sort is mandatory. | +| `sort=` | The seek ordering — at least one field, ideally ending in a unique column | +| `cursor=` | Opaque token from the previous response's `nextCursorToken` | + +The token is an opaque, versioned Base64 string carrying the sort-key values of the +last row. Treat it as a black box: never build, decode, or edit it client-side; pass +back exactly what came from the server. + +## First page, next pages, done + +First request — no cursor yet: + +```http +GET /api/customers?useKeysetPagination=true&sort=LastName:asc,Id:asc&pageSize=20 +``` + +```json +{ + "data": [ /* 20 rows */ ], + "page": 1, + "pageSize": 20, + "nextCursorToken": "eyJ2IjoxLCJ2YWx1ZXMiOlsiTWlsbGVyIiw4N119" +} +``` + +Subsequent requests feed the token back: + +```http +GET /api/customers?useKeysetPagination=true&sort=LastName:asc,Id:asc&pageSize=20&cursor=eyJ2IjoxLCJ2YWx1ZXMiOlsiTWlsbGVyIiw4N119 +``` + +When a page comes back empty (`data: []`), you have reached the end — the token stops +being produced. Because keyset mode's purpose is forward scrolling, the server does not +return a "previous page" token. + +Keyset responses also skip the `totalCount` query by default (that is the +point: no counting at all) — `totalCount` is null. Ask for a count on the +first page only if the UI needs one: `&includeCount=true`. + +## Rules that will bite you + +- **A sort is required.** Keyset without `sort` fails — the provider throws + "Keyset pagination requires at least one sort field". Order by a column, then a + tiebreaker (usually the key): `sort=OrderDate:desc,Id:desc`. +- **Offset and cursor cannot mix.** Sending `page` together with keyset mode is a + validation error (`PAGINATION_MODE_CONFLICT`) — choose one style per request. +- **The cursor must match the current sort.** The token encodes one value per sort + field; if a client replays a token against a different `sort`, the shape check fails + with `CURSOR_MISMATCH`. Changing the ordering mid-scroll resets the position. +- **Nulls limit seekability.** A cursor value that is `null` over a non-nullable key + errors with `CURSOR_NULL_VALUE`; sort a non-nullable column (or a nullable one you + can tolerate losing across) as the final tiebreaker. +- Malformed or tampered tokens fail to deserialize and are treated as + "no cursor" — the first page is returned rather than an error, so always keep tokens + server-supplied end to end. +- Data written between page fetches shows up (or disappears) naturally — keyset gives + you *stable ordering*, not a snapshot. If you need both, version the underlying query + yourself (a tag column or `CreatedDate < X` filter pair). + +## How it executes + +- **Dapper** generates a seek predicate for the ordering columns (`(A > @p0) OR (A = @p0 + AND B > @p1)` with the correct direction per field) and `LIMIT`-style paging — a single + command per page, no offset counting at all. +- **EF Core** composes the same seek predicate into the expression tree and lets the + provider translate it server-side. + +## Server-side usage (optional) + +Keyset mode can be configured directly on options instead of via the wire — e.g. a +"load more" endpoint that always pages forward: + +```csharp +var options = clientParams.ToQueryOptions(); // carries cursor + useKeysetPagination +var result = await db.Customers.AsNoTracking() + .FlexQueryAsync(options, + opt => { opt.MaxPageSize = 500; }, // ceiling stays server-defined + cancellationToken: cancellationToken); +``` + +With `QueryResult.NextCursorToken` the loop becomes trivial: + +```csharp +string? cursor = null; +do +{ + var page = await connection.FlexQueryAsync(parameters, + cfg => { cfg.MaxPageSize = 500; }, + cancellationToken: ct); + // … process page.Data … + cursor = page.NextCursorToken; +} +while (cursor is not null); +``` + +## When to stick with offset paging + +Random access ("jump to page 23"), stable page numbers in admin grids, and the ability +to show "1,234 results" cheaply all favor [offset paging](/docs/guides/paging). The two modes +share the same envelope, so UIs can grow into keyset without reworking the result shape. + +## Related + +- [Paging](/docs/guides/paging) · [Query Result](/docs/concepts/query-result) diff --git a/docs-v4/content/docs/guides/operators.mdx b/docs-v4/content/docs/guides/operators.mdx new file mode 100644 index 0000000..713f3e6 --- /dev/null +++ b/docs-v4/content/docs/guides/operators.mdx @@ -0,0 +1,164 @@ +--- +title: Operators +description: The complete operator reference - canonical names, aliases, type rules, and provider behavior. +--- + +# Operators + +Every filter, HAVING condition, and expanded-branch filter in FlexQuery is built from one +fixed set of operators. This page is the authoritative reference: the canonical names, the +aliases that normalize into them, which .NET types each operator works with, how +governance restricts them per field, and how each provider executes them. + +## Canonical operators + +The parser normalizes every recognized operator to one of these canonical strings -- +`Status:EQ:Active` and `Status:eq:Active` are the same query: + +### Comparison + +| Operator | Meaning | Works with | +|---|---|---| +| `eq` | equals | scalars, strings, enums, dates, numbers, bools | +| `neq` | not equals | as above | +| `gt` | greater than | numbers, dates, comparable values | +| `gte` | greater than or equal | as above | +| `lt` | less than | as above | +| `lte` | less than or equal | as above | + +### Text + +| Operator | Meaning | Notes | +|---|---|---| +| `contains` | substring search | case-sensitive in memory; collation-sensitive in SQL | +| `startswith` | prefix match | string properties only | +| `endswith` | suffix match | string properties only | +| `like` | SQL-style pattern | `%` = any run, `_` = one char; executed through provider `LIKE` support | + +### Sets and ranges + +| Operator | Value format | Example | +|---|---|---| +| `in` | comma-separated list | `Status:in:Active,Pending` | +| `notin` | comma-separated list | `Status:notin:Cancelled,Refunded` | +| `between` | two comma-separated bounds, inclusive | `CreatedDate:between:2024-01-01,2024-02-01` | + +### Null checks + +| Operator | Value | Example | +|---|---|---| +| `isnull` | none | `DeletedAt:isnull` | +| `isnotnull` | none | `Email:isnotnull` | + +### Collection operators + +These target **collection navigation** paths; validation rejects them on scalar fields +(`NOT_A_COLLECTION` / `TYPE_MISMATCH`): + +| Operator | Meaning | Example | +|---|---|---| +| `any` | at least one related row matches | `Orders:any:TotalAmount:gt:100` | +| `all` | every related row matches | `Orders.all:Status:eq:Delivered` | +| `count` | count of related rows, compared to a value | `Orders:count:gt:3`, `Orders.count(Status:eq:Pending):gte:2` | + +`any`/`all` take their operand as a filter expression on the related type -- one nesting +level down the same `field:op:value` grammar applies. The `count` form appends its +comparison (`:op:value`) after the collection path. + +## Aliases + +Every operator also accepts word aliases (and, where the operator arrives as its own +string, symbolic ones): + +| Canonical | Aliases | +|---|---| +| `eq` | `equal`, `equals`; `=`, `==` | +| `neq` | `ne`, `notequal`; `!=`, `<>` | +| `gt` | `greaterthan`; `>` | +| `gte` | `ge`, `greaterthanorequal`; `>=` | +| `lt` | `lessthan`; `<` | +| `lte` | `le`, `lessthanorequal`; `<=` | +| `contains` | `cn` | +| `startswith` | `starts`, `sw` | +| `endswith` | `ends`, `ew` | +| `isnull` | `null` | +| `isnotnull` | `notnull`, `isnotempty` | +| `notin` | `not in` | + +The symbolic aliases apply wherever an operator is parsed as a standalone string -- the +JSON `filters` request model and governance sets. In the colon-separated DSL stick to +the word forms (a bare `Status=x` is not a DSL filter at all -- the operator segment +lives between colons). + +Operator names are normalized to the canonical string **before** governance checks and +before execution, so an `AllowedOperators` entry written as `gte` also admits `ge`. + +## Type rules + +The validator enforces operator/property-type compatibility before the query runs: + +- `contains`, `startswith`, `endswith`, `like` require `string` properties. +- `gt`, `gte`, `lt`, `lte`, `between` require comparable types; values must convert to + the property type (`TYPE_MISMATCH` otherwise -- a `gt` on a `decimal` never falls back + to string ordering). +- `in` / `notin` items each convert to the property type. +- `isnull` / `isnotnull` take no value. +- `any`, `all`, `count` address collection navigations; dotted paths through them apply + to the nested type's members. + +Unknown operators fail with `INVALID_OPERATOR`; disallowed-by-governance operators with +`OPERATOR_NOT_ALLOWED` -- both before SQL is produced. + +## Execution by provider + +| Operator | EF Core | Dapper | In memory | +|---|---|---|---| +| comparisons | SQL predicates, parameterized | parameterized SQL | expression-tree comparisons | +| `contains` / `startswith` / `endswith` | provider `LIKE`/string translation | dialect `LIKE` with pattern construction | ordinal `string.Contains` etc. | +| `like` | `EF.Functions.Like` | dialect `LIKE` | pattern translation applied by the engine | +| `in` / `notin` | list parameterization | parameterized list / `NOT IN` | `Contains` closures | +| `between` | `>= AND <=` | `>= AND <=` | two comparisons | +| `isnull` / `isnotnull` | `IS [NOT] NULL` | `IS [NOT] NULL` | null checks | +| `any` | correlated `EXISTS` | `EXISTS` subquery | `.Any(...)` | +| `all` | `NOT EXISTS(NOT ...)` | `NOT EXISTS` subquery | `.All(...)` | +| `count` | scalar count subquery | `SELECT COUNT(...)` predicate | `.Count()` compared | + +Two semantics worth knowing: + +- `all` compiles to a double-negated `NOT EXISTS`, so an entity with **no** related rows + passes an `all` check (vacuous truth, matching SQL). +- Text comparisons are ordinal in memory and **collation-dependent** in + EF Core/Dapper -- case behavior follows the database. + +## Operator governance + +Per-field allow-lists are keyed by field (case-insensitive) and hold the canonical +operator strings: + +```csharp +opt.AllowedOperators = new(StringComparer.OrdinalIgnoreCase) +{ + ["Status"] = ["eq", "in", "notin"], + ["Age"] = ["gt", "gte", "lt", "lte", "between"], + ["City"] = ["eq"], +}; +``` + +Clients on `City` then get `OPERATOR_NOT_ALLOWED` for `City:contains:ber`; the request +never reaches the database. + +## Where operators appear + +1. The root `filter` parameter ([Filtering](/docs/guides/filtering)). +2. `expand` branch filters (`Orders(all:Status:eq:Shipped; take=5)` -- see + [Expand](/docs/guides/expand)). +3. HAVING conditions, restricted to `eq ne gt gte lt lte` comparisons over declared + aggregate values ([Grouping & Aggregates](/docs/guides/grouping)). +4. The flat `filters` condition list on `FlexQueryRequest` + (`{ "field": ..., "operator": ..., "value": ... }`). + +## Related + +- [Filtering](/docs/guides/filtering) - expression grammar and composition +- [Security & Governance](/docs/security) - field and operator allow-lists +- [Validation](/docs/guides/validation) - the full error-code catalog \ No newline at end of file diff --git a/docs-v4/content/docs/guides/paging.mdx b/docs-v4/content/docs/guides/paging.mdx new file mode 100644 index 0000000..f2a06bd --- /dev/null +++ b/docs-v4/content/docs/guides/paging.mdx @@ -0,0 +1,134 @@ +--- +title: Paging +description: Offset paging with page/pageSize, count control, distinct, and deterministic ordering. +--- + +# Paging + +Every query is paged by default. The offset pair — `page` plus `pageSize` — slices one +window out of the sorted, complete result set, and the response carries the totals clients +need to drive pagination controls. + +## Parameters + +| Wire parameter | Meaning | Default | Behavior | +|---|---|---|---| +| `page` | 1-based page number | `1` | Out-of-range and non-positive values clamp to the first page | +| `pageSize` | rows returned per page | server default `20` | Clamped to `1` … the configured ceiling (default `1000`) | + +```http +GET /api/customers?page=2&pageSize=20 +``` + +```json +{ + "data": [ /* up to 20 rows */ ], + "totalCount": 137, + "page": 2, + "pageSize": 20, + "totalPages": 7, + "hasNextPage": true, + "hasPreviousPage": true +} +``` + +- `totalCount` — rows matching the filters **before** paging (and before grouping, when + relevant; see [Grouping](/docs/guides/grouping) for the grouped-count nuance). Null if + counting is switched off. +- `totalPages`, `hasNextPage`, `hasPreviousPage` are computed from the counts — never + trust them when `totalCount` is null. +- Asking past the end gives an empty `data` and `hasNextPage: false` — it is not an + error; clients can probe total length safely. + +## Sizing limits + +The ceiling is configuration, not wire input — clients can never widen it: + +```csharp +FlexQueryCore.Configure(options => +{ + options.DefaultPageSize = 20; // used when the client omits pageSize + options.MaxPageSize = 1000; // clamps any requested pageSize down +}); +``` + +A per-request override (`opt.MaxPageSize = 50` in the `configure` delegate of a provider +call) tightens it for one endpoint; looser values are still clamped. Page size is +clamped down at parse time with no error — a client asking `pageSize=5000` simply gets +the maximum. + +## Turning counts off + +```http +GET /api/customers?page=1&pageSize=20&includeCount=false +``` + +The count query is skipped (one less round-trip per page), `totalCount`/`totalPages` +are null. The same applies globally: `options.IncludeTotalCount = false` in startup +configuration makes counting opt-in, while `includeCount=true` on the wire asks for it +per request. Use `includeCount=false` for infinite-scroll UIs where only the first page +(or none at all) needs the total. + +## Sorting is not optional + +Paged results must be sorted to be stable. Add a deterministic sort on every paged +query — ideally ending in a unique column and enforced endpoint-side with +`DefaultSortField`: + +```http +GET /api/customers?sort=LastName:asc,Id:asc&page=2&pageSize=20 +``` + +Without a total order, rows whose sort keys tie can shift positions between OFFSET +calculations, and duplicates or gaps appear across pages. + +## Distinct + +```http +GET /api/customers?distinct=true&select=City +``` + +`distinct` applies before projection so that only matching columns are compared (EF +uses the provider's `DISTINCT`; Dapper emits `SELECT DISTINCT`). It composes with +paging and counting — with `groupBy` present, DISTINCT acts on the grouped rows. + +## Full round-trip example + +```csharp +[HttpGet] +public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) +{ + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, opt => + { + opt.DefaultPageSize = 20; + opt.MaxPageSize = 200; + opt.AllowedFields = ["Id", "FirstName", "LastName", "Email", "City", "Status"]; + opt.DefaultSortField = "Id"; + }, cancellationToken: cancellationToken); + + // QueryResult — Data, TotalCount, Page, PageSize, TotalPages, HasNextPage, HasPreviousPage + return Ok(result); +} +``` + +Clients that only need a "next page" button can ignore `totalCount` entirely and poll +`hasNextPage` — the pattern that pairs naturally with +[keyset pagination](/docs/guides/keyset-pagination) below. + +## Offset vs keyset + +Offset paging with deep `page` values forces the database to count and discard skipping +rows; `page=10000` is never fast. When a UI only scrolls forward (or renders an +endless list), prefer the cursor-based mode — see +[Keyset Pagination](/docs/guides/keyset-pagination), which documents the same +`data`/`totalCount` envelope with `nextCursorToken` plus the validation rules that mix +offset and cursor parameters (an explicit `page` and a `cursor` together are rejected as +a `PAGINATION_MODE_CONFLICT`). + +## Related + +- [Keyset Pagination](/docs/guides/keyset-pagination) · [Query Result](/docs/concepts/query-result) diff --git a/docs-v4/content/docs/guides/projection.mdx b/docs-v4/content/docs/guides/projection.mdx new file mode 100644 index 0000000..f44a853 --- /dev/null +++ b/docs-v4/content/docs/guides/projection.mdx @@ -0,0 +1,170 @@ +--- +title: Projection +description: Select exactly the fields clients need — paths, aliases, wildcards, nested selection, and projection modes. +--- + +# Projection + +`select` decides which fields the response contains — trimming payloads, hiding +internal properties, and keeping SQL on the columns actually needed. Projection is +orthogonal to filtering, sorting, and paging: it runs last, on the rows and counts those +stages already produced. + +## Basic field selection + +```http +GET /api/customers?select=Id,FirstName,Email +``` + +Only the listed fields appear in `data`. Field paths use dots to reach into +navigations, and the result keeps the natural object shape: + +```http +GET /api/customers?select=Id,Orders.Id,Orders.TotalAmount +``` + +Selecting through a navigation requires that navigation to be **loaded** too — add it to +`include` (see [Include](/docs/guides/include)). Otherwise validation rejects the request: + +> The navigation path 'Orders' is referenced in the select clause but is not included. +> Add `include=Orders` or remove the path from `select`. + +## Aliases + +Clients rename output fields without touching your model — two spellings are accepted: + +```http +GET /api/customers?select=Id,FirstName:firstName2 +GET /api/customers?select=Id,FirstName as firstName2 +``` + +Aliases apply only to the response; filters and sorts keep addressing the real property +names. Under the default camelCase JSON settings, aliases are emitted as written. + +## Wildcards + +`select=*` returns every scalar field of the root type: + +```http +GET /api/customers?select=* +``` + +Restrictions enforced by validation: + +- the wildcard is valid only at the top level (`Orders.*` is not supported — list the + child fields explicitly inside a nested select instead) +- `*` cannot be combined with other selections in the same list +- `*` selects scalar properties only — navigations never come along unless asked for + via `include`/`expand` +- a duplicate wildcard (`select=*,*`) is ignored with a validation warning + +## Nested selection + +Parenthesized groups project children as nested objects — with their own inner +selection, wildcards, and aggregate-style children: + +```http +GET /api/customers?include=Orders&select=Id,Orders(TotalAmount,OrderDate) +``` + +```json +{ + "data": [ + { + "id": 7, + "orders": [ { "totalAmount": 129.90, "orderDate": "2024-02-01T00:00:00Z" } ] + } + ] +} +``` + +Rules enforced server-side: + +- navigation path aliases go on the **parent** field, not on the nested children. + `Customer(custOrders)` on a child collection inside a select is rejected. +- a nested navigation can be selected under **one alias only** — two aliases for the + same path (`Orders(a),Orders(b)`) raise `DUPLICATE_ALIAS`. +- the nested parent must also be included (`include=Orders`) — same rule as dotted + paths above. + +## Interaction with paging and counting + +Selection changes what rows look like, not which rows exist: + +- `totalCount` counts the pre-paging source set, independent of `select`. +- `distinct` + `select` de-duplicates on the projected shape — `select=City` + + `distinct=true` is the "list of cities" pattern. +- with `groupBy`, every non-aggregate field in `select` must appear in `groupBy` + (`GROUPBY_PROJECTION_MISMATCH`), and `select=*` is not allowed in grouped queries. + +## Nested projections that branch + +When a select references a deeper graph with multiple branches +(`Orders.OrderItems.Product`), FlexQuery keeps the hierarchy nested by default. The +query-string `mode` parameter reshapes that output: + +```http +GET /api/orders?include=OrderItems&select=Id,OrderItems(Product)&mode=flat +``` + +| `mode` | Shape | Notes | +|---|---|---| +| `nested` (default) | `data[].orderItems[].product.name` | the plain hierarchy | +| `flat` | collections flatten into leaf rows (SQL-join semantics via `SelectMany`); a query with `Id` **and** a leaf path collapses root values onto leaf rows | single linear path branching only — multiple branches throw | +| `flat-mixed` | like `flat`, but root scalar fields repeat on every leaf row | preferred for grid exports | + +Mode is a property of the whole request (also available programmatically as +`ProjectionMode.Flat` / `FlatMixed` / `Nested` on `QueryOptions`). Dapper rejects +multiple branching navigation paths in `Flat` mode; EF Core falls back to its +correlated-query machinery, which the provider handles server-side. + +## Complete worked example + +```http +GET /api/customers?filter=Status:eq:Active&include=Orders&select=Id,Email,Orders(Id,OrderNumber:ref,TotalAmount:amount)&sort=Id:asc&pageSize=2 +``` + +```json +{ + "data": [ + { + "id": 12, + "email": "ada@example.com", + "orders": [ + { "id": 441, "ref": "ORD-441", "amount": 210.00 }, + { "id": 489, "ref": "ORD-489", "amount": 89.00 } + ] + }, + { + "id": 37, + "email": "grace@example.com", + "orders": [ { "id": 573, "ref": "ORD-573", "amount": 129.90 } ] + } + ], + "totalCount": 42, + "page": 1, + "pageSize": 2, + "totalPages": 21, + "hasNextPage": true, + "hasPreviousPage": false +} +``` + +Reading the pieces: `filter` and `sort` operate on real property names; `include=Orders` +authorizes the relationship; the nested `Orders(...)` list projects only three child +fields, two of them under client aliases (`ref`, `amount`); paging metadata reflects the +filtered customer count; and a client that never asked for `Email` couldn't see it — +`select` is the response contract. + +## Common mistakes + +- Listing a field twice (`select=Id,FirstName,Id`) collapses silently — later + duplicates are ignored (except wildcards, which validate). +- Expecting `select` to restrict what clients can *filter*: it doesn't. Governance + (`AllowedFields`/`SelectableFields`) controls reachability on the wire; `select` + controls the output shape for authorized fields only. + +## Related + +- [Include](/docs/guides/include) · [Expand](/docs/guides/expand) · + [Typed DTO Projection](/docs/guides/typed-dto-projection) diff --git a/docs-v4/content/docs/guides/query-composition.mdx b/docs-v4/content/docs/guides/query-composition.mdx new file mode 100644 index 0000000..bc7dc5b --- /dev/null +++ b/docs-v4/content/docs/guides/query-composition.mdx @@ -0,0 +1,168 @@ +--- +title: Query Composition +description: Combine, merge, and hand-build QueryOptions - the programmable heart of FlexQuery. +--- + +# Query Composition + +Everything FlexQuery accepts from the wire converges on one object: `QueryOptions`. +Parsing, building in code, converting an adapter payload, or merging server-side rules +onto a client query are all the same operation -- producing a `QueryOptions` instance +the providers then execute. Understanding that hub is what lets you layer user input, +tenant policy, and saved report definitions without special-purpose code. + +## The four ways to get options + +```csharp +// 1. Bound query string (the normal endpoint path) +var options = parameters.ToQueryOptions(); +var fqlOptions = parameters.ToQueryOptions(QuerySyntax.Fql); // explicit syntax + +// 2. Fluent builder (returns QueryOptions from Build(); also implicitly convertible) +var built = Query.Create() + .Filter(f => f.In("Status", "Active", "Pending")) + .Sort(s => s.Descending("CreatedDate")) + .Page(1, 20) + .Build(); + +// 3. Typed request model (POST bodies, adapters) +var fromRequest = request.ToQueryOptions(); + +// 4. Hand-built +var manual = new QueryOptions +{ + Filter = new FilterGroup + { + Logic = LogicOperator.And, + Filters = + [ + new FilterCondition { Field = "Status", Operator = "eq", Value = "Active" }, + new FilterCondition { Field = "City", Operator = "in", Value = "Berlin,Munich" }, + ], + }, + Sort = [new SortNode { Field = "CreatedDate", Descending = true }], + Paging = { Page = 1, PageSize = 50 }, +}; +``` + +All four execute identically -- same validation pipeline, same governance, same result +shape: + +```csharp +var result = await db.Customers + .FlexQueryAsync(options, opt => { /* per-request governance */ }, cancellationToken: ct); +``` + +## Merging client input with server policy + +The composition pattern for multi-tenant or role-scoped APIs: parse what the client +sent, then add what they must not control. + +```csharp +var options = parameters.ToQueryOptions(); + +options.GroupBy = ["Region"]; +options.Aggregates.Add(new Aggregate +{ + Function = AggregateFunction.Sum, + Field = "Amount", + Alias = "RegionRevenue", +}); +options.Paging.PageSize = Math.Min(options.Paging.PageSize, 100); +``` + +Prefer the provider call for the rest of the policy -- governance applied through the +`configure` delegate cannot be overridden by the client later, whereas anything baked +into `QueryOptions` is data the rest of the pipeline consumes as given. + +The model classes are plain .NET types under `FlexQuery.NET.Models` (filters, +projection, paging): + +| Model | Key members | +|---|---| +| `FilterGroup` | `Logic` (`And`/`Or`), `Filters`, `Groups`, `IsNegated` | +| `FilterCondition` | `Field`, `Operator` (canonical name), `Value`, `ScopedFilter` | +| `SortNode` | `Field`, `Descending`, (`Aggregate`/`AggregateField` on grouped sorts) | +| `SelectNode` | `Field`, `Alias`, `Children` | +| `IncludeNode` | `Path`, `Filter`, `Sort`, `Take`, `Children` (the expand tree) | +| `Aggregate` | `Function` (`AggregateFunction` enum), `Field`, `Alias` | +| `PagingOptions` | `Page`, `PageSize`, `Disabled` | + +## Stage-by-stage application + +When you need the pieces yourself -- applying a parsed query to a queryable you already +built -- the individual stages are public on `IQueryable`: + +```csharp +using FlexQuery.NET; // extension methods namespace + +var queryable = db.Orders + .Where(o => o.CreatedDate > since) // your own predicates first + .ApplyFilter(options) // client filter + .ApplySort(options) // order by client sort (no keyset seek: + .ApplyPaging(options); // use the provider call for keysets) +``` + +| Method | Result | +|---|---| +| `Apply(options)` | full pipeline at once | +| `ApplyFilter(options)` | adds the validated `WHERE` | +| `ApplySort(options)` | adds ordering | +| `ApplyPaging(options)` | keyset seek or offset paging, per `options` | +| `ApplySelect(options)` | projection -- returns `IQueryable` | + +Two rules: + +- `ApplyFilter` throws `InvalidOperationException("Filter options are required.")` when + called with no filter -- call it only when `options.Filter` is set, or use `Apply`. +- `ApplySelect` changes the element type to `object` (dynamic projections), so anything + after it is no longer `IQueryable`. + +EF Core adds one more stage for graphs: `ApplyExpand(options)` composes the +include/expand trees (and the executor calls it for you inside `FlexQueryAsync`; you +only reach for it in hand-built pipelines). + +## Plain in-memory execution + +No EF, no Dapper: the core package runs the exact same options over any +`IQueryable` -- LINQ to Objects, Collections, an in-memory list: + +```csharp +QueryResult result = parsedProducts + .AsQueryable() + .FlexQuery(parameters); // sync; configure? delegate applies +``` + +This is the recommended unit-test seam: build a `List.AsQueryable()`, run a request +through it, and assert on the `QueryResult` -- same parser, validators, and operators +as production. + +## Keyset composition + +For manual cursor-driven paging over an ordered queryable, `SeekAfter` applies the +cursor boundary predicate directly: + +```csharp +var next = db.Customers + .OrderBy(c => c.LastName) + .SeekAfter(lastSeenLastNameOfPreviousPage); +``` + +For multi-field cursors and token plumbing, use the provider path instead -- keyset mode +on `QueryOptions` carries the cursor and the result carries the next token (see +[Keyset Pagination](/docs/guides/keyset-pagination)). + +## What composition does not bypass + +Whatever route a `QueryOptions` took to exist, execution still runs the full validation +pipeline against it: hand-built filters referencing unknown fields fail with the same +`QueryValidationException`, governance allow-lists apply, and paging still clamps. A +`QueryOptions` is a request, not a privilege -- only the provider call with a +`configure` delegate adds server-controlled policy. + +## Related + +- [Query Options](/docs/concepts/query-options) - the model in detail +- [Fluent API](/docs/guides/fluent-api) - the typed builder +- [Filtering](/docs/guides/filtering) / [Operators](/docs/guides/operators) - expression vocabulary +- [Providers](/docs/providers/ef-core) - execution endpoints \ No newline at end of file diff --git a/docs-v4/content/docs/guides/sorting.mdx b/docs-v4/content/docs/guides/sorting.mdx new file mode 100644 index 0000000..eecd15a --- /dev/null +++ b/docs-v4/content/docs/guides/sorting.mdx @@ -0,0 +1,113 @@ +--- +title: Sorting +description: Single- and multi-field sorts, aggregate sorts, and default sort behavior. +--- + +# Sorting + +The `sort` parameter controls result order. Sorts compose — a second field breaks ties +in the first — and are applied **before paging**, so page boundaries are stable as long +as the ordering is deterministic. + +## Basic sorts + +```http +/api/customers?sort=LastName:asc +/api/customers?sort=LastName:desc +/api/customers?sort=LastName (ascending — the default) +/api/customers?sort=LastName asc (space form, for FQL-style clients) +``` + +Direction is case-insensitive (`asc`/`ASC`/`Asc`); any other value is a parse error with +the offending item reported. + +## Multi-field sorts + +Separate fields with commas. Priority is purely list order — there is no numeric +priority suffix: + +```http +/api/orders?sort=Status:asc,OrderDate:desc,Id:asc +/api/customers?sort=City:asc,CreatedDate:desc,LastName:asc,FirstName:asc +``` + +For real-world grids, end every sort list with a unique or near-unique column (usually +the key). Without a final tiebreaker, rows with equal sort keys can move between pages. + +## Sorting by aggregates + +When the query aggregates collections, the sort can target the aggregate instead of a +scalar field: + +```http +/api/customers?sort=count:Orders:desc +/api/customers?groupBy=City&aggregate=sum:Salary:SalaryTotal&sort=SalaryTotal:desc +``` + +The aggregate form is `function:target[:direction]`. For `count` the target is a +collection navigation; for `sum`/`avg`/`min`/`max` the target is a numeric property +(dotted paths allowed). Aggregate sorts are the only way to order by computed values, +and they translate server-side: EF Core orders by `COUNT(...)`/`SUM(...)` in SQL, Dapper +generates the matching clause (with `NULLS LAST` on Oracle for grouped sorts). + +## Default sorting and governance + +Endpoints can define a stable default: + +```csharp +opt.DefaultSortField = "Id"; +opt.DefaultSortDescending = true; +``` + +Clients that omit `sort` get the default injected automatically. When `SortableFields` +governance is configured, client sort fields outside the whitelist are rejected in +strict mode (which is the default) before anything reaches the database. With +`StrictFieldValidation = false`, unauthorized sort fields are stripped — the injected +default remains if the client sent nothing else. + +### Why an explicit sort is not optional for paging + +Paging without any sorting (default or client-supplied) is non-deterministic: the same +page number can return different rows between requests, and SQL Server / Oracle require +an `ORDER BY` before `OFFSET/FETCH`. Dapper-backed queries automatically emit an +ORDER BY on the mapped key columns when paging is requested with no sort, and fail with +a clear error if the entity has no resolvable keys; EF Core surfaces the provider's own +determinism limits. Configure `DefaultSortField` on every paged endpoint so clients +never have to remember. + +## Worked example + +```http +GET /api/customers?filter=Status:eq:Active&sort=LastName:asc,CreatedDate:desc&pageSize=10&page=2 +``` + +```json +{ + "data": [ { "id": 12, "lastName": "Adams", "createdDate": "2024-02-11T09:30:00Z" }, + { "id": 31, "lastName": "Baker", "createdDate": "2024-03-02T14:00:00Z" } ], + "totalCount": 137, + "page": 2, + "pageSize": 10, + "totalPages": 14, + "hasNextPage": true, + "hasPreviousPage": true +} +``` + +The sort applies to the filtered set, `totalCount` counts that set, and page 2 shows +items 11–20 of that ordering. + +## Common mistakes + +- A `sort` field that does not exist on the model (or is not in `Selectable`/governance + scopes for the DTO in play) → `QueryValidationException` (`FIELD_NOT_FOUND`) / strip + in lenient mode; a typo'd field never silently falls back. +- Sorting a nullable property puts nulls first/last depending on the database; + don't rely on cross-database null ordering for stable pagination. +- Aggregate sort spelling — a function must be one of `sum`, `avg`, `count`, `min`, + `max` — and for grouped queries, only fields in `groupBy` or declared aggregate + aliases may be sorted; other fields fail with `GROUPBY_SORT_INVALID`. + +## Related + +- [Paging](/docs/guides/paging) · [Grouping & Aggregates](/docs/guides/grouping) diff --git a/docs-v4/content/docs/guides/typed-dto-projection.mdx b/docs-v4/content/docs/guides/typed-dto-projection.mdx new file mode 100644 index 0000000..413584b --- /dev/null +++ b/docs-v4/content/docs/guides/typed-dto-projection.mdx @@ -0,0 +1,155 @@ +--- +title: Typed DTO Projection +description: Return stable DTO contracts from dynamic queries — type maps, surface protection, and field mapping. +--- + +# Typed DTO Projection + +Dynamic queries and stable public contracts usually disagree. Entities carry internal +columns, change with migrations, and are awkward to document. Typed DTO projection lets +an endpoint accept dynamic FlexQuery input while returning a fixed response type: + +```http +GET /api/customers/dto?filter=CustomerName:eq:Ada Lovelace&select=Email&sort=Email:asc +``` + +```csharp +public record CustomerResponse +{ + public int Id { get; init; } + + // maps to Customer.FirstName — clients never learn the entity property name + public string CustomerName { get; init; } = string.Empty; + + public string Email { get; init; } = string.Empty; +} +``` + +```csharp +var result = await db.Customers + .FlexQueryAsync(parameters, cancellationToken: ct); +``` + +`FlexQueryAsync` returns `QueryResult`: every pipeline +stage — filter, sort, select, grouping, expansion, paging — runs against the entity +model, and each matching row is materialized through the entity→DTO map. + +## Registering a map + +Maps live in the application-level `FlexQueryMapping` registry. Register them once at +startup through `FlexQueryCore.Configure` (they are automatically consulted by every +later typed execution): + +```csharp +using FlexQuery.NET; + +FlexQueryCore.Configure(options => +{ + options.CreateMap() + .ForMember(d => d.CustomerName, e => e.FirstName); +}); +``` + +Members with no configured mapping map by convention (same name on both sides). +Navigation members map through `ForNavigation`: + +```csharp +// CreateMap — entity first, DTO second +options.CreateMap() + .ForNavigation(d => d.Orders, e => e.Orders); +``` + +`ForMember` accepts constant expressions (`e => "Enterprise"`), member access, and +string-returning computed calls such as `e => e.FullName()` — useful for derived output +fields. A scalar expression that cannot be reduced to a single column must be exposed +as a mapped property or a computed string member; Dapper additionally refuses computed +scalars and tells you to use the EF Core provider in that case. + +Global maps are registered once (startup). Per-request alternatives exist on the +execution options (`CreateMap`/`MapField`) when a DTO is endpoint-specific. + +## The public surface is the wire format + +When a DTO is in play, its type *replaces* the entity as the query surface: + +- `filter`/`sort`/`select`/`groupBy`/`aggregate` fields are resolved against **DTO + members** (`CustomerName`, not `FirstName`) and emitted under DTO names. +- Members with no entity backing — internal flags, EF shadows, `[NotMapped]` helpers — + cannot be referenced by clients at all: they fail with `FIELD_NOT_FOUND` like any + unknown field, even though the underlying entity has them. +- Aliased selection (`select=Email:contact`, `select=Email as contact`) applies on top, + and the response-shape converter emits exactly the selected/aliased fields for rows. +- For navigation-backed DTO members, include/expansion paths must refer to the *DTO* + name as well; the provider rewrites them to the entity navigation + (`TranslateIncludePathsToEntity`). + +This is a security property as much as a convenience: entity internals (SSN-like columns, +flags, audit fields) are invisible to the API contract unless you map them deliberately. + +## Composition example + +A public orders endpoint with an internal model — `include`, expanded branch, projection +all expressed in DTO names: + +```csharp +[HttpGet("dto")] +public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) +{ + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, + opt => opt.AllowedFields = + [ + nameof(CustomerWithOrdersDto.Id), + nameof(CustomerWithOrdersDto.CustomerName), + nameof(CustomerWithOrdersDto.Email), + ], + cancellationToken: cancellationToken); + + return Ok(result); +} +``` + +```http +GET /api/customers/dto?filter=Email:contains:@example.com + &include=Orders&select=Id,CustomerName,Orders(OrderNumber:ref,TotalAmount:amount) +``` + +```json +{ + "data": [ + { + "id": 7, + "customerName": "Ada Lovelace", + "email": "ada@example.com", + "orders": [ { "id": 441, "ref": "ORD-441", "amount": 210.00 } ] + } + ], + "totalCount": 42 +} +``` + +## Grouped queries with DTOs + +A typed response can also receive group rows: as with entity queries, the group keys +and declared aggregate aliases are the addressable fields; the DTO's writable members +must cover the projected fields, otherwise FlexQuery falls back to dynamic grouped rows +rather than failing (Dapper throws a clear `FlexQueryException` naming the field that +the response type cannot represent). + +## Rules of thumb + +- Map names to the **public** language, not the entity language; the DTO *is* the API. +- Keep governance (`AllowedFields`/`SortableFields`) aligned with the DTO surface — the + rules validate the same names clients use. +- Computed `ForMember` expressions execute per row after projection on EF; they are not + filterable, sortable, or groupable. +- One response type per endpoint contract; if two endpoints need different fields of the + same entity, they are two DTOs — the type maps make that cheap. + +## Related + +- [Projection](/docs/guides/projection) · [EF Core provider](/docs/providers/ef-core) · + [Dapper provider](/docs/providers/dapper) diff --git a/docs-v4/content/docs/guides/validation.mdx b/docs-v4/content/docs/guides/validation.mdx new file mode 100644 index 0000000..8096d1f --- /dev/null +++ b/docs-v4/content/docs/guides/validation.mdx @@ -0,0 +1,170 @@ +--- +title: Validation +description: How invalid queries fail — the pipeline, strict mode, and the error model. +--- + +# Validation + +FlexQuery treats every incoming query as untrusted input. Before anything reaches the +database, the parsed options pass a fixed rule pipeline that checks fields, operators, +types, governance lists, expansion paths, and paging-mode conflicts. This page explains +what you get back when something fails — as an exception, as a result object, or as a +stripped query. + +## Where validation runs + +Every execution path validates the same way — query-string, `FlexQueryRequest`, fluent +options, adapter output: + +``` +parse (QueryParseException on bad syntax) + → validate (QueryValidationException on bad semantics) + → translate / execute +``` + +- Parse problems throw **`QueryParseException`** (with `ParameterName`, `Syntax`, + `ReceivedValue`, and position info). +- Semantic problems throw **`QueryValidationException`**, which carries a full + `ValidationResult` in its `Result` property. +- Both derive from `FlexQueryException`, so one catch-all at the ASP.NET layer maps + them to 400-class responses — see [Error Handling](/docs/troubleshooting). + +`GovernanceValidator.ValidateConfiguration` also checks contradictory allow/block list +combinations up-front, and `QueryGovernanceOptions` startup checks surface overlapping +`AllowedFields`/`BlockedFields` style mistakes early. + +## Strict vs lenient + +`StrictFieldValidation` (default **true**) decides what "invalid" means: + +| Mode | Unknown/unauthorized field or operator | Unauthorized include/expand | No client sort supplied | +|---|---|---|---| +| Strict (default) | throws `QueryValidationException` | throws / validation error | inject `DefaultSortField` | +| Lenient (`false`) | silently **stripped from the query** | dropped | inject `DefaultSortField` | + +Lenient mode is a compatibility hatch, not a feature: clients never learn which +predicates were removed, and result sets quietly grow. Default to strict and set +`StrictFieldValidation = false` per request only where you need backwards compatibility. + +## The error model + +```csharp +try +{ + var result = await db.Customers.FlexQueryAsync(parameters, cancellationToken: ct); +} +catch (QueryValidationException ex) +{ + // ex.Result.Errors -> List + var codes = ex.Result.Errors.Select(e => e.Code); +} +``` + +`ValidationResult` exposes `IsValid`, `ToErrorMessage()`, and the `Errors` list. Each +`ValidationError` record has: + +| Member | Meaning | +|---|---| +| `Message` | human-readable explanation (safe to surface to clients) | +| `Code` | machine-readable code from the table below | +| `Field` | offending property path when applicable | + +`QueryValidationException` can be constructed from a single message (code +`VALIDATION_ERROR`) or from a full `ValidationResult` — the provider pipeline always +attaches the full result, so clients can branch on `Code`. + +## What the pipeline checks + +The registered rule set covers — in categories, not one-by-one: + +- **fields exist & are authorized** — filter/sort/select/group/aggregate/having/expansion + fields resolve against the query surface, including navigation-aware checks when the + request runs against a DTO; governance allow/block/role lists are enforced + (`FIELD_NOT_FOUND`, `FIELD_ACCESS_DENIED`, `INCLUDE_ACCESS_DENIED`, + `GOVERNANCE_FIELD_NOT_FOUND`, `NAVIGATION_PROJECTION_REQUIRES_INCLUDE`). +- **operators and types match** — only supported operators per field type, values + convertible (`INVALID_OPERATOR`, `OPERATOR_NOT_ALLOWED`, `TYPE_MISMATCH`). +- **selects are well-formed** — alias validity, duplicate/colliding selections + (`INVALID_ALIAS`, `RESERVED_ALIAS`, `DUPLICATE_ALIAS`, `DUPLICATE_WILDCARD`); nested + select syntax errors surface as `QueryParseException` on the `select` parameter. +- **include/expand discipline** — paths exist, are navigations or collection-typed + (`INCLUDE_PATH_NOT_FOUND`, `EXPAND_PATH_NOT_FOUND`, `NAVIGATION_PROPERTY_REQUIRED`, + `NOT_A_COLLECTION`), no duplicates (`EXPAND_DUPLICATE_PATH`), each expand path has a + matching include (`EXPAND_PATH_NOT_IN_INCLUDE`), no root-prefixed nesting + (`EXPAND_ROOT_PREFIXED_PATH`), sort/take only on collections + (`EXPAND_SORT_ON_REFERENCE`, `EXPAND_TAKE_ON_REFERENCE`), and include/expand are blocked + on grouped queries (`GROUPBY_INCLUDE_CONFLICT`). +- **aggregate/having coherence** — HAVING needs GROUP BY and declared aggregates + (`HAVING_WITHOUT_GROUPBY`, `HAVING_REQUIRES_GROUPBY`, `HAVING_ALIAS_MISMATCH`, + `AGGREGATE_NOT_DECLARED`), grouping/sorting rules hold (`GROUPBY_SORT_INVALID`, + `GROUPBY_PROJECTION_MISMATCH`, `GROUPBY_WILDCARD_NOT_ALLOWED`), aggregate targets are + valid (`INVALID_AGGREGATE_TARGET`, `INVALID_COUNT_TARGET`, + `AGGREGATE_SELECT_WITHOUT_GROUPBY`). +- **keyset integrity** — cursor/sort agreement (`CURSOR_MISMATCH`, `CURSOR_NULL_VALUE`) + and offset-vs-keyset conflicts (`PAGINATION_MODE_CONFLICT`). +- **DTO surface protection** — entity-only members can't be reached through the wire + when a projection type is in play (`DtoSurfaceProtectionRule`). + +## Validating without executing + +For test suites, query-linting, and admin tooling: + +```csharp +using FlexQuery.NET; // validation extension methods + +var options = parameters.ToQueryOptions(); + +ValidationResult result = options.Validate( + typeof(Customer), + new QueryExecutionOptions { AllowedFields = ["Id", "Name", "Status"] }); + +if (!result.IsValid) + return BadRequest(result.Errors); // e.g. report what the client sent wrong +``` + +There is also a `Validate(this IQueryable, QueryOptions)` overload that checks +against a concrete queryable's model, and a `ValidateOrThrow` used internally by the +providers. + +## Handling errors at the HTTP edge + +The library throws; it does **not** invent a wire format. A small action filter (or +middleware) keeps responses consistent: + +```csharp +public sealed class FlexQueryErrorFilter : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + switch (context.Exception) + { + case QueryParseException parse: + context.Result = new BadRequestObjectResult( + new { error = "invalid_query", parameter = parse.ParameterName }); + break; + case QueryValidationException validation: + context.Result = new BadRequestObjectResult( + new { error = "rejected_query", details = validation.Result.Errors }); + break; + case FlexQueryException flex: + context.Result = new BadRequestObjectResult(new { error = flex.Message }); + break; + } + } +} +``` + +Remember that unhandled provider/EF translation failures surface as provider exceptions, +not `FlexQueryException`s. + +## What validation guarantees + +A query that passes validation is not guaranteed to produce sensible business results — +it is guaranteed to contain only fields, operators, paths, and paging modes the server +declared acceptable, and to fail before the database sees anything it might have to +guess about. + +## Related + +- [Security & Governance](/docs/security) — the options the rules enforce +- [Troubleshooting](/docs/troubleshooting) — decoding every rejection diff --git a/docs-v4/content/docs/integrations/ag-grid/index.mdx b/docs-v4/content/docs/integrations/ag-grid/index.mdx new file mode 100644 index 0000000..d1b42eb --- /dev/null +++ b/docs-v4/content/docs/integrations/ag-grid/index.mdx @@ -0,0 +1,106 @@ +--- +title: AG Grid +description: Server-Side Row Model adapter for AG Grid. +section: Integrations +--- + +import { Callout } from '@/components/callout' + +# AG Grid + +AG Grid's Server-Side Row Model (SSRM) sends a structured JSON request — paging window, +filter model, sort model, row-group columns — and expects rows plus a row count back. +`FlexQuery.NET.Adapters.AgGrid` translates that contract onto `QueryOptions` in one +direction and the `QueryResult` back into the SSRM payload in the other, so a grid speaks +directly to your database through the full FlexQuery pipeline. + +## What the adapter maps + +| AG Grid concept | FlexQuery target | +|---|---| +| `startRow` / `endRow` | Paging window | +| `filterModel` (set, number, text, date, join operators) | Filter conditions/groups | +| `sortModel` | Sort nodes | +| `rowGroupCols` + `groupKeys` | `groupBy` + group filters | +| `valueCols` | Aggregates | + +## Convert the request + +```csharp +using FlexQuery.NET.Adapters.AgGrid; +using FlexQuery.NET.Adapters.AgGrid.Models; + +[HttpPost("api/ef/aggrid/customers")] +public async Task GetRows( + [FromBody] AgGridRequest request, + CancellationToken ct) +{ + var options = request.ToQueryOptions(); + + var result = await db.Customers + .FlexQueryAsync(options, cancellationToken: ct); + + return Ok(result.ToAgGridServerSideResponse(request)); +} +``` + +`ToQueryOptions()` maps the entire request model; `ToAgGridServerSideResponse` produces +the SSRM payload (`rowData` + `rowCount`, group rows carrying their child-key metadata so +drill-down works). Overloads accept an explicit `camelCase` flag and +`AgGridResponseFieldOptions` for renaming the group metadata fields (`group`, `field`, +`level`, `leafGroup`, `childCount`, �). + +## Applying onto existing options + +When you have endpoint defaults the grid should not override: + +```csharp +var options = new QueryOptions { /* your defaults */ }; +options.ApplyAgGridRequest(agGridRequest); // merges the grid request in place +``` + +## Parsing raw JSON + +For minimal APIs or controllers that read the body as `JsonElement`: + +```csharp +var options = jsonElement.ToQueryOptions(); +``` + +## Complete worked example + +A row-grouped revenue grid — grouping and aggregates flow from the grid's column config: + +```csharp +[HttpPost("api/ef/aggrid/orders")] +public async Task GetOrderRows( + [FromBody] AgGridRequest request, + CancellationToken ct) +{ + var options = request.ToQueryOptions(); + // e.g. request.rowGroupCols = [Status], request.valueCols = [{ field: Total, agg: sum }] + // -> groupBy=Status, aggregate=sum:Total + + var result = await db.Orders + .FlexQueryAsync(options, cancellationToken: ct); + + return Ok(result.ToAgGridServerSideResponse(request)); +} +``` + +The grid's group drill-down sends the same request shape with `groupKeys` populated; the +adapter turns those into group-key filters, and FlexQuery pages the matching rows. + + + Adapter-produced options flow through the same validation pipeline. A grid column that is + not in AllowedFields fails validation like any other request — whitelist + grid-visible fields explicitly. + + +## Common mistakes + + + SSRM expects the adapter's payload shape (row count at the current level, group rows with + child metadata). Always return via ToAgGridServerSideResponse, not the raw + QueryResult. + diff --git a/docs-v4/content/docs/integrations/aspnetcore/index.mdx b/docs-v4/content/docs/integrations/aspnetcore/index.mdx new file mode 100644 index 0000000..413db5e --- /dev/null +++ b/docs-v4/content/docs/integrations/aspnetcore/index.mdx @@ -0,0 +1,158 @@ +--- +title: ASP.NET Core +description: Controllers, [FieldAccess] security attributes, and JSON options. +section: Integrations +--- + +import { Callout } from '@/components/callout' + +# ASP.NET Core + +`FlexQuery.NET.AspNetCore` binds FlexQuery to the MVC model-binding and filter pipeline. It +adds three things: DI registration helpers, the `[FieldAccess]` attribute for per-endpoint +governance, and the result-shape JSON converter that makes `select` surfaces authoritative +in serialized output. + +## Setup + +```csharp +builder.Services.AddControllers() + .AddFlexQuerySecurity() // [FieldAccess] filter + result-shape JSON converter + .AddFlexQueryJson(); // result-shape JSON converter only +``` + +`AddFlexQuerySecurity` registers the `FieldAccessFilter` (which reads `[FieldAccess]` +attributes) and the `QueryResultShapeConverterFactory`. `AddFlexQueryJson` registers only +the JSON converter. A combined shortcut exists too: + +```csharp +builder.Services.AddFlexQuery(options => +{ + options.CreateMap(); +}); +``` + +This performs `FlexQueryCore.Configure(configure)` (global defaults and global type maps). + +## Endpoint pattern + +```csharp +using FlexQuery.NET; +using FlexQuery.NET.Models; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/customers")] +[FieldAccess(Allowed = ["Id", "FirstName", "Email", "Status"], AllowedIncludes = ["Orders"])] +public sealed class CustomersController(AppDbContext db) : ControllerBase +{ + [HttpGet] + public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) + { + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + + return Ok(result); + } +} +``` + +## FieldAccessAttribute + +Apply on an action (takes priority) or the controller. All properties merge into the +request's execution options before validation: + +| Property | Purpose | +|---|---| +| `Allowed` | Allow-list of field names. | +| `Blocked` | Blocked field names. | +| `Filterable` | Fields clients may filter on. | +| `Sortable` | Fields clients may sort by. | +| `Selectable` | Fields clients may select. | +| `Groupable` | Fields clients may group by. | +| `Aggregatable` | Fields clients may aggregate. | +| `AllowedIncludes` | Navigation paths clients may include or expand. | +| `DefaultSortField` / `DefaultSortDirection` | Default ordering when the client does not sort. | +| `MaxDepth` | Maximum nested field-path depth (`-1` = unset). | + +The filter resolves the attribute with **action over controller** priority, merges each +list with any already-resolved execution options, and stores the result in +`HttpContext.Items`. + +## Reading execution options from HttpContext + +The `[FieldAccess]` filter stores the resolved options on the request; the provider +call itself is driven by the options you pass. The intended pattern is to hand the +attribute's options to `FlexQueryAsync` — the `GetFlexQueryExecutionOptions()` extension +reads them back for exactly that purpose: + +```csharp +var execOptions = httpContext.GetFlexQueryExecutionOptions(); +``` + +Custom middleware, authorization checks, or adapters can inspect or extend the same +object before execution. + +## Complete worked example + +A locked-down public endpoint wiring `[FieldAccess]` into execution explicitly: + +```csharp +[ApiController] +[Route("api/public/customers")] +[FieldAccess( + Allowed = ["Id", "City", "Status"], + Sortable = ["Id", "City"], + AllowedIncludes = [], + DefaultSortField = "Id", + MaxDepth = 2)] +public sealed class PublicCustomersController(AppDbContext db) : ControllerBase +{ + [HttpGet] + public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) + { + var execOptions = HttpContext.GetFlexQueryExecutionOptions(); + + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, + opt => + { + opt.AllowedFields = execOptions.AllowedFields; + opt.SortableFields = execOptions.SortableFields; + opt.AllowedIncludes = execOptions.AllowedIncludes; + opt.DefaultSortField = execOptions.DefaultSortField; + opt.MaxFieldDepth = execOptions.MaxFieldDepth; + }, + cancellationToken: cancellationToken); + + return Ok(result); + } +} +``` + +Requests against this endpoint: `filter=City:eq:Berlin` works; `filter=Email:contains:@` +fails validation; `include=Orders` fails (empty allow-list); `pageSize=500` works but the +JSON surface only ever contains `Id`, `City`, `Status`. + + + Add FlexQuery.NET.OpenApi so Swagger documents the query parameters of + FlexQuery endpoints — see OpenAPI. + + +## Common mistakes + + + Without it, [FieldAccess] attributes are inert decoration — nothing reads + them. The filter registration is what activates per-endpoint governance. + + + + [FieldAccess] governs fields, not page sizes. Set + MaxPageSize via global config or the per-request delegate. + diff --git a/docs-v4/content/docs/integrations/kendo/index.mdx b/docs-v4/content/docs/integrations/kendo/index.mdx new file mode 100644 index 0000000..7d9815e --- /dev/null +++ b/docs-v4/content/docs/integrations/kendo/index.mdx @@ -0,0 +1,128 @@ +--- +title: Kendo UI +description: Kendo UI DataSource request adapter. +section: Integrations +--- + +import { Callout } from '@/components/callout' + +# Kendo UI + +Kendo UI's DataSource posts its state as a JSON request — page, page size, sort descriptors, +and a filter descriptor tree with nested `and`/`or` logic. `FlexQuery.NET.Adapters.Kendo` +maps that request onto `QueryOptions` so a Kendo grid speaks to your database through the +full FlexQuery pipeline. + +## What the adapter maps + +| Kendo concept | FlexQuery target | +|---|---| +| `page` / `pageSize` (or `skip` / `take`) | Paging options | +| `sort` descriptors (`field`, `dir`) | Sort nodes | +| `filter` descriptor tree (`logic`, `filters`) | Filter groups with nested logic | +| `group` descriptors (`field`, `aggregate[]`) | `groupBy` + per-group aggregate declarations | +| `aggregate` descriptors (`field`, `aggregate`) | Aggregates (grand totals / grouped) | + +Nested filter trees translate faithfully — a Kendo filter with `logic: "or"` containing +sub-filters becomes an OR group, recursively. + +## Convert the request + +```csharp +using FlexQuery.NET.Adapters.Kendo; + +[HttpPost("api/kendo/customers")] +public async Task Read( + [FromBody] KendoRequest request, + CancellationToken ct) +{ + var options = request.ToQueryOptions(); + + var result = await db.Customers + .FlexQueryAsync(options, cancellationToken: ct); + + return Ok(new + { + data = result.Data, + total = result.TotalCount ?? result.Data.Count, // Kendo expects `total` + }); +} +``` + +## Applying onto existing options + +Merge the Kendo request into endpoint defaults: + +```csharp +var options = new QueryOptions { /* your defaults */ }; +options.ApplyKendoRequest(kendoRequest); +``` + +## Parsing raw JSON + +For minimal APIs or when the DataSource payload arrives as `JsonElement`: + +```csharp +var options = jsonElement.ToQueryOptions(); +``` + +## Complete worked example + +A server-filtered, server-sorted Kendo grid: + +```javascript +// Client side +$("#grid").kendoGrid({ + dataSource: { + transport: { read: { url: "/api/kendo/customers", type: "POST" } }, + serverPaging: true, + serverSorting: true, + serverFiltering: true, + pageSize: 20, + schema: { data: "data", total: "total" }, + }, + sortable: true, + filterable: true, + pageable: true, +}); +``` + +```csharp +// Server side - the endpoint above +[HttpPost("api/kendo/customers")] +public async Task Read([FromBody] KendoRequest request, CancellationToken ct) +{ + var options = request.ToQueryOptions(); + + var result = await db.Customers + .FlexQueryAsync(options, opt => + opt.AllowedFields = ["Id", "FirstName", "City", "Status"], + ct); + + return Ok(new { data = result.Data, total = result.TotalCount ?? result.Data.Count }); +} +``` + +Client-side filtering in the Kendo filter row produces, for example: + +```json +{ "filter": { "logic": "and", "filters": [ + { "field": "City", "operator": "eq", "value": "Berlin" }, + { "field": "Status", "operator": "neq", "value": "Cancelled" } +] } } +``` + +...which FlexQuery executes as a validated, parameterized `WHERE City = @p0 AND Status <> @p1`. + + + Kendo-produced options pass through the same validation pipeline — grid columns must be + in AllowedFields, and server-side defaults you set before + ApplyKendoRequest survive the merge. + + +## Common mistakes + + + Kendo expects the payload shape { data, total }. Returning the full + FlexQuery envelope breaks the grid's schema binding — project the two fields explicitly. + diff --git a/docs-v4/content/docs/integrations/openapi/index.mdx b/docs-v4/content/docs/integrations/openapi/index.mdx new file mode 100644 index 0000000..a54f03c --- /dev/null +++ b/docs-v4/content/docs/integrations/openapi/index.mdx @@ -0,0 +1,96 @@ +--- +title: OpenAPI +description: Automatic OpenAPI/Swagger documentation for FlexQuery endpoints. +section: Integrations +--- + +import { Callout } from '@/components/callout' + +# OpenAPI + +A dynamic query API is hard to document by hand: every endpoint accepts a shifting set of +query parameters, and the request/response models are FlexQuery types your consumers have +never seen. `FlexQuery.NET.OpenApi` fills the gap — it enriches your OpenAPI document with +descriptions and canonical examples for FlexQuery models and query parameters, so Swagger UI +shows a complete, usable contract without manual annotation. It targets .NET 9 and .NET 10. + +## Setup + +```csharp +builder.Services.AddFlexQueryOpenApi(); + +builder.Services.AddOpenApi(options => +{ + options.AddFlexQuery(); +}); +``` + +Two calls with distinct jobs: + +- **`AddFlexQueryOpenApi()`** registers the schema and operation transformers with the + service collection. +- **`AddFlexQuery()`** on `OpenApiOptions` attaches those transformers to the OpenAPI + document pipeline (`Microsoft.AspNetCore.OpenApi`). + +Then expose the document as usual: + +```csharp +app.MapOpenApi(); +``` + +## What you get + +- **Schema descriptions** for all FlexQuery model types — `FlexQueryRequest`, + `FlexQueryParameters`, `QueryResult`, `FilterGroup`, `FilterCondition`, `SortNode`, + `PagingOptions`, `Aggregate`, `HavingCondition`, `IncludeNode`, `ProjectionMode`, + `LogicOperator`, `AggregateFunction`. +- **Parameter documentation** for `filter`, `select`, `sort`, `page`, `pageSize`, + `includeCount` — with format hints so consumers know what a valid value looks like. +- **Canonical examples** — production-quality, strongly typed examples for + `FlexQueryRequest`, `FlexQueryParameters`, and `QueryResult`. + +Zero further configuration — one registration per service collection and document. + +## Complete worked example + +A documented FlexQuery endpoint: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddFlexQueryOpenApi(); +builder.Services.AddOpenApi(options => options.AddFlexQuery()); + +var app = builder.Build(); +app.MapOpenApi(); +app.MapControllers(); +app.Run(); +``` + +With controllers like: + +```csharp +[HttpGet] +[ProducesResponseType(typeof(QueryResult), 200)] +public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) +{ + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + return Ok(result); +} +``` + +The generated document describes `FlexQueryParameters`' properties with usage text and +embeds a complete example request/response — a consumer can call the endpoint correctly from +Swagger UI alone. + + + The package targets Microsoft.AspNetCore.OpenApi (the built-in .NET 9+ + document pipeline). Swashbuckle-based setups migrate to AddOpenApi/ + MapOpenApi to use it — see the + migration guide. + diff --git a/docs-v4/content/docs/introduction/index.mdx b/docs-v4/content/docs/introduction/index.mdx new file mode 100644 index 0000000..5b0178f --- /dev/null +++ b/docs-v4/content/docs/introduction/index.mdx @@ -0,0 +1,103 @@ +--- +title: Overview +description: What FlexQuery.NET is, why it exists, and how the pieces fit together. +section: Introduction +--- + +import { Callout } from '@/components/callout' + +# FlexQuery.NET + +Every dynamic API eventually reimplements the same query engine: optional filters that +compose, sortable columns, paging metadata, field selection, related-data loading — each +one hand-built, each one a potential injection surface. FlexQuery.NET is that engine, done +once: it transforms query parameters sent by clients into secure, server-side expression +trees that translate to SQL. + + + GET /api/customers?filter=Status:eq:Active&sort=LastName:asc&page=1&pageSize=20&select=Id,FirstName,Email + is handled by a single FlexQueryAsync call. + + +## Why FlexQuery.NET + +- **No OData dependency** — powerful querying without OData's complexity, setup, and tight + coupling. +- **100% server-side** — all operations translate to SQL via expression trees. Nothing is + fetched and filtered in memory; there is zero client evaluation. +- **Security first** — declare allowed and blocked fields per endpoint; every request is + validated against your model and governance rules before any query runs. +- **Multi-format** — the native DSL, FQL (SQL-inspired), and MiniOData syntaxes on the same + endpoint, all parsing to one internal model. +- **Multiple providers** — Entity Framework Core, Dapper, or any `IQueryable` source. +- **Observable** — pipeline events, timing reports, and SQL previews built in. + +## How it works + +1. A client sends query parameters (`filter`, `sort`, `page`, `select`, …) or a JSON + request model. +2. FlexQuery parses them into a `QueryOptions` model using the selected query syntax. +3. Validation checks every field and operator against your entity model and governance + rules — rejected requests never reach the database. +4. The provider (EF Core or Dapper) applies the options as expressions or generated SQL. +5. A `QueryResult` returns data plus paging metadata, aggregates, and cursor tokens. + +The full pipeline is described in [Execution Pipeline](/docs/concepts/pipeline). + +## Package ecosystem + +| Package | Purpose | +|---|---| +| `FlexQuery.NET` | Core query engine — parsing, filtering, sorting, paging, projection, validation | +| `FlexQuery.NET.EntityFrameworkCore` | Async execution, includes, and typed DTO queries for EF Core | +| `FlexQuery.NET.Dapper` | SQL generation and execution for Dapper | +| `FlexQuery.NET.AspNetCore` | ASP.NET Core integration with `[FieldAccess]` security attributes | +| `FlexQuery.NET.Diagnostics` | Execution diagnostics, timing, and observability | +| `FlexQuery.NET.OpenApi` | OpenAPI/Swagger documentation for FlexQuery endpoints | +| `FlexQuery.NET.Adapters.AgGrid` | AG Grid Server-Side Row Model request/response adapter | +| `FlexQuery.NET.Adapters.Kendo` | Kendo UI DataSource request adapter | +| `FlexQuery.NET.Parsers.Fql` | FQL (FlexQuery Language) syntax parser | +| `FlexQuery.NET.Parsers.MiniOData` | Lightweight OData-compatible syntax parser | + +All packages target .NET 6, .NET 8, and .NET 10 (`FlexQuery.NET.OpenApi` targets .NET 9 +and .NET 10). + +## The shape of an endpoint + +Everything below is a complete ASP.NET Core controller — this is genuinely all it takes: + +```csharp +using FlexQuery.NET; +using FlexQuery.NET.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +[ApiController] +[Route("api/customers")] +public sealed class CustomersController(AppDbContext db) : ControllerBase +{ + [HttpGet] + public async Task Get( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) + { + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + + return Ok(result); + } +} +``` + +From there, capability grows by configuration — governance sets, expand, aggregates, +keyset paging — not by writing new endpoint code. + +## Where to next + +- [Installation](/docs/getting-started/installation) — add the packages to your project. +- [First Query](/docs/getting-started/first-query) — build a working endpoint in minutes. +- [Configuration](/docs/concepts/configuration) — global defaults and per-request + overrides. +- [Query Syntax](/docs/concepts/query-syntax) — the three supported query languages. +- [Security & Governance](/docs/security) — locking endpoints down. diff --git a/docs-v4/content/docs/migration/change-matrix.mdx b/docs-v4/content/docs/migration/change-matrix.mdx new file mode 100644 index 0000000..609ca10 --- /dev/null +++ b/docs-v4/content/docs/migration/change-matrix.mdx @@ -0,0 +1,53 @@ +--- +title: v3 → v4 Change Matrix +description: Verified v3.1.1 → v4 changes mapped to their documentation. +section: Resources +--- + +import { Callout } from '@/components/callout' + +# v3.1.1 → v4 Change Matrix + +Every row below was verified against the source diff between the `v3.1.1` release and +the current v4 implementation. + +| Area | v3.1.1 | v4 | Change | Documentation | +|---|---|---|---|---| +| Configuration entry | DI-era registration | Static immutable `FlexQueryCore.Configure()` | Changed | [Configuration](/docs/concepts/configuration) | +| EF Core options | `QueryExecutionOptions.UseNoTracking/UseSplitQuery` | `FlexQueryEfCoreOptions` + `EfCoreQueryOptions` (`bool? UseNoTracking`); `UseSplitQuery` removed | Renamed / Changed | [EF Core](/docs/providers/ef-core) | +| Dapper options | `DapperQueryOptions : BaseQueryOptions` (`Dialect`, `MappingRegistry` public) | `DapperQueryOptions : QueryGovernanceOptions` (`CommandTimeout`, `LoggerFactory`); model config moves to `FlexQueryDapper.Configure` + `ModelBuilder` | Changed | [Dapper](/docs/providers/dapper) | +| Dapper dialect | Manual `ISqlDialectResolver` | Auto-detection from `DbConnection` | Behavior changed | [Dapper](/docs/providers/dapper) | +| Dapper mapping | `MappingRegistry` + public conventions | Convention-first `ModelBuilder` + `IEntityTypeConfiguration` (conventions internal) | Replaced | [Dapper](/docs/providers/dapper) | +| Typed DTO results | Not available | `FlexQueryAsync` (4 overloads per provider) + `CreateMap`/`ForMember`/`ForNavigation` | Added | [Typed DTO Projection](/docs/guides/typed-dto-projection) | +| Filtered includes | `FilteredIncludes` / `ApplyFilteredIncludes` | `expand` trees with filter/sort/take (`QueryOptions.Expand` / `ApplyExpand`) | Replaced | [Expand](/docs/guides/expand) | +| Keyset pagination | Not available | `useKeysetPagination`, `cursor`, `NextCursorToken`, `SeekAfter` | Added | [Keyset Pagination](/docs/guides/keyset-pagination) | +| Fluent API | `FilterBuilder` only | `Query.Create()` + `FilterGroupBuilder` grammar + sort/expand/aggregate builders | Changed | [Fluent API](/docs/guides/fluent-api) | +| Sort model | `SortNode` (in `SortOption.cs`) | `SortNode` (in `Paging/SortNode.cs`) — type name unchanged | No API change | — | +| Select model | `List Select` | `List` (aliases + nested trees) | Changed | [Projection](/docs/guides/projection) | +| Aggregates | `AggregateModel` in `select`, string functions | Dedicated `aggregate` parameter, `AggregateFunction` enum, PascalCase aliases | Changed | [Grouping & Aggregates](/docs/guides/grouping) | +| HAVING | Alias-integrity validation | `HavingNode` expression tree, declared-aggregate enforcement | Behavior changed | [Grouping & Aggregates](/docs/guides/grouping) | +| DSL logic operators | Conditions joined with `,`; symbolic `&` / `\|` accepted | Conditions joined with `&` / `\|` / `AND` / `OR`; a comma is now part of the value; `;` in a filter is rejected | **Behavior changed** | [Filtering](/docs/guides/filtering) | +| Query syntaxes | DSL, JSON, Indexed, Generic, JQL, MiniOData, AutoDetect | DSL, FQL, MiniOData | Removed | [Query Syntax](/docs/concepts/query-syntax) | +| FQL parser package | `FlexQuery.NET.Parsers.Jql`, `QuerySyntax.Jql` | `FlexQuery.NET.Parsers.Fql`, `QuerySyntax.Fql` | Renamed | [Query Syntax](/docs/concepts/query-syntax) | +| Parser registration | DI services (`ServiceCollectionExtensions`, `MiniODataFeature`) | Static `Fql.Register()` / `MiniOData.Register()` | Changed | [Query Syntax](/docs/concepts/query-syntax) | +| Validation | Rule pipeline (`QueryValidator` rules) | Same pipeline extended — expand/HAVING/grouping/keyset rules + `ValidationResult`/`ValidationError` model | Extended | [Validation](/docs/guides/validation) | +| Field exceptions | `InvalidFilterFieldException` / `InvalidSortFieldException` | Unified hierarchy under `FlexQueryException` | Replaced | [Validation](/docs/guides/validation) | +| Case-insensitive filtering | `CaseInsensitive` / `CaseInsensitiveFields = true` | Removed | Removed | [Migration](/docs/migration/v3-to-v4) | +| Governance sets | On monolithic `BaseQueryOptions` | Same members on `QueryGovernanceOptions` (class split) | Changed | [Security](/docs/security) | +| `[FieldAccess]` | Allowed/Blocked/Filterable/Sortable/Selectable/Groupable/Aggregatable | + `AllowedIncludes`; class sealed | Changed | [ASP.NET Core](/docs/integrations/aspnetcore) | +| ASP.NET Core DI | v3-era `ServiceCollectionExtensions` + `QueryableAspNetCoreExtensions` | `AddFlexQuerySecurity`, `AddFlexQueryJson`, `AddFlexQuery` (global config) | Changed | [ASP.NET Core](/docs/integrations/aspnetcore) | +| OpenAPI | Not available | `FlexQuery.NET.OpenApi` (`AddFlexQueryOpenApi`, `AddFlexQuery`) | Added | [OpenAPI](/docs/integrations/openapi) | +| Diagnostics listener | `Models.IFlexQueryExecutionListener` (ValueTask ×4) | `Execution.IFlexQueryExecutionListener` — members unchanged | Namespace move | [Diagnostics](/docs/diagnostics) | +| Debug output | `DebugResult` | `QueryDebugInfo` via `ToFlexQueryDebug` | Replaced | [Diagnostics](/docs/diagnostics) | +| Result model | `QueryResult` base members | + `NextCursorToken`, `ResultShape` | Added | [Query Result](/docs/concepts/query-result) | +| Paging validation | Loose | Malformed `page`/`pageSize`/`distinct` throw; out-of-range clamps 1–1000 | Behavior changed | [Paging](/docs/guides/paging) | +| Global options | Mutable | Immutable after first call | Behavior changed | [Configuration](/docs/concepts/configuration) | +| Cancellation | Not available | `CancellationToken` across async overloads | Added | [EF Core](/docs/providers/ef-core), [Dapper](/docs/providers/dapper) | +| Adapter JSON entry | `FromAgGridJson(string)` / `FromKendoJson(string)` | `JsonElement.ToQueryOptions()`; parser/converter classes removed | Changed | [AG Grid](/docs/integrations/ag-grid), [Kendo](/docs/integrations/kendo) | +| Target frameworks | net6.0/net7.0/net8.0 era | net6.0/net8.0/net10.0 (OpenApi: net9.0/net10.0) | Changed | [Installation](/docs/getting-started/installation) | + + + Internal refactors (namespace reorganizations, internalized types, test reorganization) are + intentionally excluded — they do not affect the public developer experience. Public types + that became internal are listed in the migration guide. + diff --git a/docs-v4/content/docs/migration/v3-to-v4.mdx b/docs-v4/content/docs/migration/v3-to-v4.mdx new file mode 100644 index 0000000..524bdfc --- /dev/null +++ b/docs-v4/content/docs/migration/v3-to-v4.mdx @@ -0,0 +1,247 @@ +--- +title: Migrate from v3 +description: Meaningful changes between v3.1.1 and v4, and how to migrate. +section: Resources +--- + +import { Callout } from '@/components/callout' + +# Migrate from v3.1.1 to v4 + +This document is based on a code-level comparison of the `v3.1.1` release against +the current v4 implementation. It lists only verified, developer-affecting +changes. For a compact mapping of areas to documentation, see the +[Change Matrix](/docs/migration/change-matrix). + + + Package renames, restructured option classes, and removed legacy syntaxes require code + changes. Most are mechanical; behavioral changes are listed separately below. + + +## What changed at a glance + +- **Configuration model rebuilt** — DI registration replaced by immutable static facades. +- **Typed DTO projection added** — `FlexQueryAsync` with mapping. +- **Expand added** — replaces `FilteredIncludes` with deep, filtered, sorted trees. +- **Keyset pagination added** — `cursor` + `NextCursorToken` + `SeekAfter`. +- **Aggregates reworked** — dedicated `aggregate` parameter, typed enum, HAVING tree. +- **FQL parser replaces JQL** — package, enum, and exception renames. +- **Validation extended** — the v3 rule pipeline gains expand/having/sort coverage. +- **OpenAPI package added.** +- **Legacy syntaxes removed** — JSON, Indexed, and Generic query syntaxes are gone. + +## New features + +| Feature | v3.1.1 equivalent | Where to read | +|---|---|---| +| Typed DTO `FlexQueryAsync` (4 EF + 4 Dapper overloads) | none | [Typed DTO Projection](/docs/guides/typed-dto-projection) | +| Mapping (`CreateMap`, `ForMember`, `ForNavigation`, `FlexQueryMapping` registry) | `MapField` only | [Typed DTO Projection](/docs/guides/typed-dto-projection) | +| `expand` trees (filter/sort/take per branch) | `FilteredIncludes` | [Expand](/docs/guides/expand) | +| Keyset pagination (`useKeysetPagination`, `cursor`, `NextCursorToken`, `SeekAfter`) | none | [Keyset Pagination](/docs/guides/keyset-pagination) | +| `Query.Create()` fluent builder (with `FilterGroupBuilder`) | `FilterBuilder` only | [Fluent API](/docs/guides/fluent-api) | +| `ResultShape` output surface + JSON converter | none | [Query Result](/docs/concepts/query-result) | +| Governance extensions (`[FieldAccess(AllowedIncludes)]`, options class split) | governance sets on `BaseQueryOptions` | [Security](/docs/security) | +| `CancellationToken` on all async overloads | none | [EF Core](/docs/providers/ef-core) | +| `FlexQuery.NET.OpenApi` package | none | [OpenAPI](/docs/integrations/openapi) | +| Dapper `ModelBuilder` + `IEntityTypeConfiguration` | `MappingRegistry` | [Dapper](/docs/providers/dapper) | +| Dapper SQL execution logging with DECLARE scripts | none | [Dapper](/docs/providers/dapper) | +| DSL `AND`/`OR` keywords | symbolic `&` / `\|` only | [Query Syntax](/docs/concepts/query-syntax) | +| `select` aliases (`field:alias`, `field as alias`) | none | [Projection](/docs/guides/projection) | + +## Renamed + +| v3.1.1 | v4 | Migration | +|---|---|---| +| Package `FlexQuery.NET.Parsers.Jql` | `FlexQuery.NET.Parsers.Fql` | Update package reference. | +| `QuerySyntax.Jql` | `QuerySyntax.Fql` | Find/replace. | +| `JqlParseException : Exception` | `FqlParseException : FlexQueryException` | Update catch blocks. | +| `QueryOptions.FilteredIncludes` | `QueryOptions.Expand` | Find/replace; see Expand page for new syntax. | +| `ApplyFilteredIncludes()` | `ApplyExpand()` | Find/replace. | +| `AggregateModel` (string function) | `Aggregate` (typed `AggregateFunction`) | Update construction sites. | +| `HavingCondition` | `HavingNode` tree (`HavingLogicalNode`/`HavingConditionNode`/`HavingGroupNode`) | Update construction sites. | +| `DebugResult` | `QueryDebugInfo` | Find/replace. | +| `Models.IFlexQueryExecutionListener` | `Execution.IFlexQueryExecutionListener` | Update using directives (members unchanged). | +| `Models.QueryContext` | `Execution.QueryContext` (now sealed) | Update using directives. | +| `Models.BaseQueryOptions` | split into `Options.BaseQueryOptions` + `Options.QueryGovernanceOptions` | Adjust base-class references. | + +**Note**: `SortOption.cs` → `SortNode` was a *file* rename only — the type was already named +`SortNode` at v3.1.1. No code change is required for it. + +## Removed + +| Removed | Replacement | +|---|---| +| JSON / Indexed / Generic query syntaxes (`JsonQueryParser`, `AutoDetect`) | Native DSL, FQL, or MiniOData | +| `CaseInsensitive` / `CaseInsensitiveFields` options | — (comparisons follow provider semantics) | +| Parser DI registration (`ServiceCollectionExtensions` in parser packages, `MiniODataFeature`) | Static `Fql.Register()` / `MiniOData.Register()` | +| Deprecated `QueryOptions` members: `Skip`, `Top`, `EnableCache`, `Items`, `Ast` | `PagingOptions`, per-call options | +| `InvalidFilterFieldException` / `InvalidSortFieldException` | `QueryValidationException` with structured errors | +| Manual Dapper `Dialect` config (`ISqlDialectResolver`, `DefaultSqlDialectResolver`) | Auto-detection from the `DbConnection` | +| Dapper `MappingRegistry`/`IMappingRegistry`/`IEntityMapping`/`JoinInfo` | `ModelBuilder` + `IEntityTypeConfiguration` | +| Dapper conventions (`IEntityConvention`, `IForeignKeyConvention`, `IRelationshipConvention`, `Default*`) | Convention-first defaults (now internal) | +| `QueryableAspNetCoreExtensions.FlexQueryAsync` | Provider `FlexQueryAsync` + `[FieldAccess]` filter | +| `FromAgGridJson(string)` / `FromKendoJson(string)` | `JsonElement.ToQueryOptions()` | +| `AgGridQueryOptionsParser` / `AgGridResponseConverter` / `KendoQueryOptionsParser` | `ToQueryOptions()` / `ToAgGridServerSideResponse()` extensions | +| `UseSplitQuery` option | Split-query include hydration is now internal behavior | +| Public caches (`ExpressionCache`, `ParserCache`, `ProjectionExpressionCache`) | Internal caching (`FlexQueryCacheSettings` remains public) | +| Public helpers (`ExpressionBuilder`, `QueryBuilder`, `ProjectionOptimizer`, `GovernanceValidator`, `DynamicTypeBuilder`, `SelectTreeBuilder`, `ExpressionPrinter`, `ExpressionTreeVisualizer`, `ProjectionMetadata*`) | Not replaced — internal implementation detail | +| `FlexQueryParameters.RawParameters` (public) | Internal — use model binding | + +## Changed + +### Configuration and registration + +```csharp +// v3.1.1 - DI-era registration +services.AddFlexQueryDapper(...); +services.AddFlexQueryMiniOData(...); + +// v4 - static facades, immutable after first use +FlexQueryCore.Configure(options => { ... }); +FlexQueryDapper.Configure(options => { ... }); +FlexQueryEFCore.Configure(options => options.UseNoTracking = true); +MiniOData.Register(); +``` + +Calling any `Configure` after a query has executed throws `InvalidOperationException`. + +### No-tracking + +```csharp +// v3.1.1 - QueryExecutionOptions.UseNoTracking = true (default), UseSplitQuery +// v4 - provider facade or per-call: +FlexQueryEFCore.Configure(options => options.UseNoTracking = true); // global +opt.UseNoTracking = false; // per call (EfCoreQueryOptions) +``` + +### DSL logical operators + +```http +# v3.1.1 - symbolic only +filter=Status:eq:Active & Age:gte:18 + +# v4 - keywords AND symbolic both accepted +filter=Status:eq:Active AND Age:gte:18 +filter=Status:eq:Active & Age:gte:18 +``` + +The symbolic forms still work — this is an additive change. New in v4: `AND`/`OR` are +reserved and cannot appear as unquoted values (`name:eq:"AND"` is required). + + + v3 split combined filter conditions on ,. In v4 a comma after the value is + part of the value: Name:eq:Ann,Salary:gt:1000 now matches a + Name literally equal to "Ann,Salary:gt:1000", not two conditions + (and ; is rejected outright). Rewrite multi-condition filters to join with + & / | / AND / OR. This is the one + silent grammar change in the migration — grep stored/shared filter strings. + + +### Aggregate syntax + +```http +# v3.1.1 - aggregates inside select +select=Status,sum(Total),count(Id) + +# v4 - dedicated aggregate parameter +select=Status&aggregate=sum:Total,count:Id +``` + +Aliases are PascalCase by default (`SumTotal`); explicit aliasing: +`aggregate=sum:Total:totalSales`. + +### HAVING + +Every aggregate referenced in `having` must be declared in `aggregate` (v3.1.1's +alias-integrity rule is replaced by declared-aggregate enforcement). `having` without +`groupBy` is rejected. + +### Paging validation + +```http +# v3.1.1 - malformed page values were loosely handled +# v4 - parse throws: +page=abc → QueryParseException: '...' is not a valid page number. Page must be a positive integer. +pageSize=-5 → QueryParseException: '...' is not a valid page size. PageSize must be a positive integer. +distinct=x → QueryParseException: '...' is not a valid distinct value. Distinct must be 'true' or 'false'. +``` + +Out-of-range values (e.g. `pageSize=99999`) are clamped to `MaxPageSize` instead of erroring. + +### Exceptions + +```csharp +// v3.1.1 +catch (InvalidFilterFieldException ex) { ... } +catch (InvalidSortFieldException ex) { ... } + +// v4 - unified hierarchy rooted at FlexQueryException +catch (QueryValidationException ex) { ... } // field access violations +catch (QueryParseException ex) { ... } // malformed parameters +catch (FlexQueryException ex) { ... } // safety net for all FlexQuery errors +``` + +### Dapper model definition + +```csharp +// v3.1.1 - MappingRegistry +var registry = new MappingRegistry(); +registry.Register(...); + +// v4 - ModelBuilder with EF-style configuration +FlexQueryDapper.Configure(options => +{ + options.Model.Entity() + .ToTable("Customers") + .HasKey(c => c.Id) + .HasMany(c => c.Orders) + .HasForeignKey("CustomerId"); +}); +``` + +The dialect is auto-detected from the connection; `DapperQueryOptions` now derives from +`QueryGovernanceOptions`. + +### Provider overload shapes + +- EF Core: `FlexQueryAsync` signatures now end with `CancellationToken`; four typed + `FlexQueryAsync` overloads were added. +- Dapper: the five dynamic overloads became three (`FlexQueryParameters`, + `IDictionary`, `QueryOptions`) plus four typed overloads. + +## Provider behavior changes + +- **EF Core**: include hydration is composed as EF Core filtered includes (the + `UseSplitQuery` toggle is gone; the provider decides the SQL shape); expand branches + support per-branch filter/sort/take; grouped queries execute through a dedicated + grouped executor. +- **Dapper**: dialect auto-detection; DTO-aware SQL generation with type-map field + rewrites; include-only joins excluded from the count query; SQL execution logging. + +## Security / governance changes + +- All governance members keep their names but move to `QueryGovernanceOptions`. +- `[FieldAccess]` gains `AllowedIncludes` (and the class/filter become sealed). +- Expand paths are governed by `AllowedIncludes`. + +## Integration changes + +- **AG Grid / Kendo**: `From*Json(string)` replaced by `JsonElement.ToQueryOptions()`; + standalone parser/converter classes removed in favor of extension methods. +- **OpenAPI**: new package for `Microsoft.AspNetCore.OpenApi` (.NET 9/10) — Swashbuckle-era + guidance is obsolete. + +## Migration steps + +1. Update package references (rename `Parsers.Jql` → `Parsers.Fql`; add `OpenApi` if used). +2. Replace DI registration of parsers/providers with `Fql.Register()`, + `MiniOData.Register()`, `FlexQueryEFCore.Configure()`, `FlexQueryDapper.Configure()`. +3. Replace `FilteredIncludes` usage with `expand` syntax. +4. For Dapper: define the entity model via `options.Model` (tables, keys, relationships). +5. Move aggregates out of `select` into `aggregate`; verify `having` references declared + aggregates. +6. Replace removed exception types with `QueryValidationException` handling. +7. Remove `CaseInsensitive` configuration and JSON/Indexed/Generic syntax usage. +8. Replace `FromAgGridJson`/`FromKendoJson` with the `JsonElement` overloads. +9. Re-run test suites — paging parameter validation is stricter (malformed values now + throw) and HAVING enforcement is stricter. diff --git a/docs-v4/content/docs/providers/dapper/index.mdx b/docs-v4/content/docs/providers/dapper/index.mdx new file mode 100644 index 0000000..767dfd7 --- /dev/null +++ b/docs-v4/content/docs/providers/dapper/index.mdx @@ -0,0 +1,187 @@ +--- +title: Dapper Provider +description: Direct SQL generation and split-query hydration for Dapper-backed APIs. +--- + +import { Callout } from '@/components/callout' + +# Dapper Provider + +`FlexQuery.NET.Dapper` is the SQL-first provider: FlexQuery generates the SQL itself +(dialect-aware, fully parameterized) and executes it through Dapper on a `DbConnection`. +The same wire grammar and options model from the other pages apply; the differences are +in what you must configure — the mapping metadata and, implicitly, the dialect you get. + +## Setup + +```bash +dotnet add package FlexQuery.NET.Dapper +``` + +`using FlexQuery.NET.Dapper;` + +Because there is no DbContext to inspect, Dapper needs a model describing your tables, +columns, and relationships. Conventions cover the common case (table = class name with an +optional pluralized variant if it resolves, `{Entity}Id` foreign keys); attributes and the +builder model cover the rest. + +```csharp +using FlexQuery.NET.Dapper; + +// app startup — configure the model and defaults once +FlexQueryDapper.Configure(cfg => +{ + cfg.Model.Entity() + .ToTable("Customers") + .HasKey(c => c.Id) + .HasMany(c => c.Orders) + .HasForeignKey("CustomerId"); + + cfg.Model.Entity() + .ToTable("Orders") + .HasKey(o => o.Id) + .HasMany(o => o.OrderItems) + .HasForeignKey("OrderId"); + + cfg.Model.Entity() + .ToTable("Products") + .HasKey(p => p.Id) + .Property(p => p.SKU).HasColumnName("product_sku"); +}); +``` + +Equivalent attribute style on the entities themselves (no configure call required): +`[Table("Customers")]` and `[Column("product_sku")]` (the annotations Dapper conventions +understand) — plus the convention heuristics (`Id`-style keys, `{Principal}Id` foreign +keys) covering the rest. + +## The endpoint + +`FlexQueryAsync` extends any `DbConnection`: + +```csharp +using Dapper; // you add it yourself — required +using FlexQuery.NET.Dapper; + +[HttpGet] +public async Task Get( + [FromQuery] FlexQueryParameters parameters, + [FromServices] IDbConnectionProvider connections, + CancellationToken cancellationToken) +{ + await using var connection = connections.Open(); + + var result = await connection.FlexQueryAsync( + parameters, + opt => + { + opt.AllowedFields = ["Id", "FirstName", "LastName", "Email", "City", "Status"]; + opt.MaxPageSize = 200; + opt.DefaultSortField = "Id"; + }, + cancellationToken: cancellationToken); + + return Ok(result); +} +``` + +Notes on the execution behavior visible from here: + +**Connections:** FlexQuery opens a closed `DbConnection` for you (it will not close it +again, so `await using` scoping as shown above is the clean pattern). Command text, +parameters, and results are plain Dapper — the result shape mirrors EF Core's with +dynamic rows. + +**The receiver is only `DbConnection`** — that's what dialect detection needs (a concrete +provider type). + +## Supported providers + +The dialect is resolved from the connection type at runtime — there is no manual dialect +switch: + +| Connection | Paging | Quoting | +|---|---|---| +| SQL Server | `OFFSET n ROWS FETCH NEXT m ROWS ONLY`, `TOP` | `[name]` | +| PostgreSQL | `LIMIT m OFFSET n` | `"name"` | +| SQLite | `LIMIT m OFFSET n` | `"name"` | +| MySQL / MariaDB | `LIMIT m OFFSET n` | backticks | +| Oracle | `OFFSET n ROWS FETCH NEXT m ROWS ONLY` | `"NAME"` | + +Text comparisons (`contains`, `startswith`, `endswith`, `like`) are emitted as the +dialect's `LIKE` with parameterized `%` patterns; effective case-sensitivity follows the +database collation. + +An unsupported connection type throws `NotSupportedException` at execution. + +## Query execution model + +- **Single entity queries** are generated as one joined select: projection columns from + your `select`/type-map surface, `WHERE` from `filter` (case-insensitive contains/ + `IN`/`BETWEEN`/collection checks via `EXISTS`), `GROUP BY`/`HAVING`/ORDER BY from the + respective parameters, and dialect-correct paging. +- **Includes/expand** run as additional (split, not joined) queries per level — + `SELECT ... WHERE CustomerId IN (p0…)` for the keys on the current page — so a page of + 20 customers costs exactly 21 queries no matter how many orders exist, instead of one + giant cartesian join. `take=…` on an expand branch is implemented with + `ROW_NUMBER() OVER PARTITION` in the child query, and the per-branch `filter`/sort + fold into the child `WHERE`/`ORDER BY`. +- **Counting**: `totalCount` is a separate `SELECT COUNT...` on the un-paged filtered + query when `includeCount` requests it; grouped/distinct queries get their post-shaping + count via the same mechanism. + +## Mapping dynamic rows to your model + +`FlexQueryAsync(...)` yields `QueryResult`: each row is a dynamic object whose +fields are the requested columns — filter on `Status` but `select=Id,Email`, and rows +just expose `id`/`email`, under the alias if one was given. With no explicit `select` +the full entity surface is projected. + +Typed responses take a registered destination type — same pattern as EF: + +```csharp +var result = await connection.FlexQueryAsync( + parameters, cancellationToken: cancellationToken); +``` + +Column-to-DTO property names follow the registered map (`ForMember`, `ForNavigation`) or +exact-name convention; `ResultShape` (the effective output field list described by an +explicit `select`) drives the JSON envelope, and DTO property types coerce values +leniently (e.g. `int` ↔ `long`, `bool` from `0`/`1`, dates from strings). Keep the model +configuration covering every entity reachable by `include`, since column mapping and +child-key placement derive from it. + +## Governance, security, keyset + +All execution options (`AllowedFields`, `BlockedFields`, `AllowedIncludes`, +`SortableFields`, role-based field access, `StrictFieldValidation`, `MaxPageSize`, +`DefaultSortField`) work exactly as documented for the EF Core provider — validation +happens before SQL generation, and rejection never produces a partially built command. + +Keyset cursors are built server-side; with Dapper the seek predicate merges into the root +query itself — no offset counting at all. + +## Observability + +```csharp +var result = await connection.FlexQueryAsync( + parameters, + cfg => { cfg.LoggerFactory = loggerFactory; }, + cancellationToken: cancellationToken); +``` + +Setting `LoggerFactory` logs every executed command (`Executing Dapper query` at +Information level, category `"FlexQuery.NET.Dapper"`) with dialect-formatted SQL, +parameter types and values — including split include/expand children, counts, and +grand totals. The `Listener` (`IFlexQueryExecutionListener` on the options) exposes them +programmatically. + + + All provider overloads are async. Synchronous Dapper.Query-style execution + is not part of this package. + + +## Related + +- [EF Core Provider](/docs/providers/ef-core) — the expression-tree engine +- [Include](/docs/guides/include) · [Expand](/docs/guides/expand) diff --git a/docs-v4/content/docs/providers/ef-core/index.mdx b/docs-v4/content/docs/providers/ef-core/index.mdx new file mode 100644 index 0000000..ed68507 --- /dev/null +++ b/docs-v4/content/docs/providers/ef-core/index.mdx @@ -0,0 +1,189 @@ +--- +title: Entity Framework Core +description: FlexQueryAsync for EF Core - execution, includes, no-tracking, and SQL preview. +section: Providers +--- + +import { Callout } from '@/components/callout' + +# Entity Framework Core + +`FlexQuery.NET.EntityFrameworkCore` executes FlexQuery pipelines against `IQueryable` +sources with full SQL translation. The package has one job: take the validated +`QueryOptions` and compose them into EF Core expression trees — filters, ordering, keyset +paging, filtered includes, projections — so the database does all the work. + +## Setup + +```csharp +FlexQueryEFCore.Configure(options => +{ + options.UseNoTracking = true; +}); +``` + +`FlexQueryEFCore.Setup()` (no delegate) only registers the EF Core-specific operator +handlers (such as `like`). `UseNoTracking` defaults FlexQuery's execution to no-tracking; +configuration becomes immutable after the first call. + +## Execution methods + +All methods live on `QueryableEfCoreExtensions` in namespace +`FlexQuery.NET.EntityFrameworkCore`. Every overload accepts a `CancellationToken`. + +### Dynamic results + +```csharp +// From FlexQueryParameters (query-string bound) - the common endpoint pattern +var result = await db.Customers + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + +// From FlexQueryParameters with per-call options +var result = await db.Customers + .FlexQueryAsync(parameters, opt => opt.MaxPageSize = 100, cancellationToken); + +// From pre-parsed QueryOptions (adapter scenarios) +var result = await db.Customers + .FlexQueryAsync(queryOptions, opt => { }, cancellationToken); +``` + +### Typed DTO results + +The two-type-generic overloads materialize directly into your DTOs: + +```csharp +var result = await db.Customers + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + +// With a per-query map +var result = await db.Customers + .FlexQueryAsync(parameters, opt => + opt.CreateMap() + .ForMember(dto => dto.CustomerName, entity => entity.FirstName), + cancellationToken); +``` + +## No-tracking behavior + +FlexQuery defaults to no-tracking execution — results serialize without inverse-navigation +fixup cycles, and read-only endpoints avoid the change-tracker cost. Override per call when +you need tracked entities: + +```csharp +var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, opt => opt.UseNoTracking = false, + cancellationToken: cancellationToken); +``` + + + With tracked queries, EF populates inverse navigations (Order.Customer) via relationship + fixup. Configure ReferenceHandler.IgnoreCycles in the host JSON options so + back-references serialize as null instead of throwing. The sample application + demonstrates this with a tracked-query endpoint. + + +## Includes + +`ApplyExpand` composes the include tree into EF Core `Include`/`ThenInclude` chains, each +optionally filtered and windowed: + +```csharp +// hand-built pipeline: compose includes, then materialize yourself +var queryable = db.Customers.ApplyExpand(parameters.ToQueryOptions()); +var rows = await queryable.ToListAsync(cancellationToken); +``` + +Behavior details: + +- The include chain rides in the same query as the root select — EF's filtered-include + machinery turns each `filter`/`sort`/`take` inside an expand block into SQL + `WHERE`/`ORDER BY`/windowed subselects, so related data trims **server-side** (no + full-collection load followed by memory trimming). +- Navigation-projection selects (`select=Orders.Total...`) also pull the corresponding + navigation into the include tree automatically — validation rejects the projection + when that path is not authorized by `include`. +- Most endpoints never call `ApplyExpand` directly: `FlexQueryAsync` applies the include + pipeline from the request's `include`/`expand` options on its way to execution. + +## Grouped queries + +`groupBy`/`aggregate`/`having` run through the grouped-query executor: the grouped +`IQueryable` is projected into a dynamic row type (group keys + aggregate properties), +`having` prunes groups, and paging/ORDER BY apply over the grouped set (with Dapper +dialects emitting `NULLS LAST`-style ordering where needed). Each group returns as one +`Data` row; separate count queries keep `totalCount` (source rows) and `resultCount` +(groups) accurate even with paging on. For ungrouped aggregates, results arrive in +`QueryResult.Aggregates`; with a `groupBy`, aggregate values stay per-group inside +`Data`. + +## SQL preview and projection explain + +Two inspection methods help during development and debugging: + +```csharp +string sql = query.ToSqlPreview(); // generated SQL without executing +var plan = query.ExplainProjection(options); // projection plan explanation +``` + +`ToSqlPreview` uses EF Core's `ToQueryString()` under the hood and works after dynamic +projections are applied. `ExplainProjection` returns a human-readable plan of selected +fields, navigation usage, and optimization notes. + +## Complete worked example + +A full-featured endpoint combining the capabilities: + +```csharp +[ApiController] +[Route("api/ef/customers")] +public sealed class EfCustomersController(AppDbContext db) : ControllerBase +{ + [HttpGet] + public async Task GetCustomers( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) + { + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, opt => + { + opt.AllowedFields = ["Id", "FirstName", "LastName", "Email", "Status"]; + opt.AllowedIncludes = ["Orders", "Orders.OrderItems"]; + opt.MaxPageSize = 100; + }, cancellationToken); + + return Ok(result); + } + + [HttpGet("dto")] + public async Task GetCustomersDto( + [FromQuery] FlexQueryParameters parameters, + CancellationToken cancellationToken) + { + var result = await db.Customers + .AsNoTracking() + .FlexQueryAsync(parameters, cancellationToken: cancellationToken); + + return Ok(result); + } +} +``` + +```http +GET /api/ef/customers?filter=Status:eq:Active&expand=Orders(filter=Status:eq:Delivered; take=3)&pageSize=10 +GET /api/ef/customers/dto?select=customerName,orders(id,total) +``` + +## Common mistakes + + + FlexQueryAsync already applies the include pipeline from + options.Expand. Call ApplyExpand separately only when composing + a queryable by hand before materializing yourself. + + + + Without UseNoTracking (global) or AsNoTracking(), every result + entity enters the change tracker — memory and time for data you will only serialize. + diff --git a/docs-v4/content/docs/recipes/index.mdx b/docs-v4/content/docs/recipes/index.mdx new file mode 100644 index 0000000..2a7b946 --- /dev/null +++ b/docs-v4/content/docs/recipes/index.mdx @@ -0,0 +1,164 @@ +--- +title: Recipes +description: Common real-world patterns with FlexQuery.NET. +section: Resources +--- + +import { Callout } from '@/components/callout' + +# Recipes + +Practical patterns assembled from the building blocks in the guides. Each recipe states the +problem, the approach, and the working code. + +## Cursor-driven infinite scroll + +**Problem**: a feed or list that clients scroll through indefinitely; offset pages get +slower and rows shift between requests. + +**Approach**: keyset pagination with a deterministic sort (unique trailing `Id`): + +```http +GET /api/feed?useKeysetPagination=true&pageSize=30&sort=CreatedAt:desc,Id:desc +``` + +```csharp +var result = await db.Posts.FlexQueryAsync(parameters, cancellationToken: cancellationToken); + +return Ok(new +{ + items = result.Data, + nextCursor = result.NextCursorToken, // null = end of feed +}); +``` + +The client appends `cursor=` to the next request. + +## Role-based field visibility + +**Problem**: admins see more fields than support staff on the same endpoint. + +**Approach**: role-mapped field sets resolved from the principal: + +```csharp +var result = await db.Employees.FlexQueryAsync(parameters, opt => +{ + opt.RoleAllowedFields = new() + { + ["admin"] = ["Id", "Name", "Email", "Salary"], + ["support"] = ["Id", "Name", "Email"], + }; + opt.CurrentRole = user.IsInRole("admin") ? "admin" : "support"; +}, cancellationToken); +``` + +## Faceted dashboard with AG Grid + +**Problem**: an analytics grid where users group by a column and see sums/counts per group, +with server-side paging. + +**Approach**: AG Grid SSRM's row-group/value columns map directly to grouping and +aggregates — no custom code: + +```csharp +[HttpPost("api/aggrid/orders")] +public async Task Rows([FromBody] AgGridRequest request, CancellationToken ct) +{ + var options = request.ToQueryOptions(); // rowGroupCols -> groupBy, valueCols -> aggregate + + var result = await db.Orders.FlexQueryAsync(options, cancellationToken: ct); + + return Ok(result.ToAgGridServerSideResponse(request)); +} +``` + +## Export endpoint (no paging) + +**Problem**: an export job needs the full result set. + +**Approach**: disable paging, skip the count query, and cap upstream: + +```csharp +var scoped = db.Orders.Where(o => o.CreatedAt >= since); // upstream cap + +var result = await scoped.FlexQueryAsync(parameters, opt => +{ + opt.DisablePaging = true; + opt.IncludeTotalCount = false; // skip the count query - export does not need it +}, cancellationToken); +``` + + + Combine DisablePaging with an upstream filter that bounds the set — an + unpaged endpoint over an unbounded table is a denial-of-service vector. + + +## Search endpoint with contains + sort + +**Problem**: free-text search across name and email, ranked alphabetically. + +**Approach**: OR'd substring filters with a deterministic sort: + +```http +GET /api/customers?filter=Name:contains:ana|Email:contains:ana&sort=Name:asc,Id:asc +``` + +```csharp +var result = await db.Customers + .FlexQueryAsync(parameters, opt => + opt.FilterableFields = ["Name", "Email"], cancellationToken); +``` + +## Per-tenant data isolation + +**Problem**: every query must be scoped to the caller's tenant, no matter what the client +asks for. + +**Approach**: wrap the queryable *before* FlexQuery sees it — FlexQuery governs fields, you +govern rows: + +```csharp +var scoped = db.Orders.Where(o => o.TenantId == tenantId); +var result = await scoped.FlexQueryAsync(parameters, cancellationToken: cancellationToken); +``` + +## Public API with a strict surface + +**Problem**: a public read-only endpoint exposing exactly three fields, no includes, no +deep paths. + +**Approach**: `[FieldAccess]` declares the whole contract on the controller: + +```csharp +[FieldAccess( + Allowed = ["Id", "City", "Status"], + Sortable = ["Id", "City"], + AllowedIncludes = [], + DefaultSortField = "Id", + MaxDepth = 2)] +``` + +## DTO-shaped responses for mobile clients + +**Problem**: mobile clients need small payloads with domain vocabulary names. + +**Approach**: typed DTO projection with a per-query map: + +```csharp +var result = await db.Customers + .FlexQueryAsync(parameters, + opt => opt.CreateMap() + .ForMember(dto => dto.Name, entity => entity.FirstName), + cancellationToken); +``` + +## Multi-step wizard state via cursors + +**Problem**: a multi-page wizard needs to resume mid-result-set. + +**Approach**: pass the cursor through the wizard's state; keyset pages are stable because +they are key-based, not offset-based: + +```http +GET /api/customers?useKeysetPagination=true&pageSize=10&sort=Id:asc&cursor= +``` diff --git a/docs-v4/content/docs/security/index.mdx b/docs-v4/content/docs/security/index.mdx new file mode 100644 index 0000000..ec38b11 --- /dev/null +++ b/docs-v4/content/docs/security/index.mdx @@ -0,0 +1,151 @@ +--- +title: Security & Governance +description: Field access, governance sets, roles, and safe expression building. +section: Security +--- + +import { Callout } from '@/components/callout' + +# Security & Governance + +FlexQuery treats client-supplied queries as untrusted input. A query parameter is an +executable description of database work — which columns to read, which relations to +traverse, which values to compare — and a misconfigured dynamic API can leak rows the client +should never see, even when every individual response row is "theirs". Security in FlexQuery +is a declaration model: you declare what is allowed, and everything else fails validation +before any query executes. + +## The governance model + +All governance lives on `QueryGovernanceOptions` — the base of every execution-options type +(`QueryExecutionOptions`, `EfCoreQueryOptions`, `DapperQueryOptions`) — so the same knobs +are available per request (the `configure` delegate) and per endpoint via `[FieldAccess]`. + +| Option | Purpose | +|---|---| +| `AllowedFields` | Global allow-list of fields. | +| `BlockedFields` | Deny-list of fields. | +| `FilterableFields` | Fields clients may filter on. | +| `SortableFields` | Fields clients may sort by. | +| `SelectableFields` | Fields clients may select. | +| `GroupableFields` | Fields clients may group by. | +| `AggregatableFields` | Fields clients may aggregate. | +| `AllowedIncludes` | Navigation paths clients may include/expand. | +| `AllowedOperators` | Per-field operator allow-lists. | +| `DefaultSortField` / `DefaultSortDescending` | Default ordering. | +| `MaxFieldDepth` | Maximum nested path depth. | +| `StrictFieldValidation` | Throw on unauthorized field access (default true). | +| `RoleAllowedFields` + `CurrentRole` | Role-based field access. | +| `AllowedFieldsResolver` | Custom resolver: type → allowed fields. | + + + Governance properties moved onto QueryGovernanceOptions (base of all + execution-option types); mapping, paging defaults, syntax override, and the diagnostics + listener remain on BaseQueryOptions. Both existed in some form in v3 — if you + are migrating, the member names are unchanged; only the class layout is new. + + +## Why per-operation sets exist + +A single allow-list is too coarse. A field can be safe to *display* but dangerous to +*filter on*: + +- **Sortable** but sensitive: sorting by `Ssn` lets a client probe data distribution + through ordering even if values never appear in responses. +- **Aggregatable** but sensitive: `avg:Salary` leaks statistical information even when no + individual salary row is visible. +- **Filterable** but sensitive: `DeletedAt:isnull`-style probes reveal record existence. + +Per-operation sets let you express exactly that: visible but not sortable, filterable only +by admin, aggregatable never. + +## Operator allow-lists + +`AllowedOperators` restricts which comparison operators a field accepts — e.g. `Age` may +support range checks but not `contains`: + +```csharp +opt.AllowOperators("Age", "gte", "lt", "between"); +``` + +## Role-based access + +```csharp +opt.RoleAllowedFields = new() +{ + ["admin"] = ["Id", "Name", "Email", "Ssn"], + ["support"] = ["Id", "Name", "Email"], +}; +opt.CurrentRole = user.IsInRole("admin") ? "admin" : "support"; +``` + +Roles map to field sets; the resolved role's set becomes the effective allow-list. For +dynamic scenarios, `AllowedFieldsResolver` supplies a custom `type → fields` function. + +## Expression-level safety + +Governance decides *what* is addressable; the expression builder guarantees *how* addressing +happens: + +- Filters are never evaluated client-side; everything composes into expression trees (EF + Core) or parameterized SQL (Dapper). +- Field access resolves through safe property resolution — arbitrary member access cannot be + injected. +- Operator factories are a fixed registry; unknown operators fail validation. +- Unknown fields fail validation before any expression is built. + +## Wildcards + +Allowed-field sets support wildcard patterns (e.g. `Order*`) via the built-in wildcard +matcher, so a single rule can cover a whole family of columns case-insensitively. + +## Complete worked example + +A multi-tenant, role-aware endpoint: + +```csharp +[HttpGet("api/customers")] +public async Task Get( + [FromQuery] FlexQueryParameters parameters, + ClaimsPrincipal user, + CancellationToken cancellationToken) +{ + var result = await db.Customers + .Where(c => c.TenantId == user.GetTenantId()) // tenant isolation first + .FlexQueryAsync(parameters, opt => + { + opt.RoleAllowedFields = new() + { + ["admin"] = ["Id", "Name", "Email", "Ssn"], + ["user"] = ["Id", "Name", "Email"], + }; + opt.CurrentRole = user.IsInRole("admin") ? "admin" : "user"; + opt.AllowedIncludes = ["Orders"]; + opt.MaxFieldDepth = 3; + }, cancellationToken); + + return Ok(result); +} +``` + +Defense in depth in one example: tenant scoping happens *before* FlexQuery sees the +queryable; role-based field sets govern what is addressable; includes are whitelisted; and +path depth is capped. + +## Defense-in-depth checklist + +1. Configure `AllowedFields` or per-operation sets for every endpoint — never ship with + only the global defaults. +2. Keep `StrictFieldValidation = true`; silent field dropping hides governance drift. +3. Restrict includes with `AllowedIncludes` (prevents traversing unauthorized graphs). +4. Bound paging with `MaxPageSize`. +5. Bound path depth with `MaxFieldDepth`. +6. Restrict operators per field where the data model demands it (`AllowedOperators`). +7. Scope the `IQueryable` upstream (tenant/ownership filters) — FlexQuery governs fields, + not row-level access. + + + FlexQuery's governance is field-level. Which *rows* a caller may see (tenancy, + ownership, soft deletes) belongs in your queryable — filter it before + FlexQueryAsync. + diff --git a/docs-v4/content/docs/troubleshooting/index.mdx b/docs-v4/content/docs/troubleshooting/index.mdx new file mode 100644 index 0000000..0d9ff38 --- /dev/null +++ b/docs-v4/content/docs/troubleshooting/index.mdx @@ -0,0 +1,141 @@ +--- +title: Troubleshooting +description: Common errors and how to resolve them. +section: Resources +--- + +import { Callout } from '@/components/callout' + +# Troubleshooting + +Symptoms, causes, and fixes — ordered by how often they occur. + +## InvalidOperationException: "already been configured and is now immutable" + +`Configure` was called twice, or after a query already ran. Global configuration is +immutable by design — concurrent query execution reads it. + +**Fix**: configure once during startup (`Program.cs`), before any execution. + +## ParserNotRegisteredException + +A request asked for `QuerySyntax.Fql` or `QuerySyntax.MiniOData` but the parser package was +not registered (or the package was not installed at all). + +**Fix**: reference the parser package and register once at startup: + +```csharp +Fql.Register(); // FlexQuery.NET.Parsers.Fql +MiniOData.Register(); // FlexQuery.NET.Parsers.MiniOData +``` + +## QueryValidationException: field not allowed + +The field is not in `AllowedFields`, is in `BlockedFields`, fails a per-operation set +(`FilterableFields`, `SortableFields`, `SelectableFields`, `GroupableFields`, +`AggregatableFields`), or is unreachable under the current `CurrentRole`. + +**Fix**: either extend the governance set or correct the client request. Log rejected +requests during rollout — they show which surfaces clients actually need. + +## "Navigation projection requires include" + +A `select` references a navigation path (`Address.City`) without loading the navigation. + +**Fix**: add the navigation to `include` (scoped loading via `expand` is then also +possible, but the rule's error message names the missing `include=` value): + +```http +GET /api/customers?include=Address&select=Id,Address.City +``` + +## Duplicate expand path + +Each navigation path may be expanded at most once per query — merging duplicates would make +filter/take/sort ambiguous. + +**Fix**: merge the branch options into a single expand block: + +```http +expand=Orders(filter=Status:eq:Delivered; take=3) +``` + +## HAVING references an undeclared aggregate + +Every aggregate referenced in `having` must be declared in `aggregate`. + +**Fix**: + +```http +aggregate=sum:Total&having=sum:Total:gt:100 +``` + +## Sort validation errors on grouped queries + +Grouped queries may only sort by group keys or declared aggregates — the grouped shape has +no per-row value for anything else. + +**Fix**: sort by a key (`sort=Status:asc`) or a declared aggregate (`sort=sum:Total:desc`). + +## Dapper: wrong dialect SQL + +Dialect is auto-detected from the `DbConnection`. A wrapper connection or a mismatched +provider produces wrong quoting/paging syntax. + +**Fix**: pass the actual connection of the target provider. Manual `Dialect` configuration +no longer exists in v4. + +## No SQL logs from Dapper + +SQL logging requires an `ILogger` where `LogLevel.Information` is enabled for category +`FlexQuery.NET.Dapper`. A null logger or disabled level short-circuits to a no-op. + +**Fix**: configure logging with Information level (or higher) enabled for the category. + +## Keyset pagination skips or duplicates rows + +Ordering is not deterministic — the cursor seek boundary is ambiguous when rows share key +values. + +**Fix**: always end the sort with a unique column: + +```http +sort=CreatedAt:desc,Id:desc +``` + +## Filter syntax errors with quoted values + +In the native DSL, values containing spaces or reserved keywords must be quoted. + +**Fix**: + +```http +filter=City:eq:'New York' +``` + +## "'AND' cannot be used as an unquoted value" + +The DSL reserves the logical keywords `AND`/`OR`. A filter value that starts with one — +e.g. `filter=Name:eq:ANDREW` — is rejected with a suggestion to quote. + +**Fix**: quote the value: `filter=Name:eq:'ANDREW'` (single or double quotes both work). + +## QueryParseException on page/pageSize/distinct + +Malformed values are rejected at parse time rather than silently defaulted: + +```http +page=abc → 'abc' is not a valid page number. Page must be a positive integer. +pageSize=-5 → '-5' is not a valid page size. PageSize must be a positive integer. +distinct=x → 'x' is not a valid distinct value. Distinct must be 'true' or 'false'. +``` + +**Fix**: send well-formed values. Merely out-of-range values (e.g. `pageSize=99999`) are +clamped to the configured maximum instead of erroring. + + + QueryValidationException messages list every error with field context; parse + errors report the parameter name and parser position. When in doubt, reproduce the + request against a debug endpoint with a diagnostics collector — see + Diagnostics. + diff --git a/docs-v4/lib/docs.ts b/docs-v4/lib/docs.ts new file mode 100644 index 0000000..b2510a0 --- /dev/null +++ b/docs-v4/lib/docs.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs' +import path from 'node:path' +import matter from 'gray-matter' +import GithubSlugger from 'github-slugger' + +const CONTENT_DIR = path.join(process.cwd(), 'content', 'docs') + +export interface DocFrontmatter { + title: string + description: string + section?: string +} + +export interface Heading { + text: string + id: string + level: number +} + +export interface DocSource { + modulePath: string + frontmatter: DocFrontmatter + headings: Heading[] +} + +function walkMdxFiles(dir: string, base: string = ''): string[] { + const results: string[] = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = base ? `${base}/${entry.name}` : entry.name + if (entry.isDirectory()) { + results.push(...walkMdxFiles(path.join(dir, entry.name), rel)) + } else if (entry.isFile() && entry.name.endsWith('.mdx')) { + results.push(rel) + } + } + return results +} + +export function getAllDocSlugs(): string[][] { + if (!fs.existsSync(CONTENT_DIR)) return [] + return walkMdxFiles(CONTENT_DIR).map((file) => { + const withoutExt = file.replace(/\.mdx$/, '') + if (withoutExt.endsWith('/index')) { + return withoutExt.slice(0, -'/index'.length).split('/') + } + return withoutExt.split('/') + }) +} + +export function docModulePath(slug: string[]): string | null { + const joined = slug.join('/') + const candidates = [`${joined}.mdx`, `${joined}/index.mdx`] + for (const candidate of candidates) { + const full = path.join(CONTENT_DIR, ...candidate.split('/')) + if (fs.existsSync(full)) return candidate + } + return null +} + +export function readDocSource(slug: string[]): DocSource | null { + const modulePath = docModulePath(slug) + if (!modulePath) return null + const filePath = path.join(CONTENT_DIR, ...modulePath.split('/')) + const raw = fs.readFileSync(filePath, 'utf8') + const { data } = matter(raw) + const slugger = new GithubSlugger() + + const headings: Heading[] = [] + let inCodeFence = false + for (const line of raw.split('\n')) { + const trimmed = line.trimStart() + if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) { + inCodeFence = !inCodeFence + continue + } + if (inCodeFence) continue + const match = /^(#{2,3})\s+(.+)$/.exec(trimmed) + if (match) { + const text = match[2].replace(/[#*`]/g, '').trim() + headings.push({ text, id: slugger.slug(text), level: match[1].length }) + } + } + + return { + modulePath, + frontmatter: { + title: (data.title as string) ?? slug[slug.length - 1], + description: (data.description as string) ?? '', + section: data.section as string | undefined, + }, + headings, + } +} diff --git a/docs-v4/lib/navigation.ts b/docs-v4/lib/navigation.ts new file mode 100644 index 0000000..1af38cc --- /dev/null +++ b/docs-v4/lib/navigation.ts @@ -0,0 +1,100 @@ +export interface NavItem { + title: string + slug: string +} + +export interface NavGroup { + title: string + items: NavItem[] +} + +export const navigation: NavGroup[] = [ + { + title: 'Introduction', + items: [ + { title: 'Overview', slug: 'introduction' }, + { title: 'Installation', slug: 'getting-started/installation' }, + { title: 'First Query', slug: 'getting-started/first-query' }, + ], + }, + { + title: 'Core Concepts', + items: [ + { title: 'Configuration', slug: 'concepts/configuration' }, + { title: 'Execution Pipeline', slug: 'concepts/pipeline' }, + { title: 'Query Options', slug: 'concepts/query-options' }, + { title: 'Query Result', slug: 'concepts/query-result' }, + { title: 'Query Syntax', slug: 'concepts/query-syntax' }, + ], + }, + { + title: 'Guides', + items: [ + { title: 'Filtering', slug: 'guides/filtering' }, + { title: 'Operators', slug: 'guides/operators' }, + { title: 'Sorting', slug: 'guides/sorting' }, + { title: 'Paging', slug: 'guides/paging' }, + { title: 'Projection', slug: 'guides/projection' }, + { title: 'Include', slug: 'guides/include' }, + { title: 'Expand', slug: 'guides/expand' }, + { title: 'Grouping & Aggregates', slug: 'guides/grouping' }, + { title: 'Keyset Pagination', slug: 'guides/keyset-pagination' }, + { title: 'Fluent API', slug: 'guides/fluent-api' }, + { title: 'Query Composition', slug: 'guides/query-composition' }, + { title: 'Typed DTO Projection', slug: 'guides/typed-dto-projection' }, + { title: 'Validation', slug: 'guides/validation' }, + ], + }, + { + title: 'Providers', + items: [ + { title: 'Entity Framework Core', slug: 'providers/ef-core' }, + { title: 'Dapper', slug: 'providers/dapper' }, + ], + }, + { + title: 'Integrations', + items: [ + { title: 'ASP.NET Core', slug: 'integrations/aspnetcore' }, + { title: 'AG Grid', slug: 'integrations/ag-grid' }, + { title: 'Kendo UI', slug: 'integrations/kendo' }, + { title: 'OpenAPI', slug: 'integrations/openapi' }, + ], + }, + { + title: 'Security', + items: [{ title: 'Security & Governance', slug: 'security' }], + }, + { + title: 'Diagnostics', + items: [{ title: 'Diagnostics & Observability', slug: 'diagnostics' }], + }, + { + title: 'Resources', + items: [ + { title: 'Recipes', slug: 'recipes' }, + { title: 'Troubleshooting', slug: 'troubleshooting' }, + { title: 'Migrate from v3', slug: 'migration/v3-to-v4' }, + { title: 'v3 ' + '\u2192' + ' v4 Change Matrix', slug: 'migration/change-matrix' }, + { title: 'API Reference', slug: 'api-reference' }, + ], + }, +] + +export const allItems: NavItem[] = navigation.flatMap((g) => g.items) + +export function getNeighbors(slug: string): { prev: NavItem | null; next: NavItem | null } { + const idx = allItems.findIndex((i) => i.slug === slug) + if (idx < 0) return { prev: null, next: null } + return { + prev: idx > 0 ? allItems[idx - 1] : null, + next: idx < allItems.length - 1 ? allItems[idx + 1] : null, + } +} + +export function getGroupTitle(slug: string): string | null { + for (const g of navigation) { + if (g.items.some((i) => i.slug === slug)) return g.title + } + return null +} \ No newline at end of file diff --git a/docs-v4/mdx-components.tsx b/docs-v4/mdx-components.tsx new file mode 100644 index 0000000..184edcb --- /dev/null +++ b/docs-v4/mdx-components.tsx @@ -0,0 +1,41 @@ +import type { MDXComponents } from 'mdx/types' +import type { ComponentProps } from 'react' +import Link from 'next/link' +import { CodeBlock } from '@/components/code-block' +import { Callout } from '@/components/callout' +import { Tabs } from '@/components/tabs' +import { ApiTable } from '@/components/api-table' + +function DocTable(props: ComponentProps<'table'>) { + return ( +
+ + + ) +} + +const components: MDXComponents = { + a: ({ href = '', children, ...props }) => { + if (href.startsWith('/') || href.startsWith('#')) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, + pre: CodeBlock, + table: DocTable, + Callout, + Tabs, + ApiTable, +} + +export function useMDXComponents(): MDXComponents { + return components +} diff --git a/docs-v4/next.config.mjs b/docs-v4/next.config.mjs new file mode 100644 index 0000000..b3fbf56 --- /dev/null +++ b/docs-v4/next.config.mjs @@ -0,0 +1,32 @@ +import createMDX from '@next/mdx' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'], +} + +const withMDX = createMDX({ + options: { + remarkPlugins: [ + 'remark-gfm', + 'remark-frontmatter', + ['remark-mdx-frontmatter', { name: 'frontmatter' }], + ], + rehypePlugins: [ + 'rehype-slug', + [ + '@shikijs/rehype', + { + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultLanguage: 'text', + addLanguageClass: true, + }, + ], + ], + }, +}) + +export default withMDX(nextConfig) diff --git a/docs-v4/package.json b/docs-v4/package.json new file mode 100644 index 0000000..2f3c21f --- /dev/null +++ b/docs-v4/package.json @@ -0,0 +1,40 @@ +{ + "name": "flexquery-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node scripts/build-search-index.mjs && next dev", + "build": "node scripts/build-search-index.mjs && next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "linkcheck": "node scripts/check-links.mjs" + }, + "dependencies": { + "@mdx-js/react": "^3.1.1", + "@shikijs/rehype": "^4.4.3", + "fuse.js": "^7.5.0", + "github-slugger": "^2.0.0", + "gray-matter": "^4.0.3", + "lucide-react": "^1.42.0", + "next": "^16.3.4", + "next-themes": "^0.4.6", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@mdx-js/loader": "^3.1.1", + "@next/mdx": "^16.3.4", + "@tailwindcss/postcss": "^4.3.3", + "@types/mdx": "^2.0.14", + "@types/node": "^26.5.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-mdx-frontmatter": "^5.2.0", + "rehype-slug": "^6.0.0", + "tailwindcss": "^4.3.3", + "typescript": "~5.9.3" + } +} diff --git a/docs-v4/postcss.config.mjs b/docs-v4/postcss.config.mjs new file mode 100644 index 0000000..86e8e3c --- /dev/null +++ b/docs-v4/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss'], +} + +export default config diff --git a/docs-v4/public/search-index.json b/docs-v4/public/search-index.json new file mode 100644 index 0000000..f7734f7 --- /dev/null +++ b/docs-v4/public/search-index.json @@ -0,0 +1 @@ +[{"title":"API Reference","description":"Curated reference of the public FlexQuery.NET v4 surface per package.","section":"Resources","slug":"api-reference","headings":[{"text":"API Reference","id":"api-reference","level":1},{"text":"FlexQuery.NET (core)","id":"flexquerynet-core","level":2},{"text":"Entry points","id":"entry-points","level":3},{"text":"Request models (FlexQuery.NET.Models)","id":"request-models-flexquerynetmodels","level":3},{"text":"Sync pipeline (FlexQuery.NET extensions)","id":"sync-pipeline-flexquerynet-extensions","level":3},{"text":"Request conversion","id":"request-conversion","level":3},{"text":"Result helpers (QueryResultExtensions)","id":"result-helpers-queryresultextensions","level":3},{"text":"Validation helpers","id":"validation-helpers","level":3},{"text":"Fluent builders (FlexQuery.NET.Builders.Fluent)","id":"fluent-builders-flexquerynetbuildersfluent","level":3},{"text":"Keyset (FlexQuery.NET)","id":"keyset-flexquerynet","level":3},{"text":"Exceptions (FlexQuery.NET.Exceptions)","id":"exceptions-flexquerynetexceptions","level":3},{"text":"FlexQuery.NET.EntityFrameworkCore","id":"flexquerynetentityframeworkcore","level":2},{"text":"FlexQuery.NET.Dapper","id":"flexquerynetdapper","level":2},{"text":"FlexQuery.NET.AspNetCore","id":"flexquerynetaspnetcore","level":2},{"text":"FlexQuery.NET.OpenApi","id":"flexquerynetopenapi","level":2},{"text":"FlexQuery.NET.Diagnostics","id":"flexquerynetdiagnostics","level":2},{"text":"Adapters","id":"adapters","level":2},{"text":"AgGrid (FlexQuery.NET.Adapters.AgGrid)","id":"aggrid-flexquerynetadaptersaggrid","level":3},{"text":"Kendo (FlexQuery.NET.Adapters.Kendo)","id":"kendo-flexquerynetadapterskendo","level":3},{"text":"Parsers","id":"parsers","level":2},{"text":"Governance & security","id":"governance--security","level":2}],"body":"API Reference A curated reference of the public developer-facing surface. Internal types are omitted; only APIs necessary or useful for building on FlexQuery.NET are listed. FlexQuery.NET core Entry points | Member | Description | |---|---| | FlexQueryCore.Configure Action ? | Global options; immutable after first call. | | Query.Create | Fluent FluentQueryBuilder implicit - QueryOptions . | | FlexQueryMapping.Configure Action | Global type-map registry. | Request models FlexQuery.NET.Models | Type | Purpose | |---|---| | FlexQueryParameters | Query-string binding model Filter , Sort , Select , Include , Expand , GroupBy , Having , Aggregate , Page , PageSize , IncludeCount , Distinct , Mode , Cursor , UseKeysetPagination . | | FlexQueryRequest | Strongly-typed request model with ToQueryOptions . | | QueryOptions | Parsed options consumed by the pipeline. | | QueryResult | Result envelope Data , TotalCount , ResultCount , Page , PageSize , TotalPages , HasNextPage , HasPreviousPage , Aggregates , NextCursorToken , ResultShape . | Sync pipeline FlexQuery.NET extensions | Method | Description | |---|---| | Apply query, options | Applies the full pipeline. | | ApplyFilter / ApplySort / ApplyPaging / ApplySelect | Individual stages. | | FlexQuery query, parameters / options, configure? | Synchronous end-to-end execution. | Request conversion | Method | Description | |---|---| | FlexQueryParametersExtensions.ToQueryOptions / .ToQueryOptions QuerySyntax? | Convert bound parameters. | | FlexQueryRequestExtensions.ToQueryOptions | Convert a typed request. | Result helpers QueryResultExtensions | Method | Description | |---|---| | ToProjectedQueryResult ... | Re-projects a result into another element type. | | ToObjectResult Async | Erases T to object for polymorphic endpoints. | | ToDynamicResult Async | Erases T to dynamic . | Validation helpers | Method | Description | |---|---| | ValidationExtensions.Validate options, entityType , execOptions | Runs the rule pipeline, returns ValidationResult . | | ValidationExtensions.ValidateOrThrow ... | Runs the pipeline, throws QueryValidationException . | | options.ValidateSafe ... | Non-throwing validation for staged rollouts. | Fluent builders FlexQuery.NET.Builders.Fluent | Type | Members | |---|---| | FluentQueryBuilder | Filter Action , Sort Action , Select params string , Include params string , Expand Action , Mode , GroupBy , Aggregate Action , Having function, field, op, value , Distinct , Page , UseKeysetPagination , DisablePaging , Build , implicit QueryOptions conversion. | | FilterGroupBuilder | Equal , NotEqual , GreaterThan , GreaterThanOrEqual , LessThan , LessThanOrEqual , Contains , StartsWith , EndsWith , In , NotIn , IsNull , IsNotNull , Between , And g = ... , Or g = ... . | | FilterBuilder / FilterConditionBuilder | Field/And/Or name , terminators Eq , Neq , Contains , StartsWith , EndsWith , GreaterThan , GreaterThanOrEqual , LessThan , LessThanOrEqual , In , Between , IsNull , NotNull , Any b = ... , All b = ... , Not , AndGroup , OrGroup . | | SortBuilder | Ascending field , Descending field . | | AggregateBuilder | Sum , Count , Avg , Min , Max field, alias . | | ExpandBuilder | Path path, filter?, configureChildren? . | Keyset FlexQuery.NET | Method | Description | |---|---| | SeekAfter query, cursor | Keyset predicate on an ordered queryable. | Exceptions FlexQuery.NET.Exceptions FlexQueryException base , QueryParseException , FlexQueryParseException , DslParseException , FqlParseException , MiniODataParseException , QueryValidationException , ParserNotRegisteredException . FlexQuery.NET.EntityFrameworkCore | Member | Description | |---|---| | FlexQueryEFCore.Setup | Registers EF Core operator handlers. | | FlexQueryEFCore.Configure Action ? | Provider options UseNoTracking . | | FlexQueryAsync query, parameters, configure?, ct | Execute dynamic results . | | FlexQueryAsync query, QueryOptions, configure?, ct | Execute pre-parsed options. | | FlexQueryAsync query, ... | Typed DTO execution 4 overloads . | | ApplyExpand query, options | Include pipeline from expand tree. | | ToSqlPreview query / ExplainProjection query, options | SQL and projection inspection. | | UseEfCoreOperators options | Registers the like handler on a hand-built options object. | FlexQuery.NET.Dapper | Member | Description | |---|---| | FlexQueryDapper.Configure Action ? | Global Dapper config Model , CommandTimeout . | | FlexQueryAsync connection, parameters, configure?, ct | Execute dynamic results . | | FlexQueryAsync connection, IDictionary , configure?, ct | Execute from raw query-string values. | | FlexQueryAsync connection, QueryOptions, configure?, ct | Execute pre-parsed options. | | FlexQueryAsync connection, ... | Typed DTO execution 4 overloads . | | ModelBuilder.Entity / ApplyConfiguration / ApplyConfigurationsFromAssembly | Model mapping. | | EntityTypeBuilder .ToTable / HasKey / Property / Ignore / HasMany... | Per-entity mapping. | FlexQuery.NET.AspNetCore | Member | Description | |---|---| | AddFlexQuerySecurity this IMvcBuilder | Registers FieldAccessFilter + result-shape JSON converter. | | AddFlexQueryJson this IMvcBuilder | Result-shape JSON converter only. | | AddFlexQuery services, configure? | Combined global config registration. | | FieldAccess | Per-endpoint governance attribute 11 properties incl. AllowedIncludes . | | GetFlexQueryExecutionOptions HttpContext | Reads resolved execution options. | FlexQuery.NET.OpenApi | Member | Description | |---|---| | AddFlexQueryOpenApi services | Registers schema/operation transformers. | | AddFlexQuery this OpenApiOptions | Attaches transformers to the OpenAPI document pipeline. | FlexQuery.NET.Diagnostics | Member | Description | |---|---| | IFlexQueryExecutionListener | Four ValueTask hooks: QueryParsedAsync , QueryTranslatedAsync , QueryExecutedAsync , QueryMaterializedAsync . | | ConsoleExecutionListener | Console output listener. | | FlexQueryDiagnosticsCollector | In-memory collector; BuildReport provider, translator , Clear . | Adapters AgGrid FlexQuery.NET.Adapters.AgGrid AgGridRequest.ToQueryOptions , JsonElement.ToQueryOptions , ApplyAgGridRequest options , ToAgGridServerSideResponse ... . Kendo FlexQuery.NET.Adapters.Kendo KendoRequest.ToQueryOptions , JsonElement.ToQueryOptions , ApplyKendoRequest options . Parsers | Member | Description | |---|---| | Fql.Register | Registers the FQL parser FlexQuery.NET.Parsers.Fql . | | MiniOData.Register | Registers the MiniOData parser FlexQuery.NET.Parsers.MiniOData . | | QuerySyntax | NativeDsl / Fql / MiniOData . | | MiniODataRequest | Typed MiniOData request Filter , OrderBy , Select , Expand , Top , Skip , Count with ToQueryOptions . | Governance & security | Member | Description | |---|---| | QueryGovernanceOptions | All governance sets see Security /docs/security . | | BaseQueryOptions | Mapping MapField , CreateMap , paging defaults, QuerySyntax , DisablePaging , Listener . | | FlexQueryOptions | Global defaults: MaxPageSize 1000 , DefaultPageSize 20 , IncludeTotalCount , StrictFieldValidation , MaxFieldDepth 5 , DefaultQuerySyntax NativeDsl , CreateMap . | | FilterOperators | Canonical operator constants and normalization. |"},{"title":"Configuration","description":"Global options, provider options, and per-request overrides.","section":"Core Concepts","slug":"concepts/configuration","headings":[{"text":"Configuration","id":"configuration","level":1},{"text":"Global options","id":"global-options","level":2},{"text":"Global type maps","id":"global-type-maps","level":3},{"text":"Provider options","id":"provider-options","level":2},{"text":"EF Core","id":"ef-core","level":3},{"text":"Per-request overrides","id":"per-request-overrides","level":2},{"text":"Precedence rules","id":"precedence-rules","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"Configuration FlexQuery is configured at three levels, and every level has one job: the more general one supplies defaults, the more specific one overrides them. Understanding this layering — and the immutability rule that guards it — is the difference between predictable behavior and order-of-initialization bugs. Every Configure method throws InvalidOperationException when called after a query has already executed. This is deliberate: query execution reads configuration concurrently, and a mutable global config is a race condition. Always configure during startup. Global options FlexQueryCore.Configure runs once at startup, before any query: Property Type Default Description DefaultQuerySyntax QuerySyntax NativeDsl Syntax used when no per-request syntax is supplied. DefaultPageSize int 20 Page size when the client omits one. MaxPageSize int 1000 Maximum page size a client may request. IncludeTotalCount bool true Compute total counts by default. StrictFieldValidation bool true Throw on unauthorized field access. MaxFieldDepth int 5 Maximum nested field-path depth. Global type maps FlexQueryOptions.CreateMap registers application-level entity to DTO maps that every typed execution reuses: Per-query CreateMap registrations take precedence over global maps. See Typed DTO Projection /docs/guides/typed-dto-projection . Provider options EF Core FlexQueryEFCore.Setup no delegate only registers the EF Core-specific operator handlers, such as like . UseNoTracking defaults execution to no-tracking; each call can override it opt.UseNoTracking = false . Dapper Dapper needs a model — there is no DbContext to reflect over: Relationship configuration can also be grouped per entity in an IEntityTypeConfiguration class and applied with ApplyConfiguration / ApplyConfigurationsFromAssembly . Entity types with standard naming and Table / Column / Key attributes need no explicit configuration at all — conventions fill in the gaps. The SQL dialect is auto-detected from the DbConnection type at runtime. See Dapper /docs/providers/dapper for the full mapping API. Per-request overrides Every execution method accepts an optional Action delegate that wins over global/provider values: Typical per-request settings: governance sets see Security /docs/security , paging limits, query syntax, field mappings, per-query type maps, and the diagnostics listener. Precedence rules | Setting | Global | Provider | Per request | |---|---|---|---| | Default query syntax | Yes | — | Yes overrides global | | Page size defaults / limits | Yes | — | Yes overrides global | | Validation strictness & field depth | Yes baseline | — | Yes overrides | | Field governance sets Allowed/Blocked/… | — | — | Yes per request | | No-tracking behavior | — | Yes default | Yes per call | | Dapper model statics/ FlexQueryDapper.Configure | — | Yes | attributes per query | | Type maps | Yes global maps via FlexQueryOptions.CreateMap | — | Yes per-query wins | Common mistakes Calling Configure inside a controller or on first request throws the immutability error as soon as any earlier query ran. Configure in Program.cs / Startup only. The defaults are convenient, not restrictive. Real endpoints set AllowedFields , MaxPageSize , and AllowedIncludes per request — see the defense-in-depth checklist /docs/security defense-in-depth-checklist ."},{"title":"Execution Pipeline","description":"How a FlexQuery.NET request flows from query string to executed query.","section":"Core Concepts","slug":"concepts/pipeline","headings":[{"text":"Execution Pipeline","id":"execution-pipeline","level":1},{"text":"The stages","id":"the-stages","level":2},{"text":"2. Validate","id":"2-validate","level":3},{"text":"3. Apply","id":"3-apply","level":3},{"text":"4. Translate","id":"4-translate","level":3},{"text":"5. Execute","id":"5-execute","level":3},{"text":"Events","id":"events","level":2},{"text":"Where client code fits","id":"where-client-code-fits","level":2}],"body":"Execution Pipeline Every FlexQuery call — whether it started as a query string, a fluent build, or an adapter request — flows through the same five-stage pipeline. Knowing the stages, their order, and what each one guarantees is what lets you predict result ordering, interpret validation errors, and place custom logic at the right point. The stages 1. Parse The syntax selected for the request global default or per-request override determines which parser runs. All parsers produce the same canonical QueryOptions — the rest of the pipeline is syntax-agnostic. Grammar failures surface as QueryParseException which carries the offending parameter name, syntax, received value, and position , with the syntax-specific parse error as the inner exception — all deriving FlexQueryException . 2. Validate The rule pipeline checks the parsed options against the entity model and governance configuration. Validation runs before any expression is built, so a rejected request costs no database work. See Validation /docs/guides/validation . 3. Apply The builder composes LINQ expressions in a fixed order: 1. Filter WHERE — narrows rows first; everything downstream operates on fewer rows. 2. GroupBy / Aggregates / Having — grouping forms after filtering; HAVING prunes groups before ordering. 3. Sort ORDER BY — orders rows or groups . 4. Paging OFFSET/FETCH or keyset seek predicates — slices from the ordered set. 5. Projection SELECT — last, so only requested fields materialize. 6. Total count — computed on the filtered set, independent of paging and projection. Paging before sorting would produce arbitrary page contents; projecting before filtering would hide filterable columns. The pipeline encodes SQL semantics, which is why result ordering and page boundaries are stable. 4. Translate - EF Core : the composed expression tree hands off to EF's translation — everything becomes SQL. Include/expand branches use EF Core filtered includes .Include ... expressions with Where / OrderBy / Take inside , so the related-data window is applied server-side in EF's own generated SQL. - Dapper : FlexQuery generates the SQL itself — select list surface-aware, type-map rewritten , WHERE, GROUP BY/HAVING, ORDER BY, and dialect-specific paging. Related data loads as separate child queries batched by parent keys split-query style hydration , and expand take becomes a server-side ranked/limited child query. 5. Execute The provider executes; results materialize into QueryResult with paging metadata, optional aggregates, and the optional cursor token. Cancellation is observed across the async overloads see provider notes for scope . Events Each stage emits an event that any IFlexQueryExecutionListener can observe: | Hook | Fired when | |---|---| | QueryParsedAsync | Parameters parsed into QueryOptions . | | QueryTranslatedAsync | Provider translated the query SQL available . | | QueryExecutedAsync | Database command completed includes timing . | | QueryMaterializedAsync | Results materialized into the result shape. | Attaching a listener is a one-liner opt.Listener = ... , and FlexQueryDiagnosticsCollector accumulates all four into a report — see Diagnostics /docs/diagnostics . Where client code fits - Before parse — authentication, rate limiting. - Between parse and execute — governance via the configure delegate, tenant scoping by wrapping the IQueryable before FlexQuery sees it. - After execute — serialization result-shape enforcement , diagnostics, response shaping."},{"title":"Query Options","description":"The QueryOptions model - every option the pipeline understands.","section":"Core Concepts","slug":"concepts/query-options","headings":[{"text":"Query Options","id":"query-options","level":1},{"text":"How a request becomes options","id":"how-a-request-becomes-options","level":2},{"text":"Properties","id":"properties","level":2},{"text":"The three request models","id":"the-three-request-models","level":2},{"text":"Projection modes in detail","id":"projection-modes-in-detail","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2},{"text":"Related","id":"related","level":2}],"body":"Query Options QueryOptions namespace FlexQuery.NET.Models is the parsed form of a request — the single model every execution method consumes, regardless of where the request came from query string, fluent builder, or adapter . Understanding its properties means understanding everything the pipeline can do. How a request becomes options Three equivalent paths, one destination: Most endpoints skip explicit construction entirely — FlexQueryAsync parameters, ... converts internally. Constructing QueryOptions yourself matters when composing: adapter output, pre-built saved queries, or merging client input with server-side structure. Properties Property Type Description Filter FilterGroup? Filter expression tree — conditions, nested groups, logic operators. See Filtering . Sort List<SortNode> Ordered sort specs field, direction, optional aggregate . See Sorting . Select List<SelectNode>? Projection tree — fields, aliases, nested selections. See Projection . Includes List<string>? Navigation paths to include with all scalars. See Include . Expand List<IncludeNode>? Expansion trees with per-branch filter/sort/take. See Expand . ProjectionMode ProjectionMode Output shaping: Nested default , Flat , FlatMixed . GroupBy List<string>? Group key fields. Aggregates List<Aggregate> Aggregate specs typed AggregateFunction + field + alias . Having HavingNode? Condition tree over aggregate values — functions referenced as FUNCTION:Field:Operator:Value e.g. sum:Total:gt:100 , which must match a declared aggregate. Distinct bool? Applies Distinct . Paging PagingOptions Page, PageSize clamped 1–1000 , Disabled flag. IncludeCount bool? Whether the total count is computed. The three request models | Model | Use when | |---|---| | FlexQueryParameters | ASP.NET Core FromQuery binding of raw strings. | | FlexQueryRequest | Strongly-typed request objects OpenAPI-documented bodies , via ToQueryOptions . | | QueryOptions | Composed server-side, adapter output, saved queries. | Projection modes in detail | Mode | Behavior | |---|---| | Nested | Nested selections produce nested objects — the natural hierarchical shape. | | Flat | Nested collections flatten with SelectMany into a leaf-level rowset SQL-join semantics . | | FlatMixed | Root scalars and nested-collection fields share one output row. | Complete worked example Composing client input with server-side constraints: Common mistakes Options built in code flow through the same validation as parsed requests. Hand-built options with unknown fields still fail — by design. QueryOptions is consumed by the pipeline; mutate it before calling FlexQueryAsync , not concurrently with it. Related - Query Composition /docs/guides/query-composition — building and merging these options in code - Query Syntax /docs/concepts/query-syntax — the languages that parse into them"},{"title":"Query Result","description":"The QueryResult shape returned by every execution method.","section":"Core Concepts","slug":"concepts/query-result","headings":[{"text":"Query Result","id":"query-result","level":1},{"text":"Properties","id":"properties","level":2},{"text":"ResultShape fields","id":"resultshape-fields","level":2},{"text":"Serialization example","id":"serialization-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"Query Result Every execution method returns QueryResult — a uniform envelope that pairs the page of data with the metadata clients need for pagination UIs, aggregate displays, and cursor-based navigation. Because the envelope is the same for EF Core and Dapper, dynamic and typed results, your response contract never changes when the query does. Properties Property Type Description Data IReadOnlyList<T> The page of results entities, projected objects, or DTOs . TotalCount int? Source rows matching the query before paging — independent of what is returned in Data . Null when counting is disabled. On grouped queries it is the number of underlying rows, not the number of groups. ResultCount int? The post-shaping row total groups for grouped queries, distinct rows for distinct and what TotalPages is computed from. Null on plain queries unless the provider computes it. Page int Current 1-based page number. PageSize int Effective page size after clamping . TotalPages int Computed from total count and page size. HasNextPage bool A next page exists. HasPreviousPage bool A previous page exists. Aggregates Dictionary<string, Dictionary<string, object>>? Grand totals for ungrouped aggregate queries: field → aggregate key → value. Null otherwise. NextCursorToken string? Cursor for the next keyset page keyset mode only . ResultShape IReadOnlyList<SelectOutputField>? The effective output surface when an explicit select is present. ResultShape fields Each SelectOutputField describes one output column: | Field | Meaning | |---|---| | SourceName | The public/source field the client requested e.g. CustomerFullName . | | SourcePropertyName | The entity property it resolves to. | | OutputName | The response field name — the alias when present, otherwise the source name. | When the result-shape JSON converter is registered AddFlexQuerySecurity / AddFlexQueryJson , serialization enforces exactly this surface: anything outside it is stripped from the payload, with aliases applied. Serialization example The resultCount key is only present when a result-shape count was computed — grouped or distinct queries. Clients can build complete pagination UIs from this envelope alone: page numbers totalPages , next/previous buttons hasNextPage / hasPreviousPage , and row counts totalCount . Grouped queries Grouped queries produce one Data entry per group . Each row carries the group key s plus every aggregate under its alias, and the paging metadata is computed over groups: totalCount remains the underlying source-row count, resultCount is the number of groups, and totalPages / hasNextPage follow from the group count. Ungrouped aggregate queries ?aggregate=... without groupBy do not produce group rows; their single-row totals appear in a separate aggregates object keyed by the aggregate's source field or \"all\" , with inner entries keyed by alias: See Grouping & Aggregates /docs/guides/grouping for the full semantics. Keyset pagination When keyset mode is active, NextCursorToken carries the opaque, versioned cursor built from the sort-key values of the last row on the page. When a page comes back empty, the token is null — the standard end-of-scroll signal. Pass it back as the cursor parameter — see Keyset Pagination /docs/guides/keyset-pagination . Complete worked example Shaping a stable public response from the envelope: Common mistakes With includeCount=false , TotalCount is null — clients using it for \"N results\" UIs must handle that. Only rely on it when counting is enabled. Aggregates is populated only for grouped queries. On plain queries it is null — guard before reading."},{"title":"Query Syntax","description":"The three query languages - DSL, FQL, and MiniOData - and how to select them.","section":"Core Concepts","slug":"concepts/query-syntax","headings":[{"text":"Query Syntax","id":"query-syntax","level":1},{"text":"Selecting the syntax","id":"selecting-the-syntax","level":2},{"text":"Globally","id":"globally","level":3},{"text":"DSL filter grammar","id":"dsl-filter-grammar","level":2},{"text":"Sort syntax","id":"sort-syntax","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"Query Syntax FlexQuery accepts three query languages on the same endpoint. All three parse into the same internal QueryOptions model, and DSL and FQL expose the full feature set — filtering, sorting, projection, include/expand, grouping, aggregates, paging — interchangeably. MiniOData is intentionally a lighter compatibility layer: it covers filter, sort, select, and relationship loading its $expand maps to plain include , while grouping, aggregates, filtered expansion, and keyset paging remain DSL/FQL features. The syntax is a client-facing choice, not a server-side fork. | Syntax | Enum value | Style | Extra package | |---|---|---|---| | Native DSL | QuerySyntax.NativeDsl | filter=Status:eq:Active | built-in | | FQL | QuerySyntax.Fql | filter=Status = 'Active' | FlexQuery.NET.Parsers.Fql | | MiniOData | QuerySyntax.MiniOData | $filter=Status eq 'Active' | FlexQuery.NET.Parsers.MiniOData | Choosing a syntax : DSL is compact and URL-friendly — the default. FQL reads like SQL and suits developer-facing tools. MiniOData eases migration from OData consumers. Selecting the syntax Globally Per request Registering parsers The DSL parser is built in. FQL and MiniOData live in separate packages and must be registered once at startup: Registration must happen before any execution; requesting an unregistered syntax throws ParserNotRegisteredException . Parameter map DSL and FQL share the same parameter keys; MiniOData uses its $ -prefixed spellings for the expressions it supports. | Parameter | DSL example | FQL example | MiniOData | |---|---|---|---| | Filter | filter=Status:eq:Active | filter=Status = 'Active' | $filter=Status eq 'Active' | | Sort | sort=Name:asc,Age:desc | sort=Name ASC, Age DESC | $orderby=Name asc, Age desc | | Select | select=Id,Name,Orders.Total | select=Id,Name | $select=Id,Name | | Include | include=Orders | include=Orders | $expand=Orders | | GroupBy | groupBy=Status | groupBy=Status | — | | Aggregate | aggregate=sum:Total:TotalRevenue | aggregate=SUM Total AS TotalRevenue | — | | Having | having=sum:Total:gt:100 | having=SUM Total 100 | — | | Expand filtered | expand=Orders filter=Status:eq:'Active'; sort=OrderDate:desc; take=5 | same options shape, FQL expressions inside filter / sort / take | — | | Page / PageSize | page=1&pageSize=20 | same | $top / $skip translated to page/size | | Distinct | distinct=true | same | — | | Mode | mode=flat | same | — | | Cursor / keyset | useKeysetPagination=true&cursor=... | same | — | The MiniOData parser supports $filter , $orderby , $select , $top , $skip , $count , and $expand flat navigation paths — nested expand options are not supported . Grouping, aggregates, having, and filtered expansion are DSL/FQL features. A typed MiniODataRequest model Filter, OrderBy, Select, Expand, Top, Skip, Count with ToQueryOptions is available for strongly-typed consumers. DSL filter grammar - Logical operators: the AND / OR keywords and the symbolic & / | forms are both accepted; AND has higher precedence than OR the parser builds AND-groups inside OR-groups . - Collection operators any , all , count target collection navigations: Orders:any:Total:gt:100 . - Values containing spaces or reserved keywords must be quoted: City:eq:'New York' . - The keywords AND / OR are reserved — an unquoted value that starts with one is rejected with a hint to quote it name:eq:\"AND\" . - Null-check operators take no value: DeletedAt:isnull . FQL filter grammar FQL is SQL-inspired: values are quoted strings or numbers/booleans , operators are words or symbols, and parentheses group expressions: MiniOData filter grammar The parser is deliberately small: flat paths and the operators above. It does not implement the full OData vocabulary $apply , nested $expand options, etc. . Aggregate syntax DSL aggregates use the aggregate parameter with function:field :alias triples; FQL uses FUNCTION field AS alias : - Functions: sum , count , avg or average , min , max . - Without an explicit alias, the output field is the PascalCase field + function sum:Total → TotalSum . - Aggregates combine with groupBy ; having references declared aggregates: having=sum:Total:gt:100 DSL or having=SUM Total 100 FQL . Sort syntax Both direction spellings are accepted: Aggregate sorts use function:target:direction DSL or SUM Field DESC FQL — see Sorting /docs/guides/sorting . Complete worked example One endpoint, three syntaxes, same result: All four produce identical results. Common mistakes filter=Status = 'Active'&sort=Name:asc mixes FQL filter syntax with DSL sort syntax. The whole request parses with one syntax — use one language per request. A global DefaultQuerySyntax = QuerySyntax.Fql without Fql.Register throws ParserNotRegisteredException on first use."},{"title":"Diagnostics & Observability","description":"Execution listeners, collectors, reports, and SQL inspection.","section":"Diagnostics","slug":"diagnostics","headings":[{"text":"Diagnostics & Observability","id":"diagnostics--observability","level":1},{"text":"Execution events","id":"execution-events","level":2},{"text":"Built-in listeners","id":"built-in-listeners","level":2},{"text":"SQL inspection","id":"sql-inspection","level":2},{"text":"EF Core — ToSqlPreview","id":"ef-core--tosqlpreview","level":3},{"text":"Dapper — SQL execution logging","id":"dapper--sql-execution-logging","level":3},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"Diagnostics & Observability Dynamic queries are hard to debug precisely because they are dynamic: the SQL that executed depends on the request. FlexQuery.NET.Diagnostics exposes the pipeline as a stream of events — parse, translate, execute, materialize — that you can log, collect into a report, or inspect per stage. When a query misbehaves, the answer is in the events, not in guesses. Execution events Implement IFlexQueryExecutionListener namespace FlexQuery.NET.Execution to observe the four pipeline stages. Every method is a ValueTask -returning hook with a default no-op implementation, so you implement only what you need: | Hook | Fired when | Contains | |---|---|---| | QueryParsedAsync QueryParsedEvent e, CancellationToken ct | Parameters parsed into QueryOptions | What the client actually asked for | | QueryTranslatedAsync QueryTranslatedEvent e, ct | Provider translated the query | Generated SQL / LINQ | | QueryExecutedAsync QueryExecutedEvent e, ct | Database command completed | Execution timing and outcome | | QueryMaterializedAsync QueryMaterializedEvent e, ct | Results materialized | Result-shape details | Attach a listener per request through the execution options: Built-in listeners - ConsoleExecutionListener — writes each stage to the console; ideal for development. - FlexQueryDiagnosticsCollector — accumulates all events in memory for programmatic inspection. BuildReport aggregates the collected events into a FlexQueryDiagnosticsReport covering all four stages with durations — useful for request-scoped debug endpoints the sample application wraps this in a DiagnosticsHelper that attaches a __diagnostics object to responses during development . Attaching diagnostics to every production response leaks schema details. Gate them behind configuration, an admin role, or a query-string flag. SQL inspection Two provider-specific paths to the actual SQL: EF Core — ToSqlPreview ToSqlPreview uses EF Core's ToQueryString under the hood and works after dynamic projections are applied. ExplainProjection returns a ProjectionExplanation : selected fields, navigation usage, and optimization notes. Dapper — SQL execution logging Every Dapper command logs an Information-level entry right before execution under logger category FlexQuery.NET.Dapper . The entry contains the final SQL formatted for readability, preceded by a DECLARE block embedding the parameter values — copy-paste-ready: The logging helper only reads the SQL and parameters already passed to Dapper — it never mutates or executes anything, and it short-circuits to a no-op when the logger is null or Information level is disabled. Complete worked example A timing endpoint for investigating a slow query: SQL formatting The SQL that reaches reports and Dapper logs is rendered by a shared formatter FlexQuery.NET.SqlFormatting used by both providers — clause-per-line layout and parameter blocks come from the same component everywhere. It is an implementation detail rather than a public API; consume the formatted SQL through the listener events, the collector report, and the logger. Common mistakes A FlexQueryDiagnosticsCollector accumulates events — one instance per request, and Clear between uses. A shared instance mixes events from concurrent requests. When results look wrong, compare the parsed options stage 1 against the translated SQL stage 2 before suspecting execution — most surprises are translation-visible."},{"title":"First Query","description":"Build your first FlexQuery.NET endpoint with EF Core in a few minutes.","section":"Getting Started","slug":"getting-started/first-query","headings":[{"text":"First Query","id":"first-query","level":1},{"text":"What you are building","id":"what-you-are-building","level":2},{"text":"0. Prerequisites","id":"0-prerequisites","level":2},{"text":"1. Configure global options at startup","id":"1-configure-global-options-at-startup","level":2},{"text":"2. Create the endpoint","id":"2-create-the-endpoint","level":2},{"text":"3. Query it","id":"3-query-it","level":2},{"text":"What just happened","id":"what-just-happened","level":2},{"text":"Variations","id":"variations","level":2},{"text":"Explicit query options","id":"explicit-query-options","level":3},{"text":"Typed DTO result","id":"typed-dto-result","level":3},{"text":"Alternative query syntax","id":"alternative-query-syntax","level":3},{"text":"Next steps","id":"next-steps","level":2}],"body":"First Query This walkthrough builds a production-shaped ASP.NET Core endpoint that accepts dynamic query parameters and executes them against Entity Framework Core. It takes about five minutes, and everything you learn here composes with the rest of the documentation. What you are building One endpoint that handles, with no additional code: …as a server-side, validated, SQL-translated query — not in-memory LINQ. 0. Prerequisites - .NET 6, 8, or 10 project with EF Core set up and a Customer entity on a DbContext . - The packages installed: 1. Configure global options at startup Call FlexQueryCore.Configure once in Program.cs , before any query executes: These are defaults, not security — endpoints override them per request below. 2. Create the endpoint FlexQueryParameters is the model binder for query-string input. Pass it straight to FlexQueryAsync : What each line buys you: - AsNoTracking — read-only queries without change-tracking overhead. - AllowedFields — the only fields clients can filter, sort, or select on. - MaxPageSize — an endpoint-level ceiling clients cannot exceed it . - DefaultSortField — stable page boundaries even when clients omit sort . 3. Query it Response ASP.NET Core's default camelCase JSON : Only the selected fields appear on each row — with an explicit select , the result-shape converter emits exactly the requested surface and nothing else. What just happened 1. FlexQueryParameters bound the query string filter , sort , page , pageSize , select . 2. FlexQueryAsync parsed the parameters with the default syntax NativeDsl , validated every field against AllowedFields , and applied the pipeline — filter → sort → paging → projection — as SQL-translated expression trees. 3. Only selected columns left the database, and only allowed fields could be addressed. Variations Explicit query options When the query is composed server-side instead of parsed from the request: QueryOptions lives in FlexQuery.NET.Models ; SortNode and PagingOptions as well. Typed DTO result Project into your own response type — same endpoint shape, documented contract: See Typed DTO Projection /docs/guides/typed-dto-projection for the mapping model CreateMap , ForMember , ForNavigation . Alternative query syntax Clients can address the same endpoint with FQL or MiniOData once the parser packages are installed and registered: See Query Syntax /docs/concepts/query-syntax . Next steps - Configuration /docs/concepts/configuration — the three configuration levels. - Filtering /docs/guides/filtering — the full operator reference. - Paging /docs/guides/paging — offset vs keyset modes. - Security & Governance /docs/security — locking endpoints down properly."},{"title":"Installation","description":"Add FlexQuery.NET packages to your project.","section":"Getting Started","slug":"getting-started/installation","headings":[{"text":"Installation","id":"installation","level":1},{"text":"Core package","id":"core-package","level":2},{"text":"Provider package","id":"provider-package","level":2},{"text":"Optional packages","id":"optional-packages","level":2},{"text":"Supported frameworks","id":"supported-frameworks","level":2},{"text":"Next steps","id":"next-steps","level":2}],"body":"Installation FlexQuery.NET is distributed as a set of NuGet packages. Install the core package plus the provider package for your data access technology; optional packages add integrations and alternative syntaxes. Core package Always required. Contains the query engine: parsers, filtering, sorting, paging, projection, grouping, validation, and the fluent API. Provider package Pick exactly one provider package: The provider package supplies the execution pipeline — EF Core translates FlexQuery's expression trees through the EF stack; Dapper generates and executes SQL directly. See EF Core /docs/providers/ef-core and Dapper /docs/providers/dapper for provider specifics. Optional packages Which optional packages for which scenario: | Scenario | Packages | |---|---| | Standard ASP.NET Core + EF Core API | Core, EF Core, AspNetCore | | Swagger-documented API | + OpenApi | | Grid-driven dashboards | + Adapters.AgGrid or Adapters.Kendo | | OData/FQL clients | + Parsers.MiniOData / Parsers.Fql | | Debug/diagnostic tooling | + Diagnostics | Supported frameworks All packages target .NET 6, .NET 8, and .NET 10 , except FlexQuery.NET.OpenApi , which targets .NET 9 and .NET 10 it builds on Microsoft.AspNetCore.OpenApi . Next steps Continue with First Query /docs/getting-started/first-query to wire up your first FlexQuery endpoint."},{"title":"Expand","description":"Filtered, ordered, size-bounded related data — related rows that carry their own filter, sort, and take.","section":"","slug":"guides/expand","headings":[{"text":"Expand","id":"expand","level":1},{"text":"Grammar","id":"grammar","level":2},{"text":"Worked example: bounded order history","id":"worked-example-bounded-order-history","level":2},{"text":"Deeper nesting","id":"deeper-nesting","level":3},{"text":"Governance","id":"governance","level":2},{"text":"What expansion cannot do","id":"what-expansion-cannot-do","level":2},{"text":"Provider behavior","id":"provider-behavior","level":2},{"text":"Related","id":"related","level":2}],"body":"Expand include loads a whole related collection. expand loads a defined slice of it: each branch of the graph can carry its own filter , sort , and take — \"every customer, but only their three most recent delivered orders\". This is the pattern behind dashboard cards, order-history previews, and any UI that shows a bounded slice of related data without dragging thousands of child rows across the wire. Expand replaces the old v3 filtered-includes approach — see Migrating from v3 /docs/migration/v3-to-v4 . Grammar An expand entry is a dotted navigation path — at the top level or nested inside another entry's parentheses — optionally followed by an option block: - Options inside a block are separated by ; or , they are trimmed — either take=3;sort=Id:desc and take=3; sort=Id:desc parse fine; pick one style and stay consistent . - filter= holds a filter expression in the request's syntax a plain status:eq:Delivered for DSL; URL-encode spaces inside expressions . - sort= holds a sort expression OrderDate:desc or OrderDate DESC . - take= accepts any integer ≥ 0; take=0 loads no children at all. - Paths are validated like every other navigation: they must be navigation properties, every expand path must also appear in include , and a navigation may be expanded at most once per query. Worked example: bounded order history Model — Customer → Orders → per-branch, filtered and capped latest delivered orders: Response shape : Customers without qualifying orders still appear they just carry \"orders\": . Filter/sort/take on the expanded branch never changes the root result set or the root sort order — the root query and each expansion are separate SQL operations. Deeper nesting Expand grandchildren inside the branch — here each customer's three latest delivered orders, and for those orders the six biggest items: Child paths inside parentheses are relative to their parent. A deep tree produces one batched level per depth that has expansion options. Governance Expanded branches go through the same governance gates as root queries, evaluated against the related entity type type.member rules like Orders.Total:gt:100 remain available to the server : - Every expand path and every nested child path must satisfy AllowedIncludes . - Branch filter/sort fields must be valid for the related entity's public surface — DTO-typed surface checks apply exactly like at the root. What expansion cannot do - Duplicate expansion of the same path Orders take=1 ,Orders take=2 — rejected with EXPAND_DUPLICATE_PATH ; merge the options into one block. - Sort/take branches are collection-only; applying them to a single-valued reference navigation is rejected EXPAND_SORT_ON_REFERENCE . - Expanding a scalar property or non-navigation member is rejected EXPAND_PATH_NOT_FOUND / NAVIGATION_PROPERTY_REQUIRED . - Grouped queries cannot combine with include/expand at all GROUPBY_INCLUDE_CONFLICT . - Option keys are only filter , sort , take , and nested paths — anything else fails parse Unexpected expand option . Provider behavior - EF Core : options are applied inside EF's own filtered-include machinery — the database trims the children before rows reach memory, so a take=3 branch loads three rows per parent, not the whole collection. - Dapper : each expanded level runs as its own batched query restricted to the keys on the current parent page. take becomes a dialect-correct ranked subquery ROW_NUMBER OVER PARTITION BY ... so the trimming is also server-side, while a filter-only branch is folded straight into the child WHERE . - On both providers, expand blocks never trim parent rows: root paging and totalCount describe the full, unexpanded result set. Related - Include /docs/guides/include — full, unbounded relation loading - Projection /docs/guides/projection — shape the expanded output"},{"title":"Filtering","description":"Constrain results with the filter DSL — operators, collections, nested paths, and safety.","section":"","slug":"guides/filtering","headings":[{"text":"Filtering","id":"filtering","level":1},{"text":"Anatomy of a filter expression","id":"anatomy-of-a-filter-expression","level":2},{"text":"Operators","id":"operators","level":2},{"text":"Filtering on collections","id":"filtering-on-collections","level":2},{"text":"Provider behavior","id":"provider-behavior","level":2},{"text":"FQL and MiniOData spellings","id":"fql-and-miniodata-spellings","level":2},{"text":"Case sensitivity","id":"case-sensitivity","level":2},{"text":"When a filter is wrong","id":"when-a-filter-is-wrong","level":2},{"text":"Related","id":"related","level":2}],"body":"Filtering The filter parameter narrows which rows are returned. Expressions are parsed into a validated model and executed server-side — EF Core translates them to SQL, Dapper generates parameterized SQL, and with LINQ-to-objects they evaluate as expression trees. The database only ever returns rows that already match. Anatomy of a filter expression A single condition is field:operator:value : Combine conditions with & AND and | OR — or the equivalent AND / OR keywords case-insensitive . Parentheses group sub-expressions, or not ... negates, and AND binds tighter than OR : Commas are values, not combinators. Inside a filter, everything from the value position until the next operator is taken as one raw value — so a comma ends up inside the value. filter=Status:eq:Active,City:eq:Berlin matches a status literally equal to \"Active,City:eq:Berlin\" , not two conditions. Always combine with & / | . Values containing spaces must be quoted: Values may otherwise contain colons freely URLs, key:value pairs , and dates parse from ISO 8601 / invariant formats: Operators | Operator | Meaning | Example | |---|---|---| | eq | equals | Status:eq:Active | | neq | not equals | Status:neq:Cancelled | | gt / gte | greater than or equal | Salary:gte:50000 | | lt / lte | less than or equal | Age:lt:30 | | contains | substring | Email:contains:@example.com | | startswith | prefix match | LastName:startswith:Ann | | endswith | suffix match | LastName:endswith:son | | like | SQL wildcard pattern % = zero or more, _ = one char | Name:like:J%h | | in | value in list list items separated by , | Status:in:Active,Pending | | notin | value not in list | Status:notin:Cancelled | | between | inclusive numeric/date range two values, , | Salary:between:40000,60000 | | isnull | property is null no value part | DeletedAt:isnull | | isnotnull | property is not null no value part | Email:isnotnull | | any | at least one related item matches | see Collections | | all | every related item matches | see Collections | | count | count of related items | see Collections | Operator names are case-insensitive Status:IN:Active . Comparison semantics are type-aware: dates, numbers, GUIDs, and enums are converted to the property type before comparison — values that cannot convert to the field's type fail validation with a TYPE_MISMATCH / conversion description. Exact operator semantics, aliases, type rules, and per-provider behavior: Operators /docs/guides/operators . Filtering on collections There are two equivalent shapes for collection checks — a flat colon form and a parenthesized form: any / all take an inner condition on the related type; count takes optionally an inner condition and a numeric comparison :gt:3 etc. . Collection segments inside a dotted path are checked existence-style Any , so: matches orders that have at least one item whose product is Widget . Direct reference navigations work the same way Address.City:eq:Berlin . Expanding into navigation properties in a filter lets clients probe for the existence of related rows. Treat navigation roots like any other protected field: either authorize them through AllowedIncludes / SelectableFields governance, or keep endpoints without such fields and rely on the default governance rejection. A governance denial raises the same QueryValidationException with code FIELD_ACCESS_DENIED regardless of the requested value. Provider behavior - EF Core translates the filter into SQL — all comparisons, wildcards, and collection checks EXISTS /subqueries are database-side; values are parameterized. in / notin become list parameters, between becomes = / = 18 , and MiniOData uses its OData form Status eq 'Active' and Age ge 18 , Orders/TotalAmount gt 100 , contains Email,'@acme' . Case sensitivity contains , startswith , endswith , eq , and in compare strings case-sensitively in memory and are collation-sensitive when pushed to SQL EF Core / Dapper . If you need portable case-insensitive text search, normalize on the data side — FlexQuery has no per-request case-insensitivity switch. When a filter is wrong - Unknown or unauthorized field → QueryValidationException FIELD_NOT_FOUND , FIELD_ACCESS_DENIED — thrown unless StrictFieldValidation has been relaxed for that endpoint stripped rather than thrown; see Security /docs/security . - Unsupported operator for a field e.g. gt on a bool → TYPE_MISMATCH / INVALID_OPERATOR . - Malformed expression → QueryParseException naming the failing parameter filter , the syntax, what was expected, and the position in the string. Related - Sorting /docs/guides/sorting · Paging /docs/guides/paging · Query Syntax /docs/concepts/query-syntax FQL / MiniOData equivalents"},{"title":"Fluent API","description":"Build QueryOptions in code — typed, composable, and validated like any request.","section":"","slug":"guides/fluent-api","headings":[{"text":"Fluent API","id":"fluent-api","level":1},{"text":"Two styles, one filter model","id":"two-styles-one-filter-model","level":2},{"text":"All builder methods","id":"all-builder-methods","level":2},{"text":"Composition example: saved queries + policy","id":"composition-example-saved-queries--policy","level":2},{"text":"What the builder does not bypass","id":"what-the-builder-does-not-bypass","level":2},{"text":"Related","id":"related","level":2}],"body":"Fluent API The fluent builder gives you the full QueryOptions model without ever touching query strings. It is the programmatic twin of the wire grammar: same options, same validation, same execution methods. Use it for saved queries, server-composed policies, tests, and data-export jobs — anywhere a client should not be in control. Build returns QueryOptions ; FluentQueryBuilder also converts implicitly, so you can drop Build at call sites. Execution is provider code FlexQueryAsync overloads accept QueryOptions directly . Two styles, one filter model - FilterGroupBuilder shown above — method-per-operator: Equal / NotEqual / GreaterThan / GreaterThanOrEqual / LessThan / LessThanOrEqual / Contains / StartsWith / EndsWith / In / NotIn / IsNull / IsNotNull / Between , combined with And ... / Or ... groups. - FilterBuilder — field-first chained style: Any / All mirror the collection operators, and Field ... .Not .Eq ... negates a single condition. All builder methods Starting from Query.Create : | Method | Meaning | Options field | |---|---|---| | .Filter f = … | filter tree | Filter | | .Sort s = s.Ascending \"X\" .Descending \"Y\" | ordering list order = priority | Sort | | .Select params string | projection paths / syntax accepted by the wire select grammar | Select | | .Include params string | navigation paths | Includes | | .Expand e = e.Path \"Orders\", f = f.Equal \"Status\",\"Delivered\" , children = … | filtered relation loading | Expand | | .Mode ProjectionMode.Flat | projection shape Nested/Flat/FlatMixed | ProjectionMode | | .GroupBy params string | group keys | GroupBy | | .Aggregate a = a.Sum \"Total\" .Count \"Id\", \"orders\" | aggregates Sum/Count/Avg/Min/Max , optional alias | Aggregates | | .Having \"sum\", \"Total\", \"gt\", \"100\" | one HAVING comparison over a declared aggregate | Having | | .Distinct true | DISTINCT | Distinct | | .Page page, pageSize | offset paging | Paging | | .UseKeysetPagination pageSize, cursor? | keyset paging sort required downstream | IsKeysetMode /cursor | | .DisablePaging | return the full result set | Paging.Disabled | | .Build | produce QueryOptions | — | HAVING can also be composed as a tree AND/OR groups, FQL-style functions via the HavingNode types in FlexQuery.NET.Models.Aggregates when the single-condition overload gets too cramped. Composition example: saved queries + policy Because the output is a plain QueryOptions , the query composes further: merge adapter-parsed options ApplyAgGridRequest / ApplyKendoRequest , or keep server-side constraints separate from client-provided ones and combine filters manually. What the builder does not bypass Validation and governance are properties of execution, not of parsing: - A hand-built QueryOptions still goes through the full validator against your QueryGovernanceOptions — an unknown field or disallowed operator fails exactly like a bad query string would. - Field values are formatted as the DSL formats them dates as invariant strings, etc. , so the same type-constraints apply. - Building options does not register the request as legitimate for a model — you own what you put inside. Related - Query Options /docs/concepts/query-options - Query Composition /docs/guides/query-composition — the underlying model - Filtering /docs/guides/filtering · Paging /docs/guides/paging"},{"title":"Grouping & Aggregates","description":"Grouped queries, HAVING filters, and grand totals — SQL aggregation without writing SQL.","section":"","slug":"guides/grouping","headings":[{"text":"Grouping & Aggregates","id":"grouping--aggregates","level":1},{"text":"Group by one or more fields","id":"group-by-one-or-more-fields","level":2},{"text":"Aggregates","id":"aggregates","level":2},{"text":"HAVING — filtering groups","id":"having--filtering-groups","level":2},{"text":"Grand totals (ungrouped aggregates)","id":"grand-totals-ungrouped-aggregates","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Provider notes","id":"provider-notes","level":2},{"text":"Related","id":"related","level":2}],"body":"Grouping & Aggregates Reports like revenue per status, average salary per city, or order counts per customer are aggregation work that FlexQuery can do server-side. Three parameters describe the shape: groupBy defines the grouping, aggregate declares the aggregate functions, and having filters the groups. Grand totals come free as a bonus on ungrouped aggregate queries. Grouped queries are a different row model — no root entity, and therefore no include/expand in the same request GROUPBY_INCLUDE_CONFLICT , and select restrictions. Everything on this page holds for both EF Core and Dapper providers. Group by one or more fields Each Data row is one group. Without any aggregate , the result is the distinct set of group keys. Single-key grouping yields { status: \"Active\" } rows; dotted keys group by the related value e.g. Customer.City and surface under the request's projection naming. Aggregates aggregate is a comma-separated list of function:field :alias items. Supported functions: sum , avg average is accepted , min , max , count case-insensitive function names . count works on properties or collection navigations; as a target is not part of the DSL. - With no alias, the output name follows the field+function convention sum:TotalAmount → TotalAmountSum , count:Id → IdCount ; aliases are validated as identifiers, must be unique within the request, and replace the generated default. - Group rows carry the keys and every declared aggregate under the alias/default name. HAVING — filtering groups having conditions pair an aggregate with a comparison. The canonical DSL spelling is function:field:operator:value e.g. sum:Total:gt:100 , combined with AND , OR , and parentheses; FQL uses the SQL-like SUM Total 100 form: The rules that keep aggregates meaningful: - having requires both groupBy and at least one matching declared aggregate — otherwise HAVING_WITHOUT_GROUPBY / HAVING_REQUIRES_GROUPBY . - every condition must match a declared aggregate by function and field case-insensitive ; an unknown pairing fails with AGGREGATE_NOT_DECLARED . - operators are comparison-only eq ne gt gte lt lte with numeric type checks sum / avg targets must be numeric , and count conditions compare against a value. - in grouped queries, sort may only order by group keys or aggregate names. Grand totals ungrouped aggregates Declare aggregates without groupBy to get single-row totals across the whole filtered set — alongside the normal paged data, in a separate envelope field: The aggregate sub-dictionary keys are the aggregate's alias or its auto-generated name ; the outer key is the aggregate's source field. Grand-total queries only compute over the filtered rows — the same filter applies to data and totals. Complete worked example \"Active customers who own at least 5 orders priced over 100, shown with order count and average order value\": What each piece does: the filter narrows customers before grouping; three keys define group identity Id,FirstName,LastName ; each group computes two aggregates under explicit aliases; having drops groups with fewer than 5 matching orders the count is over the full navigation, not the filtered page ; and the groups themselves are sorted by the alias. The response Data is the group-row list, with normal paging on top — paging metadata counts groups , and totalCount reflects the underlying source rows. Provider notes - EF Core : grouping/aggregation translates to SQL GROUP BY / HAVING /aggregates — all computation is done by the database. - Dapper : GROUP BY , HAVING , key-set paging and ordered aggregate aliases are generated directly into SQL with dialect-correct ORDER BY … NULLS LAST behavior on Oracle for grouped sorts . - Nested aggregates over paths max:Orders.Total work as long as the property path is resolvable from the root entity type through the provider's translation. Related - Sorting /docs/guides/sorting · Paging /docs/guides/paging · Query Result /docs/concepts/query-result"},{"title":"Include","description":"Load related entities through navigation paths — server-side, bounded, governed.","section":"","slug":"guides/include","headings":[{"text":"Include","id":"include","level":1},{"text":"Syntax","id":"syntax","level":2},{"text":"What the response looks like","id":"what-the-response-looks-like","level":2},{"text":"Combining with select","id":"combining-with-select","level":2},{"text":"Governance: which paths are includable","id":"governance-which-paths-are-includable","level":2},{"text":"Provider behavior","id":"provider-behavior","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2},{"text":"Related","id":"related","level":2}],"body":"Include include attaches related records to each row: customers with their orders , orders with their order items and product . The loading happens server-side SQL joins / follow-up queries — never a change-tracking fixup , and the set of navigations a client may name is entirely up to your governance configuration. Syntax One or more comma-separated navigation paths, using dots for depth: - Duplicates collapse silently — include=Orders,Orders loads Orders once. - Every path must resolve to navigation properties end to end. A path that walks into a scalar property is rejected. - The wire parameter is include in the native DSL and FQL; MiniOData clients spell the same thing as $expand=Orders see Query Syntax /docs/concepts/query-syntax . What the response looks like Each included navigation appears as a property on the parent row. Two shape rules are worth knowing: - Only requested branches are sent. Children of Orders like OrderItems stay out of the response until you ask for them — including a navigation loads its scalar fields, not its own children. - Parents are never dropped. A customer with zero orders gets an empty array, and the root page/row count is unaffected by what you include. Combining with select include decides what gets loaded ; select decides what gets returned : This is the bandwidth-friendly pair — children load with only the listed fields. The rule connecting them: selecting through a navigation path requires that path in include otherwise QueryValidationException , NAVIGATION_PROJECTION_REQUIRES_INCLUDE — the message even tells you which include= value to add . Governance: which paths are includable - With AllowedIncludes set, anything outside the list is rejected with INCLUDE_ACCESS_DENIED or, in lenient mode, the unauthorized branch is dropped from the response — a security property, not a convenience . - With it unset, any valid navigation of the entity type may be included. For public endpoints, set it explicitly — unbounded include trees are the classic data-exfiltration and cartesian-blow-up vector. - Governance paths are enforced for includes and expansions alike see Expand /docs/guides/expand and Security /docs/security . Provider behavior - EF Core : includes are composed into the query as filtered Include / ThenInclude expressions; EF's own query pipeline emits the SQL join or follow-up query per provider behavior and materializes the graph. Tracked queries would additionally fix up inverses — FlexQuery runs no-tracking by default. - Dapper : FlexQuery issues one root query plus batched child queries per include level SELECT … WHERE CustomerId IN page keys , so the server work stays bounded by what is on the current page — a single include=Orders even collapses to one streamed join command behind the scenes. - Grouping queries reject include/expand outright GROUPBY_INCLUDE_CONFLICT — the row model has no parent entity to hang a graph on. Pair groupBy with aggregate instead. Common mistakes include=Orders loads every related row. A customer with 800 orders returns 800 child objects. If you need \"top 5 recent orders\", use expand with take and sort — or shape the relation with select . Bidirectional navigations Order.Customer with back-reference to Customer.Orders will serialize in circles unless the inverse is JsonIgnore — the sample domain does exactly that. Related - Expand /docs/guides/expand — filtered, sorted, size-bounded includes - Projection /docs/guides/projection — shape what loads into what returns - Security & Governance /docs/security"},{"title":"Keyset Pagination","description":"Cursor-based paging that stays fast at any scroll depth.","section":"","slug":"guides/keyset-pagination","headings":[{"text":"Keyset Pagination","id":"keyset-pagination","level":1},{"text":"The three parameters","id":"the-three-parameters","level":2},{"text":"First page, next pages, done","id":"first-page-next-pages-done","level":2},{"text":"Rules that will bite you","id":"rules-that-will-bite-you","level":2},{"text":"How it executes","id":"how-it-executes","level":2},{"text":"Server-side usage (optional)","id":"server-side-usage-optional","level":2},{"text":"When to stick with offset paging","id":"when-to-stick-with-offset-paging","level":2},{"text":"Related","id":"related","level":2}],"body":"Keyset Pagination Offset paging asks the database to count and discard rows before yours; deep page values get slower the further you scroll. Keyset cursor paging instead remembers where the last page ended and asks for whatever comes after that point — cost stays flat whether you are on page 1 or page 10,000, which makes it the right mode for infinite scrolling, mobile feeds, and large exports. The three parameters | Wire parameter | Meaning | |---|---| | useKeysetPagination=true | Requests keyset mode. Sort is mandatory. | | sort= | The seek ordering — at least one field, ideally ending in a unique column | | cursor= | Opaque token from the previous response's nextCursorToken | The token is an opaque, versioned Base64 string carrying the sort-key values of the last row. Treat it as a black box: never build, decode, or edit it client-side; pass back exactly what came from the server. First page, next pages, done First request — no cursor yet: Subsequent requests feed the token back: When a page comes back empty data: , you have reached the end — the token stops being produced. Because keyset mode's purpose is forward scrolling, the server does not return a \"previous page\" token. Keyset responses also skip the totalCount query by default that is the point: no counting at all — totalCount is null. Ask for a count on the first page only if the UI needs one: &includeCount=true . Rules that will bite you - A sort is required. Keyset without sort fails — the provider throws \"Keyset pagination requires at least one sort field\". Order by a column, then a tiebreaker usually the key : sort=OrderDate:desc,Id:desc . - Offset and cursor cannot mix. Sending page together with keyset mode is a validation error PAGINATION_MODE_CONFLICT — choose one style per request. - The cursor must match the current sort. The token encodes one value per sort field; if a client replays a token against a different sort , the shape check fails with CURSOR_MISMATCH . Changing the ordering mid-scroll resets the position. - Nulls limit seekability. A cursor value that is null over a non-nullable key errors with CURSOR_NULL_VALUE ; sort a non-nullable column or a nullable one you can tolerate losing across as the final tiebreaker. - Malformed or tampered tokens fail to deserialize and are treated as \"no cursor\" — the first page is returned rather than an error, so always keep tokens server-supplied end to end. - Data written between page fetches shows up or disappears naturally — keyset gives you stable ordering , not a snapshot. If you need both, version the underlying query yourself a tag column or CreatedDate @p0 OR A = @p0 AND B @p1 with the correct direction per field and LIMIT -style paging — a single command per page, no offset counting at all. - EF Core composes the same seek predicate into the expression tree and lets the provider translate it server-side. Server-side usage optional Keyset mode can be configured directly on options instead of via the wire — e.g. a \"load more\" endpoint that always pages forward: With QueryResult.NextCursorToken the loop becomes trivial: When to stick with offset paging Random access \"jump to page 23\" , stable page numbers in admin grids, and the ability to show \"1,234 results\" cheaply all favor offset paging /docs/guides/paging . The two modes share the same envelope, so UIs can grow into keyset without reworking the result shape. Related - Paging /docs/guides/paging · Query Result /docs/concepts/query-result"},{"title":"Operators","description":"The complete operator reference - canonical names, aliases, type rules, and provider behavior.","section":"","slug":"guides/operators","headings":[{"text":"Operators","id":"operators","level":1},{"text":"Canonical operators","id":"canonical-operators","level":2},{"text":"Comparison","id":"comparison","level":3},{"text":"Text","id":"text","level":3},{"text":"Sets and ranges","id":"sets-and-ranges","level":3},{"text":"Null checks","id":"null-checks","level":3},{"text":"Collection operators","id":"collection-operators","level":3},{"text":"Aliases","id":"aliases","level":2},{"text":"Type rules","id":"type-rules","level":2},{"text":"Execution by provider","id":"execution-by-provider","level":2},{"text":"Operator governance","id":"operator-governance","level":2},{"text":"Where operators appear","id":"where-operators-appear","level":2},{"text":"Related","id":"related","level":2}],"body":"Operators Every filter, HAVING condition, and expanded-branch filter in FlexQuery is built from one fixed set of operators. This page is the authoritative reference: the canonical names, the aliases that normalize into them, which .NET types each operator works with, how governance restricts them per field, and how each provider executes them. Canonical operators The parser normalizes every recognized operator to one of these canonical strings -- Status:EQ:Active and Status:eq:Active are the same query: Comparison | Operator | Meaning | Works with | |---|---|---| | eq | equals | scalars, strings, enums, dates, numbers, bools | | neq | not equals | as above | | gt | greater than | numbers, dates, comparable values | | gte | greater than or equal | as above | | lt | less than | as above | | lte | less than or equal | as above | Text | Operator | Meaning | Notes | |---|---|---| | contains | substring search | case-sensitive in memory; collation-sensitive in SQL | | startswith | prefix match | string properties only | | endswith | suffix match | string properties only | | like | SQL-style pattern | % = any run, _ = one char; executed through provider LIKE support | Sets and ranges | Operator | Value format | Example | |---|---|---| | in | comma-separated list | Status:in:Active,Pending | | notin | comma-separated list | Status:notin:Cancelled,Refunded | | between | two comma-separated bounds, inclusive | CreatedDate:between:2024-01-01,2024-02-01 | Null checks | Operator | Value | Example | |---|---|---| | isnull | none | DeletedAt:isnull | | isnotnull | none | Email:isnotnull | Collection operators These target collection navigation paths; validation rejects them on scalar fields NOT_A_COLLECTION / TYPE_MISMATCH : | Operator | Meaning | Example | |---|---|---| | any | at least one related row matches | Orders:any:TotalAmount:gt:100 | | all | every related row matches | Orders.all:Status:eq:Delivered | | count | count of related rows, compared to a value | Orders:count:gt:3 , Orders.count Status:eq:Pending :gte:2 | any / all take their operand as a filter expression on the related type -- one nesting level down the same field:op:value grammar applies. The count form appends its comparison :op:value after the collection path. Aliases Every operator also accepts word aliases and, where the operator arrives as its own string, symbolic ones : | Canonical | Aliases | |---|---| | eq | equal , equals ; = , == | | neq | ne , notequal ; = , < | | gt | greaterthan ; | | gte | ge , greaterthanorequal ; = | | lt | lessthan ; = AND = AND <= | two comparisons | | isnull / isnotnull | IS NOT NULL | IS NOT NULL | null checks | | any | correlated EXISTS | EXISTS subquery | .Any ... | | all | NOT EXISTS NOT ... | NOT EXISTS subquery | .All ... | | count | scalar count subquery | SELECT COUNT ... predicate | .Count compared | Two semantics worth knowing: - all compiles to a double-negated NOT EXISTS , so an entity with no related rows passes an all check vacuous truth, matching SQL . - Text comparisons are ordinal in memory and collation-dependent in EF Core/Dapper -- case behavior follows the database. Operator governance Per-field allow-lists are keyed by field case-insensitive and hold the canonical operator strings: Clients on City then get OPERATOR_NOT_ALLOWED for City:contains:ber ; the request never reaches the database. Where operators appear 1. The root filter parameter Filtering /docs/guides/filtering . 2. expand branch filters Orders all:Status:eq:Shipped; take=5 -- see Expand /docs/guides/expand . 3. HAVING conditions, restricted to eq ne gt gte lt lte comparisons over declared aggregate values Grouping & Aggregates /docs/guides/grouping . 4. The flat filters condition list on FlexQueryRequest { \"field\": ..., \"operator\": ..., \"value\": ... } . Related - Filtering /docs/guides/filtering - expression grammar and composition - Security & Governance /docs/security - field and operator allow-lists - Validation /docs/guides/validation - the full error-code catalog"},{"title":"Paging","description":"Offset paging with page/pageSize, count control, distinct, and deterministic ordering.","section":"","slug":"guides/paging","headings":[{"text":"Paging","id":"paging","level":1},{"text":"Parameters","id":"parameters","level":2},{"text":"Sizing limits","id":"sizing-limits","level":2},{"text":"Turning counts off","id":"turning-counts-off","level":2},{"text":"Sorting is not optional","id":"sorting-is-not-optional","level":2},{"text":"Distinct","id":"distinct","level":2},{"text":"Full round-trip example","id":"full-round-trip-example","level":2},{"text":"Offset vs keyset","id":"offset-vs-keyset","level":2},{"text":"Related","id":"related","level":2}],"body":"Paging Every query is paged by default. The offset pair — page plus pageSize — slices one window out of the sorted, complete result set, and the response carries the totals clients need to drive pagination controls. Parameters | Wire parameter | Meaning | Default | Behavior | |---|---|---|---| | page | 1-based page number | 1 | Out-of-range and non-positive values clamp to the first page | | pageSize | rows returned per page | server default 20 | Clamped to 1 … the configured ceiling default 1000 | - totalCount — rows matching the filters before paging and before grouping, when relevant; see Grouping /docs/guides/grouping for the grouped-count nuance . Null if counting is switched off. - totalPages , hasNextPage , hasPreviousPage are computed from the counts — never trust them when totalCount is null. - Asking past the end gives an empty data and hasNextPage: false — it is not an error; clients can probe total length safely. Sizing limits The ceiling is configuration, not wire input — clients can never widen it: A per-request override opt.MaxPageSize = 50 in the configure delegate of a provider call tightens it for one endpoint; looser values are still clamped. Page size is clamped down at parse time with no error — a client asking pageSize=5000 simply gets the maximum. Turning counts off The count query is skipped one less round-trip per page , totalCount / totalPages are null. The same applies globally: options.IncludeTotalCount = false in startup configuration makes counting opt-in, while includeCount=true on the wire asks for it per request. Use includeCount=false for infinite-scroll UIs where only the first page or none at all needs the total. Sorting is not optional Paged results must be sorted to be stable. Add a deterministic sort on every paged query — ideally ending in a unique column and enforced endpoint-side with DefaultSortField : Without a total order, rows whose sort keys tie can shift positions between OFFSET calculations, and duplicates or gaps appear across pages. Distinct distinct applies before projection so that only matching columns are compared EF uses the provider's DISTINCT ; Dapper emits SELECT DISTINCT . It composes with paging and counting — with groupBy present, DISTINCT acts on the grouped rows. Full round-trip example Clients that only need a \"next page\" button can ignore totalCount entirely and poll hasNextPage — the pattern that pairs naturally with keyset pagination /docs/guides/keyset-pagination below. Offset vs keyset Offset paging with deep page values forces the database to count and discard skipping rows; page=10000 is never fast. When a UI only scrolls forward or renders an endless list , prefer the cursor-based mode — see Keyset Pagination /docs/guides/keyset-pagination , which documents the same data / totalCount envelope with nextCursorToken plus the validation rules that mix offset and cursor parameters an explicit page and a cursor together are rejected as a PAGINATION_MODE_CONFLICT . Related - Keyset Pagination /docs/guides/keyset-pagination · Query Result /docs/concepts/query-result"},{"title":"Projection","description":"Select exactly the fields clients need — paths, aliases, wildcards, nested selection, and projection modes.","section":"","slug":"guides/projection","headings":[{"text":"Projection","id":"projection","level":1},{"text":"Basic field selection","id":"basic-field-selection","level":2},{"text":"Aliases","id":"aliases","level":2},{"text":"Wildcards","id":"wildcards","level":2},{"text":"Nested selection","id":"nested-selection","level":2},{"text":"Interaction with paging and counting","id":"interaction-with-paging-and-counting","level":2},{"text":"Nested projections that branch","id":"nested-projections-that-branch","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2},{"text":"Related","id":"related","level":2}],"body":"Projection select decides which fields the response contains — trimming payloads, hiding internal properties, and keeping SQL on the columns actually needed. Projection is orthogonal to filtering, sorting, and paging: it runs last, on the rows and counts those stages already produced. Basic field selection Only the listed fields appear in data . Field paths use dots to reach into navigations, and the result keeps the natural object shape: Selecting through a navigation requires that navigation to be loaded too — add it to include see Include /docs/guides/include . Otherwise validation rejects the request: The navigation path 'Orders' is referenced in the select clause but is not included. Add include=Orders or remove the path from select . Aliases Clients rename output fields without touching your model — two spellings are accepted: Aliases apply only to the response; filters and sorts keep addressing the real property names. Under the default camelCase JSON settings, aliases are emitted as written. Wildcards select= returns every scalar field of the root type: Restrictions enforced by validation: - the wildcard is valid only at the top level Orders. is not supported — list the child fields explicitly inside a nested select instead - cannot be combined with other selections in the same list - selects scalar properties only — navigations never come along unless asked for via include / expand - a duplicate wildcard select= , is ignored with a validation warning Nested selection Parenthesized groups project children as nested objects — with their own inner selection, wildcards, and aggregate-style children: Rules enforced server-side: - navigation path aliases go on the parent field, not on the nested children. Customer custOrders on a child collection inside a select is rejected. - a nested navigation can be selected under one alias only — two aliases for the same path Orders a ,Orders b raise DUPLICATE_ALIAS . - the nested parent must also be included include=Orders — same rule as dotted paths above. Interaction with paging and counting Selection changes what rows look like, not which rows exist: - totalCount counts the pre-paging source set, independent of select . - distinct + select de-duplicates on the projected shape — select=City + distinct=true is the \"list of cities\" pattern. - with groupBy , every non-aggregate field in select must appear in groupBy GROUPBY_PROJECTION_MISMATCH , and select= is not allowed in grouped queries. Nested projections that branch When a select references a deeper graph with multiple branches Orders.OrderItems.Product , FlexQuery keeps the hierarchy nested by default. The query-string mode parameter reshapes that output: | mode | Shape | Notes | |---|---|---| | nested default | data .orderItems .product.name | the plain hierarchy | | flat | collections flatten into leaf rows SQL-join semantics via SelectMany ; a query with Id and a leaf path collapses root values onto leaf rows | single linear path branching only — multiple branches throw | | flat-mixed | like flat , but root scalar fields repeat on every leaf row | preferred for grid exports | Mode is a property of the whole request also available programmatically as ProjectionMode.Flat / FlatMixed / Nested on QueryOptions . Dapper rejects multiple branching navigation paths in Flat mode; EF Core falls back to its correlated-query machinery, which the provider handles server-side. Complete worked example Reading the pieces: filter and sort operate on real property names; include=Orders authorizes the relationship; the nested Orders ... list projects only three child fields, two of them under client aliases ref , amount ; paging metadata reflects the filtered customer count; and a client that never asked for Email couldn't see it — select is the response contract. Common mistakes - Listing a field twice select=Id,FirstName,Id collapses silently — later duplicates are ignored except wildcards, which validate . - Expecting select to restrict what clients can filter : it doesn't. Governance AllowedFields / SelectableFields controls reachability on the wire; select controls the output shape for authorized fields only. Related - Include /docs/guides/include · Expand /docs/guides/expand · Typed DTO Projection /docs/guides/typed-dto-projection"},{"title":"Query Composition","description":"Combine, merge, and hand-build QueryOptions - the programmable heart of FlexQuery.","section":"","slug":"guides/query-composition","headings":[{"text":"Query Composition","id":"query-composition","level":1},{"text":"The four ways to get options","id":"the-four-ways-to-get-options","level":2},{"text":"Merging client input with server policy","id":"merging-client-input-with-server-policy","level":2},{"text":"Stage-by-stage application","id":"stage-by-stage-application","level":2},{"text":"Plain in-memory execution","id":"plain-in-memory-execution","level":2},{"text":"Keyset composition","id":"keyset-composition","level":2},{"text":"What composition does not bypass","id":"what-composition-does-not-bypass","level":2},{"text":"Related","id":"related","level":2}],"body":"Query Composition Everything FlexQuery accepts from the wire converges on one object: QueryOptions . Parsing, building in code, converting an adapter payload, or merging server-side rules onto a client query are all the same operation -- producing a QueryOptions instance the providers then execute. Understanding that hub is what lets you layer user input, tenant policy, and saved report definitions without special-purpose code. The four ways to get options All four execute identically -- same validation pipeline, same governance, same result shape: Merging client input with server policy The composition pattern for multi-tenant or role-scoped APIs: parse what the client sent, then add what they must not control. Prefer the provider call for the rest of the policy -- governance applied through the configure delegate cannot be overridden by the client later, whereas anything baked into QueryOptions is data the rest of the pipeline consumes as given. The model classes are plain .NET types under FlexQuery.NET.Models filters, projection, paging : | Model | Key members | |---|---| | FilterGroup | Logic And / Or , Filters , Groups , IsNegated | | FilterCondition | Field , Operator canonical name , Value , ScopedFilter | | SortNode | Field , Descending , Aggregate / AggregateField on grouped sorts | | SelectNode | Field , Alias , Children | | IncludeNode | Path , Filter , Sort , Take , Children the expand tree | | Aggregate | Function AggregateFunction enum , Field , Alias | | PagingOptions | Page , PageSize , Disabled | Stage-by-stage application When you need the pieces yourself -- applying a parsed query to a queryable you already built -- the individual stages are public on IQueryable : | Method | Result | |---|---| | Apply options | full pipeline at once | | ApplyFilter options | adds the validated WHERE | | ApplySort options | adds ordering | | ApplyPaging options | keyset seek or offset paging, per options | | ApplySelect options | projection -- returns IQueryable | Two rules: - ApplyFilter throws InvalidOperationException \"Filter options are required.\" when called with no filter -- call it only when options.Filter is set, or use Apply . - ApplySelect changes the element type to object dynamic projections , so anything after it is no longer IQueryable . EF Core adds one more stage for graphs: ApplyExpand options composes the include/expand trees and the executor calls it for you inside FlexQueryAsync ; you only reach for it in hand-built pipelines . Plain in-memory execution No EF, no Dapper: the core package runs the exact same options over any IQueryable -- LINQ to Objects, Collections, an in-memory list: This is the recommended unit-test seam: build a List .AsQueryable , run a request through it, and assert on the QueryResult -- same parser, validators, and operators as production. Keyset composition For manual cursor-driven paging over an ordered queryable, SeekAfter applies the cursor boundary predicate directly: For multi-field cursors and token plumbing, use the provider path instead -- keyset mode on QueryOptions carries the cursor and the result carries the next token see Keyset Pagination /docs/guides/keyset-pagination . What composition does not bypass Whatever route a QueryOptions took to exist, execution still runs the full validation pipeline against it: hand-built filters referencing unknown fields fail with the same QueryValidationException , governance allow-lists apply, and paging still clamps. A QueryOptions is a request, not a privilege -- only the provider call with a configure delegate adds server-controlled policy. Related - Query Options /docs/concepts/query-options - the model in detail - Fluent API /docs/guides/fluent-api - the typed builder - Filtering /docs/guides/filtering / Operators /docs/guides/operators - expression vocabulary - Providers /docs/providers/ef-core - execution endpoints"},{"title":"Sorting","description":"Single- and multi-field sorts, aggregate sorts, and default sort behavior.","section":"","slug":"guides/sorting","headings":[{"text":"Sorting","id":"sorting","level":1},{"text":"Basic sorts","id":"basic-sorts","level":2},{"text":"Multi-field sorts","id":"multi-field-sorts","level":2},{"text":"Sorting by aggregates","id":"sorting-by-aggregates","level":2},{"text":"Default sorting and governance","id":"default-sorting-and-governance","level":2},{"text":"Why an explicit sort is not optional for paging","id":"why-an-explicit-sort-is-not-optional-for-paging","level":3},{"text":"Worked example","id":"worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2},{"text":"Related","id":"related","level":2}],"body":"Sorting The sort parameter controls result order. Sorts compose — a second field breaks ties in the first — and are applied before paging , so page boundaries are stable as long as the ordering is deterministic. Basic sorts Direction is case-insensitive asc / ASC / Asc ; any other value is a parse error with the offending item reported. Multi-field sorts Separate fields with commas. Priority is purely list order — there is no numeric priority suffix: For real-world grids, end every sort list with a unique or near-unique column usually the key . Without a final tiebreaker, rows with equal sort keys can move between pages. Sorting by aggregates When the query aggregates collections, the sort can target the aggregate instead of a scalar field: The aggregate form is function:target :direction . For count the target is a collection navigation; for sum / avg / min / max the target is a numeric property dotted paths allowed . Aggregate sorts are the only way to order by computed values, and they translate server-side: EF Core orders by COUNT ... / SUM ... in SQL, Dapper generates the matching clause with NULLS LAST on Oracle for grouped sorts . Default sorting and governance Endpoints can define a stable default: Clients that omit sort get the default injected automatically. When SortableFields governance is configured, client sort fields outside the whitelist are rejected in strict mode which is the default before anything reaches the database. With StrictFieldValidation = false , unauthorized sort fields are stripped — the injected default remains if the client sent nothing else. Why an explicit sort is not optional for paging Paging without any sorting default or client-supplied is non-deterministic: the same page number can return different rows between requests, and SQL Server / Oracle require an ORDER BY before OFFSET/FETCH . Dapper-backed queries automatically emit an ORDER BY on the mapped key columns when paging is requested with no sort, and fail with a clear error if the entity has no resolvable keys; EF Core surfaces the provider's own determinism limits. Configure DefaultSortField on every paged endpoint so clients never have to remember. Worked example The sort applies to the filtered set, totalCount counts that set, and page 2 shows items 11–20 of that ordering. Common mistakes - A sort field that does not exist on the model or is not in Selectable /governance scopes for the DTO in play → QueryValidationException FIELD_NOT_FOUND / strip in lenient mode; a typo'd field never silently falls back. - Sorting a nullable property puts nulls first/last depending on the database; don't rely on cross-database null ordering for stable pagination. - Aggregate sort spelling — a function must be one of sum , avg , count , min , max — and for grouped queries, only fields in groupBy or declared aggregate aliases may be sorted; other fields fail with GROUPBY_SORT_INVALID . Related - Paging /docs/guides/paging · Grouping & Aggregates /docs/guides/grouping"},{"title":"Typed DTO Projection","description":"Return stable DTO contracts from dynamic queries — type maps, surface protection, and field mapping.","section":"","slug":"guides/typed-dto-projection","headings":[{"text":"Typed DTO Projection","id":"typed-dto-projection","level":1},{"text":"Registering a map","id":"registering-a-map","level":2},{"text":"The public surface is the wire format","id":"the-public-surface-is-the-wire-format","level":2},{"text":"Composition example","id":"composition-example","level":2},{"text":"Grouped queries with DTOs","id":"grouped-queries-with-dtos","level":2},{"text":"Rules of thumb","id":"rules-of-thumb","level":2},{"text":"Related","id":"related","level":2}],"body":"Typed DTO Projection Dynamic queries and stable public contracts usually disagree. Entities carry internal columns, change with migrations, and are awkward to document. Typed DTO projection lets an endpoint accept dynamic FlexQuery input while returning a fixed response type: FlexQueryAsync returns QueryResult : every pipeline stage — filter, sort, select, grouping, expansion, paging — runs against the entity model, and each matching row is materialized through the entity→DTO map. Registering a map Maps live in the application-level FlexQueryMapping registry. Register them once at startup through FlexQueryCore.Configure they are automatically consulted by every later typed execution : Members with no configured mapping map by convention same name on both sides . Navigation members map through ForNavigation : ForMember accepts constant expressions e = \"Enterprise\" , member access, and string-returning computed calls such as e = e.FullName — useful for derived output fields. A scalar expression that cannot be reduced to a single column must be exposed as a mapped property or a computed string member; Dapper additionally refuses computed scalars and tells you to use the EF Core provider in that case. Global maps are registered once startup . Per-request alternatives exist on the execution options CreateMap / MapField when a DTO is endpoint-specific. The public surface is the wire format When a DTO is in play, its type replaces the entity as the query surface: - filter / sort / select / groupBy / aggregate fields are resolved against DTO members CustomerName , not FirstName and emitted under DTO names. - Members with no entity backing — internal flags, EF shadows, NotMapped helpers — cannot be referenced by clients at all: they fail with FIELD_NOT_FOUND like any unknown field, even though the underlying entity has them. - Aliased selection select=Email:contact , select=Email as contact applies on top, and the response-shape converter emits exactly the selected/aliased fields for rows. - For navigation-backed DTO members, include/expansion paths must refer to the DTO name as well; the provider rewrites them to the entity navigation TranslateIncludePathsToEntity . This is a security property as much as a convenience: entity internals SSN-like columns, flags, audit fields are invisible to the API contract unless you map them deliberately. Composition example A public orders endpoint with an internal model — include , expanded branch, projection all expressed in DTO names: Grouped queries with DTOs A typed response can also receive group rows: as with entity queries, the group keys and declared aggregate aliases are the addressable fields; the DTO's writable members must cover the projected fields, otherwise FlexQuery falls back to dynamic grouped rows rather than failing Dapper throws a clear FlexQueryException naming the field that the response type cannot represent . Rules of thumb - Map names to the public language, not the entity language; the DTO is the API. - Keep governance AllowedFields / SortableFields aligned with the DTO surface — the rules validate the same names clients use. - Computed ForMember expressions execute per row after projection on EF; they are not filterable, sortable, or groupable. - One response type per endpoint contract; if two endpoints need different fields of the same entity, they are two DTOs — the type maps make that cheap. Related - Projection /docs/guides/projection · EF Core provider /docs/providers/ef-core · Dapper provider /docs/providers/dapper"},{"title":"Validation","description":"How invalid queries fail — the pipeline, strict mode, and the error model.","section":"","slug":"guides/validation","headings":[{"text":"Validation","id":"validation","level":1},{"text":"Where validation runs","id":"where-validation-runs","level":2},{"text":"Strict vs lenient","id":"strict-vs-lenient","level":2},{"text":"The error model","id":"the-error-model","level":2},{"text":"What the pipeline checks","id":"what-the-pipeline-checks","level":2},{"text":"Validating without executing","id":"validating-without-executing","level":2},{"text":"Handling errors at the HTTP edge","id":"handling-errors-at-the-http-edge","level":2},{"text":"What validation guarantees","id":"what-validation-guarantees","level":2},{"text":"Related","id":"related","level":2}],"body":"Validation FlexQuery treats every incoming query as untrusted input. Before anything reaches the database, the parsed options pass a fixed rule pipeline that checks fields, operators, types, governance lists, expansion paths, and paging-mode conflicts. This page explains what you get back when something fails — as an exception, as a result object, or as a stripped query. Where validation runs Every execution path validates the same way — query-string, FlexQueryRequest , fluent options, adapter output: - Parse problems throw QueryParseException with ParameterName , Syntax , ReceivedValue , and position info . - Semantic problems throw QueryValidationException , which carries a full ValidationResult in its Result property. - Both derive from FlexQueryException , so one catch-all at the ASP.NET layer maps them to 400-class responses — see Error Handling /docs/troubleshooting . GovernanceValidator.ValidateConfiguration also checks contradictory allow/block list combinations up-front, and QueryGovernanceOptions startup checks surface overlapping AllowedFields / BlockedFields style mistakes early. Strict vs lenient StrictFieldValidation default true decides what \"invalid\" means: | Mode | Unknown/unauthorized field or operator | Unauthorized include/expand | No client sort supplied | |---|---|---|---| | Strict default | throws QueryValidationException | throws / validation error | inject DefaultSortField | | Lenient false | silently stripped from the query | dropped | inject DefaultSortField | Lenient mode is a compatibility hatch, not a feature: clients never learn which predicates were removed, and result sets quietly grow. Default to strict and set StrictFieldValidation = false per request only where you need backwards compatibility. The error model ValidationResult exposes IsValid , ToErrorMessage , and the Errors list. Each ValidationError record has: | Member | Meaning | |---|---| | Message | human-readable explanation safe to surface to clients | | Code | machine-readable code from the table below | | Field | offending property path when applicable | QueryValidationException can be constructed from a single message code VALIDATION_ERROR or from a full ValidationResult — the provider pipeline always attaches the full result, so clients can branch on Code . What the pipeline checks The registered rule set covers — in categories, not one-by-one: - fields exist & are authorized — filter/sort/select/group/aggregate/having/expansion fields resolve against the query surface, including navigation-aware checks when the request runs against a DTO; governance allow/block/role lists are enforced FIELD_NOT_FOUND , FIELD_ACCESS_DENIED , INCLUDE_ACCESS_DENIED , GOVERNANCE_FIELD_NOT_FOUND , NAVIGATION_PROJECTION_REQUIRES_INCLUDE . - operators and types match — only supported operators per field type, values convertible INVALID_OPERATOR , OPERATOR_NOT_ALLOWED , TYPE_MISMATCH . - selects are well-formed — alias validity, duplicate/colliding selections INVALID_ALIAS , RESERVED_ALIAS , DUPLICATE_ALIAS , DUPLICATE_WILDCARD ; nested select syntax errors surface as QueryParseException on the select parameter. - include/expand discipline — paths exist, are navigations or collection-typed INCLUDE_PATH_NOT_FOUND , EXPAND_PATH_NOT_FOUND , NAVIGATION_PROPERTY_REQUIRED , NOT_A_COLLECTION , no duplicates EXPAND_DUPLICATE_PATH , each expand path has a matching include EXPAND_PATH_NOT_IN_INCLUDE , no root-prefixed nesting EXPAND_ROOT_PREFIXED_PATH , sort/take only on collections EXPAND_SORT_ON_REFERENCE , EXPAND_TAKE_ON_REFERENCE , and include/expand are blocked on grouped queries GROUPBY_INCLUDE_CONFLICT . - aggregate/having coherence — HAVING needs GROUP BY and declared aggregates HAVING_WITHOUT_GROUPBY , HAVING_REQUIRES_GROUPBY , HAVING_ALIAS_MISMATCH , AGGREGATE_NOT_DECLARED , grouping/sorting rules hold GROUPBY_SORT_INVALID , GROUPBY_PROJECTION_MISMATCH , GROUPBY_WILDCARD_NOT_ALLOWED , aggregate targets are valid INVALID_AGGREGATE_TARGET , INVALID_COUNT_TARGET , AGGREGATE_SELECT_WITHOUT_GROUPBY . - keyset integrity — cursor/sort agreement CURSOR_MISMATCH , CURSOR_NULL_VALUE and offset-vs-keyset conflicts PAGINATION_MODE_CONFLICT . - DTO surface protection — entity-only members can't be reached through the wire when a projection type is in play DtoSurfaceProtectionRule . Validating without executing For test suites, query-linting, and admin tooling: There is also a Validate this IQueryable , QueryOptions overload that checks against a concrete queryable's model, and a ValidateOrThrow used internally by the providers. Handling errors at the HTTP edge The library throws; it does not invent a wire format. A small action filter or middleware keeps responses consistent: Remember that unhandled provider/EF translation failures surface as provider exceptions, not FlexQueryException s. What validation guarantees A query that passes validation is not guaranteed to produce sensible business results — it is guaranteed to contain only fields, operators, paths, and paging modes the server declared acceptable, and to fail before the database sees anything it might have to guess about. Related - Security & Governance /docs/security — the options the rules enforce - Troubleshooting /docs/troubleshooting — decoding every rejection"},{"title":"AG Grid","description":"Server-Side Row Model adapter for AG Grid.","section":"Integrations","slug":"integrations/ag-grid","headings":[{"text":"AG Grid","id":"ag-grid","level":1},{"text":"What the adapter maps","id":"what-the-adapter-maps","level":2},{"text":"Convert the request","id":"convert-the-request","level":2},{"text":"Applying onto existing options","id":"applying-onto-existing-options","level":2},{"text":"Parsing raw JSON","id":"parsing-raw-json","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"AG Grid AG Grid's Server-Side Row Model SSRM sends a structured JSON request — paging window, filter model, sort model, row-group columns — and expects rows plus a row count back. FlexQuery.NET.Adapters.AgGrid translates that contract onto QueryOptions in one direction and the QueryResult back into the SSRM payload in the other, so a grid speaks directly to your database through the full FlexQuery pipeline. What the adapter maps | AG Grid concept | FlexQuery target | |---|---| | startRow / endRow | Paging window | | filterModel set, number, text, date, join operators | Filter conditions/groups | | sortModel | Sort nodes | | rowGroupCols + groupKeys | groupBy + group filters | | valueCols | Aggregates | Convert the request ToQueryOptions maps the entire request model; ToAgGridServerSideResponse produces the SSRM payload rowData + rowCount , group rows carrying their child-key metadata so drill-down works . Overloads accept an explicit camelCase flag and AgGridResponseFieldOptions for renaming the group metadata fields group , field , level , leafGroup , childCount , � . Applying onto existing options When you have endpoint defaults the grid should not override: Parsing raw JSON For minimal APIs or controllers that read the body as JsonElement : Complete worked example A row-grouped revenue grid — grouping and aggregates flow from the grid's column config: The grid's group drill-down sends the same request shape with groupKeys populated; the adapter turns those into group-key filters, and FlexQuery pages the matching rows. Adapter-produced options flow through the same validation pipeline. A grid column that is not in AllowedFields fails validation like any other request — whitelist grid-visible fields explicitly. Common mistakes SSRM expects the adapter's payload shape row count at the current level, group rows with child metadata . Always return via ToAgGridServerSideResponse , not the raw QueryResult ."},{"title":"ASP.NET Core","description":"Controllers, [FieldAccess] security attributes, and JSON options.","section":"Integrations","slug":"integrations/aspnetcore","headings":[{"text":"ASP.NET Core","id":"aspnet-core","level":1},{"text":"Setup","id":"setup","level":2},{"text":"Endpoint pattern","id":"endpoint-pattern","level":2},{"text":"FieldAccessAttribute","id":"fieldaccessattribute","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"ASP.NET Core FlexQuery.NET.AspNetCore binds FlexQuery to the MVC model-binding and filter pipeline. It adds three things: DI registration helpers, the FieldAccess attribute for per-endpoint governance, and the result-shape JSON converter that makes select surfaces authoritative in serialized output. Setup AddFlexQuerySecurity registers the FieldAccessFilter which reads FieldAccess attributes and the QueryResultShapeConverterFactory . AddFlexQueryJson registers only the JSON converter. A combined shortcut exists too: This performs FlexQueryCore.Configure configure global defaults and global type maps . Endpoint pattern FieldAccessAttribute Apply on an action takes priority or the controller. All properties merge into the request's execution options before validation: | Property | Purpose | |---|---| | Allowed | Allow-list of field names. | | Blocked | Blocked field names. | | Filterable | Fields clients may filter on. | | Sortable | Fields clients may sort by. | | Selectable | Fields clients may select. | | Groupable | Fields clients may group by. | | Aggregatable | Fields clients may aggregate. | | AllowedIncludes | Navigation paths clients may include or expand. | | DefaultSortField / DefaultSortDirection | Default ordering when the client does not sort. | | MaxDepth | Maximum nested field-path depth -1 = unset . | The filter resolves the attribute with action over controller priority, merges each list with any already-resolved execution options, and stores the result in HttpContext.Items . Reading execution options from HttpContext The FieldAccess filter stores the resolved options on the request; the provider call itself is driven by the options you pass. The intended pattern is to hand the attribute's options to FlexQueryAsync — the GetFlexQueryExecutionOptions extension reads them back for exactly that purpose: Custom middleware, authorization checks, or adapters can inspect or extend the same object before execution. Complete worked example A locked-down public endpoint wiring FieldAccess into execution explicitly: Requests against this endpoint: filter=City:eq:Berlin works; filter=Email:contains:@ fails validation; include=Orders fails empty allow-list ; pageSize=500 works but the JSON surface only ever contains Id , City , Status . Add FlexQuery.NET.OpenApi so Swagger documents the query parameters of FlexQuery endpoints — see OpenAPI . Common mistakes Without it, FieldAccess attributes are inert decoration — nothing reads them. The filter registration is what activates per-endpoint governance. FieldAccess governs fields, not page sizes. Set MaxPageSize via global config or the per-request delegate."},{"title":"Kendo UI","description":"Kendo UI DataSource request adapter.","section":"Integrations","slug":"integrations/kendo","headings":[{"text":"Kendo UI","id":"kendo-ui","level":1},{"text":"What the adapter maps","id":"what-the-adapter-maps","level":2},{"text":"Convert the request","id":"convert-the-request","level":2},{"text":"Applying onto existing options","id":"applying-onto-existing-options","level":2},{"text":"Parsing raw JSON","id":"parsing-raw-json","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"Kendo UI Kendo UI's DataSource posts its state as a JSON request — page, page size, sort descriptors, and a filter descriptor tree with nested and / or logic. FlexQuery.NET.Adapters.Kendo maps that request onto QueryOptions so a Kendo grid speaks to your database through the full FlexQuery pipeline. What the adapter maps | Kendo concept | FlexQuery target | |---|---| | page / pageSize or skip / take | Paging options | | sort descriptors field , dir | Sort nodes | | filter descriptor tree logic , filters | Filter groups with nested logic | | group descriptors field , aggregate | groupBy + per-group aggregate declarations | | aggregate descriptors field , aggregate | Aggregates grand totals / grouped | Nested filter trees translate faithfully — a Kendo filter with logic: \"or\" containing sub-filters becomes an OR group, recursively. Convert the request Applying onto existing options Merge the Kendo request into endpoint defaults: Parsing raw JSON For minimal APIs or when the DataSource payload arrives as JsonElement : Complete worked example A server-filtered, server-sorted Kendo grid: Client-side filtering in the Kendo filter row produces, for example: ...which FlexQuery executes as a validated, parameterized WHERE City = @p0 AND Status < @p1 . Kendo-produced options pass through the same validation pipeline — grid columns must be in AllowedFields , and server-side defaults you set before ApplyKendoRequest survive the merge. Common mistakes Kendo expects the payload shape & 123; data, total & 125; . Returning the full FlexQuery envelope breaks the grid's schema binding — project the two fields explicitly."},{"title":"OpenAPI","description":"Automatic OpenAPI/Swagger documentation for FlexQuery endpoints.","section":"Integrations","slug":"integrations/openapi","headings":[{"text":"OpenAPI","id":"openapi","level":1},{"text":"Setup","id":"setup","level":2},{"text":"What you get","id":"what-you-get","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2}],"body":"OpenAPI A dynamic query API is hard to document by hand: every endpoint accepts a shifting set of query parameters, and the request/response models are FlexQuery types your consumers have never seen. FlexQuery.NET.OpenApi fills the gap — it enriches your OpenAPI document with descriptions and canonical examples for FlexQuery models and query parameters, so Swagger UI shows a complete, usable contract without manual annotation. It targets .NET 9 and .NET 10. Setup Two calls with distinct jobs: - AddFlexQueryOpenApi registers the schema and operation transformers with the service collection. - AddFlexQuery on OpenApiOptions attaches those transformers to the OpenAPI document pipeline Microsoft.AspNetCore.OpenApi . Then expose the document as usual: What you get - Schema descriptions for all FlexQuery model types — FlexQueryRequest , FlexQueryParameters , QueryResult , FilterGroup , FilterCondition , SortNode , PagingOptions , Aggregate , HavingCondition , IncludeNode , ProjectionMode , LogicOperator , AggregateFunction . - Parameter documentation for filter , select , sort , page , pageSize , includeCount — with format hints so consumers know what a valid value looks like. - Canonical examples — production-quality, strongly typed examples for FlexQueryRequest , FlexQueryParameters , and QueryResult . Zero further configuration — one registration per service collection and document. Complete worked example A documented FlexQuery endpoint: With controllers like: The generated document describes FlexQueryParameters ' properties with usage text and embeds a complete example request/response — a consumer can call the endpoint correctly from Swagger UI alone. The package targets Microsoft.AspNetCore.OpenApi the built-in .NET 9+ document pipeline . Swashbuckle-based setups migrate to AddOpenApi / MapOpenApi to use it — see the migration guide ."},{"title":"Overview","description":"What FlexQuery.NET is, why it exists, and how the pieces fit together.","section":"Introduction","slug":"introduction","headings":[{"text":"FlexQuery.NET","id":"flexquerynet","level":1},{"text":"Why FlexQuery.NET","id":"why-flexquerynet","level":2},{"text":"How it works","id":"how-it-works","level":2},{"text":"Package ecosystem","id":"package-ecosystem","level":2},{"text":"The shape of an endpoint","id":"the-shape-of-an-endpoint","level":2},{"text":"Where to next","id":"where-to-next","level":2}],"body":"FlexQuery.NET Every dynamic API eventually reimplements the same query engine: optional filters that compose, sortable columns, paging metadata, field selection, related-data loading — each one hand-built, each one a potential injection surface. FlexQuery.NET is that engine, done once: it transforms query parameters sent by clients into secure, server-side expression trees that translate to SQL. GET /api/customers?filter=Status:eq:Active&sort=LastName:asc&page=1&pageSize=20&select=Id,FirstName,Email is handled by a single FlexQueryAsync call. Why FlexQuery.NET - No OData dependency — powerful querying without OData's complexity, setup, and tight coupling. - 100% server-side — all operations translate to SQL via expression trees. Nothing is fetched and filtered in memory; there is zero client evaluation. - Security first — declare allowed and blocked fields per endpoint; every request is validated against your model and governance rules before any query runs. - Multi-format — the native DSL, FQL SQL-inspired , and MiniOData syntaxes on the same endpoint, all parsing to one internal model. - Multiple providers — Entity Framework Core, Dapper, or any IQueryable source. - Observable — pipeline events, timing reports, and SQL previews built in. How it works 1. A client sends query parameters filter , sort , page , select , … or a JSON request model. 2. FlexQuery parses them into a QueryOptions model using the selected query syntax. 3. Validation checks every field and operator against your entity model and governance rules — rejected requests never reach the database. 4. The provider EF Core or Dapper applies the options as expressions or generated SQL. 5. A QueryResult returns data plus paging metadata, aggregates, and cursor tokens. The full pipeline is described in Execution Pipeline /docs/concepts/pipeline . Package ecosystem | Package | Purpose | |---|---| | FlexQuery.NET | Core query engine — parsing, filtering, sorting, paging, projection, validation | | FlexQuery.NET.EntityFrameworkCore | Async execution, includes, and typed DTO queries for EF Core | | FlexQuery.NET.Dapper | SQL generation and execution for Dapper | | FlexQuery.NET.AspNetCore | ASP.NET Core integration with FieldAccess security attributes | | FlexQuery.NET.Diagnostics | Execution diagnostics, timing, and observability | | FlexQuery.NET.OpenApi | OpenAPI/Swagger documentation for FlexQuery endpoints | | FlexQuery.NET.Adapters.AgGrid | AG Grid Server-Side Row Model request/response adapter | | FlexQuery.NET.Adapters.Kendo | Kendo UI DataSource request adapter | | FlexQuery.NET.Parsers.Fql | FQL FlexQuery Language syntax parser | | FlexQuery.NET.Parsers.MiniOData | Lightweight OData-compatible syntax parser | All packages target .NET 6, .NET 8, and .NET 10 FlexQuery.NET.OpenApi targets .NET 9 and .NET 10 . The shape of an endpoint Everything below is a complete ASP.NET Core controller — this is genuinely all it takes: From there, capability grows by configuration — governance sets, expand, aggregates, keyset paging — not by writing new endpoint code. Where to next - Installation /docs/getting-started/installation — add the packages to your project. - First Query /docs/getting-started/first-query — build a working endpoint in minutes. - Configuration /docs/concepts/configuration — global defaults and per-request overrides. - Query Syntax /docs/concepts/query-syntax — the three supported query languages. - Security & Governance /docs/security — locking endpoints down."},{"title":"v3 → v4 Change Matrix","description":"Verified v3.1.1 → v4 changes mapped to their documentation.","section":"Resources","slug":"migration/change-matrix","headings":[{"text":"v3.1.1 → v4 Change Matrix","id":"v311--v4-change-matrix","level":1}],"body":"v3.1.1 → v4 Change Matrix Every row below was verified against the source diff between the v3.1.1 release and the current v4 implementation. | Area | v3.1.1 | v4 | Change | Documentation | |---|---|---|---|---| | Configuration entry | DI-era registration | Static immutable FlexQueryCore.Configure | Changed | Configuration /docs/concepts/configuration | | EF Core options | QueryExecutionOptions.UseNoTracking/UseSplitQuery | FlexQueryEfCoreOptions + EfCoreQueryOptions bool? UseNoTracking ; UseSplitQuery removed | Renamed / Changed | EF Core /docs/providers/ef-core | | Dapper options | DapperQueryOptions : BaseQueryOptions Dialect , MappingRegistry public | DapperQueryOptions : QueryGovernanceOptions CommandTimeout , LoggerFactory ; model config moves to FlexQueryDapper.Configure + ModelBuilder | Changed | Dapper /docs/providers/dapper | | Dapper dialect | Manual ISqlDialectResolver | Auto-detection from DbConnection | Behavior changed | Dapper /docs/providers/dapper | | Dapper mapping | MappingRegistry + public conventions | Convention-first ModelBuilder + IEntityTypeConfiguration conventions internal | Replaced | Dapper /docs/providers/dapper | | Typed DTO results | Not available | FlexQueryAsync 4 overloads per provider + CreateMap / ForMember / ForNavigation | Added | Typed DTO Projection /docs/guides/typed-dto-projection | | Filtered includes | FilteredIncludes / ApplyFilteredIncludes | expand trees with filter/sort/take QueryOptions.Expand / ApplyExpand | Replaced | Expand /docs/guides/expand | | Keyset pagination | Not available | useKeysetPagination , cursor , NextCursorToken , SeekAfter | Added | Keyset Pagination /docs/guides/keyset-pagination | | Fluent API | FilterBuilder only | Query.Create + FilterGroupBuilder grammar + sort/expand/aggregate builders | Changed | Fluent API /docs/guides/fluent-api | | Sort model | SortNode in SortOption.cs | SortNode in Paging/SortNode.cs — type name unchanged | No API change | — | | Select model | List Select | List aliases + nested trees | Changed | Projection /docs/guides/projection | | Aggregates | AggregateModel in select , string functions | Dedicated aggregate parameter, AggregateFunction enum, PascalCase aliases | Changed | Grouping & Aggregates /docs/guides/grouping | | HAVING | Alias-integrity validation | HavingNode expression tree, declared-aggregate enforcement | Behavior changed | Grouping & Aggregates /docs/guides/grouping | | DSL logic operators | Conditions joined with , ; symbolic & / \\| accepted | Conditions joined with & / \\| / AND / OR ; a comma is now part of the value; ; in a filter is rejected | Behavior changed | Filtering /docs/guides/filtering | | Query syntaxes | DSL, JSON, Indexed, Generic, JQL, MiniOData, AutoDetect | DSL, FQL, MiniOData | Removed | Query Syntax /docs/concepts/query-syntax | | FQL parser package | FlexQuery.NET.Parsers.Jql , QuerySyntax.Jql | FlexQuery.NET.Parsers.Fql , QuerySyntax.Fql | Renamed | Query Syntax /docs/concepts/query-syntax | | Parser registration | DI services ServiceCollectionExtensions , MiniODataFeature | Static Fql.Register / MiniOData.Register | Changed | Query Syntax /docs/concepts/query-syntax | | Validation | Rule pipeline QueryValidator rules | Same pipeline extended — expand/HAVING/grouping/keyset rules + ValidationResult / ValidationError model | Extended | Validation /docs/guides/validation | | Field exceptions | InvalidFilterFieldException / InvalidSortFieldException | Unified hierarchy under FlexQueryException | Replaced | Validation /docs/guides/validation | | Case-insensitive filtering | CaseInsensitive / CaseInsensitiveFields = true | Removed | Removed | Migration /docs/migration/v3-to-v4 | | Governance sets | On monolithic BaseQueryOptions | Same members on QueryGovernanceOptions class split | Changed | Security /docs/security | | FieldAccess | Allowed/Blocked/Filterable/Sortable/Selectable/Groupable/Aggregatable | + AllowedIncludes ; class sealed | Changed | ASP.NET Core /docs/integrations/aspnetcore | | ASP.NET Core DI | v3-era ServiceCollectionExtensions + QueryableAspNetCoreExtensions | AddFlexQuerySecurity , AddFlexQueryJson , AddFlexQuery global config | Changed | ASP.NET Core /docs/integrations/aspnetcore | | OpenAPI | Not available | FlexQuery.NET.OpenApi AddFlexQueryOpenApi , AddFlexQuery | Added | OpenAPI /docs/integrations/openapi | | Diagnostics listener | Models.IFlexQueryExecutionListener ValueTask ×4 | Execution.IFlexQueryExecutionListener — members unchanged | Namespace move | Diagnostics /docs/diagnostics | | Debug output | DebugResult | QueryDebugInfo via ToFlexQueryDebug | Replaced | Diagnostics /docs/diagnostics | | Result model | QueryResult base members | + NextCursorToken , ResultShape | Added | Query Result /docs/concepts/query-result | | Paging validation | Loose | Malformed page / pageSize / distinct throw; out-of-range clamps 1–1000 | Behavior changed | Paging /docs/guides/paging | | Global options | Mutable | Immutable after first call | Behavior changed | Configuration /docs/concepts/configuration | | Cancellation | Not available | CancellationToken across async overloads | Added | EF Core /docs/providers/ef-core , Dapper /docs/providers/dapper | | Adapter JSON entry | FromAgGridJson string / FromKendoJson string | JsonElement.ToQueryOptions ; parser/converter classes removed | Changed | AG Grid /docs/integrations/ag-grid , Kendo /docs/integrations/kendo | | Target frameworks | net6.0/net7.0/net8.0 era | net6.0/net8.0/net10.0 OpenApi: net9.0/net10.0 | Changed | Installation /docs/getting-started/installation | Internal refactors namespace reorganizations, internalized types, test reorganization are intentionally excluded — they do not affect the public developer experience. Public types that became internal are listed in the migration guide ."},{"title":"Migrate from v3","description":"Meaningful changes between v3.1.1 and v4, and how to migrate.","section":"Resources","slug":"migration/v3-to-v4","headings":[{"text":"Migrate from v3.1.1 to v4","id":"migrate-from-v311-to-v4","level":1},{"text":"What changed at a glance","id":"what-changed-at-a-glance","level":2},{"text":"New features","id":"new-features","level":2},{"text":"Renamed","id":"renamed","level":2},{"text":"Removed","id":"removed","level":2},{"text":"Changed","id":"changed","level":2},{"text":"Configuration and registration","id":"configuration-and-registration","level":3},{"text":"No-tracking","id":"no-tracking","level":3},{"text":"DSL logical operators","id":"dsl-logical-operators","level":3},{"text":"Aggregate syntax","id":"aggregate-syntax","level":3},{"text":"HAVING","id":"having","level":3},{"text":"Paging validation","id":"paging-validation","level":3},{"text":"Exceptions","id":"exceptions","level":3},{"text":"Dapper model definition","id":"dapper-model-definition","level":3},{"text":"Provider overload shapes","id":"provider-overload-shapes","level":3},{"text":"Provider behavior changes","id":"provider-behavior-changes","level":2},{"text":"Security / governance changes","id":"security--governance-changes","level":2},{"text":"Integration changes","id":"integration-changes","level":2},{"text":"Migration steps","id":"migration-steps","level":2}],"body":"Migrate from v3.1.1 to v4 This document is based on a code-level comparison of the v3.1.1 release against the current v4 implementation. It lists only verified, developer-affecting changes. For a compact mapping of areas to documentation, see the Change Matrix /docs/migration/change-matrix . Package renames, restructured option classes, and removed legacy syntaxes require code changes. Most are mechanical; behavioral changes are listed separately below. What changed at a glance - Configuration model rebuilt — DI registration replaced by immutable static facades. - Typed DTO projection added — FlexQueryAsync with mapping. - Expand added — replaces FilteredIncludes with deep, filtered, sorted trees. - Keyset pagination added — cursor + NextCursorToken + SeekAfter . - Aggregates reworked — dedicated aggregate parameter, typed enum, HAVING tree. - FQL parser replaces JQL — package, enum, and exception renames. - Validation extended — the v3 rule pipeline gains expand/having/sort coverage. - OpenAPI package added. - Legacy syntaxes removed — JSON, Indexed, and Generic query syntaxes are gone. New features | Feature | v3.1.1 equivalent | Where to read | |---|---|---| | Typed DTO FlexQueryAsync 4 EF + 4 Dapper overloads | none | Typed DTO Projection /docs/guides/typed-dto-projection | | Mapping CreateMap , ForMember , ForNavigation , FlexQueryMapping registry | MapField only | Typed DTO Projection /docs/guides/typed-dto-projection | | expand trees filter/sort/take per branch | FilteredIncludes | Expand /docs/guides/expand | | Keyset pagination useKeysetPagination , cursor , NextCursorToken , SeekAfter | none | Keyset Pagination /docs/guides/keyset-pagination | | Query.Create fluent builder with FilterGroupBuilder | FilterBuilder only | Fluent API /docs/guides/fluent-api | | ResultShape output surface + JSON converter | none | Query Result /docs/concepts/query-result | | Governance extensions FieldAccess AllowedIncludes , options class split | governance sets on BaseQueryOptions | Security /docs/security | | CancellationToken on all async overloads | none | EF Core /docs/providers/ef-core | | FlexQuery.NET.OpenApi package | none | OpenAPI /docs/integrations/openapi | | Dapper ModelBuilder + IEntityTypeConfiguration | MappingRegistry | Dapper /docs/providers/dapper | | Dapper SQL execution logging with DECLARE scripts | none | Dapper /docs/providers/dapper | | DSL AND / OR keywords | symbolic & / \\| only | Query Syntax /docs/concepts/query-syntax | | select aliases field:alias , field as alias | none | Projection /docs/guides/projection | Renamed | v3.1.1 | v4 | Migration | |---|---|---| | Package FlexQuery.NET.Parsers.Jql | FlexQuery.NET.Parsers.Fql | Update package reference. | | QuerySyntax.Jql | QuerySyntax.Fql | Find/replace. | | JqlParseException : Exception | FqlParseException : FlexQueryException | Update catch blocks. | | QueryOptions.FilteredIncludes | QueryOptions.Expand | Find/replace; see Expand page for new syntax. | | ApplyFilteredIncludes | ApplyExpand | Find/replace. | | AggregateModel string function | Aggregate typed AggregateFunction | Update construction sites. | | HavingCondition | HavingNode tree HavingLogicalNode / HavingConditionNode / HavingGroupNode | Update construction sites. | | DebugResult | QueryDebugInfo | Find/replace. | | Models.IFlexQueryExecutionListener | Execution.IFlexQueryExecutionListener | Update using directives members unchanged . | | Models.QueryContext | Execution.QueryContext now sealed | Update using directives. | | Models.BaseQueryOptions | split into Options.BaseQueryOptions + Options.QueryGovernanceOptions | Adjust base-class references. | Note : SortOption.cs → SortNode was a file rename only — the type was already named SortNode at v3.1.1. No code change is required for it. Removed | Removed | Replacement | |---|---| | JSON / Indexed / Generic query syntaxes JsonQueryParser , AutoDetect | Native DSL, FQL, or MiniOData | | CaseInsensitive / CaseInsensitiveFields options | — comparisons follow provider semantics | | Parser DI registration ServiceCollectionExtensions in parser packages, MiniODataFeature | Static Fql.Register / MiniOData.Register | | Deprecated QueryOptions members: Skip , Top , EnableCache , Items , Ast | PagingOptions , per-call options | | InvalidFilterFieldException / InvalidSortFieldException | QueryValidationException with structured errors | | Manual Dapper Dialect config ISqlDialectResolver , DefaultSqlDialectResolver | Auto-detection from the DbConnection | | Dapper MappingRegistry / IMappingRegistry / IEntityMapping / JoinInfo | ModelBuilder + IEntityTypeConfiguration | | Dapper conventions IEntityConvention , IForeignKeyConvention , IRelationshipConvention , Default | Convention-first defaults now internal | | QueryableAspNetCoreExtensions.FlexQueryAsync | Provider FlexQueryAsync + FieldAccess filter | | FromAgGridJson string / FromKendoJson string | JsonElement.ToQueryOptions | | AgGridQueryOptionsParser / AgGridResponseConverter / KendoQueryOptionsParser | ToQueryOptions / ToAgGridServerSideResponse extensions | | UseSplitQuery option | Split-query include hydration is now internal behavior | | Public caches ExpressionCache , ParserCache , ProjectionExpressionCache | Internal caching FlexQueryCacheSettings remains public | | Public helpers ExpressionBuilder , QueryBuilder , ProjectionOptimizer , GovernanceValidator , DynamicTypeBuilder , SelectTreeBuilder , ExpressionPrinter , ExpressionTreeVisualizer , ProjectionMetadata | Not replaced — internal implementation detail | | FlexQueryParameters.RawParameters public | Internal — use model binding | Changed Configuration and registration Calling any Configure after a query has executed throws InvalidOperationException . No-tracking DSL logical operators The symbolic forms still work — this is an additive change. New in v4: AND / OR are reserved and cannot appear as unquoted values name:eq:\"AND\" is required . v3 split combined filter conditions on , . In v4 a comma after the value is part of the value : Name:eq:Ann,Salary:gt:1000 now matches a Name literally equal to \"Ann,Salary:gt:1000\" , not two conditions and ; is rejected outright . Rewrite multi-condition filters to join with & / | / AND / OR . This is the one silent grammar change in the migration — grep stored/shared filter strings. Aggregate syntax Aliases are PascalCase by default SumTotal ; explicit aliasing: aggregate=sum:Total:totalSales . HAVING Every aggregate referenced in having must be declared in aggregate v3.1.1's alias-integrity rule is replaced by declared-aggregate enforcement . having without groupBy is rejected. Paging validation Out-of-range values e.g. pageSize=99999 are clamped to MaxPageSize instead of erroring. Exceptions Dapper model definition The dialect is auto-detected from the connection; DapperQueryOptions now derives from QueryGovernanceOptions . Provider overload shapes - EF Core: FlexQueryAsync signatures now end with CancellationToken ; four typed FlexQueryAsync overloads were added. - Dapper: the five dynamic overloads became three FlexQueryParameters , IDictionary , QueryOptions plus four typed overloads. Provider behavior changes - EF Core : include hydration is composed as EF Core filtered includes the UseSplitQuery toggle is gone; the provider decides the SQL shape ; expand branches support per-branch filter/sort/take; grouped queries execute through a dedicated grouped executor. - Dapper : dialect auto-detection; DTO-aware SQL generation with type-map field rewrites; include-only joins excluded from the count query; SQL execution logging. Security / governance changes - All governance members keep their names but move to QueryGovernanceOptions . - FieldAccess gains AllowedIncludes and the class/filter become sealed . - Expand paths are governed by AllowedIncludes . Integration changes - AG Grid / Kendo : From Json string replaced by JsonElement.ToQueryOptions ; standalone parser/converter classes removed in favor of extension methods. - OpenAPI : new package for Microsoft.AspNetCore.OpenApi .NET 9/10 — Swashbuckle-era guidance is obsolete. Migration steps 1. Update package references rename Parsers.Jql → Parsers.Fql ; add OpenApi if used . 2. Replace DI registration of parsers/providers with Fql.Register , MiniOData.Register , FlexQueryEFCore.Configure , FlexQueryDapper.Configure . 3. Replace FilteredIncludes usage with expand syntax. 4. For Dapper: define the entity model via options.Model tables, keys, relationships . 5. Move aggregates out of select into aggregate ; verify having references declared aggregates. 6. Replace removed exception types with QueryValidationException handling. 7. Remove CaseInsensitive configuration and JSON/Indexed/Generic syntax usage. 8. Replace FromAgGridJson / FromKendoJson with the JsonElement overloads. 9. Re-run test suites — paging parameter validation is stricter malformed values now throw and HAVING enforcement is stricter."},{"title":"Dapper Provider","description":"Direct SQL generation and split-query hydration for Dapper-backed APIs.","section":"","slug":"providers/dapper","headings":[{"text":"Dapper Provider","id":"dapper-provider","level":1},{"text":"Setup","id":"setup","level":2},{"text":"The endpoint","id":"the-endpoint","level":2},{"text":"Supported providers","id":"supported-providers","level":2},{"text":"Query execution model","id":"query-execution-model","level":2},{"text":"Mapping dynamic rows to your model","id":"mapping-dynamic-rows-to-your-model","level":2},{"text":"Governance, security, keyset","id":"governance-security-keyset","level":2},{"text":"Observability","id":"observability","level":2},{"text":"Related","id":"related","level":2}],"body":"Dapper Provider FlexQuery.NET.Dapper is the SQL-first provider: FlexQuery generates the SQL itself dialect-aware, fully parameterized and executes it through Dapper on a DbConnection . The same wire grammar and options model from the other pages apply; the differences are in what you must configure — the mapping metadata and, implicitly, the dialect you get. Setup using FlexQuery.NET.Dapper; Because there is no DbContext to inspect, Dapper needs a model describing your tables, columns, and relationships. Conventions cover the common case table = class name with an optional pluralized variant if it resolves, {Entity}Id foreign keys ; attributes and the builder model cover the rest. Equivalent attribute style on the entities themselves no configure call required : Table \"Customers\" and Column \"product_sku\" the annotations Dapper conventions understand — plus the convention heuristics Id -style keys, {Principal}Id foreign keys covering the rest. The endpoint FlexQueryAsync extends any DbConnection : Notes on the execution behavior visible from here: Connections: FlexQuery opens a closed DbConnection for you it will not close it again, so await using scoping as shown above is the clean pattern . Command text, parameters, and results are plain Dapper — the result shape mirrors EF Core's with dynamic rows. The receiver is only DbConnection — that's what dialect detection needs a concrete provider type . Supported providers The dialect is resolved from the connection type at runtime — there is no manual dialect switch: | Connection | Paging | Quoting | |---|---|---| | SQL Server | OFFSET n ROWS FETCH NEXT m ROWS ONLY , TOP | name | | PostgreSQL | LIMIT m OFFSET n | \"name\" | | SQLite | LIMIT m OFFSET n | \"name\" | | MySQL / MariaDB | LIMIT m OFFSET n | backticks | | Oracle | OFFSET n ROWS FETCH NEXT m ROWS ONLY | \"NAME\" | Text comparisons contains , startswith , endswith , like are emitted as the dialect's LIKE with parameterized % patterns; effective case-sensitivity follows the database collation. An unsupported connection type throws NotSupportedException at execution. Query execution model - Single entity queries are generated as one joined select: projection columns from your select /type-map surface, WHERE from filter case-insensitive contains/ IN / BETWEEN /collection checks via EXISTS , GROUP BY / HAVING /ORDER BY from the respective parameters, and dialect-correct paging. - Includes/expand run as additional split, not joined queries per level — SELECT ... WHERE CustomerId IN p0… for the keys on the current page — so a page of 20 customers costs exactly 21 queries no matter how many orders exist, instead of one giant cartesian join. take=… on an expand branch is implemented with ROW_NUMBER OVER PARTITION in the child query, and the per-branch filter /sort fold into the child WHERE / ORDER BY . - Counting : totalCount is a separate SELECT COUNT... on the un-paged filtered query when includeCount requests it; grouped/distinct queries get their post-shaping count via the same mechanism. Mapping dynamic rows to your model FlexQueryAsync ... yields QueryResult : each row is a dynamic object whose fields are the requested columns — filter on Status but select=Id,Email , and rows just expose id / email , under the alias if one was given. With no explicit select the full entity surface is projected. Typed responses take a registered destination type — same pattern as EF: Column-to-DTO property names follow the registered map ForMember , ForNavigation or exact-name convention; ResultShape the effective output field list described by an explicit select drives the JSON envelope, and DTO property types coerce values leniently e.g. int ↔ long , bool from 0 / 1 , dates from strings . Keep the model configuration covering every entity reachable by include , since column mapping and child-key placement derive from it. Governance, security, keyset All execution options AllowedFields , BlockedFields , AllowedIncludes , SortableFields , role-based field access, StrictFieldValidation , MaxPageSize , DefaultSortField work exactly as documented for the EF Core provider — validation happens before SQL generation, and rejection never produces a partially built command. Keyset cursors are built server-side; with Dapper the seek predicate merges into the root query itself — no offset counting at all. Observability Setting LoggerFactory logs every executed command Executing Dapper query at Information level, category \"FlexQuery.NET.Dapper\" with dialect-formatted SQL, parameter types and values — including split include/expand children, counts, and grand totals. The Listener IFlexQueryExecutionListener on the options exposes them programmatically. All provider overloads are async. Synchronous Dapper.Query -style execution is not part of this package. Related - EF Core Provider /docs/providers/ef-core — the expression-tree engine - Include /docs/guides/include · Expand /docs/guides/expand"},{"title":"Entity Framework Core","description":"FlexQueryAsync for EF Core - execution, includes, no-tracking, and SQL preview.","section":"Providers","slug":"providers/ef-core","headings":[{"text":"Entity Framework Core","id":"entity-framework-core","level":1},{"text":"Setup","id":"setup","level":2},{"text":"Execution methods","id":"execution-methods","level":2},{"text":"Dynamic results","id":"dynamic-results","level":3},{"text":"Typed DTO results","id":"typed-dto-results","level":3},{"text":"No-tracking behavior","id":"no-tracking-behavior","level":2},{"text":"SQL preview and projection explain","id":"sql-preview-and-projection-explain","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Common mistakes","id":"common-mistakes","level":2}],"body":"Entity Framework Core FlexQuery.NET.EntityFrameworkCore executes FlexQuery pipelines against IQueryable sources with full SQL translation. The package has one job: take the validated QueryOptions and compose them into EF Core expression trees — filters, ordering, keyset paging, filtered includes, projections — so the database does all the work. Setup FlexQueryEFCore.Setup no delegate only registers the EF Core-specific operator handlers such as like . UseNoTracking defaults FlexQuery's execution to no-tracking; configuration becomes immutable after the first call. Execution methods All methods live on QueryableEfCoreExtensions in namespace FlexQuery.NET.EntityFrameworkCore . Every overload accepts a CancellationToken . Dynamic results Typed DTO results The two-type-generic overloads materialize directly into your DTOs: No-tracking behavior FlexQuery defaults to no-tracking execution — results serialize without inverse-navigation fixup cycles, and read-only endpoints avoid the change-tracker cost. Override per call when you need tracked entities: With tracked queries, EF populates inverse navigations Order.Customer via relationship fixup. Configure ReferenceHandler.IgnoreCycles in the host JSON options so back-references serialize as null instead of throwing. The sample application demonstrates this with a tracked-query endpoint. Includes ApplyExpand composes the include tree into EF Core Include / ThenInclude chains, each optionally filtered and windowed: Behavior details: - The include chain rides in the same query as the root select — EF's filtered-include machinery turns each filter / sort / take inside an expand block into SQL WHERE / ORDER BY /windowed subselects, so related data trims server-side no full-collection load followed by memory trimming . - Navigation-projection selects select=Orders.Total... also pull the corresponding navigation into the include tree automatically — validation rejects the projection when that path is not authorized by include . - Most endpoints never call ApplyExpand directly: FlexQueryAsync applies the include pipeline from the request's include / expand options on its way to execution. Grouped queries groupBy / aggregate / having run through the grouped-query executor: the grouped IQueryable is projected into a dynamic row type group keys + aggregate properties , having prunes groups, and paging/ORDER BY apply over the grouped set with Dapper dialects emitting NULLS LAST -style ordering where needed . Each group returns as one Data row; separate count queries keep totalCount source rows and resultCount groups accurate even with paging on. For ungrouped aggregates, results arrive in QueryResult.Aggregates ; with a groupBy , aggregate values stay per-group inside Data . SQL preview and projection explain Two inspection methods help during development and debugging: ToSqlPreview uses EF Core's ToQueryString under the hood and works after dynamic projections are applied. ExplainProjection returns a human-readable plan of selected fields, navigation usage, and optimization notes. Complete worked example A full-featured endpoint combining the capabilities: Common mistakes FlexQueryAsync already applies the include pipeline from options.Expand . Call ApplyExpand separately only when composing a queryable by hand before materializing yourself. Without UseNoTracking global or AsNoTracking , every result entity enters the change tracker — memory and time for data you will only serialize."},{"title":"Recipes","description":"Common real-world patterns with FlexQuery.NET.","section":"Resources","slug":"recipes","headings":[{"text":"Recipes","id":"recipes","level":1},{"text":"Cursor-driven infinite scroll","id":"cursor-driven-infinite-scroll","level":2},{"text":"Role-based field visibility","id":"role-based-field-visibility","level":2},{"text":"Faceted dashboard with AG Grid","id":"faceted-dashboard-with-ag-grid","level":2},{"text":"Export endpoint (no paging)","id":"export-endpoint-no-paging","level":2},{"text":"Search endpoint with contains + sort","id":"search-endpoint-with-contains--sort","level":2},{"text":"Per-tenant data isolation","id":"per-tenant-data-isolation","level":2},{"text":"Public API with a strict surface","id":"public-api-with-a-strict-surface","level":2},{"text":"DTO-shaped responses for mobile clients","id":"dto-shaped-responses-for-mobile-clients","level":2},{"text":"Multi-step wizard state via cursors","id":"multi-step-wizard-state-via-cursors","level":2}],"body":"Recipes Practical patterns assembled from the building blocks in the guides. Each recipe states the problem, the approach, and the working code. Cursor-driven infinite scroll Problem : a feed or list that clients scroll through indefinitely; offset pages get slower and rows shift between requests. Approach : keyset pagination with a deterministic sort unique trailing Id : The client appends cursor= to the next request. Role-based field visibility Problem : admins see more fields than support staff on the same endpoint. Approach : role-mapped field sets resolved from the principal: Faceted dashboard with AG Grid Problem : an analytics grid where users group by a column and see sums/counts per group, with server-side paging. Approach : AG Grid SSRM's row-group/value columns map directly to grouping and aggregates — no custom code: Export endpoint no paging Problem : an export job needs the full result set. Approach : disable paging, skip the count query, and cap upstream: Combine DisablePaging with an upstream filter that bounds the set — an unpaged endpoint over an unbounded table is a denial-of-service vector. Search endpoint with contains + sort Problem : free-text search across name and email, ranked alphabetically. Approach : OR'd substring filters with a deterministic sort: Per-tenant data isolation Problem : every query must be scoped to the caller's tenant, no matter what the client asks for. Approach : wrap the queryable before FlexQuery sees it — FlexQuery governs fields, you govern rows: Public API with a strict surface Problem : a public read-only endpoint exposing exactly three fields, no includes, no deep paths. Approach : FieldAccess declares the whole contract on the controller: DTO-shaped responses for mobile clients Problem : mobile clients need small payloads with domain vocabulary names. Approach : typed DTO projection with a per-query map: Multi-step wizard state via cursors Problem : a multi-page wizard needs to resume mid-result-set. Approach : pass the cursor through the wizard's state; keyset pages are stable because they are key-based, not offset-based:"},{"title":"Security & Governance","description":"Field access, governance sets, roles, and safe expression building.","section":"Security","slug":"security","headings":[{"text":"Security & Governance","id":"security--governance","level":1},{"text":"The governance model","id":"the-governance-model","level":2},{"text":"Why per-operation sets exist","id":"why-per-operation-sets-exist","level":2},{"text":"Operator allow-lists","id":"operator-allow-lists","level":2},{"text":"Role-based access","id":"role-based-access","level":2},{"text":"Expression-level safety","id":"expression-level-safety","level":2},{"text":"Complete worked example","id":"complete-worked-example","level":2},{"text":"Defense-in-depth checklist","id":"defense-in-depth-checklist","level":2}],"body":"Security & Governance FlexQuery treats client-supplied queries as untrusted input. A query parameter is an executable description of database work — which columns to read, which relations to traverse, which values to compare — and a misconfigured dynamic API can leak rows the client should never see, even when every individual response row is \"theirs\". Security in FlexQuery is a declaration model: you declare what is allowed, and everything else fails validation before any query executes. The governance model All governance lives on QueryGovernanceOptions — the base of every execution-options type QueryExecutionOptions , EfCoreQueryOptions , DapperQueryOptions — so the same knobs are available per request the configure delegate and per endpoint via FieldAccess . | Option | Purpose | |---|---| | AllowedFields | Global allow-list of fields. | | BlockedFields | Deny-list of fields. | | FilterableFields | Fields clients may filter on. | | SortableFields | Fields clients may sort by. | | SelectableFields | Fields clients may select. | | GroupableFields | Fields clients may group by. | | AggregatableFields | Fields clients may aggregate. | | AllowedIncludes | Navigation paths clients may include/expand. | | AllowedOperators | Per-field operator allow-lists. | | DefaultSortField / DefaultSortDescending | Default ordering. | | MaxFieldDepth | Maximum nested path depth. | | StrictFieldValidation | Throw on unauthorized field access default true . | | RoleAllowedFields + CurrentRole | Role-based field access. | | AllowedFieldsResolver | Custom resolver: type → allowed fields. | Governance properties moved onto QueryGovernanceOptions base of all execution-option types ; mapping, paging defaults, syntax override, and the diagnostics listener remain on BaseQueryOptions . Both existed in some form in v3 — if you are migrating, the member names are unchanged; only the class layout is new. Why per-operation sets exist A single allow-list is too coarse. A field can be safe to display but dangerous to filter on : - Sortable but sensitive: sorting by Ssn lets a client probe data distribution through ordering even if values never appear in responses. - Aggregatable but sensitive: avg:Salary leaks statistical information even when no individual salary row is visible. - Filterable but sensitive: DeletedAt:isnull -style probes reveal record existence. Per-operation sets let you express exactly that: visible but not sortable, filterable only by admin, aggregatable never. Operator allow-lists AllowedOperators restricts which comparison operators a field accepts — e.g. Age may support range checks but not contains : Role-based access Roles map to field sets; the resolved role's set becomes the effective allow-list. For dynamic scenarios, AllowedFieldsResolver supplies a custom type → fields function. Expression-level safety Governance decides what is addressable; the expression builder guarantees how addressing happens: - Filters are never evaluated client-side; everything composes into expression trees EF Core or parameterized SQL Dapper . - Field access resolves through safe property resolution — arbitrary member access cannot be injected. - Operator factories are a fixed registry; unknown operators fail validation. - Unknown fields fail validation before any expression is built. Wildcards Allowed-field sets support wildcard patterns e.g. Order via the built-in wildcard matcher, so a single rule can cover a whole family of columns case-insensitively. Complete worked example A multi-tenant, role-aware endpoint: Defense in depth in one example: tenant scoping happens before FlexQuery sees the queryable; role-based field sets govern what is addressable; includes are whitelisted; and path depth is capped. Defense-in-depth checklist 1. Configure AllowedFields or per-operation sets for every endpoint — never ship with only the global defaults. 2. Keep StrictFieldValidation = true ; silent field dropping hides governance drift. 3. Restrict includes with AllowedIncludes prevents traversing unauthorized graphs . 4. Bound paging with MaxPageSize . 5. Bound path depth with MaxFieldDepth . 6. Restrict operators per field where the data model demands it AllowedOperators . 7. Scope the IQueryable upstream tenant/ownership filters — FlexQuery governs fields, not row-level access. FlexQuery's governance is field-level. Which rows a caller may see tenancy, ownership, soft deletes belongs in your queryable — filter it before FlexQueryAsync ."},{"title":"Troubleshooting","description":"Common errors and how to resolve them.","section":"Resources","slug":"troubleshooting","headings":[{"text":"Troubleshooting","id":"troubleshooting","level":1},{"text":"InvalidOperationException: \"already been configured and is now immutable\"","id":"invalidoperationexception-already-been-configured-and-is-now-immutable","level":2},{"text":"ParserNotRegisteredException","id":"parsernotregisteredexception","level":2},{"text":"QueryValidationException: field not allowed","id":"queryvalidationexception-field-not-allowed","level":2},{"text":"\"Navigation projection requires include\"","id":"navigation-projection-requires-include","level":2},{"text":"Duplicate expand path","id":"duplicate-expand-path","level":2},{"text":"HAVING references an undeclared aggregate","id":"having-references-an-undeclared-aggregate","level":2},{"text":"Sort validation errors on grouped queries","id":"sort-validation-errors-on-grouped-queries","level":2},{"text":"Dapper: wrong dialect SQL","id":"dapper-wrong-dialect-sql","level":2},{"text":"No SQL logs from Dapper","id":"no-sql-logs-from-dapper","level":2},{"text":"Keyset pagination skips or duplicates rows","id":"keyset-pagination-skips-or-duplicates-rows","level":2},{"text":"Filter syntax errors with quoted values","id":"filter-syntax-errors-with-quoted-values","level":2},{"text":"\"'AND' cannot be used as an unquoted value\"","id":"and-cannot-be-used-as-an-unquoted-value","level":2},{"text":"QueryParseException on page/pageSize/distinct","id":"queryparseexception-on-pagepagesizedistinct","level":2}],"body":"Troubleshooting Symptoms, causes, and fixes — ordered by how often they occur. InvalidOperationException: \"already been configured and is now immutable\" Configure was called twice, or after a query already ran. Global configuration is immutable by design — concurrent query execution reads it. Fix : configure once during startup Program.cs , before any execution. ParserNotRegisteredException A request asked for QuerySyntax.Fql or QuerySyntax.MiniOData but the parser package was not registered or the package was not installed at all . Fix : reference the parser package and register once at startup: QueryValidationException: field not allowed The field is not in AllowedFields , is in BlockedFields , fails a per-operation set FilterableFields , SortableFields , SelectableFields , GroupableFields , AggregatableFields , or is unreachable under the current CurrentRole . Fix : either extend the governance set or correct the client request. Log rejected requests during rollout — they show which surfaces clients actually need. \"Navigation projection requires include\" A select references a navigation path Address.City without loading the navigation. Fix : add the navigation to include scoped loading via expand is then also possible, but the rule's error message names the missing include= value : Duplicate expand path Each navigation path may be expanded at most once per query — merging duplicates would make filter/take/sort ambiguous. Fix : merge the branch options into a single expand block: HAVING references an undeclared aggregate Every aggregate referenced in having must be declared in aggregate . Fix : Sort validation errors on grouped queries Grouped queries may only sort by group keys or declared aggregates — the grouped shape has no per-row value for anything else. Fix : sort by a key sort=Status:asc or a declared aggregate sort=sum:Total:desc . Dapper: wrong dialect SQL Dialect is auto-detected from the DbConnection . A wrapper connection or a mismatched provider produces wrong quoting/paging syntax. Fix : pass the actual connection of the target provider. Manual Dialect configuration no longer exists in v4. No SQL logs from Dapper SQL logging requires an ILogger where LogLevel.Information is enabled for category FlexQuery.NET.Dapper . A null logger or disabled level short-circuits to a no-op. Fix : configure logging with Information level or higher enabled for the category. Keyset pagination skips or duplicates rows Ordering is not deterministic — the cursor seek boundary is ambiguous when rows share key values. Fix : always end the sort with a unique column: Filter syntax errors with quoted values In the native DSL, values containing spaces or reserved keywords must be quoted. Fix : \"'AND' cannot be used as an unquoted value\" The DSL reserves the logical keywords AND / OR . A filter value that starts with one — e.g. filter=Name:eq:ANDREW — is rejected with a suggestion to quote. Fix : quote the value: filter=Name:eq:'ANDREW' single or double quotes both work . QueryParseException on page/pageSize/distinct Malformed values are rejected at parse time rather than silently defaulted: Fix : send well-formed values. Merely out-of-range values e.g. pageSize=99999 are clamped to the configured maximum instead of erroring. QueryValidationException messages list every error with field context; parse errors report the parameter name and parser position. When in doubt, reproduce the request against a debug endpoint with a diagnostics collector — see Diagnostics ."}] \ No newline at end of file diff --git a/docs-v4/scripts/build-search-index.mjs b/docs-v4/scripts/build-search-index.mjs new file mode 100644 index 0000000..39455b0 --- /dev/null +++ b/docs-v4/scripts/build-search-index.mjs @@ -0,0 +1,79 @@ +import fs from 'node:fs' +import path from 'node:path' +import matter from 'gray-matter' +import GithubSlugger from 'github-slugger' + +const CONTENT_DIR = path.join(process.cwd(), 'content', 'docs') +const OUT_FILE = path.join(process.cwd(), 'public', 'search-index.json') + +function walkMdxFiles(dir, base = '') { + const results = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = base ? `${base}/${entry.name}` : entry.name + if (entry.isDirectory()) { + results.push(...walkMdxFiles(path.join(dir, entry.name), rel)) + } else if (entry.isFile() && entry.name.endsWith('.mdx')) { + results.push(rel) + } + } + return results +} + +function slugToRoute(file) { + const withoutExt = file.replace(/\.mdx$/, '') + const clean = withoutExt.endsWith('/index') ? withoutExt.slice(0, -'/index'.length) : withoutExt + return clean +} + +function stripMdx(raw) { + return raw + .replace(/^---\n[\s\S]*?\n---/, '') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/~~~[\s\S]*?~~~/g, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/import\s+.*from\s+.*/g, ' ') + .replace(/[#*`>[\]()!]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function main() { + if (!fs.existsSync(CONTENT_DIR)) { + console.error('content/docs not found') + process.exit(1) + } + const files = walkMdxFiles(CONTENT_DIR) + const index = files.map((file) => { + const raw = fs.readFileSync(path.join(CONTENT_DIR, file), 'utf8') + const { data, content } = matter(raw) + const headings = [] + const slugger = new GithubSlugger() + let inFence = false + for (const line of content.split('\n')) { + const t = line.trimStart() + if (t.startsWith('```') || t.startsWith('~~~')) { + inFence = !inFence + continue + } + if (inFence) continue + const m = /^(#{1,4})\s+(.+)$/.exec(t) + if (m) { + const text = m[2].replace(/[#*`]/g, '').trim() + headings.push({ text, id: slugger.slug(text), level: m[1].length }) + } + } + return { + title: data.title ?? file, + description: data.description ?? '', + section: data.section ?? '', + slug: slugToRoute(file), + headings, + body: stripMdx(content).slice(0, 12000), + } + }) + fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }) + fs.writeFileSync(OUT_FILE, JSON.stringify(index)) + console.log(`search-index.json: ${index.length} pages`) +} + +main() diff --git a/docs-v4/scripts/check-links.mjs b/docs-v4/scripts/check-links.mjs new file mode 100644 index 0000000..21cdab8 --- /dev/null +++ b/docs-v4/scripts/check-links.mjs @@ -0,0 +1,54 @@ +import fs from 'node:fs' +import path from 'node:path' + +const CONTENT_DIR = path.join(process.cwd(), 'content', 'docs') + +function walkMdxFiles(dir, base = '') { + const results = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = base ? `${base}/${entry.name}` : entry.name + if (entry.isDirectory()) { + results.push(...walkMdxFiles(path.join(dir, entry.name), rel)) + } else if (entry.isFile() && entry.name.endsWith('.mdx')) { + results.push(rel) + } + } + return results +} + +function slugToRoute(file) { + const withoutExt = file.replace(/\.mdx$/, '') + return withoutExt.endsWith('/index') ? withoutExt.slice(0, -'/index'.length) : withoutExt +} + +function main() { + const slugs = new Set(walkMdxFiles(CONTENT_DIR).map(slugToRoute)) + const errors = [] + const mdxFiles = walkMdxFiles(CONTENT_DIR) + + for (const file of mdxFiles) { + const raw = fs.readFileSync(path.join(CONTENT_DIR, file), 'utf8') + const linkRe = /\[[^\]]*\]\((\/[^)#\s]*)(#[^)\s]*)?\)/g + let m + while ((m = linkRe.exec(raw)) !== null) { + const href = m[1] + if (href.startsWith('http')) continue + let target = href.replace(/^\/docs\/?/, '') + if (target === '') { + if (!slugs.has('introduction')) errors.push(`${file}: broken link ${href}`) + continue + } + const exists = slugs.has(target) || [...slugs].some((s) => target.startsWith(s + '/')) + if (!exists) errors.push(`${file}: broken link ${href}`) + } + } + + if (errors.length > 0) { + console.error('Broken links found:') + for (const e of errors) console.error(' - ' + e) + process.exit(1) + } + console.log(`Link check passed (${mdxFiles.length} files).`) +} + +main() diff --git a/docs-v4/tsconfig.json b/docs-v4/tsconfig.json new file mode 100644 index 0000000..1612ea7 --- /dev/null +++ b/docs-v4/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "content" + ] +}