From 266584cd87053a829c644c0115ebf217d2fcf39e Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 00:20:31 +0000 Subject: [PATCH 1/6] feat: enhance documentation workflow and add Blazor WASM playground support --- .github/workflows/docs.yml | 34 ++++++++++++ .gitignore | 8 +++ Directory.Packages.props | 11 ++++ ExpressiveSharp.slnx | 3 ++ docs/.vitepress/config.mts | 54 +++++++++++++++++++ docs/.vitepress/theme/custom.css | 1 + docs/.vitepress/theme/index.ts | 8 ++- .../ExpressiveMongoQueryable.cs | 7 +++ 8 files changed, 125 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d4d47e6..b050e9e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,6 +5,10 @@ on: branches: [main] paths: - 'docs/**' + - 'src/ExpressiveSharp.Docs.Playground.Wasm/**' + - 'src/ExpressiveSharp.Docs.PlaygroundModel/**' + - 'src/ExpressiveSharp.Docs.Playground.Core/**' + - 'src/ExpressiveSharp.Docs.Prerenderer/**' - '.github/workflows/docs.yml' workflow_dispatch: @@ -20,6 +24,36 @@ jobs: - name: Checkout main uses: actions/checkout@v4 + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Publish ExpressiveSharp Playground (Blazor WASM) + run: | + dotnet publish src/ExpressiveSharp.Docs.Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj \ + -c Release \ + -o .artifacts/playground + # Drop the Blazor publish output into VitePress' static asset folder + # so it ships as part of the site under /playground/. + rm -rf docs/public/_playground + mkdir -p docs/public/_playground + cp -r .artifacts/playground/wwwroot/. docs/public/_playground/ + # Rename to app.html so it doesn't collide with VitePress's route resolution + mv docs/public/_playground/index.html docs/public/_playground/app.htm + sed -i 's|||' docs/public/_playground/app.htm + # Copy _content/ to docs root so Blazor's dynamic imports resolve + # when the web component is hosted directly on the VitePress page + cp -r docs/public/_playground/_content docs/public/_content + # Remove BlazorMonaco static assets (no longer used) + rm -rf docs/public/_content/BlazorMonaco docs/public/_playground/_content/BlazorMonaco + + - name: Pre-render doc samples + run: | + dotnet run --project src/ExpressiveSharp.Docs.Prerenderer \ + -c Release \ + -- --docs-root docs + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index 3397fd9..c00101f 100644 --- a/.gitignore +++ b/.gitignore @@ -373,5 +373,13 @@ ReadmeSample.db docs/node_modules/ docs/.vitepress/cache/ docs/.vitepress/dist/ +docs/.vitepress/data/ + +# Blazor WASM playground build artifact (published into docs/public/_playground/ +# at docs build time — never committed; gh-pages serves the regenerated copy). +docs/public/_playground/ +docs/public/_content/ +.artifacts/ + # Worktrees .worktrees/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 66673ed..35fb0df 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,10 @@ + + @@ -14,6 +18,7 @@ + @@ -27,5 +32,11 @@ + + + + + diff --git a/ExpressiveSharp.slnx b/ExpressiveSharp.slnx index d37b3d9..8ab1525 100644 --- a/ExpressiveSharp.slnx +++ b/ExpressiveSharp.slnx @@ -16,6 +16,9 @@ + + + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6de9683..448c7e1 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,5 +1,11 @@ import {defineConfig, type DefaultTheme, type HeadConfig} from 'vitepress' import llmstxt from 'vitepress-plugin-llms' +import {expressiveSamplePlugin} from './plugins/expressive-sample' +import {readFileSync, existsSync} from 'fs' +import {resolve, dirname} from 'path' +import {fileURLToPath} from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) const base = '/ExpressiveSharp/' @@ -99,11 +105,57 @@ const headers = process.env.GITHUB_ACTIONS === "true" ? [...baseHeaders, umamiScript] : baseHeaders; +// Vite plugin: serve _playground/app.htm as raw HTML in dev mode. +// VitePress's dev server applies its SPA transform to all HTML files in +// public/, which breaks the Blazor WASM app. This middleware intercepts +// requests to _playground/app.htm and serves the raw file directly. +const mimeTypes: Record = { + '.htm': 'text/html', '.html': 'text/html', '.js': 'application/javascript', + '.mjs': 'application/javascript', '.css': 'text/css', '.json': 'application/json', + '.wasm': 'application/wasm', '.dll': 'application/octet-stream', + '.dat': 'application/octet-stream', '.br': 'application/octet-stream', + '.gz': 'application/octet-stream', '.woff': 'font/woff', '.woff2': 'font/woff2', +} + +function servePlaygroundPlugin() { + return { + name: 'serve-playground', + configureServer(server: any) { + // Serve everything under /_playground/ as raw static files so VitePress's + // SPA transform and module system don't intercept Blazor WASM resources. + server.middlewares.use((req: any, res: any, next: any) => { + const prefix = '/ExpressiveSharp/_playground/' + if (!req.url?.startsWith(prefix)) return next() + + const relPath = req.url.slice(prefix.length).split('?')[0] + const filePath = resolve(__dirname, '../public/_playground', relPath) + if (!existsSync(filePath)) return next() + + const ext = '.' + relPath.split('.').pop() + res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream') + res.end(readFileSync(filePath)) + }) + } + } +} + export default defineConfig({ title: "ExpressiveSharp", description: "Modern C# syntax in LINQ expression trees — source-generated at compile time", base, head: headers, + markdown: { + config: (md) => { + md.use(expressiveSamplePlugin) + } + }, + vue: { + template: { + compilerOptions: { + isCustomElement: (tag) => tag === 'expressive-playground', + } + } + }, themeConfig: { logo: '/logo.png', nav: [ @@ -112,6 +164,7 @@ export default defineConfig({ { text: 'Reference', link: '/reference/expressive-attribute' }, { text: 'Advanced', link: '/advanced/how-it-works' }, { text: 'Recipes', link: '/recipes/computed-properties' }, + { text: 'Playground', link: '/playground-editor' }, { text: 'Benchmarks', link: 'https://efnext.github.io/ExpressiveSharp/dev/bench/' }, ], @@ -132,6 +185,7 @@ export default defineConfig({ }, vite: { plugins: [ + servePlaygroundPlugin(), llmstxt({ domain: 'https://efnext.github.io', description: 'Modern C# syntax in LINQ expression trees — source-generated at compile time', diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 1cff4a1..8516135 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -44,3 +44,4 @@ --vp-button-brand-hover-bg: var(--vp-c-brand-1); --vp-button-brand-active-bg: var(--vp-c-brand-3); } + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 42fe9a9..6d78cbe 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,4 +1,10 @@ import DefaultTheme from 'vitepress/theme' +import ExpressiveSample from './components/ExpressiveSample.vue' import './custom.css' -export default DefaultTheme +export default { + ...DefaultTheme, + enhanceApp({ app }) { + app.component('ExpressiveSample', ExpressiveSample) + } +} diff --git a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoQueryable.cs b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoQueryable.cs index 53306de..65e7826 100644 --- a/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoQueryable.cs +++ b/src/ExpressiveSharp.MongoDB/Infrastructure/ExpressiveMongoQueryable.cs @@ -35,6 +35,13 @@ public IEnumerator GetEnumerator() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + /// + /// Returns the MongoDB aggregation pipeline (MQL) for this query without + /// executing it. Delegates to the native MongoDB queryable's ToString(). + /// + public override string ToString() + => ExpandedInnerQueryable().ToString() ?? base.ToString()!; + public IAsyncCursor ToCursor(CancellationToken cancellationToken = default) => ((IAsyncCursorSource)ExpandedInnerQueryable()).ToCursor(cancellationToken); From e135449f0a4b20d2faa29020614c74612350b49d Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 00:20:33 +0000 Subject: [PATCH 2/6] Revamped the docs to use and show actual examples. Added an interactive playground. --- .github/workflows/docs.yml | 9 +- ExpressiveSharp.slnx | 10 +- docs/.vitepress/config.mts | 110 ++++- docs/.vitepress/plugins/expressive-sample.ts | 190 ++++++++ .../theme/components/ExpressiveSample.vue | 180 +++++++ docs/advanced/block-bodied-members.md | 284 ++++++----- docs/advanced/custom-transformers.md | 17 +- docs/guide/expression-polyfill.md | 53 +- docs/guide/expressive-constructors.md | 136 +++--- docs/guide/expressive-methods.md | 159 +++--- docs/guide/expressive-properties.md | 200 ++++---- docs/guide/expressive-queryable.md | 89 ++-- docs/guide/extension-members.md | 140 +++--- docs/guide/integrations/custom-providers.md | 73 +++ .../ef-core.md} | 151 ++---- docs/guide/integrations/mongodb.md | 91 ++++ docs/guide/introduction.md | 45 +- docs/guide/migration-from-projectables.md | 125 +++-- docs/guide/quickstart.md | 245 ++++------ docs/guide/window-functions.md | 3 +- docs/index.md | 85 +--- docs/playground-editor.md | 52 ++ docs/recipes/computed-properties.md | 272 +++++------ docs/recipes/dto-projections.md | 230 ++++----- docs/recipes/external-member-mapping.md | 125 ++--- docs/recipes/modern-syntax-in-linq.md | 163 +++---- docs/recipes/nullable-navigation.md | 160 ++---- docs/recipes/reusable-query-filters.md | 193 +++----- docs/recipes/scoring-classification.md | 307 ++++-------- docs/recipes/window-functions-ranking.md | 6 +- docs/reference/expressive-attribute.md | 120 ++--- docs/reference/expressive-for.md | 136 +++--- docs/reference/null-conditional-rewrite.md | 79 ++- docs/reference/pattern-matching.md | 239 ++++----- docs/reference/switch-expressions.md | 271 +++++------ docs/reference/troubleshooting.md | 2 +- ...xpressiveSharp.Docs.Playground.Core.csproj | 34 ++ .../Services/IPlaygroundReferences.cs | 10 + .../Services/Scenarios/IPlaygroundScenario.cs | 15 + .../Services/Scenarios/IScenarioInstance.cs | 6 + .../Services/Scenarios/ScenarioRegistry.cs | 21 + .../Scenarios/ScenarioRenderTarget.cs | 11 + .../Services/Scenarios/WebshopScenario.cs | 78 +++ .../Scenarios/WebshopScenarioInstance.cs | 59 +++ .../Services/SnippetCompiler.cs | 227 +++++++++ .../Services/SnippetFormatter.cs | 54 +++ .../Components/PlaygroundHost.razor | 365 ++++++++++++++ .../Components/PlaygroundHost.razor.css | 121 +++++ ...xpressiveSharp.Docs.Playground.Wasm.csproj | 114 +++++ src/Docs/Playground.Wasm/Program.cs | 66 +++ .../Properties/launchSettings.json | 25 + .../Services/ManagedSqliteStub.cs | 213 ++++++++ .../Services/MonacoMarkerConverter.cs | 32 ++ .../Playground.Wasm/Services/MonacoTypes.cs | 119 +++++ .../Services/PlaygroundLanguageServices.cs | 260 ++++++++++ .../Services/PlaygroundReferences.cs | 91 ++++ .../Services/PlaygroundRuntime.cs | 411 ++++++++++++++++ .../Services/RoslynMonacoConverters.cs | 120 +++++ src/Docs/Playground.Wasm/_Imports.razor | 7 + src/Docs/Playground.Wasm/wwwroot/app.htm | 148 ++++++ .../wwwroot/js/monaco-interop.js | 155 ++++++ ...p.Docs.Playground.WasmWorkspaceShim.csproj | 52 ++ .../MSSharedLib1024.snk | Bin 0 -> 160 bytes .../MSSharedLib1024.snk.txt | 37 ++ .../NoOpPersistentStorageConfiguration.cs | 33 ++ ...xpressiveSharp.Docs.PlaygroundModel.csproj | 35 ++ .../Scenarios/Webshop/Customer.cs | 16 + .../Scenarios/Webshop/IWebshopQueryRoots.cs | 18 + .../Scenarios/Webshop/LineItem.cs | 12 + .../Scenarios/Webshop/Order.cs | 11 + .../Scenarios/Webshop/OrderStatus.cs | 10 + .../Scenarios/Webshop/Product.cs | 10 + .../Scenarios/Webshop/WebshopDbContext.cs | 43 ++ .../ExpressiveSharp.Docs.Prerenderer.csproj | 29 ++ .../Prerenderer/LocalPlaygroundReferences.cs | 41 ++ src/Docs/Prerenderer/Program.cs | 130 +++++ src/Docs/Prerenderer/SampleExtractor.cs | 82 ++++ src/Docs/Prerenderer/SampleRenderer.cs | 455 ++++++++++++++++++ .../Emitter/ReflectionFieldCache.cs | 14 +- .../Services/ExpressiveResolver.cs | 69 ++- 80 files changed, 6326 insertions(+), 2283 deletions(-) create mode 100644 docs/.vitepress/plugins/expressive-sample.ts create mode 100644 docs/.vitepress/theme/components/ExpressiveSample.vue create mode 100644 docs/guide/integrations/custom-providers.md rename docs/guide/{ef-core-integration.md => integrations/ef-core.md} (52%) create mode 100644 docs/guide/integrations/mongodb.md create mode 100644 docs/playground-editor.md create mode 100644 src/Docs/Playground.Core/ExpressiveSharp.Docs.Playground.Core.csproj create mode 100644 src/Docs/Playground.Core/Services/IPlaygroundReferences.cs create mode 100644 src/Docs/Playground.Core/Services/Scenarios/IPlaygroundScenario.cs create mode 100644 src/Docs/Playground.Core/Services/Scenarios/IScenarioInstance.cs create mode 100644 src/Docs/Playground.Core/Services/Scenarios/ScenarioRegistry.cs create mode 100644 src/Docs/Playground.Core/Services/Scenarios/ScenarioRenderTarget.cs create mode 100644 src/Docs/Playground.Core/Services/Scenarios/WebshopScenario.cs create mode 100644 src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs create mode 100644 src/Docs/Playground.Core/Services/SnippetCompiler.cs create mode 100644 src/Docs/Playground.Core/Services/SnippetFormatter.cs create mode 100644 src/Docs/Playground.Wasm/Components/PlaygroundHost.razor create mode 100644 src/Docs/Playground.Wasm/Components/PlaygroundHost.razor.css create mode 100644 src/Docs/Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj create mode 100644 src/Docs/Playground.Wasm/Program.cs create mode 100644 src/Docs/Playground.Wasm/Properties/launchSettings.json create mode 100644 src/Docs/Playground.Wasm/Services/ManagedSqliteStub.cs create mode 100644 src/Docs/Playground.Wasm/Services/MonacoMarkerConverter.cs create mode 100644 src/Docs/Playground.Wasm/Services/MonacoTypes.cs create mode 100644 src/Docs/Playground.Wasm/Services/PlaygroundLanguageServices.cs create mode 100644 src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs create mode 100644 src/Docs/Playground.Wasm/Services/PlaygroundRuntime.cs create mode 100644 src/Docs/Playground.Wasm/Services/RoslynMonacoConverters.cs create mode 100644 src/Docs/Playground.Wasm/_Imports.razor create mode 100644 src/Docs/Playground.Wasm/wwwroot/app.htm create mode 100644 src/Docs/Playground.Wasm/wwwroot/js/monaco-interop.js create mode 100644 src/Docs/Playground.WasmWorkspaceShim/ExpressiveSharp.Docs.Playground.WasmWorkspaceShim.csproj create mode 100644 src/Docs/Playground.WasmWorkspaceShim/MSSharedLib1024.snk create mode 100644 src/Docs/Playground.WasmWorkspaceShim/MSSharedLib1024.snk.txt create mode 100644 src/Docs/Playground.WasmWorkspaceShim/NoOpPersistentStorageConfiguration.cs create mode 100644 src/Docs/PlaygroundModel/ExpressiveSharp.Docs.PlaygroundModel.csproj create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/OrderStatus.cs create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs create mode 100644 src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopDbContext.cs create mode 100644 src/Docs/Prerenderer/ExpressiveSharp.Docs.Prerenderer.csproj create mode 100644 src/Docs/Prerenderer/LocalPlaygroundReferences.cs create mode 100644 src/Docs/Prerenderer/Program.cs create mode 100644 src/Docs/Prerenderer/SampleExtractor.cs create mode 100644 src/Docs/Prerenderer/SampleRenderer.cs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b050e9e..a4c9f51 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,10 +5,7 @@ on: branches: [main] paths: - 'docs/**' - - 'src/ExpressiveSharp.Docs.Playground.Wasm/**' - - 'src/ExpressiveSharp.Docs.PlaygroundModel/**' - - 'src/ExpressiveSharp.Docs.Playground.Core/**' - - 'src/ExpressiveSharp.Docs.Prerenderer/**' + - 'src/Docs/**' - '.github/workflows/docs.yml' workflow_dispatch: @@ -31,7 +28,7 @@ jobs: - name: Publish ExpressiveSharp Playground (Blazor WASM) run: | - dotnet publish src/ExpressiveSharp.Docs.Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj \ + dotnet publish src/Docs/Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj \ -c Release \ -o .artifacts/playground # Drop the Blazor publish output into VitePress' static asset folder @@ -50,7 +47,7 @@ jobs: - name: Pre-render doc samples run: | - dotnet run --project src/ExpressiveSharp.Docs.Prerenderer \ + dotnet run --project src/Docs/Prerenderer \ -c Release \ -- --docs-root docs diff --git a/ExpressiveSharp.slnx b/ExpressiveSharp.slnx index 8ab1525..337b763 100644 --- a/ExpressiveSharp.slnx +++ b/ExpressiveSharp.slnx @@ -16,9 +16,13 @@ - - - + + + + + + + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 448c7e1..3712b0f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -4,6 +4,7 @@ import {expressiveSamplePlugin} from './plugins/expressive-sample' import {readFileSync, existsSync} from 'fs' import {resolve, dirname} from 'path' import {fileURLToPath} from 'url' +import {createHash} from 'crypto' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -22,13 +23,20 @@ const sidebar: DefaultTheme.Sidebar = { { text: 'Core APIs', items: [ + { text: 'IExpressiveQueryable', link: '/guide/expressive-queryable' }, { text: '[Expressive] Properties', link: '/guide/expressive-properties' }, { text: '[Expressive] Methods', link: '/guide/expressive-methods' }, { text: 'Extension Members', link: '/guide/extension-members' }, { text: 'Constructor Projections', link: '/guide/expressive-constructors' }, { text: 'ExpressionPolyfill.Create', link: '/guide/expression-polyfill' }, - { text: 'IExpressiveQueryable', link: '/guide/expressive-queryable' }, - { text: 'EF Core Integration', link: '/guide/ef-core-integration' }, + ] + }, + { + text: 'Integrations', + items: [ + { text: 'EF Core', link: '/guide/integrations/ef-core' }, + { text: 'MongoDB', link: '/guide/integrations/mongodb' }, + { text: 'Custom Providers', link: '/guide/integrations/custom-providers' }, ] }, { @@ -117,6 +125,103 @@ const mimeTypes: Record = { '.gz': 'application/octet-stream', '.woff': 'font/woff', '.woff2': 'font/woff2', } +// Expands `::: expressive-sample` containers into fenced code blocks for each +// render target BEFORE VitePress or llmstxt sees the markdown. This way: +// - llms.txt sees the actual SQL / MongoDB / generator output +// - VitePress renders the fenced blocks as regular code blocks (with Shiki +// highlighting) which our markdown-it plugin picks up and wraps as tabs +// The fenced blocks are the single source of truth the Vue component reads +// from via the `data-expressive-sample` marker injected on the first block. +function expandExpressiveSamplesPlugin() { + return { + name: 'expand-expressive-samples', + enforce: 'pre' as const, + transform(code: string, id: string) { + if (!id.endsWith('.md')) return null + if (!code.includes('::: expressive-sample')) return null + + const relPath = id.includes('/docs/') + ? id.substring(id.indexOf('/docs/') + 6).replace(/\?.*$/, '') + : id + const jsonPath = resolve(__dirname, 'data/samples', relPath.replace(/\.md$/, '.json')) + if (!existsSync(jsonPath)) return null + + type Target = { label: string; language: string; output: string } + type Sample = { key: string; snippet: string; setup?: string | null; targets: Record } + let samples: Sample[] + try { samples = JSON.parse(readFileSync(jsonPath, 'utf-8')) } catch { return null } + + const lines = code.split('\n') + const result: string[] = [] + let i = 0 + while (i < lines.length) { + if (!lines[i].trimStart().startsWith('::: expressive-sample')) { + result.push(lines[i]); i++; continue + } + i++ + const bodyLines: string[] = [] + while (i < lines.length && lines[i].trimStart() !== ':::') { + bodyLines.push(lines[i]); i++ + } + i++ // closing ::: + + const body = bodyLines.join('\n').trim() + const sepIdx = body.indexOf('---setup---') + const snippet = sepIdx >= 0 ? body.slice(0, sepIdx).trim() : body + const setup = sepIdx >= 0 ? body.slice(sepIdx + '---setup---'.length).trim() : undefined + + const key = createHash('sha256') + .update(snippet + '\0' + (setup ?? '')) + .digest('hex').slice(0, 12).toLowerCase() + const sample = samples.find(s => s.key === key) + if (!sample) { + // Fallback: leave the container for our markdown-it plugin's warning + result.push('::: expressive-sample') + result.push(...bodyLines) + result.push(':::') + continue + } + + // Preserve original container — our markdown-it plugin (VitePress + // render stage) reads this and emits the interactive Vue tabs. + result.push('::: expressive-sample') + result.push(...bodyLines) + result.push(':::') + + // Also emit fenced code blocks inside a hidden div. These are invisible + // on the rendered page (Vue component handles the UI) but are included + // in the raw .md that llms.txt sees, so crawlers/LLMs get the full SQL + // and pipeline output for each render target. + result.push('') + result.push('') + result.push('') + } + return { code: result.join('\n'), map: null } + } + } +} + function servePlaygroundPlugin() { return { name: 'serve-playground', @@ -185,6 +290,7 @@ export default defineConfig({ }, vite: { plugins: [ + expandExpressiveSamplesPlugin(), servePlaygroundPlugin(), llmstxt({ domain: 'https://efnext.github.io', diff --git a/docs/.vitepress/plugins/expressive-sample.ts b/docs/.vitepress/plugins/expressive-sample.ts new file mode 100644 index 0000000..f84c548 --- /dev/null +++ b/docs/.vitepress/plugins/expressive-sample.ts @@ -0,0 +1,190 @@ +import type MarkdownIt from 'markdown-it' +import { createHash } from 'crypto' +import { readFileSync, existsSync } from 'fs' +import { resolve } from 'path' +import { createHighlighterCoreSync } from 'shiki/core' +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript' +import csharp from 'shiki/langs/csharp.mjs' +import sql from 'shiki/langs/sql.mjs' +import javascript from 'shiki/langs/javascript.mjs' +import githubLight from 'shiki/themes/github-light.mjs' +import githubDark from 'shiki/themes/github-dark.mjs' + +interface RenderedTarget { + label: string + language: string + output: string + isError?: boolean +} + +interface RenderedSample { + key: string + snippet: string + setup?: string | null + targets: Record +} + +interface HighlightedTab { + id: string + label: string + html: string // pre-highlighted HTML (dual light/dark via Shiki) + isError?: boolean +} + +const SETUP_SEPARATOR = '---setup---' +const SAMPLES_DIR = resolve(__dirname, '../data/samples') +const BASE_PATH = '/ExpressiveSharp/' + +const fileCache = new Map() + +// Single shared Shiki highlighter. Sync engine (JS regex) so we can call it +// from the markdown-it plugin's synchronous parse hook. +const highlighter = createHighlighterCoreSync({ + themes: [githubLight, githubDark], + langs: [csharp, sql, javascript], + engine: createJavaScriptRegexEngine(), +}) + +function highlight(code: string, lang: string): string { + const normalized = lang === 'plaintext' || !['csharp', 'sql', 'javascript'].includes(lang) + ? 'plaintext' : lang + try { + return highlighter.codeToHtml(code, { + lang: normalized === 'plaintext' ? 'csharp' : normalized, + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: false, // emits CSS vars so VitePress dark mode toggle works + }) + } catch { + // Fallback to escaped plain text + const escaped = code + .replace(/&/g, '&').replace(//g, '>') + return `
${escaped}
` + } +} + +function loadSamplesForFile(mdRelativePath: string): RenderedSample[] { + const jsonPath = resolve(SAMPLES_DIR, mdRelativePath.replace(/\.md$/, '.json')) + if (fileCache.has(jsonPath)) return fileCache.get(jsonPath)! + if (!existsSync(jsonPath)) { fileCache.set(jsonPath, []); return [] } + try { + const data = JSON.parse(readFileSync(jsonPath, 'utf-8')) as RenderedSample[] + fileCache.set(jsonPath, data) + return data + } catch { + fileCache.set(jsonPath, []) + return [] + } +} + +function computeStableKey(snippet: string, setup?: string | null): string { + const input = snippet + '\0' + (setup ?? '') + return createHash('sha256').update(input).digest('hex').slice(0, 12).toLowerCase() +} + +function escapeAttr(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + +function buildPlaygroundUrl(snippet: string, setup?: string | null): string { + const params = new URLSearchParams() + params.set('snippet', Buffer.from(snippet).toString('base64url')) + if (setup) params.set('setup', Buffer.from(setup).toString('base64url')) + params.set('scenario', 'webshop') + return `${BASE_PATH}playground-editor#${params.toString()}` +} + +export function expressiveSamplePlugin(md: MarkdownIt): void { + const originalParse = md.parse.bind(md) + + md.parse = function (src: string, env: any): any[] { + const relativePath: string | undefined = env?.relativePath + if (!relativePath) return originalParse(src, env) + + const samples = loadSamplesForFile(relativePath) + const lines = src.split('\n') + const result: string[] = [] + let i = 0 + + while (i < lines.length) { + const trimmed = lines[i].trimStart() + if (!trimmed.startsWith('::: expressive-sample')) { + result.push(lines[i]) + i++ + continue + } + + i++ + const bodyLines: string[] = [] + while (i < lines.length) { + const closeTrimmed = lines[i].trimStart() + if (closeTrimmed === ':::') break + bodyLines.push(lines[i]) + i++ + } + i++ // skip closing ::: + + const body = bodyLines.join('\n').trim() + const sepIdx = body.indexOf(SETUP_SEPARATOR) + + let snippet: string + let setup: string | undefined + + if (sepIdx >= 0) { + snippet = body.slice(0, sepIdx).trim() + setup = body.slice(sepIdx + SETUP_SEPARATOR.length).trim() + } else { + snippet = body + } + + const key = computeStableKey(snippet, setup) + const sample = samples.find(s => s.key === key) + + if (sample) { + // C# code (snippet + optional setup) — pre-highlighted, always visible + let csharpContent = sample.snippet + if (sample.setup) { + csharpContent += '\n\n// Setup\n' + sample.setup + } + const csharpHtml = highlight(csharpContent, 'csharp') + + // Output tabs + const outputTabs: HighlightedTab[] = [] + for (const targetId of ['sqlite', 'postgres', 'sqlserver', 'cosmos', 'mongodb', 'generator']) { + const target = sample.targets[targetId] + if (target) { + outputTabs.push({ + id: targetId, + label: target.label, + html: target.isError + ? `
${target.output.replace(/&/g, '&').replace(//g, '>')}
` + : highlight(target.output, target.language), + isError: target.isError, + }) + } + } + + const playgroundUrl = buildPlaygroundUrl(snippet, setup) + const tabsBase64 = Buffer.from(JSON.stringify(outputTabs)).toString('base64') + const csharpBase64 = Buffer.from(csharpHtml).toString('base64') + + result.push('') + result.push( + `` + ) + result.push('') + } else { + result.push('') + result.push('::: warning Pre-rendered output not available') + result.push(`Run the pre-renderer to generate output for this sample (key: \`${key}\`).`) + result.push(':::') + result.push('') + result.push('```csharp') + result.push(snippet) + result.push('```') + result.push('') + } + } + + return originalParse(result.join('\n'), env) + } +} diff --git a/docs/.vitepress/theme/components/ExpressiveSample.vue b/docs/.vitepress/theme/components/ExpressiveSample.vue new file mode 100644 index 0000000..c82d0c9 --- /dev/null +++ b/docs/.vitepress/theme/components/ExpressiveSample.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/docs/advanced/block-bodied-members.md b/docs/advanced/block-bodied-members.md index 39c514b..12e5e8e 100644 --- a/docs/advanced/block-bodied-members.md +++ b/docs/advanced/block-bodied-members.md @@ -10,20 +10,26 @@ Block-bodied member support requires explicit opt-in. Set `AllowBlockBody = true Expression-bodied members are concise but can become difficult to read when the logic involves complex conditionals: -```csharp -// Hard to read as a nested ternary -[Expressive] -public string GetCategory() => Quantity * 10 > 100 ? "Bulk" : "Regular"; - -// Much clearer as a block body -[Expressive(AllowBlockBody = true)] -public string GetCategory() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Category = o.GetCategory() }) +---setup--- +public static class OrderExt { - var threshold = Quantity * 10; - if (threshold > 100) return "Bulk"; - return "Regular"; + // Hard to read as a nested ternary + [Expressive] + public static string GetCategoryTerse(this Order o) => + o.Items.Sum(i => i.Quantity) * 10 > 100 ? "Bulk" : "Regular"; + + // Much clearer as a block body + [Expressive(AllowBlockBody = true)] + public static string GetCategory(this Order o) + { + var threshold = o.Items.Sum(i => i.Quantity) * 10; + if (threshold > 100) return "Bulk"; + return "Regular"; + } } -``` +::: Both forms generate equivalent expression trees and produce identical SQL when used with EF Core. @@ -33,15 +39,21 @@ Both forms generate equivalent expression trees and produce identical SQL when u Add `AllowBlockBody = true` to the `[Expressive]` attribute: -```csharp -[Expressive(AllowBlockBody = true)] -public string GetCategory() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Tier = o.Tier() }) +---setup--- +public static class OrderExt { - if (Price >= 100) return "Premium"; - if (Price >= 50) return "Standard"; - return "Budget"; + [Expressive(AllowBlockBody = true)] + public static string Tier(this Order o) + { + var total = o.Items.Sum(i => i.UnitPrice * i.Quantity); + if (total >= 1000m) return "Premium"; + if (total >= 100m) return "Standard"; + return "Budget"; + } } -``` +::: ### Globally via MSBuild @@ -61,13 +73,18 @@ This is equivalent to setting `AllowBlockBody = true` on every `[Expressive]` me Simple return statements are the most basic block body form: -```csharp -[Expressive(AllowBlockBody = true)] -public int GetConstant() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Constant = o.GetConstant() }) +---setup--- +public static class OrderExt { - return 42; + [Expressive(AllowBlockBody = true)] + public static int GetConstant(this Order o) + { + return 42; + } } -``` +::: --- @@ -75,43 +92,58 @@ public int GetConstant() If/else chains are converted to nested `Expression.Condition` (ternary) nodes: -```csharp -[Expressive(AllowBlockBody = true)] -public string GetCategory() +::: expressive-sample +db.Products.Select(p => new { p.Name, Category = p.GetCategory() }) +---setup--- +public static class ProductExt { - if (Price >= 100) - return "Premium"; - else if (Price >= 50) - return "Standard"; - else - return "Budget"; + [Expressive(AllowBlockBody = true)] + public static string GetCategory(this Product p) + { + if (p.ListPrice >= 100) + return "Premium"; + else if (p.ListPrice >= 50) + return "Standard"; + else + return "Budget"; + } } -``` +::: An `if` without an `else` is supported when followed by a fallback `return`: -```csharp -[Expressive(AllowBlockBody = true)] -public string GetStatus() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Status = o.GetStatus() }) +---setup--- +public static class OrderExt { - if (IsActive) - return "Active"; - return "Inactive"; // Fallback + [Expressive(AllowBlockBody = true)] + public static string GetStatus(this Order o) + { + if (o.Status == OrderStatus.Paid) + return "Active"; + return "Inactive"; // Fallback + } } -``` +::: Multiple independent early-return statements are converted to a nested ternary chain: -```csharp -[Expressive(AllowBlockBody = true)] -public string GetPriceRange() +::: expressive-sample +db.Products.Select(p => new { p.Name, Range = p.GetPriceRange() }) +---setup--- +public static class ProductExt { - if (Price > 1000) return "Very High"; - if (Price > 100) return "High"; - if (Price > 10) return "Medium"; - return "Low"; + [Expressive(AllowBlockBody = true)] + public static string GetPriceRange(this Product p) + { + if (p.ListPrice > 1000) return "Very High"; + if (p.ListPrice > 100) return "High"; + if (p.ListPrice > 10) return "Medium"; + return "Low"; + } } -``` +::: --- @@ -119,19 +151,24 @@ public string GetPriceRange() Switch statements are converted to nested conditional expressions: -```csharp -[Expressive(AllowBlockBody = true)] -public string GetLabel() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Label = o.GetLabel() }) +---setup--- +public static class OrderExt { - switch (Status) + [Expressive(AllowBlockBody = true)] + public static string GetLabel(this Order o) { - case 1: return "New"; - case 2: return "Active"; - case 3: return "Closed"; - default: return "Unknown"; + switch (o.Status) + { + case OrderStatus.Pending: return "New"; + case OrderStatus.Paid: return "Active"; + case OrderStatus.Delivered: return "Closed"; + default: return "Unknown"; + } } } -``` +::: --- @@ -139,37 +176,47 @@ public string GetLabel() Local variables declared at the method body level are emitted as `Expression.Variable` nodes within an `Expression.Block`: -```csharp -[Expressive(AllowBlockBody = true)] -public int CalculateDouble() +::: expressive-sample +db.LineItems.Select(i => new { i.Id, Doubled = i.CalculateDouble() }) +---setup--- +public static class LineItemExt { - var doubled = Price * 2; - return doubled + 5; + [Expressive(AllowBlockBody = true)] + public static decimal CalculateDouble(this LineItem i) + { + var doubled = i.UnitPrice * 2; + return doubled + 5; + } } -``` +::: Transitive references are supported: -```csharp -[Expressive(AllowBlockBody = true)] -public int CalculateComplex() +::: expressive-sample +db.LineItems.Select(i => new { i.Id, Complex = i.CalculateComplex() }) +---setup--- +public static class LineItemExt { - var a = Price * 2; - var b = a + 5; - return b + 10; + [Expressive(AllowBlockBody = true)] + public static decimal CalculateComplex(this LineItem i) + { + var a = i.UnitPrice * 2; + var b = a + 5; + return b + 10; + } } -``` +::: ::: warning Variable Duplication Caveat The `FlattenBlockExpressions` transformer (applied by `UseExpressives()` in EF Core) inlines local variables at each usage point. If a variable is referenced multiple times, its initializer is duplicated: ```csharp [Expressive(AllowBlockBody = true)] -public double Foo() +public static decimal Foo(this LineItem i) { - var x = Price * Quantity; + var x = i.UnitPrice * i.Quantity; return x + x; - // After FlattenBlockExpressions: (Price * Quantity) + (Price * Quantity) + // After FlattenBlockExpressions: (UnitPrice * Quantity) + (UnitPrice * Quantity) } ``` @@ -182,18 +229,23 @@ For pure expressions (no side effects), this is semantically identical. The gene `foreach` loops are emitted as `Expression.Loop` with the enumerator pattern (GetEnumerator/MoveNext/Current). The `ConvertLoopsToLinq` transformer then rewrites these to equivalent LINQ method calls for providers like EF Core that cannot translate loop expressions: -```csharp -[Expressive(AllowBlockBody = true)] -public double GetTotalLineItemPrice() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Total = o.GetTotalLineItemPrice() }) +---setup--- +public static class OrderExt { - var total = 0.0; - foreach (var item in LineItems) - total += item.Price; - return total; + [Expressive(AllowBlockBody = true)] + public static decimal GetTotalLineItemPrice(this Order o) + { + var total = 0m; + foreach (var item in o.Items) + total += item.UnitPrice; + return total; + } } -``` +::: -After the `ConvertLoopsToLinq` transformer, this becomes equivalent to `LineItems.Sum(item => item.Price)`. +After the `ConvertLoopsToLinq` transformer, this becomes equivalent to `o.Items.Sum(item => item.UnitPrice)`. --- @@ -230,8 +282,11 @@ If you need aggregation logic, prefer LINQ methods in an expression-bodied membe ```csharp // Instead of a loop -[Expressive] -public double TotalPrice => LineItems.Sum(i => i.Price); +public static class OrderExt +{ + [Expressive] + public static decimal TotalPrice(this Order o) => o.Items.Sum(i => i.UnitPrice); +} ``` ::: @@ -239,48 +294,39 @@ public double TotalPrice => LineItems.Sum(i => i.Price); ### If/Else to CASE WHEN -```csharp -[Expressive(AllowBlockBody = true)] -public string GetCategory() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Category = o.GetCategoryFromThreshold() }) +---setup--- +public static class OrderExt { - var threshold = Quantity * 10; - if (threshold > 100) return "Bulk"; - return "Regular"; + [Expressive(AllowBlockBody = true)] + public static string GetCategoryFromThreshold(this Order o) + { + var threshold = o.Items.Sum(i => i.Quantity) * 10; + if (threshold > 100) return "Bulk"; + return "Regular"; + } } -``` - -Generated SQL: - -```sql -SELECT CASE - WHEN ("o"."Quantity" * 10) > 100 THEN 'Bulk' - ELSE 'Regular' -END AS "Category" -FROM "Orders" AS "o" -``` +::: ### Switch Expression Equivalent Block-body switch statements and expression-bodied switch expressions produce the same SQL: -```csharp -[Expressive] -public string GetGrade() => Price switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Grade = p.GetGrade() }) +---setup--- +public static class ProductExt { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", -}; -``` - -```sql -SELECT CASE - WHEN "o"."Price" >= 100.0 THEN 'Premium' - WHEN "o"."Price" >= 50.0 THEN 'Standard' - ELSE 'Budget' -END AS "Grade" -FROM "Orders" AS "o" -``` + [Expressive] + public static string GetGrade(this Product p) => p.ListPrice switch + { + >= 100m => "Premium", + >= 50m => "Standard", + _ => "Budget", + }; +} +::: ## Side Effect Detection diff --git a/docs/advanced/custom-transformers.md b/docs/advanced/custom-transformers.md index 9513f4a..9787736 100644 --- a/docs/advanced/custom-transformers.md +++ b/docs/advanced/custom-transformers.md @@ -62,10 +62,15 @@ There are three ways to register transformers, depending on your use case. Use the `Transformers` property on `[Expressive]` to apply transformers to a specific member. These transformers run when the generated expression is resolved via `ExpressiveResolver`: -```csharp -[Expressive(Transformers = new[] { typeof(RemoveNullConditionalPatterns) })] -public string? CustomerName => Customer?.Name; -``` +::: expressive-sample +db.Orders.Select(o => new { o.Id, Customer = o.CustomerName() }) +---setup--- +public static class OrderExt +{ + [Expressive(Transformers = new[] { typeof(ExpressiveSharp.Transformers.RemoveNullConditionalPatterns) })] + public static string? CustomerName(this Order o) => o.Customer?.Name; +} +::: Multiple transformers can be specified and are applied in order: @@ -206,9 +211,9 @@ For integration testing with EF Core, use `ToQueryString()` to verify the genera public void TransformedQuery_ProducesExpectedSql() { using var ctx = CreateTestContext(); - var query = ctx.Orders + var query = ctx.Customers .AsExpressiveDbSet() - .Where(o => o.Name == "Alice") + .Where(c => c.Name == "Alice") .ToQueryString(); Assert.IsTrue(query.Contains("LOWER"), "Expected case-insensitive comparison"); diff --git a/docs/guide/expression-polyfill.md b/docs/guide/expression-polyfill.md index bc335bf..a675afc 100644 --- a/docs/guide/expression-polyfill.md +++ b/docs/guide/expression-polyfill.md @@ -4,33 +4,36 @@ ## Basic Usage +::: expressive-sample +db.Customers.Where(ExpressionPolyfill.Create((Customer c) => c.Email?.Length > 5)) +::: + +You write a regular lambda with modern syntax, and the source generator converts it into an `Expression>` at compile time. The result is a fully constructed expression tree that you can compile, pass to a LINQ provider, or inspect. The query tabs above show how each provider translates the resulting predicate. + +A standalone usage (outside of a queryable) looks like this: + ```csharp using ExpressiveSharp; // The lambda uses ?. -- normally illegal in expression trees -var expr = ExpressionPolyfill.Create((Order o) => o.Tag?.Length); -// expr is Expression> +var expr = ExpressionPolyfill.Create((Customer c) => c.Email?.Length); +// expr is Expression> var compiled = expr.Compile(); -var result = compiled(order); +var result = compiled(customer); ``` -You write a regular lambda with modern syntax, and the source generator converts it into an `Expression>` at compile time. The result is a fully constructed expression tree that you can compile, pass to a LINQ provider, or inspect. - ## With Transformers You can apply expression transformers inline: -```csharp -using ExpressiveSharp; -using ExpressiveSharp.Transformers; - -var expr = ExpressionPolyfill.Create( - (Order o) => o.Customer?.Email, - new RemoveNullConditionalPatterns()); -``` +::: expressive-sample +db.Orders.Where(ExpressionPolyfill.Create( + (Order o) => o.Customer != null && o.Customer.Country == "NL", + new ExpressiveSharp.Transformers.RemoveNullConditionalPatterns())) +::: -The generated expression tree has `RemoveNullConditionalPatterns` applied, stripping the null-check ternary so the expression reads `o.Customer.Email` directly -- suitable for SQL providers that handle null propagation natively. +The generated expression tree has `RemoveNullConditionalPatterns` applied, stripping the null-check ternary so the expression reads `o.Customer.Country` directly -- suitable for providers that handle null propagation natively. ## Use Cases @@ -38,18 +41,16 @@ The generated expression tree has `RemoveNullConditionalPatterns` applied, strip When you need modern syntax in a one-off query without decorating entity members: -```csharp -var predicate = ExpressionPolyfill.Create( - (Order o) => o.Customer?.Email != null && o.Price switch +::: expressive-sample +db.Orders.Where(ExpressionPolyfill.Create( + (Order o) => o.Customer != null && o.Customer.Email != null && o.Status switch { - >= 100 => true, - _ => false, - }); - -var results = dbContext.Orders - .Where(predicate) - .ToList(); -``` + OrderStatus.Paid => true, + OrderStatus.Shipped => true, + OrderStatus.Delivered => true, + _ => false, + })) +::: ### Testing @@ -122,4 +123,4 @@ Use `[Expressive]` when the logic belongs on an entity and will be reused across - [[Expressive] Properties](./expressive-properties) -- reusable computed properties - [IExpressiveQueryable\](./expressive-queryable) -- modern syntax directly in LINQ chains -- [EF Core Integration](./ef-core-integration) -- full EF Core setup +- [EF Core Integration](./integrations/ef-core) -- full EF Core setup diff --git a/docs/guide/expressive-constructors.md b/docs/guide/expressive-constructors.md index 536817b..c035c2f 100644 --- a/docs/guide/expressive-constructors.md +++ b/docs/guide/expressive-constructors.md @@ -1,30 +1,57 @@ # Constructor Projections -Mark a constructor with `[Expressive]` to project your DTOs directly inside LINQ queries. The generator emits a `MemberInit` expression (`new T() { Prop = value, ... }`) that EF Core can translate to a SQL projection. +Mark a constructor with `[Expressive]` to project your DTOs directly inside LINQ queries. The generator emits a `MemberInit` expression (`new T() { Prop = value, ... }`) that your LINQ provider can translate to a native projection. ## Why Constructor Projections? Without constructor projections, you must write inline anonymous types or repeat object-initializer logic in every query: -```csharp -// Without [Expressive] constructors -- repeated in every query -var dtos = ctx.Orders +::: expressive-sample +db.Orders .Select(o => new OrderSummaryDto { Id = o.Id, - Description = o.Tag ?? "N/A", - Total = o.Price * o.Quantity + Description = "Order #" + o.Id, + Total = o.Items.Sum(i => i.UnitPrice * i.Quantity), }) - .ToList(); -``` +---setup--- +public class OrderSummaryDto +{ + public int Id { get; set; } + public string Description { get; set; } = ""; + public decimal Total { get; set; } +} +::: With an `[Expressive]` constructor, you define the projection once and use it everywhere: -```csharp -var dtos = ctx.Orders - .Select(o => new OrderSummaryDto(o.Id, o.Tag ?? "N/A", o.Total)) - .ToList(); -``` +::: expressive-sample +db.Orders.Select(o => new OrderSummaryDto(o.Id, "Order #" + o.Id, o.Total())) +---setup--- +public class OrderSummaryDto +{ + public int Id { get; set; } + public string Description { get; set; } = ""; + public decimal Total { get; set; } + + public OrderSummaryDto() { } + + [Expressive] + public OrderSummaryDto(int id, string description, decimal total) + { + Id = id; + Description = description; + Total = total; + } +} + +public static class OrderExt +{ + [Expressive] + public static decimal Total(this Order o) + => o.Items.Sum(i => i.UnitPrice * i.Quantity); +} +::: ## Basic Example @@ -33,12 +60,12 @@ public class OrderSummaryDto { public int Id { get; set; } public string Description { get; set; } = ""; - public double Total { get; set; } + public decimal Total { get; set; } public OrderSummaryDto() { } // required parameterless constructor [Expressive] - public OrderSummaryDto(int id, string description, double total) + public OrderSummaryDto(int id, string description, decimal total) { Id = id; Description = description; @@ -50,7 +77,7 @@ public class OrderSummaryDto The generator produces an expression equivalent to: ```csharp -(int id, string description, double total) => new OrderSummaryDto() +(int id, string description, decimal total) => new OrderSummaryDto() { Id = id, Description = description, @@ -58,25 +85,10 @@ The generator produces an expression equivalent to: } ``` -Use it in a query: - -```csharp -var dtos = ctx.Orders - .Select(o => new OrderSummaryDto(o.Id, o.Tag ?? "N/A", o.Total)) - .ToList(); -``` - -Generated SQL: - -```sql -SELECT "o"."Id", - COALESCE("o"."Tag", 'N/A') AS "Description", - "o"."Price" * CAST("o"."Quantity" AS REAL) AS "Total" -FROM "Orders" AS "o" -``` +Use it in a query -- the query tabs above show how each provider translates the projection. ::: tip -Notice that `o.Total` is an `[Expressive]` property -- it is expanded recursively into `o.Price * o.Quantity` before SQL translation. Constructor projections compose with expressive properties and methods seamlessly. +`o.Total()` is itself an `[Expressive]` extension -- it is expanded recursively into `o.Items.Sum(i => i.UnitPrice * i.Quantity)` before translation. Constructor projections compose with expressive properties and methods seamlessly. ::: ## Requirements @@ -93,7 +105,7 @@ Constructor bodies support the following constructs: |---|---| | Simple property assignments | `Id = id;` `Description = description;` | | Local variable declarations | Inlined at each usage point | -| `if`/`else` chains | Converted to ternary expressions / SQL CASE | +| `if`/`else` chains | Converted to ternary expressions / provider CASE | | Switch expressions | Translated to nested ternary / CASE | | `base()`/`this()` initializer chains | Recursively inlines the delegated constructor's assignments | @@ -101,51 +113,49 @@ Constructor bodies support the following constructs: The generator recursively inlines delegated constructor assignments. This is useful with DTO inheritance hierarchies: -```csharp -public class PersonDto +::: expressive-sample +db.Customers.Select(c => new CustomerDetailDto(c)) +---setup--- +public class CustomerDto { - public string FullName { get; set; } = ""; - public string Email { get; set; } = ""; + public int Id { get; set; } + public string Name { get; set; } = ""; - public PersonDto() { } + public CustomerDto() { } [Expressive] - public PersonDto(Person person) + public CustomerDto(Customer c) { - FullName = person.FirstName + " " + person.LastName; - Email = person.Email; + Id = c.Id; + Name = c.Name; } } -public class EmployeeDto : PersonDto +public class CustomerDetailDto : CustomerDto { - public string Department { get; set; } = ""; - public string Grade { get; set; } = ""; + public string? Email { get; set; } + public string Tier { get; set; } = ""; - public EmployeeDto() { } + public CustomerDetailDto() { } [Expressive] - public EmployeeDto(Employee employee) : base(employee) + public CustomerDetailDto(Customer c) : base(c) { - Department = employee.Department.Name; - Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior"; + Email = c.Email; + Tier = c.Orders.Count() >= 10 ? "Gold" : "Standard"; } } - -var employees = ctx.Employees - .Select(e => new EmployeeDto(e)) - .ToList(); -``` +::: The generated expression inlines both the base constructor and the derived constructor body: ```csharp -(Employee employee) => new EmployeeDto() +(Customer c) => new CustomerDetailDto() { - FullName = employee.FirstName + " " + employee.LastName, - Email = employee.Email, - Department = employee.Department.Name, - Grade = employee.YearsOfService >= 10 ? "Senior" : "Junior" + Id = c.Id, + Name = c.Name, + Email = c.Email, + Tier = c.Orders.Count() >= 10 ? "Gold" : "Standard" } ``` @@ -157,20 +167,20 @@ Multiple `[Expressive]` constructors per class are supported -- each overload ge public class OrderDto { public int Id { get; set; } - public double Total { get; set; } + public decimal Total { get; set; } public string? Note { get; set; } public OrderDto() { } [Expressive] - public OrderDto(int id, double total) + public OrderDto(int id, decimal total) { Id = id; Total = total; } [Expressive] - public OrderDto(int id, double total, string note) + public OrderDto(int id, decimal total, string note) { Id = id; Total = total; @@ -195,4 +205,4 @@ If you have an existing `[Expressive]` factory method that returns `new T { ... - [[Expressive] Properties](./expressive-properties) -- computed properties on entities - [[Expressive] Methods](./expressive-methods) -- parameterized query fragments -- [EF Core Integration](./ef-core-integration) -- full EF Core setup and features +- [EF Core Integration](./integrations/ef-core) -- full EF Core setup and features diff --git a/docs/guide/expressive-methods.md b/docs/guide/expressive-methods.md index adabccd..a1f3d84 100644 --- a/docs/guide/expressive-methods.md +++ b/docs/guide/expressive-methods.md @@ -4,7 +4,7 @@ Expressive methods work like expressive properties but accept parameters, making ## Defining an Expressive Method -Add `[Expressive]` to any **expression-bodied method** on an entity: +Add `[Expressive]` to any **expression-bodied method**: ```csharp using ExpressiveSharp; @@ -22,120 +22,105 @@ public class Order The source generator emits a companion `Expression>` at compile time. When the method is called in a LINQ query, the expression tree is substituted automatically. +The webshop entities in these samples don't have built-in `[Expressive]` methods, so the examples below define them as extension methods in `---setup---` blocks. The behavior is identical to instance methods. + ## Using Expressive Methods in Queries -```csharp -// Pass runtime values as arguments -var expensive = ctx.Orders - .Where(o => o.IsExpensive(100)) - .ToList(); - -// Use in Select -var summary = ctx.Orders - .Select(o => new { o.Id, Expensive = o.IsExpensive(50) }) - .ToList(); -``` +::: expressive-sample +db.Orders + .Where(o => o.IsExpensive(500m)) + .Select(o => new { o.Id, Expensive = o.IsExpensive(1000m) }) +---setup--- +public static class OrderExt +{ + [Expressive] + public static bool IsExpensive(this Order o, decimal threshold) => + o.Items.Sum(i => i.UnitPrice * i.Quantity) > threshold; +} +::: -The method argument (`100` or `50`) is captured and translated into the generated SQL expression. +The method argument (`500m` or `1000m`) is captured and translated into the generated expression for each provider. ## Methods with Multiple Parameters -```csharp -public class Product +::: expressive-sample +db.Products.Select(p => new +{ + p.Id, + FinalPrice = p.CalculatePrice(0.05m, 10), +}) +---setup--- +public static class ProductExt { - public double ListPrice { get; set; } - public double DiscountRate { get; set; } - [Expressive] - public double CalculatePrice(double additionalDiscount, int quantity) => - ListPrice * (1 - DiscountRate - additionalDiscount) * quantity; + public static decimal CalculatePrice(this Product p, decimal additionalDiscount, int quantity) => + p.ListPrice * (1 - additionalDiscount) * quantity; } - -// Usage -var prices = ctx.Products - .Select(p => new - { - p.Id, - FinalPrice = p.CalculatePrice(0.05, 10) - }) - .ToList(); -``` +::: ## Switch Expressions in Methods Switch expressions and pattern matching work inside `[Expressive]` methods -- this is one of the key features that plain expression trees cannot do: -```csharp -public class Order +::: expressive-sample +db.Orders.Select(o => new { o.Id, Grade = o.GetGrade() }) +---setup--- +public static class OrderExt { - public double Price { get; set; } - [Expressive] - public string GetGrade() => Price switch - { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", - }; + public static string GetGrade(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity) switch + { + >= 100m => "Premium", + >= 50m => "Standard", + _ => "Budget", + }; } +::: -var graded = ctx.Orders - .Select(o => new { o.Id, Grade = o.GetGrade() }) - .ToList(); -``` - -Generated SQL: -```sql -SELECT "o"."Id", - CASE - WHEN "o"."Price" >= 100.0 THEN 'Premium' - WHEN "o"."Price" >= 50.0 THEN 'Standard' - ELSE 'Budget' - END AS "Grade" -FROM "Orders" AS "o" -``` +The query tabs above show how each provider translates the switch expression (typically as a CASE expression for SQL providers). ## Composing Methods and Properties -Expressive methods can call expressive properties and vice versa. The runtime expander resolves the entire chain: +Expressive methods can call other expressive members and vice versa. The runtime expander resolves the entire chain: -```csharp -public class Order +::: expressive-sample +db.Orders.Where(o => o.ExceedsThreshold(500m)).Select(o => o.Id) +---setup--- +public static class OrderExt { - public double Price { get; set; } - public int Quantity { get; set; } - public double TaxRate { get; set; } - [Expressive] - public double Subtotal => Price * Quantity; + public static decimal Subtotal(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] - public double Tax => Subtotal * TaxRate; + public static decimal Tax(this Order o, decimal rate) => + o.Subtotal() * rate; - // Method calling expressive properties [Expressive] - public bool ExceedsThreshold(double threshold) => - (Subtotal + Tax) > threshold; + public static bool ExceedsThreshold(this Order o, decimal threshold) => + (o.Subtotal() + o.Tax(0.21m)) > threshold; } - -var highValue = ctx.Orders - .Where(o => o.ExceedsThreshold(500)) - .ToList(); -``` +::: ## Block-Bodied Methods Methods can use traditional block bodies when `AllowBlockBody = true`: -```csharp -[Expressive(AllowBlockBody = true)] -public string GetCategory() +::: expressive-sample +db.Orders.Select(o => new { o.Id, Category = o.GetCategory() }) +---setup--- +public static class OrderExt { - var threshold = Quantity * 10; - if (threshold > 100) return "Bulk"; - return "Regular"; + [Expressive(AllowBlockBody = true)] + public static string GetCategory(this Order o) + { + var totalQty = o.Items.Sum(i => i.Quantity); + if (totalQty > 10) return "Bulk"; + return "Regular"; + } } -``` +::: Block bodies support: - Local variable declarations (inlined at each usage point) @@ -158,16 +143,22 @@ You can also enable block bodies globally for a project: ## Static Methods -`[Expressive]` can be applied to static methods as well: +`[Expressive]` can be applied to static methods as well. Here, `CalculateLinePrice` is a pure static helper with no receiver: -```csharp +::: expressive-sample +db.LineItems.Select(i => new +{ + i.Id, + Discounted = OrderHelpers.CalculateLinePrice(i.UnitPrice, i.Quantity), +}) +---setup--- public static class OrderHelpers { [Expressive] - public static double CalculateDiscount(double price, int quantity) => - price * quantity > 1000 ? 0.1 : 0.0; + public static decimal CalculateLinePrice(decimal price, int quantity) => + price * quantity > 1000m ? price * quantity * 0.9m : price * quantity; } -``` +::: ## Important Rules diff --git a/docs/guide/expressive-properties.md b/docs/guide/expressive-properties.md index 23d980d..2e20af3 100644 --- a/docs/guide/expressive-properties.md +++ b/docs/guide/expressive-properties.md @@ -1,6 +1,6 @@ # [Expressive] Properties -Expressive properties let you define computed values on your entities using standard C# syntax, and have those computations automatically translated into SQL when used in LINQ queries. +Expressive properties let you define computed values on your entities using standard C# syntax, and have those computations automatically translated for your LINQ provider when used in queries. ## Defining an Expressive Property @@ -25,149 +25,151 @@ public class Order The source generator emits a companion `Expression>` for `Total` and `Expression>` for `CustomerEmail` at compile time. When the property is used in a LINQ query, the expression tree is substituted automatically. +Since the webshop entities in these samples have no built-in `[Expressive]` members, the examples below define helpers as extension methods in a `---setup---` block. The behavior is identical — `[Expressive]` works on instance properties, extension properties, and methods alike. + ## Using Expressive Properties in Queries Once defined, expressive properties can be used in **any part of a LINQ query**. ### In `Select` -```csharp -var totals = ctx.Orders - .Select(o => new { o.Id, o.Total }) - .ToList(); -``` - -Generated SQL: -```sql -SELECT "o"."Id", - "o"."Price" * CAST("o"."Quantity" AS REAL) AS "Total" -FROM "Orders" AS "o" -``` +::: expressive-sample +db.Orders.Select(o => new { o.Id, Total = o.Total() }) +---setup--- +public static class OrderExt +{ + [Expressive] + public static decimal Total(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity); +} +::: ### In `Where` -```csharp -var expensive = ctx.Orders - .Where(o => o.Total > 500) - .ToList(); -``` +::: expressive-sample +db.Orders.Where(o => o.Total() > 500m).Select(o => o.Id) +---setup--- +public static class OrderExt +{ + [Expressive] + public static decimal Total(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity); +} +::: ### In `GroupBy` -```csharp -var grouped = ctx.Orders - .GroupBy(o => o.CustomerEmail) +::: expressive-sample +db.Orders + .GroupBy(o => o.CustomerEmail()) .Select(g => new { Email = g.Key, Count = g.Count() }) - .ToList(); -``` +---setup--- +public static class OrderExt +{ + [Expressive] + public static string? CustomerEmail(this Order o) => o.Customer.Email; +} +::: ### In `OrderBy` -```csharp -var sorted = ctx.Orders - .OrderByDescending(o => o.Total) - .ToList(); -``` +::: expressive-sample +db.Orders.OrderByDescending(o => o.Total()).Select(o => o.Id) +---setup--- +public static class OrderExt +{ + [Expressive] + public static decimal Total(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity); +} +::: ### In multiple clauses at once -```csharp -var query = ctx.Orders - .Where(o => o.Total > 100) - .OrderByDescending(o => o.Total) - .Select(o => new { o.Id, o.Total, o.CustomerEmail }); -``` +::: expressive-sample +db.Orders + .Where(o => o.Total() > 100m) + .OrderByDescending(o => o.Total()) + .Select(o => new { o.Id, Total = o.Total(), Email = o.CustomerEmail() }) +---setup--- +public static class OrderExt +{ + [Expressive] + public static decimal Total(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity); + + [Expressive] + public static string? CustomerEmail(this Order o) => o.Customer.Email; +} +::: ## Composing Expressive Properties -Expressive properties can reference **other expressive properties**. The entire chain is expanded transitively into the final SQL: +Expressive members can reference **other expressive members**. The entire chain is expanded transitively into the final query: -```csharp -public class Order +::: expressive-sample +db.Orders.Select(o => new { o.Id, Total = o.TotalWithTax(0.21m) }) +---setup--- +public static class OrderExt { - public double Price { get; set; } - public int Quantity { get; set; } - public double TaxRate { get; set; } - - [Expressive] - public double Subtotal => Price * Quantity; - [Expressive] - public double Tax => Subtotal * TaxRate; // references Subtotal + public static decimal Subtotal(this Order o) => + o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] - public double Total => Subtotal + Tax; // references Subtotal and Tax + public static decimal Tax(this Order o, decimal taxRate) => + o.Subtotal() * taxRate; [Expressive] - public double TotalWithTax => Total * (1 + TaxRate); // references Total + public static decimal TotalWithTax(this Order o, decimal taxRate) => + o.Subtotal() + o.Tax(taxRate); } -``` - -When you query `Total`, the runtime expander recursively resolves `Subtotal` and `Tax`, producing a fully flattened SQL expression: - -```csharp -var result = ctx.Orders - .Select(o => new { o.Id, o.Total }) - .ToList(); -``` - -```sql -SELECT "o"."Id", - ("o"."Price" * CAST("o"."Quantity" AS REAL)) + - (("o"."Price" * CAST("o"."Quantity" AS REAL)) * "o"."TaxRate") AS "Total" -FROM "Orders" AS "o" -``` +::: -All computation happens in the database -- no data is loaded into memory. +When you query `TotalWithTax`, the runtime expander recursively resolves `Subtotal` and `Tax`, producing a fully flattened expression — the query tabs above show the translation for each provider. All computation happens in the database — no data is loaded into memory. ## Null-Conditional Properties -The null-conditional operator `?.` works naturally in expressive properties: +The null-conditional operator `?.` works naturally in expressive members: -```csharp -public class Order +::: expressive-sample +db.Orders.Select(o => new { o.Id, Email = o.CustomerEmail() }) +---setup--- +public static class OrderExt { - public Customer? Customer { get; set; } - [Expressive] - public string? CustomerEmail => Customer?.Email; + public static string? CustomerEmail(this Order o) => o.Customer?.Email; [Expressive] - public string? CustomerCity => Customer?.Address?.City; + public static string? CustomerCountry(this Order o) => o.Customer?.Country; } -``` +::: The source generator emits a faithful null-check ternary expression. When used with EF Core and `UseExpressives()`, the `RemoveNullConditionalPatterns` transformer strips the null checks for SQL providers that handle null propagation natively. ## Block-Bodied Properties -By default, `[Expressive]` only supports expression-bodied properties (`=>`). To use block bodies with `if`/`else`, local variables, and other statements, set `AllowBlockBody = true`: +By default, `[Expressive]` only supports expression-bodied members (`=>`). To use block bodies with `if`/`else`, local variables, and other statements, set `AllowBlockBody = true`: -```csharp -[Expressive(AllowBlockBody = true)] -public string Category +::: expressive-sample +db.Orders.Select(o => new { o.Id, Category = o.Category() }) +---setup--- +public static class OrderExt { - get + [Expressive(AllowBlockBody = true)] + public static string Category(this Order o) { - var threshold = Quantity * 10; - if (threshold > 100) return "Bulk"; + var totalQty = o.Items.Sum(i => i.Quantity); + if (totalQty > 10) return "Bulk"; return "Regular"; } } -``` - -EF Core translates block bodies to SQL CASE expressions: +::: -```sql -SELECT CASE - WHEN ("o"."Quantity" * 10) > 100 THEN 'Bulk' - ELSE 'Regular' -END AS "Category" -FROM "Orders" AS "o" -``` +Block bodies translate to CASE expressions — the query tabs above show how each provider renders the conditional. ::: warning -Block bodies are experimental. Not all constructs are supported -- `while`/`do-while`, `try`/`catch`, `async`/`await`, assignments, and `++`/`--` are not translatable. Use expression-bodied properties for full compatibility. +Block bodies are experimental. Not all constructs are supported -- `while`/`do-while`, `try`/`catch`, `async`/`await`, assignments, and `++`/`--` are not translatable. Use expression-bodied members for full compatibility. ::: You can also enable block bodies globally for an entire project via MSBuild instead of opting in per-member: @@ -180,25 +182,25 @@ You can also enable block bodies globally for an entire project via MSBuild inst ## Expanding Properties Manually -You can expand `[Expressive]` properties manually in expression trees outside of EF Core: +You can expand `[Expressive]` members manually in expression trees outside of your query provider: ```csharp -Expression> expr = o => o.Total; -// expr body is: o.Total (opaque property access) +Expression> expr = o => o.Total(); +// expr body is: o.Total() (opaque method call) var expanded = expr.ExpandExpressives(); -// expanded body is: o.Price * o.Quantity (translatable by any LINQ provider) +// expanded body is: o.Items.Sum(i => i.UnitPrice * i.Quantity) ``` -This is useful when you work with LINQ providers other than EF Core or need to inspect the expanded expression tree. +This is useful when you work with LINQ providers directly or need to inspect the expanded expression tree. ## Important Rules -- The property **must be expression-bodied** (using `=>`) unless `AllowBlockBody = true` is set. +- The member **must be expression-bodied** (using `=>`) unless `AllowBlockBody = true` is set. - The expression must be translatable by your LINQ provider -- it can only use members that the provider understands (mapped columns, navigation properties, and other `[Expressive]` members). -- The property body has access to `this` (the entity instance) and its navigation properties. -- If a property has no body, the generator reports diagnostic **EXP0001**. -- If a property uses a block body without opting in, the generator reports diagnostic **EXP0004**. +- The body has access to `this` (the entity or extension receiver) and its navigation properties. +- If a member has no body, the generator reports diagnostic **EXP0001**. +- If a member uses a block body without opting in, the generator reports diagnostic **EXP0004**. ## Next Steps diff --git a/docs/guide/expressive-queryable.md b/docs/guide/expressive-queryable.md index 2c4a68f..1f77c91 100644 --- a/docs/guide/expressive-queryable.md +++ b/docs/guide/expressive-queryable.md @@ -1,21 +1,17 @@ # IExpressiveQueryable\ -`IExpressiveQueryable` enables modern C# syntax directly in LINQ chains -- null-conditional operators, switch expressions, and pattern matching work in `.Where()`, `.Select()`, `.OrderBy()`, and more, on any `IQueryable`. +`IExpressiveQueryable` is the core provider-agnostic API. It enables modern C# syntax directly in LINQ chains — null-conditional operators, switch expressions, and pattern matching work in `.Where()`, `.Select()`, `.OrderBy()`, and more — on any `IQueryable`. ## Basic Usage Wrap any `IQueryable` with `.AsExpressive()`: -```csharp -using ExpressiveSharp; - -var results = queryable - .AsExpressive() - .Where(o => o.Customer?.Email != null) - .Select(o => new { o.Id, Name = o.Customer?.Name ?? "Unknown" }) - .OrderBy(o => o.Name) - .ToList(); -``` +::: expressive-sample +db.Customers + .Where(c => c.Email != null && c.Email.Length > 5) + .Select(c => new { c.Id, Name = c.Name }) + .OrderBy(c => c.Name) +::: The source generator intercepts these calls at compile time and rewrites the delegate lambdas to proper expression trees. There is no runtime overhead from delegate-to-expression conversion. @@ -28,7 +24,7 @@ At compile time, the `PolyfillInterceptorGenerator` uses C# 13 method intercepto 1. Converts the delegate lambda into an `Expression>` using `Expression.*` factory calls 2. Forwards the expression to the underlying `IQueryable` LINQ method -The delegate stubs are never actually called at runtime -- they are completely replaced by the interceptor. +The delegate stubs are never actually called at runtime — they are completely replaced by the interceptor. ## Available LINQ Methods @@ -73,6 +69,25 @@ On .NET 10 and later, these additional methods are available: - `AggregateBy` - `Index` +## Pattern Matching and Switch Expressions + +Switch expressions, null-conditional operators, and pattern matching compose naturally in the chain: + +::: expressive-sample +db.Orders + .Select(o => new + { + o.Id, + Tier = o.Status switch + { + OrderStatus.Paid => "Confirmed", + OrderStatus.Shipped => "Out for delivery", + OrderStatus.Delivered => "Complete", + _ => "Pending" + } + }) +::: + ## EF Core: Include and ThenInclude When using `IExpressiveQueryable` with EF Core, `Include` and `ThenInclude` are fully supported with chain continuity: @@ -81,7 +96,7 @@ When using `IExpressiveQueryable` with EF Core, `Include` and `ThenInclude` a var orders = ctx.Set() .AsExpressive() .Include(o => o.Customer) - .ThenInclude(c => c.Address) + .ThenInclude(c => c.Orders) .Where(o => o.Customer?.Email != null) .ToList(); ``` @@ -136,7 +151,6 @@ var orders = ctx.Set() .IgnoreQueryFilters() .TagWith("Admin query") .Where(o => o.Customer?.Email != null) - .Select(o => new { o.Id, Email = o.Customer?.Email }) .ToList(); ``` @@ -149,26 +163,19 @@ Requires the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package `ExecuteUpdate` and `ExecuteUpdateAsync` are supported on `IExpressiveQueryable`, enabling modern C# syntax inside `SetProperty` value expressions — which is normally impossible in expression trees: ```csharp -ctx.ExpressiveSet() +ctx.ExpressiveSet() .ExecuteUpdate(s => s - .SetProperty(p => p.Tag, p => p.Price switch + .SetProperty(o => o.Status, o => o.Price switch { - > 100 => "premium", - > 50 => "standard", - _ => "budget" - }) - .SetProperty(p => p.Category, p => p.Category ?? "Uncategorized")); + > 100 => OrderStatus.Paid, + > 50 => OrderStatus.Pending, + _ => OrderStatus.Refunded + })); ``` This generates a single SQL `UPDATE` with `CASE WHEN` and `COALESCE` expressions — no entity loading required. -`ExecuteDelete` works out of the box on `IExpressiveQueryable` without any stubs (it has no lambda parameter): - -```csharp -ctx.ExpressiveSet() - .Where(p => p.Price switch { < 10 => true, _ => false }) - .ExecuteDelete(); -``` +`ExecuteDelete` works out of the box on `IExpressiveQueryable` without any stubs (it has no lambda parameter). ## IAsyncEnumerable Support @@ -184,24 +191,22 @@ await foreach (var order in ctx.Set() } ``` -## ExpressiveDbSet\ vs AsExpressive() - -With EF Core, you have two options for modern syntax: +## Choosing the Right Entry Point -| | `ExpressiveDbSet` | `.AsExpressive()` | -|---|---|---| -| **Setup** | Property on `DbContext` | Call on any `IQueryable` | -| **`[Expressive]` expansion** | Automatic | Requires `UseExpressives()` separately | -| **Include/ThenInclude** | Yes | Yes | -| **Async methods** | Yes | Yes | -| **Works outside EF Core** | No | Yes (any `IQueryable`) | +| Entry point | When to use | +|---|---| +| `.AsExpressive()` on `IQueryable` | Any provider (EF Core, MongoDB, custom, in-memory) | +| `ExpressiveDbSet` on `DbContext` | EF Core — preferred, also triggers `[Expressive]` expansion via `UseExpressives()` | +| `.AsExpressive()` on `IMongoCollection` | MongoDB | +| `ExpressionPolyfill.Create(...)` | You need a bare `Expression` (no queryable involved) | ::: tip -For EF Core projects, `ExpressiveDbSet` is the most convenient option -- it combines both `[Expressive]` expansion and modern syntax in one API. Use `.AsExpressive()` when you need modern syntax on a non-EF Core `IQueryable` or want explicit control over the wrapping. +For EF Core projects, `ExpressiveDbSet` is the most convenient option — it combines both `[Expressive]` expansion and modern syntax in one API. Use `.AsExpressive()` when you need modern syntax on a non-EF Core `IQueryable` or want explicit control over the wrapping. ::: ## Next Steps -- [EF Core Integration](./ef-core-integration) -- full setup with `ExpressiveDbSet` and `UseExpressives()` -- [ExpressionPolyfill.Create](./expression-polyfill) -- inline expression trees without LINQ chains -- [[Expressive] Properties](./expressive-properties) -- reusable computed properties +- [EF Core Integration](./integrations/ef-core) — full setup with `ExpressiveDbSet` and `UseExpressives()` +- [MongoDB Integration](./integrations/mongodb) — MongoDB-specific setup +- [ExpressionPolyfill.Create](./expression-polyfill) — inline expression trees without LINQ chains +- [[Expressive] Properties](./expressive-properties) — reusable computed properties diff --git a/docs/guide/extension-members.md b/docs/guide/extension-members.md index b0a2dbe..8d71b09 100644 --- a/docs/guide/extension-members.md +++ b/docs/guide/extension-members.md @@ -1,87 +1,75 @@ # Extension Members -ExpressiveSharp supports `[Expressive]` on both traditional extension methods (any .NET version) and C# 14 extension members (.NET 10+). This lets you define query logic outside of your entity classes — useful for keeping entities clean, applying logic to types you don't own, or grouping related query helpers. +ExpressiveSharp supports `[Expressive]` on both traditional extension methods (any .NET version) and C# 14 extension members (.NET 10+). This lets you define query logic outside of your entity classes -- useful for keeping entities clean, applying logic to types you don't own, or grouping related query helpers. ## Extension Methods -Add `[Expressive]` to any extension method in a **static class**: - -```csharp -using ExpressiveSharp; +Add `[Expressive]` to any extension method in a **static class** and use it inside your queries: +::: expressive-sample +db.Orders + .Where(o => o.IsHighValue(500m)) + .Select(o => new { o.Id, Email = o.SafeCustomerEmail() }) +---setup--- public static class OrderExtensions { [Expressive] - public static bool IsHighValue(this Order order, double threshold) - => order.Price * order.Quantity > threshold; + public static bool IsHighValue(this Order order, decimal threshold) + => order.Items.Sum(i => i.UnitPrice * i.Quantity) > threshold; [Expressive] public static string? SafeCustomerEmail(this Order order) - => order.Customer?.Email; + => order.Customer != null ? order.Customer.Email : null; } -``` - -Use them in queries just like regular extension methods: - -```csharp -var results = db.Orders - .AsExpressiveDbSet() - .Where(o => o.IsHighValue(500)) - .Select(o => new { o.Id, Email = o.SafeCustomerEmail() }) - .ToList(); -``` +::: -The extension method body is inlined into the expression tree — EF Core sees `o.Price * o.Quantity > 500`, not a method call. +The extension method body is inlined into the expression tree -- the provider sees the expanded arithmetic and member access, not a method call. The query tabs above show how each provider translates the result. ## Extension Methods on Non-Entity Types Extension methods work on **any type**, not just entities: -```csharp -public static class IntExtensions -{ - [Expressive] - public static int Squared(this int i) => i * i; -} - +::: expressive-sample +db.Products.Where(p => p.Name.ContainsIgnoreCase("widget")) +---setup--- public static class StringExtensions { [Expressive] public static bool ContainsIgnoreCase(this string source, string value) => source.ToLower().Contains(value.ToLower()); } -``` +::: -Usage in queries: +Primitive extensions compose the same way: -```csharp -var results = db.Players - .AsExpressiveDbSet() - .Select(p => new { p.Name, SquaredScore = p.Score.Squared() }) - .ToList(); - -var widgets = db.Products - .AsExpressiveDbSet() - .Where(p => p.Name.ContainsIgnoreCase("widget")) - .ToList(); -``` +::: expressive-sample +db.LineItems.Select(i => new { i.Id, SquaredQty = i.Quantity.Squared() }) +---setup--- +public static class IntExtensions +{ + [Expressive] + public static int Squared(this int i) => i * i; +} +::: ## Composing Extension Methods -Extension methods can reference other `[Expressive]` members — properties, methods, or other extension methods. `ExpandExpressives()` resolves them transitively: +Extension methods can reference other `[Expressive]` members -- properties, methods, or other extension methods. `ExpandExpressives()` resolves them transitively: -```csharp -public static class UserExtensions +::: expressive-sample +db.Customers.Where(c => c.IsVip()) +---setup--- +public static class CustomerExtensions { [Expressive] - public static double TotalSpent(this User user) - => user.Orders.Sum(o => o.Total); // Total is [Expressive] on Order + public static decimal TotalSpent(this Customer c) + => c.Orders.Sum(o => o.Items.Sum(i => i.UnitPrice * i.Quantity)); [Expressive] - public static bool IsVip(this User user) - => user.TotalSpent() > 10000; // calls another [Expressive] extension + public static bool IsVip(this Customer c) + => c.TotalSpent() > 10000m; // calls another [Expressive] extension } -``` +::: ## C# 14 Extension Members (.NET 10+) @@ -93,30 +81,25 @@ public static class OrderExtensions extension(Order o) { [Expressive] - public double Total => o.Price * o.Quantity; + public decimal Total => o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] - public string Grade => o.Price switch + public string Grade => o.Items.Sum(i => i.UnitPrice * i.Quantity) switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", + >= 1000m => "Premium", + >= 100m => "Standard", + _ => "Budget", }; [Expressive] - public int ScaledQuantity(int factor) => o.Quantity * factor; + public int ScaledItemCount(int factor) => o.Items.Count() * factor; } } -``` - -These are used like regular properties and methods on the extended type: -```csharp -var results = db.Orders - .AsExpressiveDbSet() - .Where(o => o.Total > 500) - .Select(o => new { o.Id, o.Total, o.Grade }) - .ToList(); +// Use like any other property/method: +var orders = db.Orders + .Where(o => o.Total > 500m) + .Select(o => new { o.Id, o.Total, o.Grade }); ``` ### Extension Members on Primitives and Interfaces @@ -133,35 +116,28 @@ public static class IntExtensions } } -public static class EntityExtensions -{ - extension(IEntity e) - { - [Expressive] - public string Label => e.Id + ": " + e.Name; - } -} +db.LineItems.Select(i => new { i.Id, SquaredQty = i.Quantity.Squared }); ``` ### Block Bodies and Switch Expressions -C# 14 extension members support all the same features as regular `[Expressive]` members — block bodies, switch expressions, pattern matching, and null-conditional operators: +C# 14 extension members support all the same features as regular `[Expressive]` members -- block bodies, switch expressions, pattern matching, and null-conditional operators: ```csharp -public static class EntityExtensions +public static class OrderExtensions { - extension(Entity e) + extension(Order o) { [Expressive(AllowBlockBody = true)] public string GetStatus() { - if (e.IsActive && e.Value > 0) - return "Active"; - return "Inactive"; + if (o.Status == OrderStatus.Delivered && o.Items.Count() > 0) + return "Completed"; + return "In Progress"; } [Expressive] - public bool IsHighValue => e.Value is > 100; + public bool IsHighValue => o.Items.Sum(i => i.UnitPrice * i.Quantity) is > 100m; } } ``` @@ -191,7 +167,7 @@ See [[ExpressiveFor] Mapping](/reference/expressive-for) for details on mapping ## See Also -- [[Expressive] Properties](./expressive-properties) — defining computed properties on entities directly -- [[Expressive] Methods](./expressive-methods) — defining computed methods on entities -- [Reusable Query Filters](/recipes/reusable-query-filters) — practical example of extension methods as reusable filters -- [[ExpressiveFor] Mapping](/reference/expressive-for) — mapping existing methods on types you don't own +- [[Expressive] Properties](./expressive-properties) -- defining computed properties on entities directly +- [[Expressive] Methods](./expressive-methods) -- defining computed methods on entities +- [Reusable Query Filters](/recipes/reusable-query-filters) -- practical example of extension methods as reusable filters +- [[ExpressiveFor] Mapping](/reference/expressive-for) -- mapping existing methods on types you don't own diff --git a/docs/guide/integrations/custom-providers.md b/docs/guide/integrations/custom-providers.md new file mode 100644 index 0000000..8875f25 --- /dev/null +++ b/docs/guide/integrations/custom-providers.md @@ -0,0 +1,73 @@ +# Custom Providers + +ExpressiveSharp is not tied to EF Core or MongoDB — it works with any `IQueryable`. This page covers how to use it with your own provider, a third-party LINQ provider, or even LINQ to Objects. + +## The Core Contract + +The `ExpressiveSharp` package provides two provider-neutral entry points: + +- `.AsExpressive()` — wraps any `IQueryable` in an `IExpressiveQueryable`. Modern C# syntax works in the query; `[Expressive]` members are expanded; the normalized expression tree is handed to the underlying provider. +- `ExpressionPolyfill.Create(...)` — builds an `Expression` from a delegate lambda that uses modern syntax, for cases where you need a bare expression tree rather than a queryable. + +Neither of these requires EF Core, MongoDB, or any specific provider assembly. + +## Wrapping an IQueryable + +Any `IQueryable` works: + +```csharp +using ExpressiveSharp; + +IQueryable raw = GetCustomers(); // your own provider +var customers = raw.AsExpressive(); + +var results = customers + .Where(c => c.Email != null && c.IsVip) // [Expressive] + modern syntax + .Select(c => new { c.Name, c.Email }) + .ToList(); +``` + +The query runs through your provider's own translation pipeline after ExpressiveSharp has: + +1. Expanded `[Expressive]` member accesses into their generated expression trees +2. Normalized the tree via the built-in transformers (null-conditional flattening, block lifting, tuple comparison flattening) + +## LINQ to Objects + +The same extension works on `IEnumerable.AsQueryable()`: + +```csharp +var people = peopleList.AsQueryable().AsExpressive(); + +var filtered = people + .Where(p => p.Employer?.Name == "Acme" && p.IsActive) // [Expressive] IsActive + .ToList(); +``` + +Useful for unit tests or in-memory scenarios where you want the same `[Expressive]` logic you use in your database code path. + +## ExpressionPolyfill.Create + +Sometimes you don't have a queryable — you need an `Expression>` to pass to a method that takes one. `ExpressionPolyfill.Create` builds that expression from a delegate lambda, so you can use modern syntax inline: + +```csharp +using ExpressiveSharp; + +Expression> predicate = + ExpressionPolyfill.Create((Customer c) => c.Email?.Length > 5); + +// Use it with your own API that takes Expression +provider.Query(predicate); +``` + +See [ExpressionPolyfill.Create](../expression-polyfill) for the full API. + +## Implementing a Custom Plugin + +If you're building a provider and want to ship ExpressiveSharp-specific transformers (e.g., to handle a dialect quirk), implement `IExpressionTreeTransformer` and register it via the plugin architecture. See [Custom Transformers](../../advanced/custom-transformers) for the full walkthrough. + +## Next Steps + +- [IExpressiveQueryable\](../expressive-queryable) — full API surface +- [ExpressionPolyfill.Create](../expression-polyfill) — build `Expression` inline +- [Custom Transformers](../../advanced/custom-transformers) — hook into the pipeline diff --git a/docs/guide/ef-core-integration.md b/docs/guide/integrations/ef-core.md similarity index 52% rename from docs/guide/ef-core-integration.md rename to docs/guide/integrations/ef-core.md index f49ab05..235a0c3 100644 --- a/docs/guide/ef-core-integration.md +++ b/docs/guide/integrations/ef-core.md @@ -1,6 +1,6 @@ -# EF Core Integration +# EF Core -ExpressiveSharp provides first-class EF Core integration through the `ExpressiveSharp.EntityFrameworkCore` package. This page covers the full setup and all EF Core-specific features. +The `ExpressiveSharp.EntityFrameworkCore` package provides first-class integration with Entity Framework Core for all relational providers (SQL Server, PostgreSQL, SQLite, MySQL, Oracle) and Cosmos DB. This page covers EF Core-specific setup and features. ## Installation @@ -8,7 +8,7 @@ ExpressiveSharp provides first-class EF Core integration through the `Expressive dotnet add package ExpressiveSharp.EntityFrameworkCore ``` -This package depends on `ExpressiveSharp` (core runtime) and includes Roslyn analyzers and code fixes. +Depends on `ExpressiveSharp` (core runtime) and includes Roslyn analyzers and code fixes. ## UseExpressives() Configuration @@ -33,17 +33,17 @@ services.AddDbContext(options => `UseExpressives()` automatically: -1. **Expands `[Expressive]` member references** -- walks query expression trees and replaces opaque property/method accesses with the generated expression trees -2. **Marks `[Expressive]` properties as unmapped** -- adds a model convention that tells EF Core to ignore these properties in the database model (no corresponding column) +1. **Expands `[Expressive]` member references** — walks query expression trees and replaces opaque property/method accesses with the generated expression trees +2. **Marks `[Expressive]` properties as unmapped** — adds a model convention that tells EF Core to ignore these properties in the database model (no corresponding column) 3. **Applies database-friendly transformers** (in this order): - - `ConvertLoopsToLinq` -- converts loop expressions to LINQ method calls - - `RemoveNullConditionalPatterns` -- strips null-check ternaries for SQL providers - - `FlattenTupleComparisons` -- rewrites tuple field access to direct comparisons - - `FlattenConcatArrayCalls` -- flattens `string.Concat(string[])` into chained 2/3/4-arg `Concat` calls - - `FlattenBlockExpressions` -- inlines block-local variables and removes `Expression.Block` nodes + - `ConvertLoopsToLinq` — converts loop expressions to LINQ method calls + - `RemoveNullConditionalPatterns` — strips null-check ternaries for SQL providers + - `FlattenTupleComparisons` — rewrites tuple field access to direct comparisons + - `FlattenConcatArrayCalls` — flattens `string.Concat(string[])` into chained 2/3/4-arg `Concat` calls + - `FlattenBlockExpressions` — inlines block-local variables and removes `Expression.Block` nodes ::: warning String interpolation format specifiers -String interpolation with format specifiers like `$"{Price:F2}"` generates `ToString(format)` at the expression tree level. EF Core cannot translate `ToString(string)` to SQL -- in a final `Select` projection this silently falls back to client evaluation, but in `Where`, `OrderBy`, or other server-evaluated positions it throws at runtime. Simple interpolation without format specifiers (e.g., `$"Order #{Id}"`) is usually server-translatable because EF Core natively translates the 2/3/4-argument `string.Concat` overloads to SQL concatenation. For interpolations with 5+ parts, the emitter produces `string.Concat(string[])`; the `FlattenConcatArrayCalls` transformer listed above rewrites this into EF Core-translatable `Concat` calls. +String interpolation with format specifiers like `$"{Price:F2}"` generates `ToString(format)` at the expression tree level. EF Core cannot translate `ToString(string)` to SQL — in a final `Select` projection this silently falls back to client evaluation, but in `Where`, `OrderBy`, or other server-evaluated positions it throws at runtime. Simple interpolation without format specifiers (e.g., `$"Order #{Id}"`) is usually server-translatable because EF Core natively translates the 2/3/4-argument `string.Concat` overloads to SQL concatenation. For interpolations with 5+ parts, the emitter produces `string.Concat(string[])`; the `FlattenConcatArrayCalls` transformer listed above rewrites this into EF Core-translatable `Concat` calls. ::: ## ExpressiveDbSet\ @@ -64,19 +64,18 @@ public class MyDbContext : DbContext `this.ExpressiveSet()` is a convenience extension method that calls `Set().AsExpressiveDbSet()`. You can also call `.AsExpressiveDbSet()` on any `DbSet` or `IQueryable` directly. ::: -With `ExpressiveDbSet`, modern C# syntax works directly -- no `.AsExpressive()` needed: +With `ExpressiveDbSet`, modern C# syntax works directly — no `.AsExpressive()` needed: -```csharp -var results = ctx.Orders - .Where(o => o.Customer?.Email != null) +::: expressive-sample +db.Orders + .Where(o => o.Customer.Email != null) .Select(o => new { o.Id, - o.Total, // [Expressive] property -- expanded automatically - Grade = o.GetGrade() // [Expressive] method -- expanded automatically + Total = o.Items.Sum(i => i.UnitPrice * i.Quantity), + Email = o.Customer.Email }) - .ToList(); -``` +::: ## Include and ThenInclude @@ -85,14 +84,8 @@ var results = ctx.Orders ```csharp var orders = ctx.Orders .Include(o => o.Customer) - .ThenInclude(c => c.Address) + .ThenInclude(c => c.Orders) .Where(o => o.Customer?.Name != null) - .Select(o => new - { - o.Id, - Name = o.Customer?.Name, - City = o.Customer?.Address?.City - }) .ToList(); ``` @@ -100,7 +93,7 @@ String-based includes are also supported: ```csharp var orders = ctx.Orders - .Include("Customer.Address") + .Include("Customer") .Where(o => o.Customer?.Name != null) .ToList(); ``` @@ -116,17 +109,11 @@ var hasAliceOrders = await ctx.Orders // Async element access var firstOrder = await ctx.Orders - .FirstOrDefaultAsync(o => o.Total > 100); + .FirstOrDefaultAsync(o => o.Price * o.Quantity > 100); // Async aggregation var totalRevenue = await ctx.Orders - .SumAsync(o => o.Total); - -var avgPrice = await ctx.Orders - .AverageAsync(o => o.Price); - -var maxTotal = await ctx.Orders - .MaxAsync(o => o.Total); + .SumAsync(o => o.Price * o.Quantity); ``` Supported async methods: @@ -165,18 +152,12 @@ With the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package, you // Requires: .UseExpressives(o => o.UseRelationalExtensions()) ctx.Orders .ExecuteUpdate(s => s - .SetProperty(o => o.Tag, o => o.Price switch + .SetProperty(o => o.Status, o => o.Price switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget" + >= 100 => OrderStatus.Paid, + >= 50 => OrderStatus.Pending, + _ => OrderStatus.Refunded })); - -// Async variant -await ctx.Orders - .ExecuteUpdateAsync(s => s.SetProperty( - o => o.Tag, - o => o.Customer?.Name ?? "Unknown")); ``` Switch expressions and null-conditional operators inside `SetProperty` value lambdas are normally rejected by the C# compiler in expression tree contexts. The source generator converts them to `CASE WHEN` and `COALESCE` SQL expressions. @@ -218,90 +199,22 @@ public class MyPlugin : IExpressivePlugin } ``` -Plugins can: -- Register additional EF Core services via dependency injection -- Provide custom expression tree transformers that are applied to all queries - The built-in `RelationalExtensions` package (for window functions) uses this plugin architecture. ## NuGet Packages | Package | Description | |---------|-------------| -| [`ExpressiveSharp`](https://www.nuget.org/packages/ExpressiveSharp/) | Core runtime -- `[Expressive]` attribute, source generator, expression expansion, transformers | -| [`ExpressiveSharp.EntityFrameworkCore`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore/) | EF Core integration -- `UseExpressives()`, `ExpressiveDbSet`, Include/ThenInclude, async methods, analyzers and code fixes | -| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | Relational extensions -- `ExecuteUpdate`/`ExecuteUpdateAsync` with modern syntax, SQL window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) | +| [`ExpressiveSharp`](https://www.nuget.org/packages/ExpressiveSharp/) | Core runtime — `[Expressive]` attribute, source generator, expression expansion, transformers | +| [`ExpressiveSharp.EntityFrameworkCore`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore/) | EF Core integration — `UseExpressives()`, `ExpressiveDbSet`, Include/ThenInclude, async methods, analyzers and code fixes | +| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | Relational extensions — `ExecuteUpdate`/`ExecuteUpdateAsync` with modern syntax, SQL window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) | ::: info The `ExpressiveSharp.EntityFrameworkCore` package bundles Roslyn analyzers and code fixes from `ExpressiveSharp.EntityFrameworkCore.CodeFixers`. These provide compile-time diagnostics and IDE quick-fix actions for common issues like missing `[Expressive]` attributes. ::: -## Full Example - -```csharp -using ExpressiveSharp; -using ExpressiveSharp.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; - -public class Order -{ - public int Id { get; set; } - public double Price { get; set; } - public int Quantity { get; set; } - public string? Tag { get; set; } - public int CustomerId { get; set; } - public Customer? Customer { get; set; } - - [Expressive] - public double Total => Price * Quantity; - - [Expressive] - public string GetGrade() => Price switch - { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", - }; -} - -public class Customer -{ - public int Id { get; set; } - public string? Name { get; set; } - public string? Email { get; set; } - public Address? Address { get; set; } -} - -public class AppDbContext : DbContext -{ - public ExpressiveDbSet Orders => this.ExpressiveSet(); - public DbSet Customers { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder options) - => options.UseSqlite("Data Source=app.db").UseExpressives(); -} - -// Query with full feature set -using var ctx = new AppDbContext(); - -var results = await ctx.Orders - .Include(o => o.Customer) - .AsNoTracking() - .Where(o => o.Customer?.Email != null) - .OrderByDescending(o => o.Total) - .Select(o => new - { - o.Id, - o.Total, - Email = o.Customer?.Email, - Grade = o.GetGrade() - }) - .ToListAsync(); -``` - ## Next Steps -- [Window Functions](./window-functions) -- SQL window functions via the RelationalExtensions package -- [IExpressiveQueryable\](./expressive-queryable) -- modern syntax on any `IQueryable` -- [[Expressive] Properties](./expressive-properties) -- computed properties in depth -- [Quick Start](./quickstart) -- minimal setup walkthrough +- [Window Functions](../window-functions) — SQL window functions via the RelationalExtensions package +- [IExpressiveQueryable\](../expressive-queryable) — the core provider-agnostic API +- [[Expressive] Properties](../expressive-properties) — computed properties in depth diff --git a/docs/guide/integrations/mongodb.md b/docs/guide/integrations/mongodb.md new file mode 100644 index 0000000..d938262 --- /dev/null +++ b/docs/guide/integrations/mongodb.md @@ -0,0 +1,91 @@ +# MongoDB + +The `ExpressiveSharp.MongoDB` package integrates ExpressiveSharp with the official `MongoDB.Driver` LINQ provider. Modern C# syntax — null-conditional operators, switch expressions, pattern matching — and `[Expressive]` members are translated into MongoDB aggregation pipelines. + +## Installation + +```bash +dotnet add package ExpressiveSharp.MongoDB +``` + +Depends on `ExpressiveSharp` (core runtime) and `MongoDB.Driver` 3.x. + +## Basic Setup + +Call `.AsExpressive()` on an `IMongoCollection` to get an `IExpressiveMongoQueryable`: + +```csharp +using ExpressiveSharp.MongoDB.Extensions; +using MongoDB.Driver; + +var client = new MongoClient("mongodb://localhost:27017"); +var db = client.GetDatabase("shop"); + +var customers = db.GetCollection("customers").AsExpressive(); +var orders = db.GetCollection("orders").AsExpressive(); +``` + +That's it. Both modern syntax and `[Expressive]` member expansion are now active: + +::: expressive-sample +db.Customers + .Where(c => c.Email != null && c.Orders.Count() > 5) + .Select(c => new { c.Name, OrderCount = c.Orders.Count() }) +::: + +## What AsExpressive() Does + +`AsExpressive()` wraps MongoDB's LINQ provider with ExpressiveSharp's query provider: + +1. **Expands `[Expressive]` member references** — walks the expression tree and replaces opaque property/method accesses with the generated expression trees +2. **Applies MongoDB-friendly transformers** — strips null-conditional patterns, flattens blocks, normalizes tuple access +3. **Delegates execution to MongoDB's aggregation pipeline** — the rewritten tree is handed back to the MongoDB LINQ provider unchanged in shape + +No custom MQL is emitted — MongoDB's own translator does all the heavy lifting after ExpressiveSharp has normalized the tree. + +## Async Methods + +All MongoDB async LINQ methods (from `MongoQueryable`) work with modern syntax via interceptors. They are stubs on `IExpressiveMongoQueryable` that forward to their `MongoQueryable` counterparts: + +```csharp +// Predicate / element access +var exists = await customers.AnyAsync(c => c.Orders.Count() > 0); +var first = await customers.FirstOrDefaultAsync(c => c.Email != null); +var count = await customers.CountAsync(c => c.Country == "US"); + +// Aggregation +var total = await orders.SumAsync(o => o.Price * o.Quantity); +var avg = await orders.AverageAsync(o => o.Price); +``` + +## Inspecting the Pipeline + +Call `.ToString()` on an `IExpressiveMongoQueryable` to see the generated aggregation pipeline without executing it: + +```csharp +var query = customers + .Where(c => c.Email != null) + .Select(c => new { c.Name, c.Email }); + +Console.WriteLine(query.ToString()); +``` + +Output: + +``` +shop.customers.Aggregate([ + { "$match" : { "Email" : { "$ne" : null } } }, + { "$project" : { "Name" : "$Name", "Email" : "$Email", "_id" : 0 } } +]) +``` + +## Caveats + +- **No navigation properties.** MongoDB is a document store; `[Expressive]` members that reach across collections (`customer.Orders`) assume the related data is embedded as a subdocument. If your schema uses references across collections, project and `$lookup` explicitly. +- **No cross-field `[Expressive]` with untracked fields.** MongoDB's LINQ provider requires every field referenced in a projection or filter to exist in the document schema. An `[Expressive]` member that references a non-persisted field won't translate. + +## Next Steps + +- [IExpressiveQueryable\](../expressive-queryable) — the core provider-agnostic API +- [[Expressive] Properties](../expressive-properties) — computed properties in depth +- [Custom Providers](./custom-providers) — use `.AsExpressive()` on any `IQueryable` diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 9634e50..6530ae4 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,10 +1,20 @@ # Introduction -**ExpressiveSharp** is a Roslyn source generator that enables modern C# syntax in LINQ expression trees. It generates `Expression` factory code at compile time, so you can use null-conditional operators, switch expressions, pattern matching, and more in queries that target EF Core or any LINQ provider. +**ExpressiveSharp** is a Roslyn source generator that enables modern C# syntax in LINQ expression trees. It generates `Expression` factory code at compile time, so you can use null-conditional operators, switch expressions, pattern matching, and more in queries against **any** LINQ provider. + +## Works With + +ExpressiveSharp is provider-agnostic. It layers on top of `IQueryable` and integrates with: + +- **EF Core** — every provider (SQLite, SQL Server, PostgreSQL, MySQL, Cosmos DB, Oracle, …) via `ExpressiveSharp.EntityFrameworkCore` +- **MongoDB** — via `ExpressiveSharp.MongoDB`, translating to MQL aggregation pipelines +- **Any `IQueryable`** — wrap with `.AsExpressive()` and you get modern syntax on your own provider or any third-party one + +The samples throughout these docs show the same query rendered against SQLite, PostgreSQL, SQL Server, Cosmos DB, and MongoDB side by side — so you always see how the construct translates for your target. ## The Two Problems -When using C# with LINQ providers like EF Core, you hit two walls: +When using C# with LINQ providers, you hit two walls: ### 1. Expression tree syntax restrictions @@ -18,7 +28,7 @@ Expression trees (`Expression>`) only support a restricted subset of C ### 2. Computed properties are opaque to LINQ providers -You define `public string FullName => FirstName + " " + LastName` and use it in a query, but EF Core cannot see inside the property getter. It either throws a runtime translation error, or worse, silently fetches the entire entity to evaluate `FullName` on the client (overfetching). The only workaround is to duplicate the logic as an inline expression in every query that needs it. +You define `public string FullName => FirstName + " " + LastName` and use it in a query, but the provider cannot see inside the property getter. It either throws a runtime translation error, or worse, silently fetches the entire entity to evaluate `FullName` on the client (overfetching). The only workaround is to duplicate the logic as an inline expression in every query that needs it. ## How ExpressiveSharp Works @@ -34,15 +44,15 @@ Two Roslyn incremental source generators analyze your code: ### Runtime (expression expansion) -When you query with EF Core (via `UseExpressives()`) or call `.ExpandExpressives()` manually, an `ExpressionVisitor` walks the query tree and replaces opaque `[Expressive]` member accesses with the pre-built expression trees. Transformers then adapt the trees for the target provider (stripping null-conditional patterns for SQL, flattening blocks, etc.). +When your query executes, an `ExpressionVisitor` walks the tree and replaces opaque `[Expressive]` member accesses with the pre-built expression trees. Provider-agnostic transformers then normalize the tree (stripping null-conditional patterns, flattening blocks, etc.) before handing off to the underlying LINQ provider. ``` Your LINQ query -> [Expressive member accesses replaced with generated expressions] - -> [Transformers adapt for SQL provider] + -> [Transformers normalize the tree] -> Expanded expression tree - -> EF Core SQL translation - -> SQL query + -> Provider translation (SQL / MQL / …) + -> Executed query ``` All expression trees are generated at compile time. There is no runtime reflection or expression compilation. @@ -53,12 +63,13 @@ Mark computed properties and methods with `[Expressive]` to generate companion e | Scenario | API | |---|---| -| **EF Core** -- modern syntax + `[Expressive]` expansion on `DbSet` | [`ExpressiveDbSet`](./ef-core-integration) (or [`UseExpressives()`](./ef-core-integration) for global expansion) | -| **Any `IQueryable`** -- modern syntax + `[Expressive]` expansion | [`.AsExpressive()`](./expressive-queryable) | -| **EF Core** -- SQL window functions (ROW_NUMBER, RANK, etc.) | [`WindowFunction.*`](./window-functions) (install `RelationalExtensions` package) | -| **Advanced** -- build an `Expression` inline, no attribute needed | [`ExpressionPolyfill.Create`](./expression-polyfill) | -| **Advanced** -- expand `[Expressive]` members in an existing expression tree | `.ExpandExpressives()` | -| **Advanced** -- make third-party/BCL members expressable | `[ExpressiveFor]` | +| **Any `IQueryable`** — modern syntax + `[Expressive]` expansion | [`.AsExpressive()`](./expressive-queryable) | +| **EF Core** — full integration (DbSet + async + Include) | [`ExpressiveDbSet` / `UseExpressives()`](./integrations/ef-core) | +| **MongoDB** — `.AsExpressive()` on `IMongoCollection` | [MongoDB integration](./integrations/mongodb) | +| **SQL window functions** (ROW_NUMBER, RANK, etc.) | [`WindowFunction.*`](./window-functions) (install `RelationalExtensions` package) | +| **Advanced** — build an `Expression` inline, no attribute needed | [`ExpressionPolyfill.Create`](./expression-polyfill) | +| **Advanced** — expand `[Expressive]` members in an existing expression tree | `.ExpandExpressives()` | +| **Advanced** — make third-party/BCL members expressable | `[ExpressiveFor]` | ## Comparison with Similar Libraries @@ -82,6 +93,7 @@ Mark computed properties and methods with `[Expressive]` to generate companion e | `with` expressions (records) | Yes | No | No | No | | Collection expressions | Yes | No | No | No | | Customizable transformer pipeline | Yes | No | No | No | +| MongoDB support | Yes | No | No | No | | Plugin architecture (EF Core) | Yes | No | No | No | | External member mapping | Yes (`[ExpressiveFor]`) | No | No | No | | Not coupled to EF Core | Yes | No | No | No | @@ -94,10 +106,11 @@ Mark computed properties and methods with `[Expressive]` to generate companion e | **ExpressiveSharp** | C# 12 | C# 14 | | **ExpressiveSharp.Abstractions** | C# 12 | C# 14 | | **ExpressiveSharp.EntityFrameworkCore** | EF Core 8.x | EF Core 10.x | +| **ExpressiveSharp.MongoDB** | MongoDB.Driver 3.x | MongoDB.Driver 3.x | | **ExpressiveSharp.EntityFrameworkCore.RelationalExtensions** | EF Core 8.x | EF Core 10.x | ## Next Steps -- [Quick Start](./quickstart) -- install, configure, and run your first query -- [[Expressive] Properties](./expressive-properties) -- computed properties translated to SQL -- [EF Core Integration](./ef-core-integration) -- full setup for Entity Framework Core +- [Quick Start](./quickstart) — install, configure, and run your first query +- [IExpressiveQueryable\](./expressive-queryable) — the core provider-agnostic API +- [[Expressive] Properties](./expressive-properties) — computed properties translated to your provider diff --git a/docs/guide/migration-from-projectables.md b/docs/guide/migration-from-projectables.md index d6af831..d8145ae 100644 --- a/docs/guide/migration-from-projectables.md +++ b/docs/guide/migration-from-projectables.md @@ -156,17 +156,17 @@ static string FullNameExpr(MyEntity e) => e.FirstName + " " + e.LastName; `[ExpressiveFor]` also enables a use case that `UseMemberBody` never supported -- providing expression tree bodies for methods on types you do not own: -```csharp -using ExpressiveSharp.Mapping; - -// Make Math.Clamp usable in EF Core queries -[ExpressiveFor(typeof(Math), nameof(Math.Clamp))] -static double Clamp(double value, double min, double max) - => value < min ? min : (value > max ? max : value); - -// Now this translates to SQL instead of throwing: -db.Orders.Where(o => Math.Clamp(o.Price, 20, 100) > 50) -``` +::: expressive-sample +db.LineItems.Where(i => Math.Clamp((double)i.UnitPrice, 20, 100) > 50) +---setup--- +public static class MathExpressives +{ + // Make Math.Clamp usable in EF Core queries + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(Math), nameof(Math.Clamp))] + static double Clamp(double value, double min, double max) + => value < min ? min : (value > max ? max : value); +} +::: **Scenario 3: Constructors** @@ -256,12 +256,11 @@ After migrating, you gain access to features that Projectables never had. Here a Use `IExpressiveQueryable` or `ExpressiveDbSet` to write LINQ queries with modern C# syntax: -```csharp -var results = ctx.Orders - .Where(o => o.Customer?.Email != null) - .Select(o => new { o.Id, Name = o.Customer?.Name ?? "Unknown" }) - .ToList(); -``` +::: expressive-sample +db.Orders + .Where(o => o.Customer.Email != null) + .Select(o => new { o.Id, Name = o.Customer.Name ?? "Unknown" }) +::: See [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq). @@ -269,58 +268,83 @@ See [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq). Create expression trees inline without needing an attribute: -```csharp -var expr = ExpressionPolyfill.Create((Order o) => o.Tag?.Length); -``` +::: expressive-sample +db.Customers.Where(ExpressionPolyfill.Create((Customer c) => c.Email?.Length > 5)) +::: ### Switch Expressions and Pattern Matching -```csharp -[Expressive] -public string GetGrade() => Price switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Grade = p.GetGrade() }) +---setup--- +public static class ProductExt { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", -}; + [Expressive] + public static string GetGrade(this Product p) => p.ListPrice switch + { + >= 100m => "Premium", + >= 50m => "Standard", + _ => "Budget", + }; +} +::: -[Expressive] -public bool IsSpecialOrder => this is { Quantity: > 100, Price: >= 50 }; -``` +::: expressive-sample +db.LineItems.Where(i => i.IsSpecialLine()) +---setup--- +public static class LineItemExt +{ + [Expressive] + public static bool IsSpecialLine(this LineItem i) => i is { Quantity: > 100, UnitPrice: >= 50m }; +} +::: See [Scoring and Classification](/recipes/scoring-classification). ### Constructor Projections -```csharp -public class OrderSummary +::: expressive-sample +db.Orders.Select(o => OrderSummaryBuilder.From(o)) +---setup--- +public sealed class OrderSummary +{ + public int Id { get; init; } + public decimal Total { get; init; } +} + +public static class OrderSummaryBuilder { [Expressive] - public OrderSummary(Order o) + public static OrderSummary From(Order o) => new OrderSummary { - Id = o.Id; - Total = o.Price * o.Quantity; - } + Id = o.Id, + Total = o.Items.Sum(i => i.UnitPrice * i.Quantity), + }; } -``` +::: See [DTO Projections with Constructors](/recipes/dto-projections). ### External Member Mapping -```csharp -using ExpressiveSharp.Mapping; - -[ExpressiveFor(typeof(Math), nameof(Math.Abs))] -static int Abs(int value) => value < 0 ? -value : value; -``` +::: expressive-sample +db.LineItems.Where(i => Math.Abs(i.Quantity) > 0) +---setup--- +public static class MathExpressives +{ + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(Math), nameof(Math.Abs))] + static int Abs(int value) => value < 0 ? -value : value; +} +::: See [External Member Mapping](/recipes/external-member-mapping). ### Custom Transformers -```csharp -public class MyTransformer : IExpressionTreeTransformer +::: expressive-sample +db.LineItems.Select(i => new { i.Id, Adjusted = i.AdjustedTotal() }) +---setup--- +public class MyTransformer : ExpressiveSharp.IExpressionTreeTransformer { public Expression Transform(Expression expression) { @@ -328,9 +352,12 @@ public class MyTransformer : IExpressionTreeTransformer } } -[Expressive(Transformers = new[] { typeof(MyTransformer) })] -public double AdjustedTotal => Price * Quantity * 1.1; -``` +public static class LineItemExt +{ + [Expressive(Transformers = new[] { typeof(MyTransformer) })] + public static decimal AdjustedTotal(this LineItem i) => i.UnitPrice * i.Quantity * 1.1m; +} +::: ### SQL Window Functions @@ -342,7 +369,7 @@ var ranked = dbContext.Orders.Select(o => new o.Id, Rank = WindowFunction.Rank( Window.PartitionBy(o.CustomerId) - .OrderByDescending(o.GrandTotal)) + .OrderByDescending(o.PlacedAt)) }); ``` diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 0e5cd9d..ecea179 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -1,32 +1,47 @@ # Quick Start -This guide walks you through a complete end-to-end example -- from installing the NuGet packages to seeing the generated SQL. +This guide walks you through a complete end-to-end example — from installing the NuGet packages to seeing the translated output for your provider. ## Prerequisites - .NET 8 SDK or later (.NET 10 also supported) -- A LINQ provider such as EF Core (any provider: SQLite, SQL Server, PostgreSQL, etc.) +- A LINQ provider. ExpressiveSharp integrates with **EF Core**, **MongoDB**, or **any `IQueryable`**. -## Step 1 -- Install the Packages +## Step 1 — Install the Packages + +Install the core package first: ```bash dotnet add package ExpressiveSharp ``` -For EF Core integration, also install: +Then pick the integration that matches your data source: -```bash +::: code-group + +```bash [EF Core] dotnet add package ExpressiveSharp.EntityFrameworkCore ``` +```bash [MongoDB] +dotnet add package ExpressiveSharp.MongoDB +``` + +```bash [Custom IQueryable] +# Nothing else — call .AsExpressive() on your IQueryable +``` + +::: + | Package | Purpose | |---------|---------| -| `ExpressiveSharp` | Core runtime -- expression expansion, transformers, `IExpressiveQueryable`, `ExpressionPolyfill` (includes everything from Abstractions) | -| `ExpressiveSharp.Abstractions` | Lightweight -- `[Expressive]` attribute, `[ExpressiveFor]`, `IExpressionTreeTransformer`, source generator only (no runtime services) | -| `ExpressiveSharp.EntityFrameworkCore` | EF Core integration -- `UseExpressives()`, `ExpressiveDbSet`, Include/ThenInclude, async methods, analyzers and code fixes | -| `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` | SQL window functions -- ROW_NUMBER, RANK, DENSE_RANK, NTILE (experimental) | +| `ExpressiveSharp` | Core runtime — expression expansion, transformers, `IExpressiveQueryable`, `ExpressionPolyfill` (includes Abstractions) | +| `ExpressiveSharp.Abstractions` | Lightweight — `[Expressive]` attribute, `[ExpressiveFor]`, `IExpressionTreeTransformer`, source generator only (no runtime services) | +| `ExpressiveSharp.EntityFrameworkCore` | EF Core integration — `UseExpressives()`, `ExpressiveDbSet`, Include/ThenInclude, async methods, analyzers and code fixes | +| `ExpressiveSharp.MongoDB` | MongoDB integration — `.AsExpressive()` on `IMongoCollection`, MQL aggregation translation | +| `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` | SQL window functions — ROW_NUMBER, RANK, DENSE_RANK, NTILE (experimental) | -## Step 2 -- Define Your Entities +## Step 2 — Define Your Entities Add `[Expressive]` to any property or method whose body you want translated into an expression tree: @@ -36,28 +51,29 @@ using ExpressiveSharp; public class Customer { public int Id { get; set; } - public string FirstName { get; set; } = ""; - public string LastName { get; set; } = ""; + public string Name { get; set; } = ""; public string? Email { get; set; } + public ICollection Orders { get; set; } = new List(); + + // Computed property — reusable in any query, translated for any provider + [Expressive] + public bool IsVip => Orders.Count() > 10; } public class Order { public int Id { get; set; } - public double Price { get; set; } + public decimal Price { get; set; } public int Quantity { get; set; } - public string? Tag { get; set; } - public int CustomerId { get; set; } - public Customer? Customer { get; set; } + public Customer Customer { get; set; } = null!; - // Computed property -- reusable in any query, translated to SQL [Expressive] - public double Total => Price * Quantity; + public decimal Total => Price * Quantity; - // Switch expression -- normally illegal in expression trees + // Switch expression — normally illegal in expression trees [Expressive] - public string GetGrade() => Price switch + public string Grade => Price switch { >= 100 => "Premium", >= 50 => "Standard", @@ -66,156 +82,107 @@ public class Order } ``` -The source generator runs at **compile time** and emits a companion `Expression` for each `[Expressive]` member -- no runtime reflection. +The source generator runs at **compile time** and emits a companion `Expression` for each `[Expressive]` member — no runtime reflection. -## Step 3 -- Configure EF Core +## Step 3 — Wire Up Your Provider -Call `UseExpressives()` on your `DbContextOptionsBuilder`: +::: code-group -```csharp +```csharp [EF Core] +using ExpressiveSharp.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; -var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .UseExpressives() - .Options; -``` - -This automatically: -- Expands `[Expressive]` member references in queries -- Marks `[Expressive]` properties as unmapped in the EF model -- Applies database-friendly transformers - -### With Dependency Injection - -```csharp -services.AddDbContext(options => - options.UseSqlite(connectionString) - .UseExpressives()); -``` - -## Step 4 -- Use [Expressive] Members in Queries - -Use `ExpressiveDbSet` for direct modern syntax support on your `DbSet`: - -```csharp -public class MyDbContext : DbContext +public class AppDbContext : DbContext { - public DbSet OrdersRaw { get; set; } - public DbSet Customers { get; set; } - - // Shorthand for Set().AsExpressiveDbSet() + // ExpressiveSet lets modern C# syntax flow through DbSet chains + public ExpressiveDbSet Customers => this.ExpressiveSet(); public ExpressiveDbSet Orders => this.ExpressiveSet(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.UseSqlite("Data Source=app.db") + .UseExpressives(); // register [Expressive] expansion } ``` -Now query with modern C# syntax -- null-conditional operators, switch expressions, and `[Expressive]` members all work: +```csharp [MongoDB] +using ExpressiveSharp.MongoDB.Extensions; +using MongoDB.Driver; -```csharp -var results = ctx.Orders - .Where(o => o.Customer?.Email != null) - .Select(o => new - { - o.Id, - o.Total, - Email = o.Customer?.Email, - Grade = o.GetGrade() - }) - .ToList(); +var db = new MongoClient("mongodb://localhost:27017").GetDatabase("shop"); +var customers = db.GetCollection("customers").AsExpressive(); +var orders = db.GetCollection("orders").AsExpressive(); ``` -## Step 5 -- Check the Generated SQL - -Use `ToQueryString()` to inspect the SQL: - -```csharp -var query = ctx.Orders - .Where(o => o.Customer?.Email != null) - .Select(o => new - { - o.Id, - o.Total, - Email = o.Customer?.Email, - Grade = o.GetGrade() - }); +```csharp [Custom IQueryable] +using ExpressiveSharp; -Console.WriteLine(query.ToQueryString()); +// Any IQueryable — your own provider, LINQ to Objects, etc. +IQueryable raw = GetCustomers(); +var customers = raw.AsExpressive(); ``` -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - "o"."Price" * CAST("o"."Quantity" AS REAL) AS "Total", - "c"."Email", - CASE - WHEN "o"."Price" >= 100.0 THEN 'Premium' - WHEN "o"."Price" >= 50.0 THEN 'Standard' - ELSE 'Budget' - END AS "Grade" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -WHERE "c"."Email" IS NOT NULL -``` +::: -The `?.` operator, the `Total` property, and the `GetGrade()` switch expression are all translated to SQL. No data is loaded into memory for filtering or projection. +## Step 4 — Write Modern-Syntax Queries -## Complete Working Example +Modern C# syntax — null-conditional operators, switch expressions, pattern matching, and `[Expressive]` member access — all work directly in the query: -```csharp -using ExpressiveSharp; -using ExpressiveSharp.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; - -// Entities -public class Order +::: expressive-sample +db.Orders + .Where(o => o.Customer.Email != null && o.Total() > 50) + .Select(o => new { o.Id, Total = o.Total(), Grade = o.Grade(), Email = o.Customer.Email }) + .OrderByDescending(x => x.Total) + .Take(10) +---setup--- +public static class OrderExt { - public int Id { get; set; } - public double Price { get; set; } - public int Quantity { get; set; } - public Customer? Customer { get; set; } - public int CustomerId { get; set; } - + // Computed sum of line items — reusable in any query, translated to SQL/MQL [Expressive] - public double Total => Price * Quantity; + public static decimal Total(this Order o) => o.Items.Sum(i => i.UnitPrice * i.Quantity); + // Switch expression over the computed total — illegal in raw expression trees, + // but [Expressive] expands it into a provider-translatable tree. [Expressive] - public string GetGrade() => Price switch + public static string Grade(this Order o) => o.Total() switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", + >= 100m => "Premium", + >= 50m => "Standard", + _ => "Budget", }; } +::: -public class Customer -{ - public int Id { get; set; } - public string? Email { get; set; } -} +The tabs above show how this exact query translates for each provider. The `?.` operator, the `[Expressive]` `Total` and `Grade` members, and the switch expression inside `Grade` are all compiled into the provider's native query language — no data is loaded into memory for filtering or projection. -// DbContext -public class AppDbContext : DbContext -{ - public ExpressiveDbSet Orders => this.ExpressiveSet(); - public DbSet Customers { get; set; } +## Step 5 — Inspect the Generated Query - protected override void OnConfiguring(DbContextOptionsBuilder options) - => options.UseSqlite("Data Source=app.db").UseExpressives(); -} +::: code-group -// Query -using var ctx = new AppDbContext(); -var results = ctx.Orders - .Where(o => o.Customer?.Email != null) - .Select(o => new { o.Id, o.Total, Grade = o.GetGrade() }) - .ToList(); +```csharp [EF Core] +// Use ToQueryString() to inspect the SQL without executing +var sql = ctx.Orders + .Where(o => o.Customer.Email != null) + .Select(o => new { o.Id, o.Grade }) + .ToQueryString(); +Console.WriteLine(sql); ``` +```csharp [MongoDB] +// ToString() on the queryable yields the aggregation pipeline +var pipeline = orders + .Where(o => o.Customer.Email != null) + .Select(o => new { o.Id, o.Grade }) + .ToString(); +Console.WriteLine(pipeline); +``` + +::: + ## Next Steps -- [[Expressive] Properties](./expressive-properties) -- computed properties in depth -- [[Expressive] Methods](./expressive-methods) -- parameterized query fragments -- [Constructor Projections](./expressive-constructors) -- project DTOs directly in queries -- [EF Core Integration](./ef-core-integration) -- full EF Core setup and features -- [IExpressiveQueryable\](./expressive-queryable) -- modern syntax on any `IQueryable` +- [IExpressiveQueryable\](./expressive-queryable) — the core provider-agnostic API +- [[Expressive] Properties](./expressive-properties) — computed properties in depth +- [[Expressive] Methods](./expressive-methods) — parameterized query fragments +- [Constructor Projections](./expressive-constructors) — project DTOs directly in queries +- [EF Core Integration](./integrations/ef-core) — full EF Core setup +- [MongoDB Integration](./integrations/mongodb) — full MongoDB setup diff --git a/docs/guide/window-functions.md b/docs/guide/window-functions.md index 743a8c3..710f2a8 100644 --- a/docs/guide/window-functions.md +++ b/docs/guide/window-functions.md @@ -286,6 +286,7 @@ Window functions are implemented as a plugin using the `IExpressivePlugin` archi ## Next Steps -- [EF Core Integration](./ef-core-integration) -- full EF Core setup and features +- [EF Core Integration](./integrations/ef-core) -- full EF Core setup and features - [IExpressiveQueryable\](./expressive-queryable) -- modern syntax in LINQ chains - [Introduction](./introduction) -- overview of all ExpressiveSharp APIs + diff --git a/docs/index.md b/docs/index.md index 5e5fa08..e2dc094 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: "ExpressiveSharp" text: "Modern C# syntax in LINQ expression trees" - tagline: Write null-conditional operators, switch expressions, and pattern matching in your queries — source-generated at compile time with zero runtime overhead. + tagline: Write null-conditional operators, switch expressions, and pattern matching in your queries — source-generated at compile time with zero runtime overhead. Works with EF Core, MongoDB, and any IQueryable provider. actions: - theme: brand text: Introduction @@ -26,8 +26,8 @@ features: details: "Null-conditional ?., switch expressions, pattern matching, string interpolation, tuples, list patterns, and more — all valid inside expression trees." - icon: "\U0001F310" - title: Not Just EF Core - details: Works with any LINQ provider or standalone. ExpressionPolyfill.Create gives you inline expression trees with modern syntax, no EF Core required. + title: Provider-Agnostic + details: Works with EF Core (every provider — SQL Server, Postgres, SQLite, Cosmos, …), MongoDB, and any IQueryable. One library, every backend. - icon: "\u26A1" title: Zero Runtime Overhead @@ -43,7 +43,7 @@ features: - icon: "\U0001F3D7\uFE0F" title: Constructor Projections - details: "Mark a constructor with [Expressive] to project DTOs directly in queries — new OrderDto(o) translates to a full SQL projection." + details: "Mark a constructor with [Expressive] to project DTOs directly in queries — new OrderDto(o) translates to a full provider projection." - icon: "\U0001F4CA" title: SQL Window Functions @@ -51,7 +51,7 @@ features: - icon: "\U0001F527" title: Customizable Transformer Pipeline - details: "Four built-in transformers adapt expression trees for SQL providers, plus plugin-contributed transformers. Implement IExpressionTreeTransformer for custom rewrites." + details: "Built-in transformers adapt expression trees for your provider, plus plugin-contributed transformers. Implement IExpressionTreeTransformer for custom rewrites." - icon: "\U0001FA7A" title: Roslyn Analyzers & Code Fixes @@ -63,15 +63,14 @@ features: **Without ExpressiveSharp** — you hit two walls immediately: ```csharp -// Problem 1: Computed properties are opaque to EF Core +// Problem 1: Computed properties are opaque to LINQ providers public class Order { - public double Price { get; set; } + public decimal Price { get; set; } public int Quantity { get; set; } - public Customer? Customer { get; set; } - // EF Core can't see inside this — it throws or silently fetches everything - public double Total => Price * Quantity; + // The provider can't see inside this — it throws or silently fetches everything + public decimal Total => Price * Quantity; } // Problem 2: Modern C# syntax is illegal in expression trees @@ -85,66 +84,29 @@ db.Orders You end up duplicating formulas as inline expressions and writing ugly ternary chains. -**With ExpressiveSharp** — write natural C#, the source generator handles the rest: +**With ExpressiveSharp** — write natural C#. The source generator handles it, and your provider (EF Core / MongoDB / your own `IQueryable`) gets a clean, translatable expression tree. Every doc page's live samples render the same query for SQLite, Postgres, SQL Server, Cosmos, MongoDB, and the generator output side-by-side — so you see exactly how it translates for your stack. -```csharp -public class Order +::: expressive-sample +db.Orders + .Where(o => o.Customer.Email != null && o.Total() > 500m) + .Select(o => new { o.Id, Total = o.Total(), Grade = o.Grade(), Email = o.Customer.Email }) +---setup--- +public static class OrderExt { - public double Price { get; set; } - public int Quantity { get; set; } - public Customer? Customer { get; set; } - [Expressive] - public double Total => Price * Quantity; // translated to SQL + public static decimal Total(this Order o) => o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] - public string GetGrade() => Price switch // switch expression -> SQL CASE + public static string Grade(this Order o) => o.Total() switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", + >= 1000m => "Premium", + >= 100m => "Standard", + _ => "Budget", }; } +::: -// Extension methods and C# 14 extension properties work too -public static class OrderExtensions -{ - [Expressive] - public static bool IsHighValue(this Order o) => o.Total > 500; -} - -// C# 14 extension members (.NET 10+) -public static class OrderReviewExtensions -{ - extension(Order o) - { - [Expressive] - public bool NeedsReview => o.Customer?.Email == null && o.Total > 100; - } -} - -// ?. syntax, computed properties, switch expressions — all translated to SQL -var results = db.Orders - .AsExpressiveDbSet() - .Where(o => o.Customer?.Email != null && o.IsHighValue()) - .Select(o => new { o.Id, o.Total, Grade = o.GetGrade(), o.NeedsReview }) - .ToList(); -``` - -```sql -SELECT "o"."Id", - "o"."Price" * CAST("o"."Quantity" AS REAL) AS "Total", - CASE - WHEN "o"."Price" >= 100.0 THEN 'Premium' - WHEN "o"."Price" >= 50.0 THEN 'Standard' - ELSE 'Budget' - END AS "Grade" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -WHERE "c"."Email" IS NOT NULL -``` - -Computed properties are **inlined into SQL** — no client-side evaluation, no N+1. Modern syntax **just works**. +Computed properties are **inlined into the provider's native query language** — no client-side evaluation, no N+1. Modern syntax **just works**. ## NuGet Packages @@ -153,4 +115,5 @@ Computed properties are **inlined into SQL** — no client-side evaluation, no N | [`ExpressiveSharp`](https://www.nuget.org/packages/ExpressiveSharp/) | Core runtime — expression expansion, transformers, `IExpressiveQueryable`, `ExpressionPolyfill` | | [`ExpressiveSharp.Abstractions`](https://www.nuget.org/packages/ExpressiveSharp.Abstractions/) | Lightweight — attributes (`[Expressive]`, `[ExpressiveFor]`), `IExpressionTreeTransformer`, source generator only | | [`ExpressiveSharp.EntityFrameworkCore`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore/) | EF Core integration — `UseExpressives()`, `ExpressiveDbSet`, Include/ThenInclude, async methods | +| [`ExpressiveSharp.MongoDB`](https://www.nuget.org/packages/ExpressiveSharp.MongoDB/) | MongoDB integration — `.AsExpressive()` on `IMongoCollection`, MQL translation | | [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | SQL window functions — ROW_NUMBER, RANK, DENSE_RANK, NTILE (experimental) | diff --git a/docs/playground-editor.md b/docs/playground-editor.md new file mode 100644 index 0000000..90dd09c --- /dev/null +++ b/docs/playground-editor.md @@ -0,0 +1,52 @@ +--- +layout: page +title: Playground +--- + + + + + + diff --git a/docs/recipes/computed-properties.md b/docs/recipes/computed-properties.md index f136e3b..9947099 100644 --- a/docs/recipes/computed-properties.md +++ b/docs/recipes/computed-properties.md @@ -1,13 +1,15 @@ # Computed Entity Properties -This recipe shows how to define reusable computed properties on your entities and use them across multiple query operations -- all translated to SQL without any duplication. +This recipe shows how to define reusable computed values on your entities and use them across multiple query operations -- all translated to SQL without any duplication. ## The Pattern -Define computed values as `[Expressive]` properties directly on your entity. These properties can then be used in `Select`, `Where`, `GroupBy`, `OrderBy`, and any combination thereof. `[Expressive]` members can reference other `[Expressive]` members, so you can build from simple building blocks to complex compositions. +Define computed values as `[Expressive]` members -- either as properties directly on your entity, or as extension methods in a helper class when you cannot modify the entity. These members can then be used in `Select`, `Where`, `GroupBy`, `OrderBy`, and any combination thereof. `[Expressive]` members can reference other `[Expressive]` members, so you can build from simple building blocks to complex compositions. ## Example: Order Totals +For entities you own, put `[Expressive]` properties directly on them: + ```csharp public class Order { @@ -29,195 +31,159 @@ public class Order } ``` +When you cannot modify the entity, define the same logic as extension methods in a helper class. Here we compute order totals over the webshop `Order` / `LineItem` model: + ### Use in Select -```csharp -var summaries = dbContext.Orders - .Select(o => new OrderSummaryDto - { - Id = o.Id, - Subtotal = o.Subtotal, - Tax = o.Tax, - GrandTotal = o.GrandTotal - }) - .ToList(); -``` +::: expressive-sample +db.Orders + .Select(o => new { o.Id, Subtotal = o.Subtotal(), Tax = o.Tax(), GrandTotal = o.GrandTotal() }) +---setup--- +public static class OrderTotals +{ + [Expressive] + public static decimal Subtotal(this Order o) + => o.Items.Sum(i => i.UnitPrice * i.Quantity); -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - (SELECT COALESCE(SUM("p"."ListPrice" * "i"."Quantity"), 0) - FROM "OrderItems" AS "i" - INNER JOIN "Products" AS "p" ON "i"."ProductId" = "p"."Id" - WHERE "o"."Id" = "i"."OrderId") AS "Subtotal", - (SELECT COALESCE(SUM("p"."ListPrice" * "i"."Quantity"), 0) - FROM "OrderItems" AS "i" - INNER JOIN "Products" AS "p" ON "i"."ProductId" = "p"."Id" - WHERE "o"."Id" = "i"."OrderId") * "o"."TaxRate" AS "Tax", - (SELECT COALESCE(SUM("p"."ListPrice" * "i"."Quantity"), 0) - FROM "OrderItems" AS "i" - INNER JOIN "Products" AS "p" ON "i"."ProductId" = "p"."Id" - WHERE "o"."Id" = "i"."OrderId") * (1 + "o"."TaxRate") AS "GrandTotal" -FROM "Orders" AS "o" -``` + [Expressive] + public static decimal Tax(this Order o) => o.Subtotal() * 0.2m; + + [Expressive] + public static decimal GrandTotal(this Order o) => o.Subtotal() + o.Tax(); +} +::: ### Use in Where -```csharp -// Only load high-value orders -var highValue = dbContext.Orders - .Where(o => o.GrandTotal > 1000) - .ToList(); -``` +::: expressive-sample +db.Orders.Where(o => o.GrandTotal() > 1000) +---setup--- +public static class OrderTotals +{ + [Expressive] + public static decimal Subtotal(this Order o) + => o.Items.Sum(i => i.UnitPrice * i.Quantity); -### Use in OrderBy + [Expressive] + public static decimal Tax(this Order o) => o.Subtotal() * 0.2m; -```csharp -// Sort by computed value -- top 10 by total -var ranked = dbContext.Orders - .OrderByDescending(o => o.GrandTotal) - .Take(10) - .ToList(); -``` + [Expressive] + public static decimal GrandTotal(this Order o) => o.Subtotal() + o.Tax(); +} +::: -### All Together +### Use in OrderBy -```csharp -var report = dbContext.Orders - .Where(o => o.GrandTotal > 500) - .OrderByDescending(o => o.GrandTotal) - .GroupBy(o => o.CreatedDate.Year) - .Select(g => new - { - Year = g.Key, - Count = g.Count(), - TotalRevenue = g.Sum(o => o.GrandTotal) - }) - .ToList(); -``` +::: expressive-sample +db.Orders.OrderByDescending(o => o.GrandTotal()).Take(10) +---setup--- +public static class OrderTotals +{ + [Expressive] + public static decimal Subtotal(this Order o) + => o.Items.Sum(i => i.UnitPrice * i.Quantity); -All computed values are evaluated **in the database** -- no data is fetched to memory for filtering or aggregation. + [Expressive] + public static decimal Tax(this Order o) => o.Subtotal() * 0.2m; -## Example: User Profile + [Expressive] + public static decimal GrandTotal(this Order o) => o.Subtotal() + o.Tax(); +} +::: -```csharp -public class User -{ - public string FirstName { get; set; } = ""; - public string LastName { get; set; } = ""; - public DateTime BirthDate { get; set; } - public DateTime? LastLoginDate { get; set; } +### All Together +::: expressive-sample +db.Orders + .Where(o => o.GrandTotal() > 500) + .GroupBy(o => o.PlacedAt.Year) + .Select(g => new { Year = g.Key, Count = g.Count(), TotalRevenue = g.Sum(o => o.GrandTotal()) }) +---setup--- +public static class OrderTotals +{ [Expressive] - public string FullName => FirstName + " " + LastName; + public static decimal Subtotal(this Order o) + => o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] - public string DisplayName => FirstName + " " + LastName.Substring(0, 1) + "."; + public static decimal Tax(this Order o) => o.Subtotal() * 0.2m; [Expressive] - public bool IsActive => LastLoginDate != null - && LastLoginDate >= DateTime.UtcNow.AddDays(-30); + public static decimal GrandTotal(this Order o) => o.Subtotal() + o.Tax(); } -``` - -```csharp -// Find active users, sorted by name -var results = dbContext.Users - .Where(u => u.IsActive) - .OrderBy(u => u.FullName) - .Select(u => new { u.FullName, u.DisplayName }) - .ToList(); -``` +::: -Generated SQL (SQLite): +All computed values are evaluated **in the database** -- no data is fetched to memory for filtering or aggregation. -```sql -SELECT "u"."FirstName" || ' ' || "u"."LastName" AS "FullName", - "u"."FirstName" || ' ' || SUBSTR("u"."LastName", 1, 1) || '.' AS "DisplayName" -FROM "Users" AS "u" -WHERE "u"."LastLoginDate" IS NOT NULL - AND "u"."LastLoginDate" >= DATETIME('now', '-30 days') -ORDER BY "u"."FirstName" || ' ' || "u"."LastName" -``` +## Example: Customer Profile -## Example: Product Catalog +String concatenation, date arithmetic, and nullable checks all translate cleanly to SQL: -```csharp -public class Product +::: expressive-sample +db.Customers + .Where(c => c.IsActive()) + .OrderBy(c => c.DisplayName()) + .Select(c => new { c.Id, Display = c.DisplayName() }) +---setup--- +public static class CustomerProfile { - public int Id { get; set; } - public decimal ListPrice { get; set; } - public decimal DiscountRate { get; set; } - public int StockQuantity { get; set; } - public int ReorderPoint { get; set; } + [Expressive] + public static string DisplayName(this Customer c) + => c.Name + (c.Country != null ? " (" + c.Country + ")" : ""); [Expressive] - public decimal DiscountedPrice => ListPrice * (1 - DiscountRate); + public static bool IsActive(this Customer c) + => c.JoinedAt >= new DateTime(2023, 1, 1); +} +::: + +## Example: Product Catalog + +Boolean flags and arithmetic combine naturally. Here `IsAvailable` and a derived price-tier flag compose into a single predicate: +::: expressive-sample +db.Products + .Where(p => p.IsAvailable() && p.IsBudget()) + .OrderBy(p => p.StockQuantity) + .Select(p => new { p.Id, p.Name, p.ListPrice, p.StockQuantity }) +---setup--- +public static class ProductCatalog +{ [Expressive] - public decimal SavingsAmount => ListPrice - DiscountedPrice; + public static bool IsAvailable(this Product p) => p.StockQuantity > 0; [Expressive] - public bool IsAvailable => StockQuantity > 0; + public static bool IsBudget(this Product p) => p.ListPrice < 50m; [Expressive] - public bool NeedsReorder => StockQuantity <= ReorderPoint; + public static decimal SavingsVs(this Product p, decimal msrp) + => msrp - p.ListPrice; } -``` - -```csharp -// Available products on sale that need restocking -var reorder = dbContext.Products - .Where(p => p.IsAvailable && p.NeedsReorder && p.DiscountedPrice < 50) - .OrderBy(p => p.StockQuantity) - .Select(p => new - { - p.Id, - p.DiscountedPrice, - p.SavingsAmount, - p.StockQuantity - }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "p"."Id", - "p"."ListPrice" * (1 - "p"."DiscountRate") AS "DiscountedPrice", - "p"."ListPrice" - "p"."ListPrice" * (1 - "p"."DiscountRate") AS "SavingsAmount", - "p"."StockQuantity" -FROM "Products" AS "p" -WHERE "p"."StockQuantity" > 0 - AND "p"."StockQuantity" <= "p"."ReorderPoint" - AND "p"."ListPrice" * (1 - "p"."DiscountRate") < 50 -ORDER BY "p"."StockQuantity" -``` +::: ## Collection Aggregates -Computed properties can include LINQ aggregation over navigation collections: +Computed members can include LINQ aggregation over navigation collections. EF Core translates these to efficient correlated subqueries: -```csharp -public class Customer +::: expressive-sample +db.Customers + .Where(c => c.LifetimeSpend() > 500m) + .Select(c => new { c.Id, c.Name, Spend = c.LifetimeSpend(), Orders = c.OrderCount() }) +---setup--- +public static class CustomerStats { - public ICollection Orders { get; set; } - public ICollection Reviews { get; set; } - [Expressive] - public int OrderCount => Orders.Count(); + public static int OrderCount(this Customer c) => c.Orders.Count(); [Expressive] - public decimal LifetimeSpend => Orders.Sum(o => o.GrandTotal); + public static decimal LifetimeSpend(this Customer c) + => c.Orders.Sum(o => o.Items.Sum(i => i.UnitPrice * i.Quantity)); [Expressive] - public bool HasRecentOrder => - Orders.Any(o => o.CreatedDate >= DateTime.UtcNow.AddDays(-30)); + public static bool HasRecentOrder(this Customer c) + => c.Orders.Any(o => o.PlacedAt >= new DateTime(2024, 1, 1)); } -``` - -EF Core translates these to efficient correlated subqueries. +::: ## Tips @@ -226,15 +192,19 @@ EF Core translates these to efficient correlated subqueries. ::: ::: tip Keep it pure -Expressive properties should be pure computations with no side effects. Everything must be translatable to SQL by your LINQ provider. +Expressive members should be pure computations with no side effects. Everything must be translatable to SQL by your LINQ provider. +::: + +::: tip Property vs. extension method +If you own the entity, prefer `[Expressive]` properties for a natural call site (`o.GrandTotal`). For types you cannot modify, `[Expressive]` extension methods (`o.GrandTotal()`) are equivalent in translation power. ::: ::: warning Avoid N+1 traps -If a computed property references navigation properties, make sure to structure your queries so EF Core can generate a single efficient query. Using computed properties in `Select` and `Where` at the top level is safe. +If a computed member references navigation properties, make sure to structure your queries so EF Core can generate a single efficient query. Using computed members in `Select` and `Where` at the top level is safe. ::: ## See Also -- [Reusable Query Filters](/recipes/reusable-query-filters) -- Boolean computed properties as filter predicates -- [DTO Projections with Constructors](/recipes/dto-projections) -- project computed values into DTOs -- [Scoring and Classification](/recipes/scoring-classification) -- computed properties with switch expressions +- [Reusable Query Filters](./reusable-query-filters) -- Boolean computed properties as filter predicates +- [DTO Projections with Constructors](./dto-projections) -- project computed values into DTOs +- [Scoring and Classification](./scoring-classification) -- computed properties with switch expressions diff --git a/docs/recipes/dto-projections.md b/docs/recipes/dto-projections.md index 2ceb88c..e3bc2e3 100644 --- a/docs/recipes/dto-projections.md +++ b/docs/recipes/dto-projections.md @@ -25,200 +25,182 @@ If the mapping changes you must update every `Select` that uses it. Mark a constructor with `[Expressive]` and call it directly in your query. The source generator emits a `MemberInit` expression that EF Core translates to SQL: -```csharp +::: expressive-sample +db.Customers + .Where(c => c.Country != null) + .Select(c => new CustomerDto(c)) +---setup--- public class CustomerDto { public int Id { get; set; } - public string FullName { get; set; } = ""; - public bool IsActive { get; set; } + public string Name { get; set; } = ""; + public string? Country { get; set; } public int OrderCount { get; set; } - public CustomerDto() { } // parameterless constructor required + public CustomerDto() { } [Expressive] public CustomerDto(Customer c) { Id = c.Id; - FullName = c.FirstName + " " + c.LastName; - IsActive = c.IsActive; + Name = c.Name; + Country = c.Country; OrderCount = c.Orders.Count(); } } -``` - -```csharp -// Clean -- mapping defined once, used everywhere -var customers = dbContext.Customers - .Where(c => c.IsActive) - .Select(c => new CustomerDto(c)) - .ToList(); -``` +::: The constructor body is inlined as SQL -- no data is fetched to memory for the projection. -Generated SQL (SQLite): - -```sql -SELECT "c"."Id", - "c"."FirstName" || ' ' || "c"."LastName" AS "FullName", - "c"."IsActive", - (SELECT COUNT(*) - FROM "Orders" AS "o" - WHERE "c"."Id" = "o"."CustomerId") AS "OrderCount" -FROM "Customers" AS "c" -WHERE "c"."IsActive" -``` - ## Basic Constructor Projection: OrderSummaryDto A straightforward example showing how constructor parameters map to SQL expressions: -```csharp +::: expressive-sample +db.Orders + .Select(o => new OrderSummaryDto(o.Id, o.Status.ToString(), o.ItemCount())) +---setup--- +public static class OrderExt +{ + [Expressive] + public static int ItemCount(this Order o) => o.Items.Count(); +} + public class OrderSummaryDto { public int Id { get; set; } public string Description { get; set; } = ""; - public double Total { get; set; } + public int Items { get; set; } public OrderSummaryDto() { } [Expressive] - public OrderSummaryDto(int id, string description, double total) + public OrderSummaryDto(int id, string description, int items) { Id = id; Description = description; - Total = total; + Items = items; } } -``` - -```csharp -var dtos = dbContext.Orders - .Select(o => new OrderSummaryDto(o.Id, o.Tag ?? "N/A", o.Total)) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - COALESCE("o"."Tag", 'N/A') AS "Description", - "o"."Price" * CAST("o"."Quantity" AS REAL) AS "Total" -FROM "Orders" AS "o" -``` +::: ::: tip -Notice that `o.Total` is an `[Expressive]` property -- it gets expanded to `Price * Quantity` automatically. Constructor projections compose naturally with computed properties. +Notice that `o.ItemCount()` is an `[Expressive]` extension method -- it gets expanded to `o.Items.Count()` automatically. Constructor projections compose naturally with computed members. ::: ## Inheritance Chains with Base Initializers When your DTOs form an inheritance hierarchy, use `: base(...)` to avoid duplicating base-class assignments. The generator inlines both the base and derived assignments: -```csharp -public class PersonDto +::: expressive-sample +db.Customers.Select(c => new PremiumCustomerDto(c)) +---setup--- +public class CustomerBaseDto { - public string FullName { get; set; } = ""; - public string Email { get; set; } = ""; + public int Id { get; set; } + public string Name { get; set; } = ""; - public PersonDto() { } + public CustomerBaseDto() { } [Expressive] - public PersonDto(Person p) + public CustomerBaseDto(Customer c) { - FullName = p.FirstName + " " + p.LastName; - Email = p.Email; + Id = c.Id; + Name = c.Name; } } -public class EmployeeDto : PersonDto +public class PremiumCustomerDto : CustomerBaseDto { - public string Department { get; set; } = ""; - public string Grade { get; set; } = ""; + public string Country { get; set; } = ""; + public string Tier { get; set; } = ""; - public EmployeeDto() { } + public PremiumCustomerDto() { } [Expressive] - public EmployeeDto(Employee e) : base(e) // PersonDto assignments inlined automatically + public PremiumCustomerDto(Customer c) : base(c) { - Department = e.Department.Name; - Grade = e.YearsOfService >= 10 ? "Senior" : "Junior"; + Country = c.Country ?? "Unknown"; + Tier = c.Orders.Count() >= 10 ? "Gold" : "Standard"; } } -``` - -```csharp -var employees = dbContext.Employees - .Select(e => new EmployeeDto(e)) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "e"."FirstName" || ' ' || "e"."LastName" AS "FullName", - "e"."Email", - "d"."Name" AS "Department", - CASE - WHEN "e"."YearsOfService" >= 10 THEN 'Senior' - ELSE 'Junior' - END AS "Grade" -FROM "Employees" AS "e" -INNER JOIN "Departments" AS "d" ON "e"."DepartmentId" = "d"."Id" -``` +::: -All fields -- `FullName`, `Email`, `Department`, and `Grade` -- are projected in a single query. +All fields -- `Id`, `Name`, `Country`, and `Tier` -- are projected in a single query. ## Constructor Overloads If you need different projections from the same DTO, use constructor overloads. Each gets its own generated expression: -```csharp -public class OrderSummaryDto +::: expressive-sample +db.Orders.Select(o => new OrderDto(o)) +---setup--- +public class OrderDto { public int Id { get; set; } - public double Total { get; set; } + public int ItemCount { get; set; } public string? CustomerName { get; set; } - public OrderSummaryDto() { } + public OrderDto() { } // Full projection (with customer name -- requires navigation join) [Expressive] - public OrderSummaryDto(Order o) + public OrderDto(Order o) { Id = o.Id; - Total = o.GrandTotal; - CustomerName = o.Customer.FirstName + " " + o.Customer.LastName; + ItemCount = o.Items.Count(); + CustomerName = o.Customer.Name; } // Lightweight projection (no navigation join needed) [Expressive] - public OrderSummaryDto(Order o, bool lightweight) + public OrderDto(Order o, bool lightweight) { Id = o.Id; - Total = o.GrandTotal; + ItemCount = o.Items.Count(); CustomerName = null; } } -``` +::: -```csharp -// Full projection -- joins Customer table -var full = dbContext.Orders - .Select(o => new OrderSummaryDto(o)) - .ToList(); +The lightweight variant is called the same way, just with the extra argument: -// Lightweight projection -- no join -var light = dbContext.Orders - .Select(o => new OrderSummaryDto(o, true)) - .ToList(); -``` +::: expressive-sample +db.Orders.Select(o => new OrderDto(o, true)) +---setup--- +public class OrderDto +{ + public int Id { get; set; } + public int ItemCount { get; set; } + public string? CustomerName { get; set; } + + public OrderDto() { } + + [Expressive] + public OrderDto(Order o) + { + Id = o.Id; + ItemCount = o.Items.Count(); + CustomerName = o.Customer.Name; + } + + [Expressive] + public OrderDto(Order o, bool lightweight) + { + Id = o.Id; + ItemCount = o.Items.Count(); + CustomerName = null; + } +} +::: ## Using Switch Expressions in Constructors Constructor bodies support the same modern C# syntax as other `[Expressive]` members: -```csharp +::: expressive-sample +db.Products.Select(p => new ProductDto(p)) +---setup--- public class ProductDto { public int Id { get; set; } @@ -233,30 +215,16 @@ public class ProductDto { Id = p.Id; Name = p.Name; - Price = p.SalePrice; - PriceTier = p.SalePrice switch + Price = p.ListPrice; + PriceTier = p.ListPrice switch { - > 500 => "Premium", - > 100 => "Standard", - _ => "Budget" + > 500m => "Premium", + > 100m => "Standard", + _ => "Budget" }; } } -``` - -Generated SQL (SQLite): - -```sql -SELECT "p"."Id", - "p"."Name", - "p"."ListPrice" * (1 - "p"."DiscountRate") AS "Price", - CASE - WHEN "p"."ListPrice" * (1 - "p"."DiscountRate") > 500 THEN 'Premium' - WHEN "p"."ListPrice" * (1 - "p"."DiscountRate") > 100 THEN 'Standard' - ELSE 'Budget' - END AS "PriceTier" -FROM "Products" AS "p" -``` +::: ## Using `[ExpressiveForConstructor]` for External Types @@ -270,7 +238,7 @@ static ExternalOrderDto CreateDto(int id, string name) => new ExternalOrderDto { Id = id, Name = name }; ``` -See [External Member Mapping](/recipes/external-member-mapping) for details. +See [External Member Mapping](./external-member-mapping) for details. ## Tips @@ -288,6 +256,6 @@ Constructor bodies are block-bodied by nature, but they do **not** require `Allo ## See Also -- [Computed Entity Properties](/recipes/computed-properties) -- reusable computed values referenced in constructor projections -- [External Member Mapping](/recipes/external-member-mapping) -- `[ExpressiveForConstructor]` for types you do not own -- [Scoring and Classification](/recipes/scoring-classification) -- switch expressions and pattern matching in projections +- [Computed Entity Properties](./computed-properties) -- reusable computed values referenced in constructor projections +- [External Member Mapping](./external-member-mapping) -- `[ExpressiveForConstructor]` for types you do not own +- [Scoring and Classification](./scoring-classification) -- switch expressions and pattern matching in projections diff --git a/docs/recipes/external-member-mapping.md b/docs/recipes/external-member-mapping.md index 57286a0..82d14f2 100644 --- a/docs/recipes/external-member-mapping.md +++ b/docs/recipes/external-member-mapping.md @@ -17,42 +17,18 @@ If a member already has `[Expressive]`, adding `[ExpressiveFor]` targeting it is ## Static Method: `Math.Clamp` -`Math.Clamp` is a BCL method that EF Core cannot translate. Provide an expression-tree equivalent: - -```csharp -using ExpressiveSharp.Mapping; +`Math.Clamp` is a BCL method that some providers cannot translate. Provide an expression-tree equivalent: +::: expressive-sample +db.Products.Select(p => new { p.Id, ClampedPrice = Math.Clamp(p.ListPrice, 20m, 100m) }) +---setup--- static class MathMappings { - [ExpressiveFor(typeof(Math), nameof(Math.Clamp))] - static double Clamp(double value, double min, double max) + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(Math), nameof(Math.Clamp))] + static decimal Clamp(decimal value, decimal min, decimal max) => value < min ? min : (value > max ? max : value); } -``` - -Now `Math.Clamp` works in EF Core queries: - -```csharp -var results = dbContext.Orders - .Select(o => new - { - o.Id, - ClampedPrice = Math.Clamp(o.Price, 20.0, 100.0) - }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - CASE - WHEN "o"."Price" < 20.0 THEN 20.0 - WHEN "o"."Price" > 100.0 THEN 100.0 - ELSE "o"."Price" - END AS "ClampedPrice" -FROM "Orders" AS "o" -``` +::: ::: tip The call site is unchanged -- you still write `Math.Clamp(...)`. The `ExpressiveReplacer` detects the mapping at runtime and substitutes the ternary expression automatically. @@ -62,34 +38,20 @@ The call site is unchanged -- you still write `Math.Clamp(...)`. The `Expressive Another common BCL method that some providers cannot translate: -```csharp -using ExpressiveSharp.Mapping; - +::: expressive-sample +db.Customers.Where(c => !string.IsNullOrWhiteSpace(c.Email)) +---setup--- static class StringMappings { - [ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))] + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))] static bool IsNullOrWhiteSpace(string? s) => s == null || s.Trim().Length == 0; } -``` - -```csharp -var results = dbContext.Customers - .Where(c => !string.IsNullOrWhiteSpace(c.Email)) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT * -FROM "Customers" AS "c" -WHERE NOT ("c"."Email" IS NULL OR LENGTH(TRIM("c"."Email")) = 0) -``` +::: -## Instance Property on Your Own Type +## Instance Members on Your Own Type -For instance properties or methods, the first parameter of the stub is the receiver: +For instance properties or methods, the first parameter of the stub is the receiver. You can use this to provide a SQL-friendly alternative for a property whose body relies on non-translatable logic: ```csharp using ExpressiveSharp.Mapping; @@ -120,15 +82,6 @@ var names = dbContext.People .ToList(); ``` -Generated SQL (SQLite): - -```sql -SELECT "p"."Id", - "p"."FirstName" || ' ' || "p"."LastName" AS "FullName" -FROM "People" AS "p" -ORDER BY "p"."FirstName" || ' ' || "p"."LastName" -``` - ## `[ExpressiveForConstructor]` for Constructors When you need to provide an expression-tree body for a constructor on a type you do not own: @@ -157,42 +110,34 @@ static OrderDto CreateOrderDto(int id, string name) ```csharp var dtos = dbContext.Orders - .Select(o => new OrderDto(o.Id, o.Tag ?? "N/A")) + .Select(o => new OrderDto(o.Id, o.Status.ToString())) .ToList(); ``` -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - COALESCE("o"."Tag", 'N/A') AS "Name" -FROM "Orders" AS "o" -``` - ## Combining with EF Core Queries -`[ExpressiveFor]` mappings integrate seamlessly with `UseExpressives()` and `ExpressiveDbSet`: - -```csharp -var results = ctx.Orders - .Where(o => Math.Clamp(o.Price, 20, 100) > 50) - .Where(o => !string.IsNullOrWhiteSpace(o.Tag)) - .Select(o => new - { - o.Id, - SafePrice = Math.Clamp(o.Price, 20, 100), - Label = o.Customer?.FullName ?? "Unknown" - }) - .ToList(); -``` +`[ExpressiveFor]` mappings integrate seamlessly with `UseExpressives()` and `ExpressiveDbSet`. Here we combine `Math.Clamp` on a numeric field with `string.IsNullOrWhiteSpace` on a nullable string field: -All three mappings (`Math.Clamp`, `string.IsNullOrWhiteSpace`, `Person.FullName`) are expanded automatically. +::: expressive-sample +db.Customers + .Where(c => !string.IsNullOrWhiteSpace(c.Email)) + .Select(c => new { c.Id, c.Name, Label = c.Country ?? "Unknown" }) +---setup--- +static class Mappings +{ + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))] + static bool IsNullOrWhiteSpace(string? s) + => s == null || s.Trim().Length == 0; +} +::: ## Common Use Cases ### Math Functions ```csharp +using ExpressiveSharp.Mapping; + static class MathMappings { [ExpressiveFor(typeof(Math), nameof(Math.Clamp))] @@ -212,6 +157,8 @@ static class MathMappings ### String Helpers ```csharp +using ExpressiveSharp.Mapping; + static class StringMappings { [ExpressiveFor(typeof(string), nameof(string.IsNullOrEmpty))] @@ -227,6 +174,8 @@ static class StringMappings ### DateTime Calculations ```csharp +using ExpressiveSharp.Mapping; + static class DateTimeMappings { // Custom helper method on your utility class @@ -263,6 +212,6 @@ Many `[ExpressiveFor]` use cases exist because of syntax limitations in other li ## See Also -- [DTO Projections with Constructors](/recipes/dto-projections) -- `[ExpressiveForConstructor]` in depth -- [Computed Entity Properties](/recipes/computed-properties) -- `[Expressive]` on your own types -- [Migrating from Projectables](/guide/migration-from-projectables) -- replacing `UseMemberBody` with `[ExpressiveFor]` +- [DTO Projections with Constructors](./dto-projections) -- `[ExpressiveForConstructor]` in depth +- [Computed Entity Properties](./computed-properties) -- `[Expressive]` on your own types +- [Migrating from Projectables](../guide/migration-from-projectables) -- replacing `UseMemberBody` with `[ExpressiveFor]` diff --git a/docs/recipes/modern-syntax-in-linq.md b/docs/recipes/modern-syntax-in-linq.md index aa6bfdd..b883aa1 100644 --- a/docs/recipes/modern-syntax-in-linq.md +++ b/docs/recipes/modern-syntax-in-linq.md @@ -20,7 +20,7 @@ var results = dbContext.Orders { o.Id, Name = o.Customer != null ? o.Customer.Name : "Unknown", - Grade = o.Price >= 100 ? "Premium" : (o.Price >= 50 ? "Standard" : "Budget") + Grade = o.Items.Count() >= 10 ? "Premium" : (o.Items.Count() >= 5 ? "Standard" : "Budget") }) .ToList(); ``` @@ -33,24 +33,22 @@ ExpressiveSharp offers three ways to use modern syntax in LINQ chains. Each targ Works with **any** `IQueryable` -- not tied to EF Core: -```csharp -var results = queryable - .AsExpressive() +::: expressive-sample +db.Orders .Where(o => o.Customer?.Email != null) .Select(o => new { o.Id, Name = o.Customer?.Name ?? "Unknown", - Grade = o.Price switch + Grade = o.Items.Count() switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget" + >= 10 => "Premium", + >= 5 => "Standard", + _ => "Budget" } }) - .OrderBy(o => o.Name) - .ToList(); -``` + .OrderBy(x => x.Name) +::: The source generator intercepts these calls at compile time and rewrites the delegate lambdas to expression trees. The chain continues as an `IExpressiveQueryable`, preserving the ability to use modern syntax in subsequent calls. @@ -66,28 +64,39 @@ public class MyDbContext : DbContext } ``` -```csharp -// Modern syntax works directly -- no .AsExpressive() needed -var results = ctx.Orders +::: expressive-sample +db.Orders .Where(o => o.Customer?.Email != null) .Select(o => new { o.Id, - o.Total, + Total = o.Total(), Grade = o.GetGrade() }) - .ToList(); -``` +---setup--- +public static class OrderDbSetExt +{ + [Expressive] + public static decimal Total(this Order o) => o.Items.Sum(i => i.UnitPrice * i.Quantity); + + [Expressive] + public static string GetGrade(this Order o) => o.Items.Count() switch + { + >= 10 => "Premium", + >= 5 => "Standard", + _ => "Budget" + }; +} +::: `ExpressiveDbSet` also preserves chain continuity for EF Core-specific operations: ```csharp var result = await ctx.Orders .Include(o => o.Customer) - .ThenInclude(c => c.Address) .AsNoTracking() .Where(o => o.Customer?.Name == "Alice") - .FirstOrDefaultAsync(o => o.Total > 100); + .FirstOrDefaultAsync(o => o.Items.Count() > 3); ``` ### 3. `ExpressionPolyfill.Create` -- For Standalone Expression Trees @@ -96,10 +105,10 @@ When you need an `Expression` without a queryable at all: ```csharp // Returns Expression> -- intercepted at compile time -var expr = ExpressionPolyfill.Create((Order o) => o.Tag?.Length); +var expr = ExpressionPolyfill.Create((Order o) => o.Customer?.Name!.Length); // With transformers -var expr = ExpressionPolyfill.Create( +var expr2 = ExpressionPolyfill.Create( (Order o) => o.Customer?.Email, new RemoveNullConditionalPatterns()); ``` @@ -110,99 +119,79 @@ This is useful for building expression trees that you pass to other APIs, or for ### Null-Conditional in Where -```csharp -var results = ctx.Orders +::: expressive-sample +db.Orders .Where(o => o.Customer?.Email != null) - .Where(o => o.Customer?.Address?.City == "Seattle") - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "o".* -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -LEFT JOIN "Addresses" AS "a" ON "c"."AddressId" = "a"."Id" -WHERE "c"."Email" IS NOT NULL - AND "a"."City" = 'Seattle' -``` + .Where(o => o.Customer?.Country == "US") +::: ### Switch Expressions in Select -```csharp -var results = ctx.Orders +::: expressive-sample +db.Orders .Select(o => new { o.Id, - Tier = o.Price switch + Tier = o.Items.Count() switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget" + >= 10 => "Premium", + >= 5 => "Standard", + _ => "Budget" }, - Priority = o.Quantity switch + Priority = o.Status switch { - >= 100 => "Bulk", - >= 10 => "Normal", - _ => "Small" + OrderStatus.Pending => "Urgent", + OrderStatus.Paid => "Normal", + _ => "Low" } }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - CASE - WHEN "o"."Price" >= 100.0 THEN 'Premium' - WHEN "o"."Price" >= 50.0 THEN 'Standard' - ELSE 'Budget' - END AS "Tier", - CASE - WHEN "o"."Quantity" >= 100 THEN 'Bulk' - WHEN "o"."Quantity" >= 10 THEN 'Normal' - ELSE 'Small' - END AS "Priority" -FROM "Orders" AS "o" -``` +::: ### Pattern Matching in OrderBy -```csharp -var results = ctx.Orders - .OrderBy(o => o.Price switch +::: expressive-sample +db.Orders + .OrderBy(o => o.Items.Count() switch { - >= 100 => 1, - >= 50 => 2, - _ => 3 + >= 10 => 1, + >= 5 => 2, + _ => 3 }) - .ThenBy(o => o.Customer?.Name ?? "ZZZ") - .ToList(); -``` + .ThenBy(o => o.Customer!.Name ?? "ZZZ") +::: ### Combining [Expressive] Members with Inline Modern Syntax The two approaches compose naturally. `[Expressive]` members are expanded, and inline modern syntax is rewritten, all in the same query: -```csharp -var results = ctx.Orders - .Where(o => o.IsRecent && o.Customer?.Region == "US") +::: expressive-sample +db.Orders + .Where(o => o.IsRecent() && o.Customer!.Country == "US") .Select(o => new { o.Id, - o.Total, // [Expressive] property - o.CustomerEmail, // [Expressive] property with ?. - Tier = o.Total switch // inline switch on [Expressive] result + Total = o.Total(), // [Expressive] method + CustomerEmail = o.CustomerEmail(), // [Expressive] method with ?. + Tier = o.Total() switch // inline switch on [Expressive] result { - >= 1000 => "Premium", - >= 250 => "Standard", - _ => "Basic" + >= 1000m => "Premium", + >= 250m => "Standard", + _ => "Basic" } }) - .ToList(); -``` +---setup--- +public static class OrderCombinedExt +{ + [Expressive] + public static bool IsRecent(this Order o) => o.PlacedAt >= new DateTime(2024, 1, 1); + + [Expressive] + public static decimal Total(this Order o) => o.Items.Sum(i => i.UnitPrice * i.Quantity); + + [Expressive] + public static string? CustomerEmail(this Order o) => o.Customer?.Email; +} +::: ## When to Use Which Approach @@ -251,7 +240,7 @@ The source generator rewrites calls at their exact call site in your source code ::: ::: tip ToQueryString() for debugging -Use `.ToQueryString()` to inspect the generated SQL and verify that your modern syntax is being translated correctly. +Use `.ToQueryString()` to inspect the generated query text and verify that your modern syntax is being translated correctly. ::: ## See Also diff --git a/docs/recipes/nullable-navigation.md b/docs/recipes/nullable-navigation.md index 58ac5f3..f19cb6e 100644 --- a/docs/recipes/nullable-navigation.md +++ b/docs/recipes/nullable-navigation.md @@ -28,124 +28,48 @@ Unlike some other libraries, ExpressiveSharp does not expose a `NullConditionalR ## Single-Level Example -```csharp -public class Order +::: expressive-sample +db.Orders.Select(o => new { o.Id, CustomerEmail = o.CustomerEmail() }) +---setup--- +public static class OrderNullNavExt { - public int Id { get; set; } - public Customer? Customer { get; set; } - [Expressive] - public string? CustomerEmail => Customer?.Email; + public static string? CustomerEmail(this Order o) => o.Customer?.Email; } -``` - -```csharp -var orders = dbContext.Orders - .Select(o => new { o.Id, o.CustomerEmail }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - "c"."Email" AS "CustomerEmail" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -``` +::: -The `?.` operator is removed by the transformer, and EF Core produces a clean `LEFT JOIN`. If `Customer` is `NULL`, the SQL returns `NULL` for `Email` -- exactly matching the C# semantics. +The `?.` operator is removed by the transformer (for SQL providers), and EF Core produces a clean `LEFT JOIN`. If `Customer` is `NULL`, the result is `NULL` for `Email` -- exactly matching the C# semantics. ## Multi-Level Chain -Deeply nested nullable navigation chains work the same way: - -```csharp -public class User -{ - public int Id { get; set; } - public Address? Address { get; set; } -} +Deeply nested nullable navigation chains work the same way. For the webshop model, the chain `Order -> Customer -> Country` traverses one nullable level: -public class Address +::: expressive-sample +db.Orders.Select(o => new { o.Id, Country = o.CustomerCountry() }) +---setup--- +public static class OrderMultiNav { - public int Id { get; set; } - public City? City { get; set; } -} - -public class City -{ - public int Id { get; set; } - public string? PostalCode { get; set; } -} -``` - -```csharp -public class User -{ - // ... - [Expressive] - public string? PostalCode => Address?.City?.PostalCode; + public static string? CustomerCountry(this Order o) => o.Customer?.Country; } -``` - -```csharp -var results = dbContext.Users - .Select(u => new { u.Id, u.PostalCode }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "u"."Id", - "c"."PostalCode" -FROM "Users" AS "u" -LEFT JOIN "Addresses" AS "a" ON "u"."AddressId" = "a"."Id" -LEFT JOIN "Cities" AS "c" ON "a"."CityId" = "c"."Id" -``` +::: Each `?.` in the chain produces a `LEFT JOIN`. The transformer strips all the ternaries, and the database handles null propagation naturally. ## Using with IExpressiveQueryable (Modern Syntax) -You do not need an `[Expressive]` property to use `?.` in queries. With `IExpressiveQueryable` or `ExpressiveDbSet`, you can write null-conditional operators directly in your LINQ lambdas: +You do not need an `[Expressive]` member to use `?.` in queries. With `IExpressiveQueryable` or `ExpressiveDbSet`, you can write null-conditional operators directly in your LINQ lambdas: -```csharp -// Using ExpressiveDbSet (EF Core) -var results = ctx.Orders +::: expressive-sample +db.Orders .Where(o => o.Customer?.Email != null) .Select(o => new { o.Id, Name = o.Customer?.Name ?? "Unknown", - City = o.Customer?.Address?.City?.Name + Country = o.Customer?.Country }) - .ToList(); -``` - -```csharp -// Using IExpressiveQueryable (any IQueryable) -var results = queryable - .AsExpressive() - .Where(o => o.Customer?.Email != null) - .Select(o => new { o.Id, Email = o.Customer?.Email }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT "o"."Id", - COALESCE("c"."Name", 'Unknown') AS "Name", - "c0"."Name" AS "City" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -LEFT JOIN "Addresses" AS "a" ON "c"."AddressId" = "a"."Id" -LEFT JOIN "Cities" AS "c0" ON "a"."CityId" = "c0"."Id" -WHERE "c"."Email" IS NOT NULL -``` +::: See [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq) for more examples. @@ -153,29 +77,22 @@ See [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq) for more exam Combine `?.` with `??` for default values: -```csharp -public class Order +::: expressive-sample +db.Orders.Select(o => new +{ + CustomerName = o.CustomerName(), + ShippingCountry = o.ShippingCountry() +}) +---setup--- +public static class OrderNullCoalesce { - public Customer? Customer { get; set; } - [Expressive] - public string CustomerName => Customer?.Name ?? "Guest"; + public static string CustomerName(this Order o) => o.Customer?.Name ?? "Guest"; [Expressive] - public string ShippingCity => Customer?.Address?.City?.Name ?? "No City"; + public static string ShippingCountry(this Order o) => o.Customer?.Country ?? "Unknown"; } -``` - -Generated SQL (SQLite): - -```sql -SELECT COALESCE("c"."Name", 'Guest') AS "CustomerName", - COALESCE("c0"."Name", 'No City') AS "ShippingCity" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -LEFT JOIN "Addresses" AS "a" ON "c"."AddressId" = "a"."Id" -LEFT JOIN "Cities" AS "c0" ON "a"."CityId" = "c0"."Id" -``` +::: ## Without EF Core: Applying the Transformer Manually @@ -183,10 +100,15 @@ If you are not using EF Core (and therefore not using `UseExpressives()`), you c ### Per-member -```csharp -[Expressive(Transformers = new[] { typeof(RemoveNullConditionalPatterns) })] -public string? CustomerName => Customer?.Name; -``` +::: expressive-sample +db.Orders.Select(o => o.CustomerNameSafe()) +---setup--- +public static class OrderPerMemberTransformer +{ + [Expressive(Transformers = new[] { typeof(ExpressiveSharp.Transformers.RemoveNullConditionalPatterns) })] + public static string? CustomerNameSafe(this Order o) => o.Customer?.Name; +} +::: ### Globally @@ -194,7 +116,7 @@ public string? CustomerName => Customer?.Name; ExpressiveOptions.Default.AddTransformers(new RemoveNullConditionalPatterns()); // All subsequent ExpandExpressives() calls strip null-conditional patterns -Expression> expr = o => o.CustomerName; +Expression> expr = o => o.CustomerNameSafe(); var expanded = expr.ExpandExpressives(); ``` @@ -213,7 +135,7 @@ If you are using EF Core with `UseExpressives()`, null-conditional handling is f ::: ::: warning Non-SQL providers -If your LINQ provider does not handle null propagation natively (for example, Cosmos DB or an in-memory provider used in tests), you may want to **not** apply `RemoveNullConditionalPatterns`. The faithful ternary pattern will evaluate correctly in those environments. +If your LINQ provider does not handle null propagation natively (for example, an in-memory provider used in tests), you may want to **not** apply `RemoveNullConditionalPatterns`. The faithful ternary pattern will evaluate correctly in those environments. ::: ## See Also diff --git a/docs/recipes/reusable-query-filters.md b/docs/recipes/reusable-query-filters.md index e484dfc..6dfff84 100644 --- a/docs/recipes/reusable-query-filters.md +++ b/docs/recipes/reusable-query-filters.md @@ -8,132 +8,88 @@ Define your filtering criteria as `[Expressive]` members that return `bool`. Use ## Example: Active Entity Filter -```csharp -public class User +::: expressive-sample +db.Customers.Where(c => c.IsActive()) +---setup--- +public static class CustomerActiveExt { - public int Id { get; set; } - public bool IsDeleted { get; set; } - public DateTime? LastLoginDate { get; set; } - public DateTime? EmailVerifiedDate { get; set; } - public bool IsAdmin { get; set; } - [Expressive] - public bool IsActive => - !IsDeleted - && EmailVerifiedDate != null - && LastLoginDate >= DateTime.UtcNow.AddDays(-90); + public static bool IsActive(this Customer c) => + c.Email != null + && c.JoinedAt >= new DateTime(2023, 1, 1); } -``` - -```csharp -// Reuse everywhere -var activeUsers = dbContext.Users.Where(u => u.IsActive).ToList(); -var activeAdmins = dbContext.Users.Where(u => u.IsActive && u.IsAdmin).ToList(); -var activeCount = dbContext.Users.Count(u => u.IsActive); -``` +::: -Generated SQL (SQLite): +Reuse everywhere: -```sql -SELECT * -FROM "Users" AS "u" -WHERE "u"."IsDeleted" = 0 - AND "u"."EmailVerifiedDate" IS NOT NULL - AND "u"."LastLoginDate" >= DATETIME('now', '-90 days') -``` +::: expressive-sample +db.Customers.Where(c => c.IsActive() && c.Country == "US").Select(c => c.Id) +---setup--- +public static class CustomerActiveExt2 +{ + [Expressive] + public static bool IsActive(this Customer c) => + c.Email != null + && c.JoinedAt >= new DateTime(2023, 1, 1); +} +::: ## Example: Parameterized Filters with Extension Methods Extension methods are ideal for filters that accept parameters: -```csharp -public static class OrderExtensions +::: expressive-sample +db.Orders + .Where(o => o.IsWithinDateRange(new DateTime(2024, 1, 1), new DateTime(2024, 12, 31))) + .Where(o => o.IsHighValue(500m)) +---setup--- +public static class OrderParamFilters { [Expressive] public static bool IsWithinDateRange(this Order order, DateTime from, DateTime to) => - order.CreatedDate >= from && order.CreatedDate <= to; + order.PlacedAt >= from && order.PlacedAt <= to; [Expressive] public static bool IsHighValue(this Order order, decimal threshold) => - order.GrandTotal >= threshold; + order.Items.Sum(i => i.UnitPrice * i.Quantity) >= threshold; [Expressive] - public static bool BelongsToRegion(this Order order, string region) => - order.ShippingAddress != null && order.ShippingAddress.Region == region; + public static bool BelongsToCountry(this Order order, string country) => + order.Customer != null && order.Customer.Country == country; } -``` - -```csharp -var from = DateTime.UtcNow.AddMonths(-1); -var to = DateTime.UtcNow; - -var recentHighValueOrders = dbContext.Orders - .Where(o => o.IsWithinDateRange(from, to)) - .Where(o => o.IsHighValue(500m)) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT * -FROM "Orders" AS "o" -WHERE "o"."CreatedDate" >= @from - AND "o"."CreatedDate" <= @to - AND "o"."GrandTotal" >= 500.0 -``` +::: ::: tip -Parameters (`from`, `to`, `500m`) are captured as SQL parameters -- there is no string concatenation or SQL injection risk. +Parameters (`from`, `to`, `500m`) are captured as provider parameters -- there is no string concatenation or SQL injection risk. ::: ## Example: Composing Filters Build complex filters by composing simpler `[Expressive]` members: -```csharp -public class Order +::: expressive-sample +db.Orders.Where(o => o.IsRecentPaidOrder()) +---setup--- +public static class OrderComposedFilters { - public int Id { get; set; } - public DateTime CreatedDate { get; set; } - public DateTime? FulfilledDate { get; set; } - public bool HasOpenReturnRequest { get; set; } - [Expressive] - public bool IsFulfilled => FulfilledDate != null; + public static bool IsPaid(this Order o) => o.Status == OrderStatus.Paid; [Expressive] - public bool IsRecent => CreatedDate >= DateTime.UtcNow.AddDays(-30); + public static bool IsRecent(this Order o) => o.PlacedAt >= new DateTime(2024, 1, 1); // Composed from simpler [Expressive] members [Expressive] - public bool IsRecentFulfilledOrder => IsFulfilled && IsRecent; -} + public static bool IsRecentPaidOrder(this Order o) => o.IsPaid() && o.IsRecent(); -public static class OrderExtensions -{ [Expressive] public static bool IsEligibleForReturn(this Order order) => - order.IsFulfilled - && order.FulfilledDate >= DateTime.UtcNow.AddDays(-30) - && !order.HasOpenReturnRequest; + order.Status == OrderStatus.Delivered + && order.PlacedAt >= new DateTime(2024, 1, 1); } -``` - -```csharp -// Dashboard query -var fulfilledRecently = dbContext.Orders - .Where(o => o.IsRecentFulfilledOrder) - .ToList(); - -// Return eligibility check -var returnable = dbContext.Orders - .Where(o => o.IsEligibleForReturn()) - .Select(o => new { o.Id, o.FulfilledDate }) - .ToList(); -``` +::: -The composed filters are expanded recursively -- `IsRecentFulfilledOrder` references `IsFulfilled` and `IsRecent`, which are both expanded to their underlying expressions before SQL translation. +The composed filters are expanded recursively -- `IsRecentPaidOrder` references `IsPaid` and `IsRecent`, which are both expanded to their underlying expressions before translation. ## Example: Global Query Filters with EF Core @@ -142,13 +98,13 @@ The composed filters are expanded recursively -- `IsRecentFulfilledOrder` refere ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Soft-delete global filter using an [Expressive] property + // Active-order global filter using an [Expressive] extension modelBuilder.Entity() - .HasQueryFilter(o => !o.IsDeleted); + .HasQueryFilter(o => o.Status != OrderStatus.Refunded); // Tenant isolation filter modelBuilder.Entity() - .HasQueryFilter(o => o.TenantId == _currentTenantId); + .HasQueryFilter(o => o.CustomerId == _currentCustomerId); } ``` @@ -167,62 +123,55 @@ var allOrders = dbContext.Orders `[Expressive]` members pair naturally with the Specification pattern: -```csharp +::: expressive-sample +db.Orders.Where(o => o.RequiresAttention()).Select(o => o.Id) +---setup--- public static class OrderSpecifications { [Expressive] public static bool IsActive(this Order order) => - !order.IsCancelled && !order.IsDeleted; + order.Status != OrderStatus.Refunded; [Expressive] public static bool IsOverdue(this Order order) => order.IsActive() - && order.DueDate < DateTime.UtcNow - && !order.IsFulfilled; + && order.PlacedAt < new DateTime(2024, 6, 1) + && order.Status == OrderStatus.Pending; [Expressive] public static bool RequiresAttention(this Order order) => order.IsOverdue() - || order.HasOpenDispute - || order.PaymentStatus == PaymentStatus.Failed; + || order.Status == OrderStatus.Pending; } -``` - -```csharp -// Dashboard: count orders requiring attention -var attentionCount = await dbContext.Orders - .Where(o => o.RequiresAttention()) - .CountAsync(); - -// Alert users with overdue orders -var overdueUserIds = await dbContext.Orders - .Where(o => o.IsOverdue()) - .Select(o => o.UserId) - .Distinct() - .ToListAsync(); -``` +::: -All specification methods are expanded recursively -- `RequiresAttention` calls `IsOverdue`, which calls `IsActive`. The entire chain becomes a flat SQL `WHERE` clause. +All specification methods are expanded recursively -- `RequiresAttention` calls `IsOverdue`, which calls `IsActive`. The entire chain becomes a flat `WHERE` clause. ## Using Filters with ExpressiveDbSet With `ExpressiveDbSet`, you can combine `[Expressive]` filters with inline modern syntax: -```csharp -var results = ctx.Orders - .Where(o => o.IsActive() && o.Customer?.Region == "US") +::: expressive-sample +db.Orders + .Where(o => o.IsActive() && o.Customer.Country == "US") .Select(o => new { o.Id, - Status = o.PaymentStatus switch + StatusLabel = o.Status switch { - PaymentStatus.Paid => "Paid", - PaymentStatus.Pending => "Pending", - _ => "Other" + OrderStatus.Paid => "Paid", + OrderStatus.Pending => "Pending", + _ => "Other" } }) - .ToList(); -``` +---setup--- +public static class OrderActiveForDbSet +{ + [Expressive] + public static bool IsActive(this Order order) => + order.Status != OrderStatus.Refunded; +} +::: See [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq) for more on this approach. @@ -241,7 +190,7 @@ Use extension methods for cross-entity or parameterized filters. ::: ::: warning Keep filters pure -Filter members should only read data, never modify it. Everything in the body must be translatable to SQL. +Filter members should only read data, never modify it. Everything in the body must be translatable by your provider. ::: ## See Also diff --git a/docs/recipes/scoring-classification.md b/docs/recipes/scoring-classification.md index 05d9ed2..2c884e2 100644 --- a/docs/recipes/scoring-classification.md +++ b/docs/recipes/scoring-classification.md @@ -1,174 +1,108 @@ # Scoring & Classification -This recipe shows how to use C# pattern matching -- switch expressions, `is` patterns, relational patterns, and more -- inside `[Expressive]` members to compute scores, grades, tiers, and labels directly in SQL. +This recipe shows how to use C# pattern matching -- switch expressions, `is` patterns, relational patterns, and more -- inside `[Expressive]` members to compute scores, tiers, and labels directly in SQL. -## Grading with Relational Patterns +## Banding with Relational Patterns -Classic grading logic maps numeric ranges to labels. The switch expression reads naturally and the generator translates it to a SQL `CASE` expression: +Mapping numeric ranges to labels reads naturally as a switch expression and translates to a SQL `CASE`: -```csharp -public class Student +::: expressive-sample +db.Products + .GroupBy(p => p.PriceBand()) + .Select(g => new { Band = g.Key, Count = g.Count() }) + .OrderBy(x => x.Band) +---setup--- +public static class ProductBand { - public int Id { get; set; } - public int Score { get; set; } - [Expressive] - public string Grade => Score switch + public static string PriceBand(this Product p) => p.ListPrice switch { - >= 90 => "A", - >= 80 => "B", - >= 70 => "C", - >= 60 => "D", - _ => "F" + >= 500m => "A", + >= 200m => "B", + >= 100m => "C", + >= 50m => "D", + _ => "E" }; [Expressive] - public bool IsPassing => Score >= 60; - - [Expressive] - public bool IsHonors => Score >= 90; + public static bool IsPremium(this Product p) => p.ListPrice >= 500m; } -``` - -```csharp -// Grade distribution report -var distribution = dbContext.Students - .GroupBy(s => s.Grade) - .Select(g => new { Grade = g.Key, Count = g.Count() }) - .OrderBy(x => x.Grade) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT CASE - WHEN "s"."Score" >= 90 THEN 'A' - WHEN "s"."Score" >= 80 THEN 'B' - WHEN "s"."Score" >= 70 THEN 'C' - WHEN "s"."Score" >= 60 THEN 'D' - ELSE 'F' -END AS "Grade", -COUNT(*) AS "Count" -FROM "Students" AS "s" -GROUP BY CASE - WHEN "s"."Score" >= 90 THEN 'A' - WHEN "s"."Score" >= 80 THEN 'B' - WHEN "s"."Score" >= 70 THEN 'C' - WHEN "s"."Score" >= 60 THEN 'D' - ELSE 'F' -END -ORDER BY "Grade" -``` +::: ## Customer Tiers with `and` / `or` Patterns Use `and` and `or` patterns to express range bands cleanly: -```csharp -public class Customer +::: expressive-sample +db.Customers + .GroupBy(c => c.Tier()) + .Select(g => new { Tier = g.Key, Count = g.Count() }) +---setup--- +public static class CustomerTier { - public int Id { get; set; } - public int LifetimeOrderCount { get; set; } - public decimal LifetimeSpend { get; set; } + [Expressive] + public static int OrderCount(this Customer c) => c.Orders.Count(); [Expressive] - public string Tier => LifetimeSpend switch + public static string Tier(this Customer c) => c.OrderCount() switch { - >= 10_000 => "Platinum", - >= 5_000 and < 10_000 => "Gold", - >= 1_000 and < 5_000 => "Silver", - _ => "Bronze" + >= 50 => "Platinum", + >= 20 and < 50 => "Gold", + >= 5 and < 20 => "Silver", + _ => "Bronze" }; [Expressive] - public bool IsLoyalty => LifetimeOrderCount >= 10; + public static bool IsLoyalty(this Customer c) => c.OrderCount() >= 10; } -``` - -```csharp -// Segment customers for a marketing campaign -var segments = dbContext.Customers - .GroupBy(c => c.Tier) - .Select(g => new { Tier = g.Key, Count = g.Count(), TotalSpend = g.Sum(c => c.LifetimeSpend) }) - .ToList(); -``` - -Generated SQL (SQLite): - -```sql -SELECT CASE - WHEN "c"."LifetimeSpend" >= 10000 THEN 'Platinum' - WHEN "c"."LifetimeSpend" >= 5000 AND "c"."LifetimeSpend" < 10000 THEN 'Gold' - WHEN "c"."LifetimeSpend" >= 1000 AND "c"."LifetimeSpend" < 5000 THEN 'Silver' - ELSE 'Bronze' -END AS "Tier", -COUNT(*) AS "Count", -COALESCE(SUM("c"."LifetimeSpend"), 0) AS "TotalSpend" -FROM "Customers" AS "c" -GROUP BY CASE - WHEN "c"."LifetimeSpend" >= 10000 THEN 'Platinum' - WHEN "c"."LifetimeSpend" >= 5000 AND "c"."LifetimeSpend" < 10000 THEN 'Gold' - WHEN "c"."LifetimeSpend" >= 1000 AND "c"."LifetimeSpend" < 5000 THEN 'Silver' - ELSE 'Bronze' -END -``` +::: -## Risk Scoring with Property Patterns +## Multi-Field Classification with Property Patterns Property patterns match on multiple fields of the current instance simultaneously. This is useful for multi-dimensional classification: -```csharp -public class Loan +::: expressive-sample +db.Products + .Select(p => new { p.Id, p.Name, Category = p.StockCategory() }) +---setup--- +public static class StockClassifier { - public int Id { get; set; } - public int CreditScore { get; set; } - public decimal DebtToIncomeRatio { get; set; } - public decimal LoanAmount { get; set; } - [Expressive] - public string RiskCategory => this switch + public static string StockCategory(this Product p) => p switch { - { CreditScore: >= 750, DebtToIncomeRatio: < 0.3m } => "Low", - { CreditScore: >= 700 } => "Medium", - { CreditScore: >= 600 } => "High", - _ => "Very High" + { StockQuantity: 0 } => "OutOfStock", + { StockQuantity: < 10, ListPrice: >= 500m } => "LowStockPremium", + { StockQuantity: < 10 } => "LowStock", + { StockQuantity: >= 100 } => "WellStocked", + _ => "Normal" }; } -``` +::: -```csharp -// Risk distribution across the loan portfolio -var riskReport = dbContext.Loans - .GroupBy(l => l.RiskCategory) - .Select(g => new - { - Risk = g.Key, - Count = g.Count(), - TotalExposure = g.Sum(l => l.LoanAmount) - }) - .ToList(); -``` +## `is` Patterns for Boolean Flags -Generated SQL (SQLite): - -```sql -SELECT CASE - WHEN "l"."CreditScore" >= 750 AND "l"."DebtToIncomeRatio" < 0.3 THEN 'Low' - WHEN "l"."CreditScore" >= 700 THEN 'Medium' - WHEN "l"."CreditScore" >= 600 THEN 'High' - ELSE 'Very High' -END AS "Risk", -COUNT(*) AS "Count", -COALESCE(SUM("l"."LoanAmount"), 0) AS "TotalExposure" -FROM "Loans" AS "l" -GROUP BY CASE - WHEN "l"."CreditScore" >= 750 AND "l"."DebtToIncomeRatio" < 0.3 THEN 'Low' - WHEN "l"."CreditScore" >= 700 THEN 'Medium' - WHEN "l"."CreditScore" >= 600 THEN 'High' - ELSE 'Very High' -END -``` +Use `is` patterns for concise Boolean members: + +::: expressive-sample +db.Products + .Where(p => p.IsInStock() && p.IsBudget()) + .Select(p => new { p.Id, p.Name, p.ListPrice }) +---setup--- +public static class ProductFlags +{ + [Expressive] + public static bool IsInStock(this Product p) => p.StockQuantity is > 0; + + [Expressive] + public static bool NeedsReorder(this Product p) => p.StockQuantity is >= 0 and <= 5; + + [Expressive] + public static bool IsBudget(this Product p) => p.ListPrice is > 0m and < 50m; + + [Expressive] + public static bool HasNoStock(this Product p) => p.StockQuantity is 0; +} +::: ## Positional Patterns @@ -196,36 +130,11 @@ public class Location public string Hemisphere => Position switch { (>= 0, _) => "Northern", - _ => "Southern" + _ => "Southern" }; } ``` -## `is` Patterns for Boolean Flags - -Use `is` patterns for concise Boolean properties: - -```csharp -public class Product -{ - public int Stock { get; set; } - public decimal Price { get; set; } - public int ReorderPoint { get; set; } - - [Expressive] - public bool IsInStock => Stock is > 0; - - [Expressive] - public bool NeedsReorder => Stock is >= 0 and <= ReorderPoint; - - [Expressive] - public bool IsBudget => Price is > 0 and < 25; - - [Expressive] - public bool HasNoStock => Stock is 0; -} -``` - ## List Patterns ExpressiveSharp supports list patterns for fixed-length matching: @@ -250,63 +159,55 @@ public class Measurement ## Combining Classification with Aggregation -Compose `[Expressive]` classification properties to build rich query results: +Compose `[Expressive]` classification members to build rich query results: -```csharp -public class Order +::: expressive-sample +db.Orders + .Where(o => o.IsRecent()) + .GroupBy(o => o.ValueBand()) + .Select(g => new { Band = g.Key, Count = g.Count(), Total = g.Sum(o => o.GrandTotal()) }) + .OrderBy(x => x.Band) +---setup--- +public static class OrderScoring { - public int Id { get; set; } - public decimal GrandTotal { get; set; } - public DateTime CreatedDate { get; set; } + [Expressive] + public static decimal GrandTotal(this Order o) + => o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] - public string ValueBand => GrandTotal switch + public static string ValueBand(this Order o) => o.GrandTotal() switch { - >= 1000 => "High", - >= 250 => "Medium", - _ => "Low" + >= 1000m => "High", + >= 250m => "Medium", + _ => "Low" }; [Expressive] - public bool IsRecent => CreatedDate >= DateTime.UtcNow.AddDays(-30); + public static bool IsRecent(this Order o) => o.PlacedAt >= new DateTime(2024, 1, 1); } -``` - -```csharp -// Breakdown of recent orders by value band -var breakdown = dbContext.Orders - .Where(o => o.IsRecent) - .GroupBy(o => o.ValueBand) - .Select(g => new - { - Band = g.Key, - Count = g.Count(), - Total = g.Sum(o => o.GrandTotal) - }) - .OrderBy(x => x.Band) - .ToList(); -``` +::: -## Using Switch Expressions with ExpressiveDbSet +## Using Switch Expressions Inline in LINQ Chains -You can also use switch expressions directly in LINQ chains via `ExpressiveDbSet`, without defining a separate `[Expressive]` property: +You can also use switch expressions directly in LINQ chains via `ExpressiveDbSet` or `IExpressiveQueryable`, without defining a separate `[Expressive]` member: -```csharp -var results = ctx.Orders +::: expressive-sample +db.Orders .Select(o => new { o.Id, - Tier = o.GrandTotal switch + Tier = o.Status switch { - >= 1000 => "Premium", - >= 250 => "Standard", - _ => "Basic" + OrderStatus.Delivered => "Completed", + OrderStatus.Shipped => "InTransit", + OrderStatus.Paid => "Awaiting", + OrderStatus.Pending => "New", + _ => "Other" } }) - .ToList(); -``` +::: -See [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq) for more on this approach. +See [Modern Syntax in LINQ Chains](./modern-syntax-in-linq) for more on this approach. ## Tips @@ -319,11 +220,11 @@ The generator emits a ternary chain in arm order. Put the most restrictive cases ::: ::: tip Compose with filters -Classification properties work in `Where`, `GroupBy`, and `OrderBy` just like any other `[Expressive]` member. This is how you build reporting queries that compute business categories entirely in SQL. +Classification members work in `Where`, `GroupBy`, and `OrderBy` just like any other `[Expressive]` member. This is how you build reporting queries that compute business categories entirely in SQL. ::: ## See Also -- [Computed Entity Properties](/recipes/computed-properties) -- building blocks for classification -- [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq) -- switch expressions inline in queries -- [Nullable Navigation Properties](/recipes/nullable-navigation) -- safely handling null in classification logic +- [Computed Entity Properties](./computed-properties) -- building blocks for classification +- [Modern Syntax in LINQ Chains](./modern-syntax-in-linq) -- switch expressions inline in queries +- [Nullable Navigation Properties](./nullable-navigation) -- safely handling null in classification logic diff --git a/docs/recipes/window-functions-ranking.md b/docs/recipes/window-functions-ranking.md index 5358c34..444bdca 100644 --- a/docs/recipes/window-functions-ranking.md +++ b/docs/recipes/window-functions-ranking.md @@ -382,6 +382,6 @@ Some databases do not support `WHERE` directly on window function results. EF Co ## See Also -- [Computed Entity Properties](/recipes/computed-properties) -- combine computed properties with window functions -- [Modern Syntax in LINQ Chains](/recipes/modern-syntax-in-linq) -- modern syntax alongside window functions -- [Scoring and Classification](/recipes/scoring-classification) -- CASE expressions and window-based ranking together +- [Computed Entity Properties](./computed-properties) -- combine computed properties with window functions +- [Modern Syntax in LINQ Chains](./modern-syntax-in-linq) -- modern syntax alongside window functions +- [Scoring and Classification](./scoring-classification) -- CASE expressions and window-based ranking together diff --git a/docs/reference/expressive-attribute.md b/docs/reference/expressive-attribute.md index 417631e..19fdd6c 100644 --- a/docs/reference/expressive-attribute.md +++ b/docs/reference/expressive-attribute.md @@ -31,15 +31,20 @@ Enables block-bodied member support. Without this flag, using a block body (`{ } When not explicitly set on the attribute, the MSBuild property `Expressive_AllowBlockBody` is used as the global default (also defaults to `false`). -```csharp -[Expressive(AllowBlockBody = true)] -public string GetCategory() +::: expressive-sample +db.Orders.Select(o => o.GetCategory()) +---setup--- +public static class OrderBlockExt { - var threshold = Quantity * 10; - if (threshold > 100) return "Bulk"; - return "Regular"; + [Expressive(AllowBlockBody = true)] + public static string GetCategory(this Order o) + { + var threshold = o.Items.Count() * 10; + if (threshold > 100) return "Bulk"; + return "Regular"; + } } -``` +::: Or enable globally for the entire project: @@ -58,10 +63,15 @@ Or enable globally for the entire project: Specifies additional `IExpressionTreeTransformer` types to apply at runtime when the expression is resolved. Each type must have a parameterless constructor. -```csharp -[Expressive(Transformers = new[] { typeof(RemoveNullConditionalPatterns) })] -public string? CustomerName => Customer?.Name; -``` +::: expressive-sample +db.Orders.Select(o => o.CustomerName()) +---setup--- +public static class OrderTransformerExt +{ + [Expressive(Transformers = new[] { typeof(ExpressiveSharp.Transformers.RemoveNullConditionalPatterns) })] + public static string? CustomerName(this Order o) => o.Customer?.Name; +} +::: See [Expression Transformers](./expression-transformers) for the full list of built-in transformers and how to create custom ones. @@ -92,25 +102,25 @@ ExpressiveSharp does not have a compatibility mode setting. Expression expansion After marking members with `[Expressive]`, you can manually expand them in expression trees using the `.ExpandExpressives()` extension method: ```csharp -Expression> expr = o => o.Total; -// expr body is: o.Total (opaque property access) +Expression> expr = o => o.Total(); +// expr body is: o.Total() (opaque method call) var expanded = expr.ExpandExpressives(); -// expanded body is: o.Price * o.Quantity (translatable by EF Core / other providers) +// expanded body is: o.Items.Sum(i => i.UnitPrice * i.Quantity) (translatable by your provider) ``` This replaces `[Expressive]` member references with their generated expression trees. Expansion is recursive -- if `TotalWithTax` references `Total`, both are expanded: ```csharp [Expressive] -public double Total => Price * Quantity; +public static decimal Total(this Order o) => o.Items.Sum(i => i.UnitPrice * i.Quantity); [Expressive] -public double TotalWithTax => Total * (1 + TaxRate); +public static decimal TotalWithTax(this Order o) => o.Total() * 1.08m; -Expression> expr = o => o.TotalWithTax; +Expression> expr = o => o.TotalWithTax(); var expanded = expr.ExpandExpressives(); -// expanded body is: (o.Price * o.Quantity) * (1 + o.TaxRate) +// expanded body is: o.Items.Sum(i => i.UnitPrice * i.Quantity) * 1.08m ``` You can also pass transformers to `ExpandExpressives()`: @@ -128,93 +138,67 @@ expr.ExpandExpressives(); // RemoveNullConditionalPatterns applied automatically ## Complete Example -```csharp -public class Order +::: expressive-sample +db.Orders + .Where(o => o.CustomerEmail() != null) + .Select(o => new OrderSummaryDto(o.Id, o.SafeTag(), o.Total())) +---setup--- +public static class OrderComplete { - public int Id { get; set; } - public double Price { get; set; } - public int Quantity { get; set; } - public string? Tag { get; set; } - public Customer? Customer { get; set; } - - // Simple computed property + // Simple computed method [Expressive] - public double Total => Price * Quantity; + public static decimal Total(this Order o) => o.Items.Sum(i => i.UnitPrice * i.Quantity); // Composing expressives [Expressive] - public double TotalWithTax => Total * (1 + 0.08); + public static decimal TotalWithTax(this Order o) => o.Total() * 1.08m; // Null-conditional operators -- always generates faithful ternary [Expressive] - public string? CustomerEmail => Customer?.Email; + public static string? CustomerEmail(this Order o) => o.Customer?.Email; // Switch expressions with pattern matching [Expressive] - public string GetGrade() => Price switch + public static string GetGrade(this Order o) => o.Items.Count() switch { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", + >= 10 => "Premium", + >= 5 => "Standard", + _ => "Budget", }; // Per-member transformer - [Expressive(Transformers = new[] { typeof(RemoveNullConditionalPatterns) })] - public string? CustomerName => Customer?.Name; + [Expressive(Transformers = new[] { typeof(ExpressiveSharp.Transformers.RemoveNullConditionalPatterns) })] + public static string? CustomerNameSafe(this Order o) => o.Customer?.Name; // Block body (opt-in) [Expressive(AllowBlockBody = true)] - public string GetCategory() + public static string GetCategory(this Order o) { - var threshold = Quantity * 10; + var threshold = o.Items.Count() * 10; if (threshold > 100) return "Bulk"; return "Regular"; } -} -// Extension methods must be in a static class -public static class OrderExtensions -{ + // Extension method with null-coalescing [Expressive] - public static string? SafeTag(this Order o) => o.Tag ?? "N/A"; + public static string SafeTag(this Order o) => o.Customer != null ? o.Customer.Name : "N/A"; } public class OrderSummaryDto { public int Id { get; set; } public string Description { get; set; } = ""; - public double Total { get; set; } + public decimal Total { get; set; } public OrderSummaryDto() { } - // Constructor projection -- translates to SQL MemberInit + // Constructor projection -- translates to MemberInit [Expressive] - public OrderSummaryDto(int id, string description, double total) + public OrderSummaryDto(int id, string description, decimal total) { Id = id; Description = description; Total = total; } } -``` - -Usage in an EF Core query: - -```csharp -var results = db.Orders - .AsExpressiveDbSet() - .Where(o => o.Customer?.Email != null) - .Select(o => new OrderSummaryDto(o.Id, o.Tag ?? "N/A", o.Total)) - .ToList(); -``` - -Generated SQL: - -```sql -SELECT "o"."Id", - COALESCE("o"."Tag", 'N/A') AS "Description", - "o"."Price" * CAST("o"."Quantity" AS REAL) AS "Total" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -WHERE "c"."Email" IS NOT NULL -``` +::: diff --git a/docs/reference/expressive-for.md b/docs/reference/expressive-for.md index ef90eab..41d4b1a 100644 --- a/docs/reference/expressive-for.md +++ b/docs/reference/expressive-for.md @@ -24,48 +24,31 @@ You write a static stub method whose body defines the expression-tree replacemen Map a static method by matching its parameter signature: -```csharp -static class MathMappings +::: expressive-sample +db.Orders.Where(o => System.Math.Clamp(o.Items.Count(), 0, 100) > 5) +---setup--- +public static class MathMappings { - [ExpressiveFor(typeof(Math), nameof(Math.Clamp))] - static double Clamp(double value, double min, double max) + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(System.Math), nameof(System.Math.Clamp))] + public static int ClampInt(int value, int min, int max) => value < min ? min : (value > max ? max : value); } -``` - -Now `Math.Clamp` can be used in queries: - -```csharp -var results = db.Orders - .AsExpressiveDbSet() - .Where(o => Math.Clamp(o.Price, 20, 100) > 50) - .ToList(); -``` - -Generated SQL: - -```sql -SELECT "o"."Id", "o"."Price", "o"."Quantity" -FROM "Orders" AS "o" -WHERE CASE - WHEN "o"."Price" < 20.0 THEN 20.0 - WHEN "o"."Price" > 100.0 THEN 100.0 - ELSE "o"."Price" -END > 50.0 -``` +::: ## Instance Method Mapping For instance methods, the first parameter represents the receiver: -```csharp -static class StringMappings +::: expressive-sample +db.Products.Where(p => p.Name.Contains("box")) +---setup--- +public static class StringMappings { - [ExpressiveFor(typeof(string), nameof(string.Contains))] - static bool Contains(string self, string value) + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(string), nameof(string.Contains))] + public static bool Contains(string self, string value) => self.IndexOf(value) >= 0; } -``` +::: ## Instance Property Mapping @@ -89,8 +72,14 @@ The stub can use any C# syntax that `[Expressive]` supports -- switch expression Use `[ExpressiveForConstructor]` to provide an expression-tree body for a constructor on a type you do not own: ```csharp -[ExpressiveForConstructor(typeof(MyDto))] -static MyDto Create(int id, string name) => new MyDto { Id = id, Name = name }; +public static class MyDtoBuilder +{ + // Applied to a static stub method that returns the target type — the + // generator replaces `new MyDto(id, name)` call sites with the stub's body. + [ExpressiveForConstructor(typeof(MyDto))] + public static MyDto Build(int id, string name) + => new MyDto { Id = id, Name = name }; +} ``` ## Properties @@ -102,15 +91,20 @@ Both `[ExpressiveFor]` and `[ExpressiveForConstructor]` support the same optiona | `AllowBlockBody` | `bool` | `false` | Enables block-bodied stubs (`if`/`else`, local variables, etc.) | | `Transformers` | `Type[]?` | `null` | Per-mapping transformers applied when expanding the mapped member | -```csharp -[ExpressiveFor(typeof(Math), nameof(Math.Clamp), AllowBlockBody = true)] -static double Clamp(double value, double min, double max) +::: expressive-sample +db.Orders.Where(o => System.Math.Clamp(o.Items.Count(), 0, 100) > 5) +---setup--- +public static class MathBlockMappings { - if (value < min) return min; - if (value > max) return max; - return value; + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(System.Math), nameof(System.Math.Clamp), AllowBlockBody = true)] + public static int ClampInt(int value, int min, int max) + { + if (value < min) return min; + if (value > max) return max; + return value; + } } -``` +::: ## Diagnostics @@ -131,43 +125,49 @@ If a member already has `[Expressive]`, adding `[ExpressiveFor]` targeting it is ## Complete Usage Example -```csharp -using ExpressiveSharp.Mapping; - -// Map Math.Clamp for SQL translation -static class MathMappings +::: expressive-sample +db.Orders + .Where(o => !string.IsNullOrWhiteSpace(o.Customer.Name)) + .Where(o => System.Math.Clamp(o.Items.Count(), 0, 100) > 5) + .Select(o => new OrderMappingDto(o.Id, o.Customer.Name ?? "N/A")) +---setup--- +public static class MathMappingsComplete { - [ExpressiveFor(typeof(Math), nameof(Math.Clamp))] - static double Clamp(double value, double min, double max) + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(System.Math), nameof(System.Math.Clamp))] + public static int ClampInt(int value, int min, int max) => value < min ? min : (value > max ? max : value); } -// Map string.IsNullOrWhiteSpace for SQL translation -static class StringMappings +public static class StringMappingsComplete { - [ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))] - static bool IsNullOrWhiteSpace(string? s) + [ExpressiveSharp.Mapping.ExpressiveFor(typeof(string), nameof(string.IsNullOrWhiteSpace))] + public static bool IsNullOrWhiteSpace(string? s) => s == null || s.Trim().Length == 0; } -// Map a third-party DTO constructor -static class DtoMappings +public class OrderMappingDto { - [ExpressiveForConstructor(typeof(ExternalDto))] - static ExternalDto Create(int id, string name) - => new ExternalDto { Id = id, Name = name }; + public int Id { get; set; } + public string Name { get; set; } = ""; + + // The constructor that call sites (new OrderMappingDto(id, name)) invoke. + public OrderMappingDto(int id, string name) + { + Id = id; + Name = name; + } } -``` - -Using the mappings in an EF Core query: -```csharp -var results = db.Orders - .AsExpressiveDbSet() - .Where(o => !string.IsNullOrWhiteSpace(o.Tag)) - .Where(o => Math.Clamp(o.Price, 20, 100) > 50) - .Select(o => new ExternalDto(o.Id, o.Tag ?? "N/A")) - .ToList(); -``` +public static class OrderMappingDtoBuilder +{ + // Provides a translatable body for the constructor above — call sites + // `new OrderMappingDto(id, name)` are rewritten to this object-init form + // during expression-tree expansion, so the provider sees a translatable + // MemberInit instead of a constructor call. + [ExpressiveSharp.Mapping.ExpressiveForConstructor(typeof(OrderMappingDto))] + public static OrderMappingDto Build(int id, string name) + => new OrderMappingDto(0, "") { Id = id, Name = name }; +} +::: -All three mapped members are replaced with their expression-tree equivalents and translated to SQL. No changes are needed at call sites. +All three mapped members are replaced with their expression-tree equivalents and translated for your provider. No changes are needed at call sites. diff --git a/docs/reference/null-conditional-rewrite.md b/docs/reference/null-conditional-rewrite.md index fe497a1..edcd5c2 100644 --- a/docs/reference/null-conditional-rewrite.md +++ b/docs/reference/null-conditional-rewrite.md @@ -1,6 +1,6 @@ # Null-Conditional Rewrite -Expression trees -- the representation LINQ providers like EF Core use internally -- cannot directly express the null-conditional operator (`?.`). ExpressiveSharp handles this transparently by generating faithful null-check ternaries at compile time and providing a transformer to strip them when targeting SQL databases. +Expression trees -- the representation LINQ providers use internally -- cannot directly express the null-conditional operator (`?.`). ExpressiveSharp handles this transparently by generating faithful null-check ternaries at compile time and providing a transformer to strip them when targeting providers that handle NULL propagation natively. ## The Problem @@ -25,7 +25,17 @@ ExpressiveSharp always generates a **faithful ternary** for null-conditional ope Customer != null ? Customer.Email : default(string) ``` -This is the generated expression tree equivalent -- it preserves the exact semantics of the original C# code. There is no per-member configuration needed; `?.` simply works. +This is the generated expression tree equivalent -- it preserves the exact semantics of the original C# code. There is no per-member configuration needed; `?.` simply works. The tabs on the sample below show how each provider renders this ternary. + +::: expressive-sample +db.Orders.Select(o => new { o.Id, Email = o.CustomerEmail() }) +---setup--- +public static class OrderExt +{ + [Expressive] + public static string? CustomerEmail(this Order o) => o.Customer?.Email; +} +::: ::: info Unlike Projectables, which required a per-member `NullConditionalRewriteSupport` enum (`None`, `Ignore`, or `Rewrite`), ExpressiveSharp always generates the faithful ternary. The stripping of null checks is handled separately by the `RemoveNullConditionalPatterns` transformer. @@ -62,69 +72,52 @@ No additional configuration is needed. All `[Expressive]` members with `?.` oper If you are not using EF Core (or want per-member control without `UseExpressives()`), apply the transformer on individual members: -```csharp -[Expressive(Transformers = new[] { typeof(RemoveNullConditionalPatterns) })] -public string? CustomerName => Customer?.Name; -``` +::: expressive-sample +db.Orders.Select(o => new { o.Id, Name = o.CustomerName() }) +---setup--- +public static class OrderExt +{ + [Expressive(Transformers = new[] { typeof(ExpressiveSharp.Transformers.RemoveNullConditionalPatterns) })] + public static string? CustomerName(this Order o) => o.Customer?.Name; +} +::: Or apply it when expanding expressions manually: ```csharp Expression> expr = o => o.CustomerEmail; -var expanded = expr.ExpandExpressives(new RemoveNullConditionalPatterns()); +var expanded = expr.ExpandExpressives(new ExpressiveSharp.Transformers.RemoveNullConditionalPatterns()); ``` ## Multi-Level Nullable Chain -Chained null-conditional operators generate nested ternaries: +Chained null-conditional operators generate nested ternaries. The tabs below show how the nesting translates for each provider: -```csharp -[Expressive] -public string? CustomerCity => Customer?.Address?.City; -``` +::: expressive-sample +db.Orders.Select(o => new { o.Id, Country = o.CustomerCountry() }) +---setup--- +public static class OrderExt +{ + [Expressive] + public static string? CustomerCountry(this Order o) => o.Customer?.Country; +} +::: Generated expression (before transformer): ```csharp Customer != null - ? (Customer.Address != null ? Customer.Address.City : default(string)) + ? (Customer.Country != null ? Customer.Country : default(string)) : default(string) ``` After `RemoveNullConditionalPatterns`: ```csharp -Customer.Address.City -``` - -### SQL Output Comparison - -**Without transformer** (faithful ternary preserved): - -```sql -SELECT CASE - WHEN "c"."Id" IS NOT NULL THEN - CASE - WHEN "a"."Id" IS NOT NULL THEN "a"."City" - ELSE NULL - END - ELSE NULL -END -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -LEFT JOIN "Addresses" AS "a" ON "c"."AddressId" = "a"."Id" -``` - -**With transformer** (null checks stripped -- applied by `UseExpressives()`): - -```sql -SELECT "a"."City" -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -LEFT JOIN "Addresses" AS "a" ON "c"."AddressId" = "a"."Id" +Customer.Country ``` -The second form is cleaner and produces identical results because the `LEFT JOIN` already handles the null case -- if there is no matching customer or address, the column value is `NULL`. +The tabs above reflect the transformer-applied output for SQL providers (null checks stripped); client-side or non-SQL providers preserve the faithful ternary. ## When to Keep the Null Checks @@ -146,4 +139,4 @@ In these cases, do not apply `RemoveNullConditionalPatterns`, and the faithful t | Explicit null checks | `Rewrite` mode on the attribute | Default behavior (always faithful) | | Global control | Not available | `UseExpressives()` applies transformer globally | -The ExpressiveSharp approach is simpler: write `?.` naturally, and the right thing happens based on whether you are targeting a SQL database (transformer strips null checks) or not (ternaries preserved). +The ExpressiveSharp approach is simpler: write `?.` naturally, and the right thing happens based on whether your provider handles NULL propagation (transformer strips null checks) or not (ternaries preserved). diff --git a/docs/reference/pattern-matching.md b/docs/reference/pattern-matching.md index 2691788..a3818af 100644 --- a/docs/reference/pattern-matching.md +++ b/docs/reference/pattern-matching.md @@ -1,6 +1,6 @@ # Pattern Matching -The ExpressiveSharp source generator rewrites C# pattern-matching constructs into expression-tree-compatible ternary and binary expressions. LINQ providers like EF Core translate these into SQL `CASE` expressions. +The ExpressiveSharp source generator rewrites C# pattern-matching constructs into expression-tree-compatible ternary and binary expressions. LINQ providers translate these into their native conditional syntax (SQL `CASE`, MongoDB `$cond`/`$switch`, etc.). ## Supported Patterns @@ -24,64 +24,67 @@ The ExpressiveSharp source generator rewrites C# pattern-matching constructs int ### Relational `and` / `or` -```csharp -// Range check -[Expressive] -public bool IsInRange => Value is >= 1 and <= 100; -``` - -Generated expression: - -```csharp -Value >= 1 && Value <= 100 -``` +A range check using an `[Expressive]` helper. The tabs show how each provider translates it. -```csharp -// Alternative values -[Expressive] -public bool IsEdge => Value is 0 or 100; -``` +::: expressive-sample +db.Products.Where(p => p.IsReasonablyPriced()).Select(p => p.Name) +---setup--- +public static class ProductExt +{ + [Expressive] + public static bool IsReasonablyPriced(this Product p) => p.ListPrice is >= 1m and <= 100m; +} +::: -Generated expression: +Alternative values with `or`: -```csharp -Value == 0 || Value == 100 -``` +::: expressive-sample +db.Orders.Where(o => o.IsBoundary()).Select(o => o.Id) +---setup--- +public static class OrderExt +{ + [Expressive] + public static bool IsBoundary(this Order o) => o.Items.Count is 0 or 100; +} +::: ### `not null` / `not` -```csharp -[Expressive] -public bool HasName => Name is not null; -``` - -Generated expression: - -```csharp -!(Name == null) -``` +::: expressive-sample +db.Customers.Where(c => c.HasEmail()).Select(c => c.Name) +---setup--- +public static class CustomerExt +{ + [Expressive] + public static bool HasEmail(this Customer c) => c.Email is not null; +} +::: ### Property Patterns -```csharp -[Expressive] -public static bool IsActiveAndPositive(this Entity entity) => - entity is { IsActive: true, Value: > 0 }; -``` - -Generated expression: - -```csharp -entity != null && entity.IsActive == true && entity.Value > 0 -``` +::: expressive-sample +db.Orders.Where(o => o.IsLargePaid()).Select(o => o.Id) +---setup--- +public static class OrderExt +{ + [Expressive] + public static bool IsLargePaid(this Order o) => + o is { Status: ExpressiveSharp.Docs.PlaygroundModel.Webshop.OrderStatus.Paid, Items.Count: > 5 }; +} +::: Property patterns can be nested: -```csharp -[Expressive] -public bool HasValidCustomer => - Customer is { Name: not null, Address: { City: not null } }; -``` +::: expressive-sample +db.Orders.Where(o => o.HasNamedCustomer()).Select(o => o.Id) +---setup--- +public static class OrderExt +{ + [Expressive] + public static bool HasNamedCustomer(this Order o) => + o is { Customer: { Name: not null, Country: not null } }; +} +::: ### Positional / Deconstruct Patterns @@ -115,76 +118,57 @@ Switch expressions are the most common use of pattern matching in `[Expressive]` ### Relational and Constant Patterns -```csharp -[Expressive] -public string GetGrade() => Score switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Grade = p.GetGrade() }) +---setup--- +public static class ProductExt { - >= 90 => "A", - >= 80 => "B", - >= 70 => "C", - _ => "F", -}; -``` - -Generated expression: - -```csharp -Score >= 90 ? "A" -: Score >= 80 ? "B" -: Score >= 70 ? "C" -: "F" -``` - -EF Core translates this to: + [Expressive] + public static string GetGrade(this Product p) => p.ListPrice switch + { + >= 500m => "A", + >= 100m => "B", + >= 20m => "C", + _ => "F", + }; +} +::: -```sql -SELECT CASE - WHEN "e"."Score" >= 90 THEN 'A' - WHEN "e"."Score" >= 80 THEN 'B' - WHEN "e"."Score" >= 70 THEN 'C' - ELSE 'F' -END -``` +The tabs above show how each provider renders the generated ternary chain. ### `and` / `or` Combined Patterns -```csharp -[Expressive] -public string GetBand() => Score switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Band = p.GetBand() }) +---setup--- +public static class ProductExt { - >= 90 and <= 100 => "Excellent", - >= 70 and < 90 => "Good", - _ => "Poor", -}; -``` - -Generated expression: - -```csharp -(Score >= 90 && Score <= 100) ? "Excellent" -: (Score >= 70 && Score < 90) ? "Good" -: "Poor" -``` + [Expressive] + public static string GetBand(this Product p) => p.StockQuantity switch + { + >= 90 and <= 100 => "Excellent", + >= 70 and < 90 => "Good", + _ => "Poor", + }; +} +::: ### `when` Guards -```csharp -[Expressive] -public string Classify() => Value switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Class = p.Classify() }) +---setup--- +public static class ProductExt { - 4 when IsSpecial => "Special Four", - 4 => "Regular Four", - _ => "Other", -}; -``` - -Generated expression: - -```csharp -(Value == 4 && IsSpecial) ? "Special Four" -: Value == 4 ? "Regular Four" -: "Other" -``` + [Expressive] + public static string Classify(this Product p) => p.StockQuantity switch + { + 4 when p.Category == "Special" => "Special Four", + 4 => "Regular Four", + _ => "Other", + }; +} +::: ### Type Patterns with Declaration Variables @@ -208,34 +192,25 @@ Declaration variables (the `c` and `r` in the example above) are supported in sw Patterns can be nested arbitrarily: -```csharp -[Expressive] -public string ClassifyOrder() => this switch +::: expressive-sample +db.Orders.Select(o => new { o.Id, Tag = o.ClassifyOrder() }) +---setup--- +public static class OrderExt { - { Customer: { Tier: CustomerTier.Premium }, Total: >= 100 } => "VIP Order", - { Customer: not null, Total: >= 50 } => "Standard Order", - _ => "Basic Order", -}; -``` - -## SQL Generation - -All pattern-matching constructs compile down to nested `CASE` expressions in SQL. The generator produces a chain of conditional (ternary) expressions, which EF Core maps directly to SQL `CASE WHEN ... THEN ... ELSE ... END`. + [Expressive] + public static string ClassifyOrder(this Order o) => o switch + { + { Customer.Country: "US", Items.Count: >= 10 } => "VIP Order", + { Customer: not null, Items.Count: >= 5 } => "Standard Order", + _ => "Basic Order", + }; +} +::: -For complex nested patterns, the SQL output may contain nested `CASE` expressions: +## Translation Output -```sql -SELECT CASE - WHEN "c"."Tier" = 2 AND ("o"."Price" * "o"."Quantity") >= 100.0 - THEN 'VIP Order' - WHEN "c"."Id" IS NOT NULL AND ("o"."Price" * "o"."Quantity") >= 50.0 - THEN 'Standard Order' - ELSE 'Basic Order' -END -FROM "Orders" AS "o" -LEFT JOIN "Customers" AS "c" ON "o"."CustomerId" = "c"."Id" -``` +All pattern-matching constructs compile down to nested conditional expressions. The generator produces a chain of ternaries, which each provider maps to its own conditional syntax (SQL `CASE WHEN ... THEN ... ELSE ... END`, MongoDB `$switch`/`$cond`, etc.). The tabs on the samples above let you inspect the exact translation for your target. ::: warning -Keep patterns reasonably simple for SQL translation. Very deeply nested patterns produce complex SQL that may be harder to debug and could impact query performance. +Keep patterns reasonably simple for translation. Very deeply nested patterns produce complex output that may be harder to debug and could impact query performance. ::: diff --git a/docs/reference/switch-expressions.md b/docs/reference/switch-expressions.md index 5da3de9..79a6f00 100644 --- a/docs/reference/switch-expressions.md +++ b/docs/reference/switch-expressions.md @@ -1,54 +1,47 @@ # Switch Expressions -Switch expressions are one of the most useful C# features that ExpressiveSharp enables in expression trees. They are translated to nested ternary expressions at compile time, which LINQ providers like EF Core map to SQL `CASE` expressions. +Switch expressions are one of the most useful C# features that ExpressiveSharp enables in expression trees. They are translated to nested ternary expressions at compile time, which LINQ providers map to their native conditional forms (SQL `CASE`, MongoDB `$switch`, etc.). ## Basic Syntax Mark any property or method with `[Expressive]` and use a switch expression in the body: -```csharp -[Expressive] -public string GetGrade() => Price switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Tier = p.GetTier() }) +---setup--- +public static class ProductExt { - >= 100 => "Premium", - >= 50 => "Standard", - _ => "Budget", -}; -``` - -The source generator produces a chain of conditional expressions: - -```csharp -Price >= 100 ? "Premium" -: Price >= 50 ? "Standard" -: "Budget" -``` - -EF Core translates this to: + [Expressive] + public static string GetTier(this Product p) => p.ListPrice switch + { + >= 100m => "Premium", + >= 50m => "Standard", + _ => "Budget", + }; +} +::: -```sql -SELECT CASE - WHEN "o"."Price" >= 100.0 THEN 'Premium' - WHEN "o"."Price" >= 50.0 THEN 'Standard' - ELSE 'Budget' -END AS "Grade" -FROM "Orders" AS "o" -``` +The source generator produces a chain of conditional expressions that each provider renders in its own dialect (see the tabs above). ## Relational Patterns Relational operators (`<`, `<=`, `>`, `>=`) work in switch arms: -```csharp -[Expressive] -public string PriceCategory => Price switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Category = p.PriceCategory() }) +---setup--- +public static class ProductExt { - < 10 => "Cheap", - < 50 => "Moderate", - < 100 => "Expensive", - >= 100 => "Premium", -}; -``` + [Expressive] + public static string PriceCategory(this Product p) => p.ListPrice switch + { + < 10m => "Cheap", + < 50m => "Moderate", + < 100m => "Expensive", + >= 100m => "Premium", + }; +} +::: ::: warning Without a discard arm (`_`), the generated expression has no fallback. If no arm matches at runtime, a `SwitchExpressionException` would be thrown in C#. In SQL, the result is `NULL` (the `ELSE` clause is omitted). Always include a discard arm for safety. @@ -58,64 +51,59 @@ Without a discard arm (`_`), the generated expression has no fallback. If no arm Combine patterns with `and` and `or` for range checks and alternatives: -```csharp -[Expressive] -public string GetBand() => Score switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Band = p.GetBand() }) +---setup--- +public static class ProductExt { - >= 90 and <= 100 => "Excellent", - >= 70 and < 90 => "Good", - >= 50 and < 70 => "Average", - _ => "Poor", -}; -``` - -Generated SQL: - -```sql -CASE - WHEN "s"."Score" >= 90 AND "s"."Score" <= 100 THEN 'Excellent' - WHEN "s"."Score" >= 70 AND "s"."Score" < 90 THEN 'Good' - WHEN "s"."Score" >= 50 AND "s"."Score" < 70 THEN 'Average' - ELSE 'Poor' -END -``` + [Expressive] + public static string GetBand(this Product p) => p.StockQuantity switch + { + >= 90 and <= 100 => "Excellent", + >= 70 and < 90 => "Good", + >= 50 and < 70 => "Average", + _ => "Poor", + }; +} +::: Using `or` for alternative values: -```csharp -[Expressive] -public string GetDayType() => DayOfWeek switch +::: expressive-sample +db.Orders.Select(o => new { o.Id, Type = o.GetDayType() }) +---setup--- +public static class OrderExt { - 0 or 6 => "Weekend", - _ => "Weekday", -}; -``` + [Expressive] + public static string GetDayType(this Order o) => (int)o.PlacedAt.DayOfWeek switch + { + 0 or 6 => "Weekend", + _ => "Weekday", + }; +} +::: ## `when` Guards Guards add additional boolean conditions to switch arms: -```csharp -[Expressive] -public string Classify() => Quantity switch +::: expressive-sample +db.LineItems.Select(i => new { i.Id, Tag = i.Classify() }) +---setup--- +public static class LineItemExt { - > 100 when Price < 10 => "Bulk Bargain", - > 100 => "Bulk Order", - > 0 => "Standard", - _ => "Empty", -}; -``` - -Generated expression: - -```csharp -(Quantity > 100 && Price < 10) ? "Bulk Bargain" -: Quantity > 100 ? "Bulk Order" -: Quantity > 0 ? "Standard" -: "Empty" -``` + [Expressive] + public static string Classify(this LineItem i) => i.Quantity switch + { + > 100 when i.UnitPrice < 10m => "Bulk Bargain", + > 100 => "Bulk Order", + > 0 => "Standard", + _ => "Empty", + }; +} +::: -The guard condition is combined with the pattern using `&&` in the generated expression. +The guard condition is combined with the pattern using `&&` in the generated expression, which each provider renders as part of its conditional form. ## Type Patterns with Declaration Variables @@ -147,87 +135,88 @@ Declaration variables work within switch arms. The generated expression binds th Match against specific constant values: -```csharp -[Expressive] -public string StatusLabel => StatusCode switch +::: expressive-sample +db.Orders.Select(o => new { o.Id, Label = o.StatusLabel() }) +---setup--- +public static class OrderExt { - 0 => "Pending", - 1 => "Active", - 2 => "Completed", - 3 => "Cancelled", - _ => "Unknown", -}; -``` + [Expressive] + public static string StatusLabel(this Order o) => (int)o.Status switch + { + 0 => "Pending", + 1 => "Paid", + 2 => "Shipped", + 3 => "Delivered", + 4 => "Refunded", + _ => "Unknown", + }; +} +::: ## Nested Switch Expressions Switch expressions can be nested for multi-dimensional classification: -```csharp -[Expressive] -public string GetPriority() => Category switch +::: expressive-sample +db.Products.Select(p => new { p.Name, Priority = p.GetPriority() }) +---setup--- +public static class ProductExt { - "Electronics" => Price switch + [Expressive] + public static string GetPriority(this Product p) => p.Category switch { - >= 500 => "High", - >= 100 => "Medium", - _ => "Low", - }, - "Food" => "Standard", - _ => "Default", -}; -``` - -Generated SQL: - -```sql -CASE - WHEN "o"."Category" = 'Electronics' THEN - CASE - WHEN "o"."Price" >= 500.0 THEN 'High' - WHEN "o"."Price" >= 100.0 THEN 'Medium' - ELSE 'Low' - END - WHEN "o"."Category" = 'Food' THEN 'Standard' - ELSE 'Default' -END -``` + "Electronics" => p.ListPrice switch + { + >= 500m => "High", + >= 100m => "Medium", + _ => "Low", + }, + "Food" => "Standard", + _ => "Default", + }; +} +::: ## Property Patterns in Switch Arms Match against an object's properties: -```csharp -[Expressive] -public string ClassifyOrder() => this switch +::: expressive-sample +db.LineItems.Select(i => new { i.Id, Tag = i.ClassifyItem() }) +---setup--- +public static class LineItemExt { - { Quantity: > 100, Price: >= 50 } => "Large Premium", - { Quantity: > 100 } => "Large Standard", - { Price: >= 50 } => "Small Premium", - _ => "Small Standard", -}; -``` + [Expressive] + public static string ClassifyItem(this LineItem i) => i switch + { + { Quantity: > 100, UnitPrice: >= 50m } => "Large Premium", + { Quantity: > 100 } => "Large Standard", + { UnitPrice: >= 50m } => "Small Premium", + _ => "Small Standard", + }; +} +::: -## SQL `CASE` Expression Output +## Pattern-to-Condition Cheat Sheet -All switch expressions map to SQL `CASE` expressions. Here is a summary of how different patterns translate: +All switch expressions map to conditional expressions in the target language (SQL `CASE`, MongoDB `$switch`, etc.). Here is a summary of how different patterns translate at the C# layer: -| C# Pattern | SQL Condition | -|-------------|---------------| -| `>= 100` | `WHEN col >= 100` | -| `>= 80 and < 90` | `WHEN col >= 80 AND col < 90` | -| `1 or 2` | `WHEN col = 1 OR col = 2` | -| `"Premium"` | `WHEN col = 'Premium'` | -| `_ (discard)` | `ELSE` | -| `> 50 when Flag` | `WHEN col > 50 AND flag = 1` | +| C# Pattern | Generated Condition | +|-------------|---------------------| +| `>= 100` | `col >= 100` | +| `>= 80 and < 90` | `col >= 80 && col < 90` | +| `1 or 2` | `col == 1 \|\| col == 2` | +| `"Premium"` | `col == "Premium"` | +| `_ (discard)` | fallback (`else`) branch | +| `> 50 when Flag` | `col > 50 && Flag` | ## Best Practices -1. **Always include a discard arm** (`_`) to ensure the `CASE` expression has an `ELSE` clause. +1. **Always include a discard arm** (`_`) to ensure the conditional has a fallback branch. -2. **Keep arms simple** for SQL translation. Each arm's pattern and result should be a simple expression. Avoid calling methods that cannot be translated to SQL. +2. **Keep arms simple** for translation. Each arm's pattern and result should be a simple expression. Avoid calling methods that cannot be translated by your provider. -3. **Order arms from most specific to least specific**, just as you would in C#. The generated ternary chain evaluates top-to-bottom, matching the SQL `CASE WHEN` evaluation order. +3. **Order arms from most specific to least specific**, just as you would in C#. The generated ternary chain evaluates top-to-bottom, matching the provider's conditional evaluation order. 4. **Prefer switch expressions over nested ternaries** for readability. The source generator produces ternary chains regardless, but the switch expression in your source code is easier to read and maintain. diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index c0ca13a..b2bbee1 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -195,7 +195,7 @@ services.AddDbContext(options => .UseExpressives()); ``` -See [EF Core Integration](../guide/ef-core-integration) for the full setup guide. +See [EF Core Integration](../guid./integrations/ef-core) for the full setup guide. --- diff --git a/src/Docs/Playground.Core/ExpressiveSharp.Docs.Playground.Core.csproj b/src/Docs/Playground.Core/ExpressiveSharp.Docs.Playground.Core.csproj new file mode 100644 index 0000000..9794c59 --- /dev/null +++ b/src/Docs/Playground.Core/ExpressiveSharp.Docs.Playground.Core.csproj @@ -0,0 +1,34 @@ + + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Docs/Playground.Core/Services/IPlaygroundReferences.cs b/src/Docs/Playground.Core/Services/IPlaygroundReferences.cs new file mode 100644 index 0000000..82c58c6 --- /dev/null +++ b/src/Docs/Playground.Core/Services/IPlaygroundReferences.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Docs.Playground.Core.Services; + +public interface IPlaygroundReferences +{ + ImmutableArray References { get; } + bool IsLoaded { get; } +} diff --git a/src/Docs/Playground.Core/Services/Scenarios/IPlaygroundScenario.cs b/src/Docs/Playground.Core/Services/Scenarios/IPlaygroundScenario.cs new file mode 100644 index 0000000..550c69f --- /dev/null +++ b/src/Docs/Playground.Core/Services/Scenarios/IPlaygroundScenario.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; + +public interface IPlaygroundScenario +{ + string Id { get; } + string Title { get; } + string DefaultSnippet { get; } + string? DefaultSetup { get; } + string WrapperTemplate { get; } + IReadOnlyList ReferenceAssemblies { get; } + IReadOnlyList RenderTargets { get; } + IScenarioInstance CreateInstance(); +} diff --git a/src/Docs/Playground.Core/Services/Scenarios/IScenarioInstance.cs b/src/Docs/Playground.Core/Services/Scenarios/IScenarioInstance.cs new file mode 100644 index 0000000..98ec8a4 --- /dev/null +++ b/src/Docs/Playground.Core/Services/Scenarios/IScenarioInstance.cs @@ -0,0 +1,6 @@ +namespace ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; + +public interface IScenarioInstance : IAsyncDisposable +{ + object QueryArgument { get; } +} diff --git a/src/Docs/Playground.Core/Services/Scenarios/ScenarioRegistry.cs b/src/Docs/Playground.Core/Services/Scenarios/ScenarioRegistry.cs new file mode 100644 index 0000000..eaf0174 --- /dev/null +++ b/src/Docs/Playground.Core/Services/Scenarios/ScenarioRegistry.cs @@ -0,0 +1,21 @@ +namespace ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; + +public static class ScenarioRegistry +{ + public static readonly IPlaygroundScenario Webshop = new WebshopScenario(); + + public static readonly IReadOnlyList All = new[] { Webshop }; + + public static IPlaygroundScenario Default => Webshop; + + public static IPlaygroundScenario Resolve(string? id) + { + if (string.IsNullOrEmpty(id)) return Default; + foreach (var scenario in All) + { + if (scenario.Id.Equals(id, StringComparison.OrdinalIgnoreCase)) + return scenario; + } + return Default; + } +} diff --git a/src/Docs/Playground.Core/Services/Scenarios/ScenarioRenderTarget.cs b/src/Docs/Playground.Core/Services/Scenarios/ScenarioRenderTarget.cs new file mode 100644 index 0000000..b79295c --- /dev/null +++ b/src/Docs/Playground.Core/Services/Scenarios/ScenarioRenderTarget.cs @@ -0,0 +1,11 @@ +namespace ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; + +public sealed record ScenarioRenderTarget( + string Id, + string Label, + string OutputLanguage, + Func Render) +{ + public Func? GetQueryArgument { get; init; } + public IReadOnlyList? LazyLoadAssemblies { get; init; } +} diff --git a/src/Docs/Playground.Core/Services/Scenarios/WebshopScenario.cs b/src/Docs/Playground.Core/Services/Scenarios/WebshopScenario.cs new file mode 100644 index 0000000..b58600f --- /dev/null +++ b/src/Docs/Playground.Core/Services/Scenarios/WebshopScenario.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using ExpressiveSharp; +using ExpressiveSharp.Docs.PlaygroundModel.Webshop; +using ExpressiveSharp.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; + +public sealed class WebshopScenario : IPlaygroundScenario +{ + public string Id => "webshop"; + + public string Title => "Web shop"; + + public string DefaultSnippet => + "db.Customers.Where(x => x.Email != null && x.Email.Length > 5).Select(x => x.Email)"; + + public string? DefaultSetup => null; + + public string WrapperTemplate => """ + // + #nullable enable + namespace ExpressiveSharp.Docs.Playground.Snippet; + + using System; + using System.Linq; + using System.Linq.Expressions; + using ExpressiveSharp; + using ExpressiveSharp.EntityFrameworkCore; + using ExpressiveSharp.Docs.PlaygroundModel.Webshop; + + public static class __Snippet + { + public static IQueryable Run(IWebshopQueryRoots db) + => /*__SNIPPET__*/; + } + + /*__SETUP__*/ + """; + + public IReadOnlyList ReferenceAssemblies { get; } = new[] + { + typeof(IExpressiveQueryable<>).Assembly, + typeof(ExpressiveAttribute).Assembly, + typeof(ExpressiveDbSet<>).Assembly, + typeof(DbContext).Assembly, + typeof(RelationalQueryableExtensions).Assembly, + typeof(WebshopDbContext).Assembly, + }; + + public IReadOnlyList RenderTargets { get; } = new[] + { + new ScenarioRenderTarget( + Id: "sqlite", + Label: "EF Core + SQLite", + OutputLanguage: "sql", + Render: static (queryable, _) => queryable.ToQueryString()) + { + GetQueryArgument = static instance => ((WebshopScenarioInstance)instance).SqliteRoots, + }, + + new ScenarioRenderTarget( + Id: "postgres", + Label: "EF Core + PostgreSQL", + OutputLanguage: "sql", + Render: static (queryable, _) => queryable.ToQueryString()) + { + GetQueryArgument = static instance => ((WebshopScenarioInstance)instance).PostgresRoots, + LazyLoadAssemblies = new[] + { + "Npgsql.dll", + "Npgsql.EntityFrameworkCore.PostgreSQL.dll", + }, + }, + }; + + public IScenarioInstance CreateInstance() => new WebshopScenarioInstance(); +} diff --git a/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs b/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs new file mode 100644 index 0000000..501f965 --- /dev/null +++ b/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs @@ -0,0 +1,59 @@ +using ExpressiveSharp; +using ExpressiveSharp.Docs.PlaygroundModel.Webshop; +using ExpressiveSharp.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; + +public sealed class WebshopScenarioInstance : IScenarioInstance +{ + private readonly WebshopDbContext _sqlite; + private WebshopDbContext? _postgres; + + public WebshopScenarioInstance() + { + _sqlite = new WebshopDbContext( + new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .UseExpressives() + // Disable EF Core's process-wide internal service provider cache. + // Without this, failing queries in one sample can cache bad + // compiled-query state that breaks later samples with seemingly + // unrelated errors (e.g., "'System.Object' cannot be used for + // return type 'System.String'"). + .EnableServiceProviderCaching(false) + .Options); + } + + public IWebshopQueryRoots SqliteRoots => new DbContextRoots(_sqlite); + + public IWebshopQueryRoots PostgresRoots + => new DbContextRoots(_postgres ??= BuildPostgresContext()); + + private static WebshopDbContext BuildPostgresContext() => + new(new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=playground;Username=postgres;Password=postgres") + .UseExpressives() + .EnableServiceProviderCaching(false) + .Options); + + public object QueryArgument => SqliteRoots; + + public async ValueTask DisposeAsync() + { + await _sqlite.DisposeAsync(); + if (_postgres is not null) + await _postgres.DisposeAsync(); + } + + // Adapts a WebshopDbContext to IWebshopQueryRoots. + private sealed class DbContextRoots : IWebshopQueryRoots + { + private readonly WebshopDbContext _ctx; + public DbContextRoots(WebshopDbContext ctx) { _ctx = ctx; } + public IExpressiveQueryable Customers => _ctx.Customers; + public IExpressiveQueryable Orders => _ctx.Orders; + public IExpressiveQueryable Products => _ctx.Products; + public IExpressiveQueryable LineItems => _ctx.LineItems; + } +} diff --git a/src/Docs/Playground.Core/Services/SnippetCompiler.cs b/src/Docs/Playground.Core/Services/SnippetCompiler.cs new file mode 100644 index 0000000..c4a5265 --- /dev/null +++ b/src/Docs/Playground.Core/Services/SnippetCompiler.cs @@ -0,0 +1,227 @@ +using System.Reflection; +using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; +using ExpressiveSharp.Generator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace ExpressiveSharp.Docs.Playground.Core.Services; + +public sealed class SnippetCompiler +{ + public const string SnippetFilePath = "/snippet/__Snippet.cs"; + public const string SnippetTypeFullName = "ExpressiveSharp.Docs.Playground.Snippet.__Snippet"; + + internal const string SnippetPlaceholder = "/*__SNIPPET__*/"; + internal const string SetupPlaceholder = "/*__SETUP__*/"; + + private readonly IPlaygroundReferences _references; + + public SnippetCompiler(IPlaygroundReferences references) + { + _references = references; + } + + public CompileResult Compile(string snippetExpression, string? setupCode, IPlaygroundScenario scenario) + { + if (!_references.IsLoaded) + throw new InvalidOperationException( + "PlaygroundReferences must be loaded before Compile is called."); + + var wrap = SnippetWrap.Build(scenario.WrapperTemplate, snippetExpression, setupCode); + + var parseOptions = CSharpParseOptions.Default + .WithLanguageVersion(LanguageVersion.CSharp13) + .WithFeatures(new[] + { + new KeyValuePair("InterceptorsNamespaces", "ExpressiveSharp.Generated.Interceptors"), + }); + + var snippetTree = CSharpSyntaxTree.ParseText( + wrap.Source, + parseOptions, + path: SnippetFilePath); + + var compilationOptions = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + optimizationLevel: OptimizationLevel.Release, + allowUnsafe: false) + .WithNullableContextOptions(NullableContextOptions.Enable) + .WithSpecificDiagnosticOptions(new Dictionary + { + ["CS1702"] = ReportDiagnostic.Suppress, + }); + + var compilation = CSharpCompilation.Create( + assemblyName: "ExpressiveSharp.Docs.Playground.Snippet_" + Guid.NewGuid().ToString("N"), + syntaxTrees: new[] { snippetTree }, + references: _references.References, + options: compilationOptions); + + var driver = CSharpGeneratorDriver + .Create(new ExpressiveGenerator(), new PolyfillInterceptorGenerator()) + .WithUpdatedParseOptions(parseOptions); + + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var updatedCompilation, + out var generatorDiagnostics); + + var generatedSources = new List(); + foreach (var runResult in driver.GetRunResult().Results) + foreach (var generatedSource in runResult.GeneratedSources) + generatedSources.Add(new GeneratedSource( + generatedSource.HintName, + generatedSource.SourceText.ToString())); + + using var peStream = new MemoryStream(); + var emitResult = updatedCompilation.Emit(peStream); + + var diagnostics = generatorDiagnostics + .Concat(emitResult.Diagnostics) + .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning) + .Select(SnippetDiagnostic.From) + .ToList(); + + if (!emitResult.Success) + { + return new CompileResult(false, null, generatedSources, diagnostics, wrap); + } + + peStream.Position = 0; + var assembly = Assembly.Load(peStream.ToArray()); + return new CompileResult(true, assembly, generatedSources, diagnostics, wrap); + } +} + +public sealed class SnippetWrap +{ + public string Source { get; } + public LinePosition SnippetOrigin { get; } + public LinePosition SnippetEnd { get; } + public LinePosition? SetupOrigin { get; } + public LinePosition? SetupEnd { get; } + + private SnippetWrap(string source, LinePosition snippetOrigin, LinePosition snippetEnd, LinePosition? setupOrigin, LinePosition? setupEnd) + { + Source = source; + SnippetOrigin = snippetOrigin; + SnippetEnd = snippetEnd; + SetupOrigin = setupOrigin; + SetupEnd = setupEnd; + } + + public static SnippetWrap Build(string template, string snippetExpression, string? setupCode) + { + var afterSetup = template.Replace(SnippetCompiler.SetupPlaceholder, setupCode ?? ""); + var setupRange = ComputeSubstitutionRange(template, SnippetCompiler.SetupPlaceholder, setupCode ?? ""); + + var afterBoth = afterSetup.Replace(SnippetCompiler.SnippetPlaceholder, snippetExpression); + var snippetRange = ComputeSubstitutionRange(afterSetup, SnippetCompiler.SnippetPlaceholder, snippetExpression); + + return new SnippetWrap( + source: afterBoth, + snippetOrigin: snippetRange.start, + snippetEnd: snippetRange.end, + setupOrigin: string.IsNullOrEmpty(setupCode) ? null : setupRange.start, + setupEnd: string.IsNullOrEmpty(setupCode) ? null : setupRange.end); + } + + private static (LinePosition start, LinePosition end) ComputeSubstitutionRange( + string haystack, + string placeholder, + string replacement) + { + var idx = haystack.IndexOf(placeholder, StringComparison.Ordinal); + if (idx < 0) + { + return (new LinePosition(int.MaxValue, 0), new LinePosition(int.MaxValue, 0)); + } + var start = OffsetToLinePosition(haystack, idx); + var end = OffsetToLinePosition(replacement, replacement.Length, baseLine: start.Line, baseColumn: start.Character); + return (start, end); + } + + private static LinePosition OffsetToLinePosition(string text, int offset, int baseLine = 0, int baseColumn = 0) + { + var line = baseLine; + var col = baseColumn; + for (var i = 0; i < offset && i < text.Length; i++) + { + if (text[i] == '\n') + { + line++; + col = 0; + } + else if (text[i] != '\r') + { + col++; + } + } + return new LinePosition(line, col); + } + + public bool IsInSnippet(LinePosition position) => + IsBetween(position, SnippetOrigin, SnippetEnd); + + public bool IsInSetup(LinePosition position) => + SetupOrigin is { } start && SetupEnd is { } end && IsBetween(position, start, end); + + private static bool IsBetween(LinePosition position, LinePosition start, LinePosition end) + { + if (position.Line < start.Line || position.Line > end.Line) return false; + if (position.Line == start.Line && position.Character < start.Character) return false; + if (position.Line == end.Line && position.Character > end.Character) return false; + return true; + } + + public LinePosition ToSnippetRelative(LinePosition wrapped) + { + var line = wrapped.Line - SnippetOrigin.Line; + var col = wrapped.Line == SnippetOrigin.Line + ? wrapped.Character - SnippetOrigin.Character + : wrapped.Character; + return new LinePosition(Math.Max(0, line), Math.Max(0, col)); + } + + public LinePosition ToWrapped(LinePosition snippetRelative) + { + var line = snippetRelative.Line + SnippetOrigin.Line; + var col = snippetRelative.Line == 0 + ? snippetRelative.Character + SnippetOrigin.Character + : snippetRelative.Character; + return new LinePosition(line, col); + } +} + +public sealed record CompileResult( + bool Success, + Assembly? Assembly, + IReadOnlyList GeneratedSources, + IReadOnlyList Diagnostics, + SnippetWrap Wrap); + +public sealed record GeneratedSource(string HintName, string Source); + +public sealed record SnippetDiagnostic( + DiagnosticSeverity Severity, + string Id, + string Message, + LinePositionSpan? Span, + bool IsInSource) +{ + public static SnippetDiagnostic From(Diagnostic d) => new( + d.Severity, + d.Id, + d.GetMessage(), + d.Location.IsInSource ? d.Location.GetLineSpan().Span : null, + d.Location.IsInSource); + + public override string ToString() + { + var loc = Span is { } s + ? $" @ ({s.Start.Line + 1},{s.Start.Character + 1})-({s.End.Line + 1},{s.End.Character + 1})" + : ""; + return $"{Severity} {Id}: {Message}{loc}"; + } +} diff --git a/src/Docs/Playground.Core/Services/SnippetFormatter.cs b/src/Docs/Playground.Core/Services/SnippetFormatter.cs new file mode 100644 index 0000000..5a4b96c --- /dev/null +++ b/src/Docs/Playground.Core/Services/SnippetFormatter.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ExpressiveSharp.Docs.Playground.Core.Services; + +public static class SnippetFormatter +{ + public static string Format(string snippet) + { + if (string.IsNullOrWhiteSpace(snippet)) return snippet; + + try + { + var expr = SyntaxFactory.ParseExpression(snippet); + + foreach (var diag in expr.GetDiagnostics()) + if (diag.Severity == DiagnosticSeverity.Error) + return snippet; + + var dotsToBreak = new List(); + ExpressionSyntax cursor = expr; + while (true) + { + if (cursor is InvocationExpressionSyntax invocation) + { + cursor = invocation.Expression; + continue; + } + if (cursor is MemberAccessExpressionSyntax member) + { + dotsToBreak.Add(member.OperatorToken); + cursor = member.Expression; + continue; + } + break; + } + + if (dotsToBreak.Count < 2) return snippet; + + var newline = SyntaxFactory.EndOfLine("\n"); + var indent = SyntaxFactory.Whitespace(" "); + var rewritten = expr.ReplaceTokens(dotsToBreak, (oldToken, _) => + oldToken.WithLeadingTrivia( + oldToken.LeadingTrivia.Add(newline).Add(indent))); + + return rewritten.ToFullString(); + } + catch + { + return snippet; + } + } +} diff --git a/src/Docs/Playground.Wasm/Components/PlaygroundHost.razor b/src/Docs/Playground.Wasm/Components/PlaygroundHost.razor new file mode 100644 index 0000000..5de0f4e --- /dev/null +++ b/src/Docs/Playground.Wasm/Components/PlaygroundHost.razor @@ -0,0 +1,365 @@ +@using ExpressiveSharp.Docs.Playground.Wasm.Services +@using ExpressiveSharp.Docs.Playground.Core.Services +@using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios +@using Microsoft.JSInterop +@implements IDisposable +@inject PlaygroundRuntime Runtime +@inject IJSRuntime JS + +@if (!_initialized) +{ +
+ + Loading Roslyn + EF Core in your browser… +
+ @if (!string.IsNullOrEmpty(_initError)) + { +
@_initError
+ } +} +else +{ +
+
+ + + @if (_running) + { + Compiling… + } +
+ +
+
+
+
+
+
+
+
+ + @if (_result is not null && !_result.Success) + { +
+ Error + @if (!string.IsNullOrEmpty(_result.ErrorMessage)) + { +
@_result.ErrorMessage
+ } + @if (_result.Diagnostics.Count > 0) + { +
    + @foreach (var d in _result.Diagnostics) + { +
  • @d.ToString()
  • + } +
+ } +
+ } + + @if (_result is not null && _result.SetupErrorMessages.Count > 0) + { +
+ Setup errors +
    + @foreach (var msg in _result.SetupErrorMessages) + { +
  • @msg
  • + } +
+
+ } +
+} + +@code { + [Parameter] public string? Scenario { get; set; } + [Parameter] public string? Snippet { get; set; } + [Parameter] public string? Setup { get; set; } + [Parameter] public string? Target { get; set; } + + private bool _initialized; + private string? _initError; + private bool _running; + private Task? _initTask; + + private readonly string _componentId = Guid.NewGuid().ToString("N"); + private string _editorElementId => $"playground-editor-{_componentId}"; + private string _outputElementId => $"playground-output-{_componentId}"; + + private bool _editorReady; + private bool _outputEditorReady; + private IReadOnlyList _pendingMarkers = Array.Empty(); + private string? _pendingOutputText; + private string? _pendingOutputLanguage; + private string? _lastPushedLanguage; + private string? _lastBoundSnippet; + private string? _modelUri; + private DotNetObjectReference? _dotnetRef; + + private IPlaygroundScenario _scenario = ScenarioRegistry.Default; + private string _snippet = ScenarioRegistry.Default.DefaultSnippet; + private string? _setup = ScenarioRegistry.Default.DefaultSetup; + private string _targetId = ScenarioRegistry.Default.RenderTargets.Count > 0 + ? ScenarioRegistry.Default.RenderTargets[0].Id + : PlaygroundRuntime.GeneratorTargetId; + private RenderResult? _result; + + private CancellationTokenSource? _debounceCts; + private bool _pendingRun; + private const int DebounceMs = 300; + + protected override async Task OnParametersSetAsync() + { + _scenario = ScenarioRegistry.Resolve(Scenario); + + var resolvedSnippet = !string.IsNullOrEmpty(Snippet) ? Snippet! : _scenario.DefaultSnippet; + if (resolvedSnippet != _lastBoundSnippet) + { + _snippet = SnippetFormatter.Format(resolvedSnippet); + _lastBoundSnippet = resolvedSnippet; + if (_editorReady) + await JS.InvokeVoidAsync("monacoInterop.setValue", _editorElementId, _snippet); + } + + _setup = !string.IsNullOrEmpty(Setup) ? Setup : _scenario.DefaultSetup; + _targetId = ResolveInitialTargetId(); + + _initTask ??= InitializeRuntimeAsync(); + await _initTask; + + if (_initialized) + await RunAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_editorReady || !_initialized) return; + + _dotnetRef = DotNetObjectReference.Create(this); + + // Create input editor + _modelUri = await JS.InvokeAsync("monacoInterop.create", _editorElementId, + new MonacoEditorOptions + { + Language = "csharp", + Value = _snippet, + FontSize = 13, + LineNumbers = "off", + LineDecorationsWidth = 4, + WordWrap = "on", + Scrollbar = new MonacoScrollbarOptions { AlwaysConsumeMouseWheel = false }, + }, + _dotnetRef); + _editorReady = true; + + // Register with language services + if (_modelUri is not null) + await Runtime.LanguageServices.RegisterEditorAsync(_modelUri, _snippet, _setup, _scenario); + + // Flush pending markers + if (_pendingMarkers.Count > 0) + { + var pending = _pendingMarkers; + _pendingMarkers = Array.Empty(); + await PushSnippetMarkersAsync(pending); + } + + // Create output editor + await JS.InvokeAsync("monacoInterop.create", _outputElementId, + new MonacoEditorOptions + { + Language = ResolveOutputLanguage(), + Value = "", + ReadOnly = true, + FontSize = 13, + LineNumbers = "off", + LineDecorationsWidth = 4, + WordWrap = "on", + RenderLineHighlight = "none", + Scrollbar = new MonacoScrollbarOptions + { + AlwaysConsumeMouseWheel = true, + Vertical = "visible", + Horizontal = "auto", + VerticalScrollbarSize = 12, + HorizontalScrollbarSize = 12, + }, + }, + null); + _outputEditorReady = true; + + // Flush pending output + if (_pendingOutputText is not null) + { + var text = _pendingOutputText; + var lang = _pendingOutputLanguage; + _pendingOutputText = null; + _pendingOutputLanguage = null; + await PushOutputAsync(text, lang); + } + } + + [JSInvokable] + public async Task OnContentChanged() + { + if (!_editorReady) return; + + _debounceCts?.Cancel(); + var cts = new CancellationTokenSource(); + _debounceCts = cts; + + var liveText = await JS.InvokeAsync("monacoInterop.getValue", _editorElementId); + if (_modelUri is not null && _initialized) + _ = Runtime.LanguageServices.UpdateEditorAsync(_modelUri, liveText, _setup, _scenario); + + try + { + await Task.Delay(DebounceMs, cts.Token); + } + catch (TaskCanceledException) + { + return; + } + + _snippet = liveText; + await RunAsync(); + } + + private string ResolveInitialTargetId() + { + var shared = Runtime.SharedTargetId; + if (shared is not null && IsValidTargetForScenario(shared)) + return shared; + + if (!string.IsNullOrEmpty(Target) && IsValidTargetForScenario(Target!)) + return Target!; + + if (_scenario.RenderTargets.Count > 0) + return _scenario.RenderTargets[0].Id; + + return PlaygroundRuntime.GeneratorTargetId; + } + + private bool IsValidTargetForScenario(string targetId) + { + if (targetId == PlaygroundRuntime.GeneratorTargetId) return true; + foreach (var t in _scenario.RenderTargets) + if (t.Id == targetId) return true; + return false; + } + + private async Task InitializeRuntimeAsync() + { + try + { + await Runtime.InitializeAsync(); + _initialized = true; + } + catch (Exception ex) + { + _initError = ex.GetType().Name + ": " + ex.Message; + } + } + + private async Task OnTargetSelected() + { + Runtime.SetSharedTargetId(_targetId); + await JS.InvokeVoidAsync("expressivePlayground.broadcastTarget", _targetId); + + // Re-run with the new target to update output + if (_initialized) + await RunAsync(); + } + + private string MarkerOwner => $"expressive-{_componentId}"; + + private string ResolveOutputLanguage() + { + if (_targetId == PlaygroundRuntime.GeneratorTargetId) return "csharp"; + foreach (var t in _scenario.RenderTargets) + if (t.Id == _targetId) return t.OutputLanguage; + return "plaintext"; + } + + private async Task PushOutputAsync(string text, string? language) + { + if (!_outputEditorReady) + { + _pendingOutputText = text; + _pendingOutputLanguage = language; + return; + } + + if (language is not null && language != _lastPushedLanguage) + { + await JS.InvokeVoidAsync("monacoInterop.setModelLanguage", _outputElementId, language); + _lastPushedLanguage = language; + } + await JS.InvokeVoidAsync("monacoInterop.setValue", _outputElementId, text); + } + + private async Task RunAsync() + { + if (!_initialized) return; + + if (_running) + { + _pendingRun = true; + return; + } + + _running = true; + StateHasChanged(); + try + { + do + { + _pendingRun = false; + var result = await Runtime.RunAsync(_snippet, _setup, _targetId, _scenario); + _result = result; + await PushSnippetMarkersAsync(result.SnippetMarkers); + if (result.Success && result.Output is not null) + await PushOutputAsync(result.Output, ResolveOutputLanguage()); + } while (_pendingRun); + } + finally + { + _running = false; + StateHasChanged(); + } + } + + private async Task PushSnippetMarkersAsync(IReadOnlyList markers) + { + if (!_editorReady) + { + _pendingMarkers = markers; + return; + } + var monacoMarkers = MonacoMarkerConverter.ToMonaco(markers); + await JS.InvokeVoidAsync("monacoInterop.setModelMarkers", _editorElementId, MarkerOwner, monacoMarkers); + } + + public void Dispose() + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + + if (_modelUri is not null && _initialized) + _ = Runtime.LanguageServices.UnregisterEditorAsync(_modelUri); + + // Fire-and-forget editor disposal + _ = JS.InvokeVoidAsync("monacoInterop.dispose", _editorElementId); + _ = JS.InvokeVoidAsync("monacoInterop.dispose", _outputElementId); + + _dotnetRef?.Dispose(); + } +} diff --git a/src/Docs/Playground.Wasm/Components/PlaygroundHost.razor.css b/src/Docs/Playground.Wasm/Components/PlaygroundHost.razor.css new file mode 100644 index 0000000..9945d18 --- /dev/null +++ b/src/Docs/Playground.Wasm/Components/PlaygroundHost.razor.css @@ -0,0 +1,121 @@ +.playground { + display: flex; + flex-direction: column; + gap: 0.75rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--pg-text, #1f2328); +} + +.playground-loading { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 1rem 1.25rem; + border: 1px solid var(--pg-border, #e1e4e8); + background: var(--pg-surface, #f6f8fa); + border-radius: 6px; + color: var(--pg-text-muted, #57606a); + font-size: 0.9375rem; +} + +.playground-spinner { + width: 1rem; + height: 1rem; + border: 2px solid var(--pg-border, #d0d7de); + border-top-color: var(--pg-brand, #7c3aed); + border-radius: 50%; + animation: playground-spin 0.8s linear infinite; +} + +@keyframes playground-spin { + to { transform: rotate(360deg); } +} + +.playground-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.playground-row label { + font-weight: 600; + margin: 0; +} + +.playground-select { + padding: 0.35rem 0.5rem; + border: 1px solid var(--pg-border, #ccc); + border-radius: 4px; + background: var(--pg-select-bg, #fff); + color: var(--pg-text, inherit); + font: inherit; +} + +.playground-panes { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: stretch; +} + +.playground-pane { + flex: 1 1 280px; + min-width: 280px; + height: 22rem; + border: 1px solid var(--pg-border, #d0d7de); + border-radius: 6px; + overflow: hidden; + box-sizing: border-box; +} + +.playground-output-editor { + background: var(--pg-output-bg, #fafbfc); +} + +::deep .monaco-editor-container { + width: 100%; + height: 100%; +} + +::deep .monaco-editor { + padding: 0.5rem 0; +} + +.playground-status { + color: var(--pg-text-muted, #666); + font-size: 0.875rem; + font-style: italic; +} + +.playground-error-block { + border: 1px solid var(--pg-error-border, #f5c2c7); + background: var(--pg-error-bg, #fdf0f1); + padding: 0.75rem 1rem; + border-radius: 6px; +} + +.playground-error-block strong { + display: block; + color: var(--pg-error-text, #842029); + margin-bottom: 0.5rem; +} + +.playground-error-block ul { + margin: 0.5rem 0 0; + padding-left: 1.25rem; +} + +.playground-error-block li { + color: var(--pg-error-text, #842029); + font-family: ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; +} + +.playground-error { + margin: 0; + color: var(--pg-error-text, #842029); + font-family: ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace; + font-size: 0.8125rem; + white-space: pre-wrap; +} diff --git a/src/Docs/Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj b/src/Docs/Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj new file mode 100644 index 0000000..693e848 --- /dev/null +++ b/src/Docs/Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj @@ -0,0 +1,114 @@ + + + + + + + net10.0 + enable + enable + false + + + false + + + true + + + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Docs/Playground.Wasm/Program.cs b/src/Docs/Playground.Wasm/Program.cs new file mode 100644 index 0000000..9e5facd --- /dev/null +++ b/src/Docs/Playground.Wasm/Program.cs @@ -0,0 +1,66 @@ +using ExpressiveSharp.Docs.Playground.Wasm.Components; +using ExpressiveSharp.Docs.Playground.Wasm.Services; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.JSInterop; + +ManagedSqliteStub.Register(); + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.RootComponents.RegisterCustomElement("expressive-playground"); + +// BaseAddress must point to the playground's own directory (where _framework/ +// lives) so PlaygroundReferences can fetch reference DLLs. When the web +// component is hosted on a VitePress page, HostEnvironment.BaseAddress is +// the docs site root — not the playground subdirectory. We detect this by +// checking whether the base ends with /playground/. +var baseAddress = builder.HostEnvironment.BaseAddress; +if (!baseAddress.TrimEnd('/').EndsWith("/_playground", StringComparison.OrdinalIgnoreCase) + && !baseAddress.TrimEnd('/').EndsWith("/playground", StringComparison.OrdinalIgnoreCase)) + baseAddress = baseAddress.TrimEnd('/') + "/_playground/"; +builder.Services.AddSingleton(sp => new HttpClient +{ + BaseAddress = new Uri(baseAddress) +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var host = builder.Build(); + +// Register Monaco completion + hover providers via our JS interop module. +// The DotNetObjectReference callbacks dispatch to PlaygroundLanguageServices. +var runtime = host.Services.GetRequiredService(); +var jsRuntime = host.Services.GetRequiredService(); + +var providerRef = DotNetObjectReference.Create(new MonacoLanguageProviderBridge(runtime)); +await jsRuntime.InvokeVoidAsync("monacoInterop.registerCompletionProvider", providerRef); +await jsRuntime.InvokeVoidAsync("monacoInterop.registerHoverProvider", providerRef); + +await host.RunAsync(); + +/// +/// Bridge object exposed to JS via DotNetObjectReference. Monaco's completion +/// and hover providers call back into these [JSInvokable] methods. +/// +internal sealed class MonacoLanguageProviderBridge +{ + private readonly PlaygroundRuntime _runtime; + + public MonacoLanguageProviderBridge(PlaygroundRuntime runtime) => _runtime = runtime; + + [JSInvokable] + public async Task ProvideCompletionItems(string modelUri, MonacoPosition position) + { + if (!_runtime.IsInitialized) return null; + return await _runtime.LanguageServices.GetCompletionsAsync(modelUri, position); + } + + [JSInvokable] + public async Task ProvideHover(string modelUri, MonacoPosition position) + { + if (!_runtime.IsInitialized) return null; + return await _runtime.LanguageServices.GetHoverAsync(modelUri, position); + } +} diff --git a/src/Docs/Playground.Wasm/Properties/launchSettings.json b/src/Docs/Playground.Wasm/Properties/launchSettings.json new file mode 100644 index 0000000..eb0eaf7 --- /dev/null +++ b/src/Docs/Playground.Wasm/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5268", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7058;http://localhost:5268", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Docs/Playground.Wasm/Services/ManagedSqliteStub.cs b/src/Docs/Playground.Wasm/Services/ManagedSqliteStub.cs new file mode 100644 index 0000000..dbc3ab7 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/ManagedSqliteStub.cs @@ -0,0 +1,213 @@ +// ManagedSqliteStub — a fully-managed SQLitePCL.ISQLite3Provider implementation +// that EF Core can interrogate at startup *without* needing the native sqlite3 +// engine. The playground only ever calls ToQueryString() on a queryable; that +// path goes through EF Core's relational query translator and never executes +// real SQL, but the SqliteUpdateSqlGenerator constructor probes +// connection.ServerVersion → SQLitePCL.raw.sqlite3_libversion() during DI +// graph construction. This stub answers the metadata calls that EF Core makes +// during model build, and throws NotSupportedException for anything that would +// require running real queries. +// +// The interface has 152 methods (utf8z is a ref struct so DispatchProxy can't +// be used). Most just throw — only a handful of "metadata" calls have real +// answers. Generated from reflection at design time and committed. + +#pragma warning disable IDE0060 // Unused parameters — interface forces us to declare them + +using SQLitePCL; + +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +public sealed class ManagedSqliteStub : ISQLite3Provider +{ + /// + /// Registers this stub as the global SQLitePCL provider. Idempotent. + /// Must be called before any DbContext that uses Sqlite.Core is constructed. + /// + public static void Register() + { + try + { + SQLitePCL.raw.SetProvider(new ManagedSqliteStub()); + } + catch (System.InvalidOperationException) + { + // SetProvider throws if a provider is already registered. Tolerate + // double-registration so the stub can be installed eagerly from + // multiple host entrypoints (Program.cs, test setup, etc). + } + } + + // ── Metadata calls EF Core makes during DbContext init ───────────────── + + // Reported as the SQLite library version. EF Core uses this for feature + // detection (RETURNING clause support added in 3.35). Reporting a recent + // version unlocks all relational translator features. + public utf8z sqlite3_libversion() => utf8z.FromString("3.45.0"); + + // Companion to libversion. Format is major*1_000_000 + minor*1_000 + patch. + public int sqlite3_libversion_number() => 3045000; + + public utf8z sqlite3_sourceid() => utf8z.FromString("managed-stub-no-sourceid"); + + // 1 = single-threaded. WASM is single-threaded anyway. + public int sqlite3_threadsafe() => 1; + + public string GetNativeLibraryName() => "managed-stub"; + + // SQLitePCL.raw initializes by calling these once. They must succeed. + public int sqlite3_initialize() => 0; // SQLITE_OK + public int sqlite3_shutdown() => 0; + public int sqlite3_config_log(global::SQLitePCL.delegate_log @func, global::System.Object @v) => 0; + public int sqlite3_enable_shared_cache(int @enable) => 0; + + // ── Everything else throws ───────────────────────────────────────────── + + private static System.Exception NotSupported(string method) => + new System.NotSupportedException( + $"ManagedSqliteStub does not implement '{method}'. " + + "The Playground only supports ToQueryString() — query execution is not allowed."); + + public int sqlite3__vfs__delete(global::SQLitePCL.utf8z @vfs, global::SQLitePCL.utf8z @pathname, int @syncDir) => throw NotSupported("sqlite3__vfs__delete"); + public int sqlite3_backup_finish(global::System.IntPtr @backup) => throw NotSupported("sqlite3_backup_finish"); + public global::SQLitePCL.sqlite3_backup sqlite3_backup_init(global::SQLitePCL.sqlite3 @destDb, global::SQLitePCL.utf8z @destName, global::SQLitePCL.sqlite3 @sourceDb, global::SQLitePCL.utf8z @sourceName) => throw NotSupported("sqlite3_backup_init"); + public int sqlite3_backup_pagecount(global::SQLitePCL.sqlite3_backup @backup) => throw NotSupported("sqlite3_backup_pagecount"); + public int sqlite3_backup_remaining(global::SQLitePCL.sqlite3_backup @backup) => throw NotSupported("sqlite3_backup_remaining"); + public int sqlite3_backup_step(global::SQLitePCL.sqlite3_backup @backup, int @nPage) => throw NotSupported("sqlite3_backup_step"); + public int sqlite3_bind_blob(global::SQLitePCL.sqlite3_stmt @stmt, int @index, System.ReadOnlySpan @blob) => throw NotSupported("sqlite3_bind_blob"); + public int sqlite3_bind_double(global::SQLitePCL.sqlite3_stmt @stmt, int @index, double @val) => throw NotSupported("sqlite3_bind_double"); + public int sqlite3_bind_int(global::SQLitePCL.sqlite3_stmt @stmt, int @index, int @val) => throw NotSupported("sqlite3_bind_int"); + public int sqlite3_bind_int64(global::SQLitePCL.sqlite3_stmt @stmt, int @index, long @val) => throw NotSupported("sqlite3_bind_int64"); + public int sqlite3_bind_null(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_bind_null"); + public int sqlite3_bind_parameter_count(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_bind_parameter_count"); + public int sqlite3_bind_parameter_index(global::SQLitePCL.sqlite3_stmt @stmt, global::SQLitePCL.utf8z @strName) => throw NotSupported("sqlite3_bind_parameter_index"); + public global::SQLitePCL.utf8z sqlite3_bind_parameter_name(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_bind_parameter_name"); + public int sqlite3_bind_text(global::SQLitePCL.sqlite3_stmt @stmt, int @index, System.ReadOnlySpan @text) => throw NotSupported("sqlite3_bind_text"); + public int sqlite3_bind_text(global::SQLitePCL.sqlite3_stmt @stmt, int @index, global::SQLitePCL.utf8z @text) => throw NotSupported("sqlite3_bind_text"); + public int sqlite3_bind_text16(global::SQLitePCL.sqlite3_stmt @stmt, int @index, System.ReadOnlySpan @text) => throw NotSupported("sqlite3_bind_text16"); + public int sqlite3_bind_zeroblob(global::SQLitePCL.sqlite3_stmt @stmt, int @index, int @size) => throw NotSupported("sqlite3_bind_zeroblob"); + public int sqlite3_blob_bytes(global::SQLitePCL.sqlite3_blob @blob) => throw NotSupported("sqlite3_blob_bytes"); + public int sqlite3_blob_close(global::System.IntPtr @blob) => throw NotSupported("sqlite3_blob_close"); + public int sqlite3_blob_open(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @db_utf8, global::SQLitePCL.utf8z @table_utf8, global::SQLitePCL.utf8z @col_utf8, long @rowid, int @flags, out global::SQLitePCL.sqlite3_blob @blob) => throw NotSupported("sqlite3_blob_open"); + public int sqlite3_blob_read(global::SQLitePCL.sqlite3_blob @blob, System.Span @b, int @offset) => throw NotSupported("sqlite3_blob_read"); + public int sqlite3_blob_reopen(global::SQLitePCL.sqlite3_blob @blob, long @rowid) => throw NotSupported("sqlite3_blob_reopen"); + public int sqlite3_blob_write(global::SQLitePCL.sqlite3_blob @blob, System.ReadOnlySpan @b, int @offset) => throw NotSupported("sqlite3_blob_write"); + public int sqlite3_busy_timeout(global::SQLitePCL.sqlite3 @db, int @ms) => throw NotSupported("sqlite3_busy_timeout"); + public int sqlite3_changes(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_changes"); + public int sqlite3_clear_bindings(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_clear_bindings"); + public int sqlite3_close(global::System.IntPtr @db) => throw NotSupported("sqlite3_close"); + public int sqlite3_close_v2(global::System.IntPtr @db) => throw NotSupported("sqlite3_close_v2"); + public System.ReadOnlySpan sqlite3_column_blob(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_blob"); + public int sqlite3_column_bytes(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_bytes"); + public int sqlite3_column_count(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_column_count"); + public global::SQLitePCL.utf8z sqlite3_column_database_name(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_database_name"); + public global::SQLitePCL.utf8z sqlite3_column_decltype(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_decltype"); + public double sqlite3_column_double(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_double"); + public int sqlite3_column_int(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_int"); + public long sqlite3_column_int64(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_int64"); + public global::SQLitePCL.utf8z sqlite3_column_name(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_name"); + public global::SQLitePCL.utf8z sqlite3_column_origin_name(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_origin_name"); + public global::SQLitePCL.utf8z sqlite3_column_table_name(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_table_name"); + public global::SQLitePCL.utf8z sqlite3_column_text(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_text"); + public int sqlite3_column_type(global::SQLitePCL.sqlite3_stmt @stmt, int @index) => throw NotSupported("sqlite3_column_type"); + public void sqlite3_commit_hook(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.delegate_commit @func, global::System.Object @v) => throw NotSupported("sqlite3_commit_hook"); + public global::SQLitePCL.utf8z sqlite3_compileoption_get(int @n) => throw NotSupported("sqlite3_compileoption_get"); + public int sqlite3_compileoption_used(global::SQLitePCL.utf8z @sql) => throw NotSupported("sqlite3_compileoption_used"); + public int sqlite3_complete(global::SQLitePCL.utf8z @sql) => throw NotSupported("sqlite3_complete"); + public int sqlite3_config(int @op) => throw NotSupported("sqlite3_config"); + public int sqlite3_config(int @op, int @val) => throw NotSupported("sqlite3_config(int,int)"); + public int sqlite3_create_collation(global::SQLitePCL.sqlite3 @db, global::System.Byte[] @name, global::System.Object @v, global::SQLitePCL.delegate_collation @func) => throw NotSupported("sqlite3_create_collation"); + public int sqlite3_create_function(global::SQLitePCL.sqlite3 @db, global::System.Byte[] @name, int @nArg, int @flags, global::System.Object @v, global::SQLitePCL.delegate_function_scalar @func) => throw NotSupported("sqlite3_create_function(scalar)"); + public int sqlite3_create_function(global::SQLitePCL.sqlite3 @db, global::System.Byte[] @name, int @nArg, int @flags, global::System.Object @v, global::SQLitePCL.delegate_function_aggregate_step @func_step, global::SQLitePCL.delegate_function_aggregate_final @func_final) => throw NotSupported("sqlite3_create_function(aggregate)"); + public int sqlite3_data_count(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_data_count"); + public int sqlite3_db_config(global::SQLitePCL.sqlite3 @db, int @op, global::SQLitePCL.utf8z @val) => throw NotSupported("sqlite3_db_config(utf8z)"); + public int sqlite3_db_config(global::SQLitePCL.sqlite3 @db, int @op, int @val, out int @result) => throw NotSupported("sqlite3_db_config(int,out int)"); + public int sqlite3_db_config(global::SQLitePCL.sqlite3 @db, int @op, global::System.IntPtr @ptr, int @int0, int @int1) => throw NotSupported("sqlite3_db_config(IntPtr)"); + public global::SQLitePCL.utf8z sqlite3_db_filename(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @att) => throw NotSupported("sqlite3_db_filename"); + public global::System.IntPtr sqlite3_db_handle(global::System.IntPtr @stmt) => throw NotSupported("sqlite3_db_handle"); + public int sqlite3_db_readonly(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @dbName) => throw NotSupported("sqlite3_db_readonly"); + public int sqlite3_db_status(global::SQLitePCL.sqlite3 @db, int @op, out int @current, out int @highest, int @resetFlg) => throw NotSupported("sqlite3_db_status"); + public int sqlite3_deserialize(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @schema, global::System.IntPtr @data, long @szDb, long @szBuf, int @flags) => throw NotSupported("sqlite3_deserialize"); + public int sqlite3_enable_load_extension(global::SQLitePCL.sqlite3 @db, int @enable) => throw NotSupported("sqlite3_enable_load_extension"); + public int sqlite3_errcode(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_errcode"); + public global::SQLitePCL.utf8z sqlite3_errmsg(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_errmsg"); + public global::SQLitePCL.utf8z sqlite3_errstr(int @rc) => throw NotSupported("sqlite3_errstr"); + public int sqlite3_exec(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @sql, global::SQLitePCL.delegate_exec @callback, global::System.Object @user_data, out global::System.IntPtr @errMsg) => throw NotSupported("sqlite3_exec"); + public int sqlite3_extended_errcode(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_extended_errcode"); + public int sqlite3_extended_result_codes(global::SQLitePCL.sqlite3 @db, int @onoff) => throw NotSupported("sqlite3_extended_result_codes"); + public int sqlite3_finalize(global::System.IntPtr @stmt) => throw NotSupported("sqlite3_finalize"); + public void sqlite3_free(global::System.IntPtr @p) => throw NotSupported("sqlite3_free"); + public int sqlite3_get_autocommit(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_get_autocommit"); + public long sqlite3_hard_heap_limit64(long @n) => throw NotSupported("sqlite3_hard_heap_limit64"); + public void sqlite3_interrupt(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_interrupt"); + public int sqlite3_key(global::SQLitePCL.sqlite3 @db, System.ReadOnlySpan @key) => throw NotSupported("sqlite3_key"); + public int sqlite3_key_v2(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @dbname, System.ReadOnlySpan @key) => throw NotSupported("sqlite3_key_v2"); + public int sqlite3_keyword_count() => throw NotSupported("sqlite3_keyword_count"); + public int sqlite3_keyword_name(int @i, out string @name) => throw NotSupported("sqlite3_keyword_name"); + public long sqlite3_last_insert_rowid(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_last_insert_rowid"); + public int sqlite3_limit(global::SQLitePCL.sqlite3 @db, int @id, int @newVal) => throw NotSupported("sqlite3_limit"); + public int sqlite3_load_extension(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @zFile, global::SQLitePCL.utf8z @zProc, out global::SQLitePCL.utf8z @pzErrMsg) => throw NotSupported("sqlite3_load_extension"); + public void sqlite3_log(int @errcode, global::SQLitePCL.utf8z @s) => throw NotSupported("sqlite3_log"); + public global::System.IntPtr sqlite3_malloc(int @n) => throw NotSupported("sqlite3_malloc"); + public global::System.IntPtr sqlite3_malloc64(long @n) => throw NotSupported("sqlite3_malloc64"); + public long sqlite3_memory_highwater(int @resetFlag) => throw NotSupported("sqlite3_memory_highwater"); + public long sqlite3_memory_used() => throw NotSupported("sqlite3_memory_used"); + public global::System.IntPtr sqlite3_next_stmt(global::SQLitePCL.sqlite3 @db, global::System.IntPtr @stmt) => throw NotSupported("sqlite3_next_stmt"); + public int sqlite3_open(global::SQLitePCL.utf8z @filename, out global::System.IntPtr @db) => throw NotSupported("sqlite3_open"); + public int sqlite3_open_v2(global::SQLitePCL.utf8z @filename, out global::System.IntPtr @db, int @flags, global::SQLitePCL.utf8z @vfs) => throw NotSupported("sqlite3_open_v2"); + public int sqlite3_prepare_v2(global::SQLitePCL.sqlite3 @db, System.ReadOnlySpan @sql, out global::System.IntPtr @stmt, out System.ReadOnlySpan @remain) => throw NotSupported("sqlite3_prepare_v2(span)"); + public int sqlite3_prepare_v2(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @sql, out global::System.IntPtr @stmt, out global::SQLitePCL.utf8z @remain) => throw NotSupported("sqlite3_prepare_v2(utf8z)"); + public int sqlite3_prepare_v3(global::SQLitePCL.sqlite3 @db, System.ReadOnlySpan @sql, uint @flags, out global::System.IntPtr @stmt, out System.ReadOnlySpan @remain) => throw NotSupported("sqlite3_prepare_v3(span)"); + public int sqlite3_prepare_v3(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @sql, uint @flags, out global::System.IntPtr @stmt, out global::SQLitePCL.utf8z @remain) => throw NotSupported("sqlite3_prepare_v3(utf8z)"); + public void sqlite3_profile(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.delegate_profile @func, global::System.Object @v) => throw NotSupported("sqlite3_profile"); + public void sqlite3_progress_handler(global::SQLitePCL.sqlite3 @db, int @instructions, global::SQLitePCL.delegate_progress @func, global::System.Object @v) => throw NotSupported("sqlite3_progress_handler"); + public int sqlite3_rekey(global::SQLitePCL.sqlite3 @db, System.ReadOnlySpan @key) => throw NotSupported("sqlite3_rekey"); + public int sqlite3_rekey_v2(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @dbname, System.ReadOnlySpan @key) => throw NotSupported("sqlite3_rekey_v2"); + public int sqlite3_reset(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_reset"); + public void sqlite3_result_blob(global::System.IntPtr @context, System.ReadOnlySpan @val) => throw NotSupported("sqlite3_result_blob"); + public void sqlite3_result_double(global::System.IntPtr @context, double @val) => throw NotSupported("sqlite3_result_double"); + public void sqlite3_result_error(global::System.IntPtr @context, System.ReadOnlySpan @strErr) => throw NotSupported("sqlite3_result_error(span)"); + public void sqlite3_result_error(global::System.IntPtr @context, global::SQLitePCL.utf8z @strErr) => throw NotSupported("sqlite3_result_error(utf8z)"); + public void sqlite3_result_error_code(global::System.IntPtr @context, int @code) => throw NotSupported("sqlite3_result_error_code"); + public void sqlite3_result_error_nomem(global::System.IntPtr @context) => throw NotSupported("sqlite3_result_error_nomem"); + public void sqlite3_result_error_toobig(global::System.IntPtr @context) => throw NotSupported("sqlite3_result_error_toobig"); + public void sqlite3_result_int(global::System.IntPtr @context, int @val) => throw NotSupported("sqlite3_result_int"); + public void sqlite3_result_int64(global::System.IntPtr @context, long @val) => throw NotSupported("sqlite3_result_int64"); + public void sqlite3_result_null(global::System.IntPtr @context) => throw NotSupported("sqlite3_result_null"); + public void sqlite3_result_text(global::System.IntPtr @context, System.ReadOnlySpan @val) => throw NotSupported("sqlite3_result_text(span)"); + public void sqlite3_result_text(global::System.IntPtr @context, global::SQLitePCL.utf8z @val) => throw NotSupported("sqlite3_result_text(utf8z)"); + public void sqlite3_result_zeroblob(global::System.IntPtr @context, int @n) => throw NotSupported("sqlite3_result_zeroblob"); + public void sqlite3_rollback_hook(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.delegate_rollback @func, global::System.Object @v) => throw NotSupported("sqlite3_rollback_hook"); + public global::System.IntPtr sqlite3_serialize(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @schema, out long @size, int @flags) => throw NotSupported("sqlite3_serialize"); + public int sqlite3_set_authorizer(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.delegate_authorizer @authorizer, global::System.Object @user_data) => throw NotSupported("sqlite3_set_authorizer"); + public int sqlite3_snapshot_cmp(global::SQLitePCL.sqlite3_snapshot @p1, global::SQLitePCL.sqlite3_snapshot @p2) => throw NotSupported("sqlite3_snapshot_cmp"); + public void sqlite3_snapshot_free(global::System.IntPtr @snap) => throw NotSupported("sqlite3_snapshot_free"); + public int sqlite3_snapshot_get(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @schema, out global::System.IntPtr @snap) => throw NotSupported("sqlite3_snapshot_get"); + public int sqlite3_snapshot_open(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @schema, global::SQLitePCL.sqlite3_snapshot @snap) => throw NotSupported("sqlite3_snapshot_open"); + public int sqlite3_snapshot_recover(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @name) => throw NotSupported("sqlite3_snapshot_recover"); + public long sqlite3_soft_heap_limit64(long @n) => throw NotSupported("sqlite3_soft_heap_limit64"); + public global::SQLitePCL.utf8z sqlite3_sql(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_sql"); + public int sqlite3_status(int @op, out int @current, out int @highwater, int @resetFlag) => throw NotSupported("sqlite3_status"); + public int sqlite3_step(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_step"); + public int sqlite3_stmt_busy(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_stmt_busy"); + public int sqlite3_stmt_isexplain(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_stmt_isexplain"); + public int sqlite3_stmt_readonly(global::SQLitePCL.sqlite3_stmt @stmt) => throw NotSupported("sqlite3_stmt_readonly"); + public int sqlite3_stmt_status(global::SQLitePCL.sqlite3_stmt @stmt, int @op, int @resetFlg) => throw NotSupported("sqlite3_stmt_status"); + public int sqlite3_stricmp(global::System.IntPtr @p, global::System.IntPtr @q) => throw NotSupported("sqlite3_stricmp"); + public int sqlite3_strnicmp(global::System.IntPtr @p, global::System.IntPtr @q, int @n) => throw NotSupported("sqlite3_strnicmp"); + public int sqlite3_table_column_metadata(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @dbName, global::SQLitePCL.utf8z @tblName, global::SQLitePCL.utf8z @colName, out global::SQLitePCL.utf8z @dataType, out global::SQLitePCL.utf8z @collSeq, out int @notNull, out int @primaryKey, out int @autoInc) => throw NotSupported("sqlite3_table_column_metadata"); + public int sqlite3_total_changes(global::SQLitePCL.sqlite3 @db) => throw NotSupported("sqlite3_total_changes"); + public void sqlite3_trace(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.delegate_trace @func, global::System.Object @v) => throw NotSupported("sqlite3_trace"); + public void sqlite3_update_hook(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.delegate_update @func, global::System.Object @v) => throw NotSupported("sqlite3_update_hook"); + public System.ReadOnlySpan sqlite3_value_blob(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_blob"); + public int sqlite3_value_bytes(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_bytes"); + public double sqlite3_value_double(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_double"); + public int sqlite3_value_int(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_int"); + public long sqlite3_value_int64(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_int64"); + public global::SQLitePCL.utf8z sqlite3_value_text(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_text"); + public int sqlite3_value_type(global::System.IntPtr @p) => throw NotSupported("sqlite3_value_type"); + public int sqlite3_wal_autocheckpoint(global::SQLitePCL.sqlite3 @db, int @n) => throw NotSupported("sqlite3_wal_autocheckpoint"); + public int sqlite3_wal_checkpoint(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @dbName) => throw NotSupported("sqlite3_wal_checkpoint"); + public int sqlite3_wal_checkpoint_v2(global::SQLitePCL.sqlite3 @db, global::SQLitePCL.utf8z @dbName, int @eMode, out int @logSize, out int @framesCheckPointed) => throw NotSupported("sqlite3_wal_checkpoint_v2"); + public int sqlite3_win32_set_directory(int @typ, global::SQLitePCL.utf8z @path) => throw NotSupported("sqlite3_win32_set_directory"); +} diff --git a/src/Docs/Playground.Wasm/Services/MonacoMarkerConverter.cs b/src/Docs/Playground.Wasm/Services/MonacoMarkerConverter.cs new file mode 100644 index 0000000..d90a1a9 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/MonacoMarkerConverter.cs @@ -0,0 +1,32 @@ +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +internal static class MonacoMarkerConverter +{ + public static List ToMonaco(IReadOnlyList markers) + { + var result = new List(markers.Count); + foreach (var m in markers) + { + result.Add(new MonacoMarkerData + { + Severity = ToMonacoSeverity(m.Severity), + Message = $"{m.Code}: {m.Message}", + StartLineNumber = m.StartLine, + StartColumn = m.StartColumn, + EndLineNumber = m.EndLine, + EndColumn = m.EndColumn, + }); + } + return result; + } + + private static int ToMonacoSeverity(DiagnosticSeverity severity) => severity switch + { + DiagnosticSeverity.Error => MonacoMarkerSeverity.Error, + DiagnosticSeverity.Warning => MonacoMarkerSeverity.Warning, + DiagnosticSeverity.Info => MonacoMarkerSeverity.Info, + _ => MonacoMarkerSeverity.Hint, + }; +} diff --git a/src/Docs/Playground.Wasm/Services/MonacoTypes.cs b/src/Docs/Playground.Wasm/Services/MonacoTypes.cs new file mode 100644 index 0000000..0897de2 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/MonacoTypes.cs @@ -0,0 +1,119 @@ +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +// DTOs for JSInterop serialization with Monaco editor. +// These replace the BlazorMonaco C# types with plain records +// that serialize cleanly to/from the Monaco JS API. + +public sealed class MonacoPosition +{ + public int LineNumber { get; set; } + public int Column { get; set; } +} + +public sealed class MonacoRange +{ + public int StartLineNumber { get; set; } + public int StartColumn { get; set; } + public int EndLineNumber { get; set; } + public int EndColumn { get; set; } +} + +public sealed class MonacoEditorOptions +{ + public string Language { get; set; } = "plaintext"; + public string Value { get; set; } = ""; + public bool ReadOnly { get; set; } + public int FontSize { get; set; } = 13; + public string LineNumbers { get; set; } = "off"; + public int LineDecorationsWidth { get; set; } = 4; + public string WordWrap { get; set; } = "off"; + public string RenderLineHighlight { get; set; } = "line"; + public MonacoScrollbarOptions? Scrollbar { get; set; } +} + +public sealed class MonacoScrollbarOptions +{ + public bool? AlwaysConsumeMouseWheel { get; set; } + public string? Vertical { get; set; } + public string? Horizontal { get; set; } + public int? VerticalScrollbarSize { get; set; } + public int? HorizontalScrollbarSize { get; set; } +} + +public sealed class MonacoMarkerData +{ + public int Severity { get; set; } + public string Message { get; set; } = ""; + public int StartLineNumber { get; set; } + public int StartColumn { get; set; } + public int EndLineNumber { get; set; } + public int EndColumn { get; set; } +} + +// Completion types +public sealed class MonacoCompletionList +{ + public List Suggestions { get; set; } = new(); + public bool Incomplete { get; set; } +} + +public sealed class MonacoCompletionItem +{ + public string Label { get; set; } = ""; + public int Kind { get; set; } + public string InsertText { get; set; } = ""; + public string? SortText { get; set; } + public string? FilterText { get; set; } + public string? Detail { get; set; } + public MonacoRange? Range { get; set; } +} + +// Hover types +public sealed class MonacoHover +{ + public List Contents { get; set; } = new(); + public MonacoRange? Range { get; set; } +} + +public sealed class MonacoMarkdownString +{ + public string Value { get; set; } = ""; + public bool IsTrusted { get; set; } +} + +// Monaco CompletionItemKind enum values (matching monaco.languages.CompletionItemKind) +public static class MonacoCompletionItemKind +{ + public const int Method = 0; + public const int Function = 1; + public const int Constructor = 2; + public const int Field = 3; + public const int Variable = 4; + public const int Class = 5; + public const int Struct = 6; + public const int Interface = 7; + public const int Module = 8; + public const int Property = 9; + public const int Event = 10; + public const int Operator = 11; + public const int Unit = 12; + public const int Value = 13; + public const int Constant = 14; + public const int Enum = 15; + public const int EnumMember = 16; + public const int Keyword = 17; + public const int Text = 18; + public const int Color = 19; + public const int Reference = 23; + public const int Snippet = 27; + public const int TypeParameter = 24; +} + +// Monaco MarkerSeverity values (matching monaco.MarkerSeverity) +public static class MonacoMarkerSeverity +{ + public const int Hint = 1; + public const int Info = 2; + public const int Warning = 4; + public const int Error = 8; +} diff --git a/src/Docs/Playground.Wasm/Services/PlaygroundLanguageServices.cs b/src/Docs/Playground.Wasm/Services/PlaygroundLanguageServices.cs new file mode 100644 index 0000000..f2f7118 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/PlaygroundLanguageServices.cs @@ -0,0 +1,260 @@ +// PlaygroundLanguageServices — long-lived AdhocWorkspace that backs Monaco's +// completion + hover providers. Separate from SnippetCompiler (which builds +// fresh CSharpCompilations per keystroke for the run path); the workspace's +// incremental semantic model gives sub-ms per-keystroke completion. +// +// Architecture inspired by DotNetLab/src/Compiler/LanguageServices.cs (MIT): +// one AdhocWorkspace, one Project, one Document per +// instance routed by Monaco model URI. The DefaultPersistentStorageConfiguration +// cctor PNSE is bypassed by the WorkspaceShim project — see its header. + +using ExpressiveSharp.Docs.Playground.Core.Services; +using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; +using ExpressiveSharp.Docs.Playground.Wasm.WorkspaceShim; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.QuickInfo; +using Microsoft.CodeAnalysis.Text; + +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +internal sealed class PlaygroundLanguageServices : IDisposable +{ + // Distinct from SnippetCompiler.SnippetFilePath so the run path's + // [InterceptsLocation] interceptors stay decoupled from workspace docs. + private const string DocumentPathPrefix = "/snippet/__Snippet_"; + + private readonly AdhocWorkspace _workspace; + private readonly ProjectId _projectId; + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly Dictionary _modelToDocument = new(StringComparer.Ordinal); + private readonly Dictionary _wrapByDoc = new(); + + public PlaygroundLanguageServices(PlaygroundReferences references) + { + if (!references.IsLoaded) + throw new InvalidOperationException( + "PlaygroundReferences must be loaded before PlaygroundLanguageServices is constructed."); + + // Append the WorkspaceShim assembly AFTER MefHostServices.DefaultAssemblies. + // The shim's NoOpPersistentStorageConfiguration is exported with + // ServiceLayer.Test which gives it MEF priority over Roslyn's broken + // DefaultPersistentStorageConfiguration, so the latter's cctor never + // runs and Process.GetCurrentProcess() (PNSE on WASM) is never called. + var hostServices = MefHostServices.Create( + MefHostServices.DefaultAssemblies + .Append(typeof(NoOpPersistentStorageConfiguration).Assembly)); + + _workspace = new AdhocWorkspace(hostServices); + + var projectInfo = ProjectInfo.Create( + id: ProjectId.CreateNewId(), + version: VersionStamp.Create(), + name: "PlaygroundProject", + assemblyName: "PlaygroundProject", + language: LanguageNames.CSharp, + metadataReferences: references.References, + // WithConcurrentBuild(false): WASM is single-threaded, parallel + // Roslyn compile threads deadlock or throw. + compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable) + .WithConcurrentBuild(false), + parseOptions: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13)); + + _workspace.AddProject(projectInfo); + _projectId = projectInfo.Id; + } + + /// + /// Forces MEF composition during page load instead of lazily on the first + /// keystroke. Adds a throwaway document, asks for its CompletionService + /// (the act of resolving it triggers MEF + the cctor moment WorkspaceShim + /// guards against), discards. Subsequent real completions hit warm caches. + /// + public async Task PrewarmAsync() + { + await _lock.WaitAsync(); + try + { + const string warmupSource = "class __Warmup { void M() { var x = 1; } }"; + var docInfo = DocumentInfo.Create( + id: DocumentId.CreateNewId(_projectId), + name: "__Warmup.cs", + filePath: "/snippet/__Warmup.cs", + loader: TextLoader.From(TextAndVersion.Create(SourceText.From(warmupSource), VersionStamp.Create()))); + + var newSolution = _workspace.CurrentSolution.AddDocument(docInfo); + if (!_workspace.TryApplyChanges(newSolution)) + return; + + var doc = _workspace.CurrentSolution.GetDocument(docInfo.Id); + if (doc is not null) + { + var completionService = CompletionService.GetService(doc); + if (completionService is not null) + _ = await completionService.GetCompletionsAsync(doc, caretPosition: warmupSource.Length - 3); + } + + _workspace.TryApplyChanges(_workspace.CurrentSolution.RemoveDocument(docInfo.Id)); + } + finally + { + _lock.Release(); + } + } + + public async Task RegisterEditorAsync(string modelUri, string snippetText, string? setupText, IPlaygroundScenario scenario) + { + var wrap = SnippetWrap.Build(scenario.WrapperTemplate, snippetText, setupText); + var docInfo = DocumentInfo.Create( + id: DocumentId.CreateNewId(_projectId), + name: "__Snippet.cs", + filePath: $"{DocumentPathPrefix}{Guid.NewGuid():N}.cs", + loader: TextLoader.From(TextAndVersion.Create(SourceText.From(wrap.Source), VersionStamp.Create()))); + + await _lock.WaitAsync(); + try + { + // Drop any prior document under the same URI (rare — happens if + // an instance disposes and remounts) to avoid duplicate routing. + if (_modelToDocument.TryGetValue(modelUri, out var existingId)) + { + _workspace.TryApplyChanges(_workspace.CurrentSolution.RemoveDocument(existingId)); + _wrapByDoc.Remove(existingId); + } + + if (!_workspace.TryApplyChanges(_workspace.CurrentSolution.AddDocument(docInfo))) + return; + + _modelToDocument[modelUri] = docInfo.Id; + _wrapByDoc[docInfo.Id] = wrap; + } + finally + { + _lock.Release(); + } + } + + // Per-keystroke (no debounce — Roslyn diffs the syntax tree, sub-ms). + public async Task UpdateEditorAsync(string modelUri, string snippetText, string? setupText, IPlaygroundScenario scenario) + { + var wrap = SnippetWrap.Build(scenario.WrapperTemplate, snippetText, setupText); + + await _lock.WaitAsync(); + try + { + if (!_modelToDocument.TryGetValue(modelUri, out var docId)) + return; + + _workspace.TryApplyChanges( + _workspace.CurrentSolution.WithDocumentText(docId, SourceText.From(wrap.Source))); + _wrapByDoc[docId] = wrap; + } + finally + { + _lock.Release(); + } + } + + // Best-effort: silently no-ops if the model URI is unknown. + public async Task UnregisterEditorAsync(string modelUri) + { + await _lock.WaitAsync(); + try + { + if (!_modelToDocument.TryGetValue(modelUri, out var docId)) + return; + + _workspace.TryApplyChanges(_workspace.CurrentSolution.RemoveDocument(docId)); + _modelToDocument.Remove(modelUri); + _wrapByDoc.Remove(docId); + } + finally + { + _lock.Release(); + } + } + + public async Task GetCompletionsAsync(string modelUri, MonacoPosition position) + { + await _lock.WaitAsync(); + try + { + if (!_modelToDocument.TryGetValue(modelUri, out var docId)) return null; + if (!_wrapByDoc.TryGetValue(docId, out var wrap)) return null; + + var doc = _workspace.CurrentSolution.GetDocument(docId); + if (doc is null) return null; + + var completionService = CompletionService.GetService(doc); + if (completionService is null) return null; + + var text = await doc.GetTextAsync().ConfigureAwait(false); + var caretOffset = MonacoPositionToCaretOffset(position, text, wrap); + if (caretOffset < 0) return null; + + var roslynList = await completionService.GetCompletionsAsync(doc, caretOffset).ConfigureAwait(false); + if (roslynList is null || roslynList.ItemsList.Count == 0) return null; + + return RoslynMonacoConverters.ToMonacoCompletionList(roslynList, text, wrap); + } + finally + { + _lock.Release(); + } + } + + public async Task GetHoverAsync(string modelUri, MonacoPosition position) + { + await _lock.WaitAsync(); + try + { + if (!_modelToDocument.TryGetValue(modelUri, out var docId)) return null; + if (!_wrapByDoc.TryGetValue(docId, out var wrap)) return null; + + var doc = _workspace.CurrentSolution.GetDocument(docId); + if (doc is null) return null; + + var quickInfoService = QuickInfoService.GetService(doc); + if (quickInfoService is null) return null; + + var text = await doc.GetTextAsync().ConfigureAwait(false); + var caretOffset = MonacoPositionToCaretOffset(position, text, wrap); + if (caretOffset < 0) return null; + + var quickInfo = await quickInfoService.GetQuickInfoAsync(doc, caretOffset).ConfigureAwait(false); + if (quickInfo is null) return null; + + return RoslynMonacoConverters.ToMonacoHover(quickInfo, text, wrap); + } + finally + { + _lock.Release(); + } + } + + // Returns -1 if the position falls outside the snippet region. + private static int MonacoPositionToCaretOffset(MonacoPosition position, SourceText text, SnippetWrap wrap) + { + // Monaco is 1-based, LinePosition is 0-based. + var snippetRelative = new LinePosition( + line: Math.Max(0, position.LineNumber - 1), + character: Math.Max(0, position.Column - 1)); + var wrapped = wrap.ToWrapped(snippetRelative); + + if (wrapped.Line < 0 || wrapped.Line >= text.Lines.Count) + return -1; + var lineSpan = text.Lines[wrapped.Line]; + if (wrapped.Character > lineSpan.End - lineSpan.Start) + return -1; + return text.Lines.GetPosition(wrapped); + } + + public void Dispose() + { + _workspace.Dispose(); + _lock.Dispose(); + } +} diff --git a/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs b/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs new file mode 100644 index 0000000..2e171e6 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs @@ -0,0 +1,91 @@ +// PlaygroundReferences — fetches the reference assemblies the SnippetCompiler +// needs into MetadataReference instances. In a Blazor WASM app every loaded +// assembly is shipped under /_framework/.dll, so we use HttpClient to +// pull the raw bytes and feed them to MetadataReference.CreateFromImage. +// +// The reference set is the union of every scenario's ReferenceAssemblies, +// loaded once at startup and cached. Adding a new scenario in Phase 2 +// automatically extends the reference set with its assemblies — no edits to +// this file are required. + +using System.Collections.Immutable; +using Basic.Reference.Assemblies; +using ExpressiveSharp.Docs.Playground.Core.Services; +using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +internal sealed class PlaygroundReferences : IPlaygroundReferences +{ + private readonly HttpClient _http; + private ImmutableArray _references; + + public PlaygroundReferences(HttpClient http) + { + _http = http; + } + + public ImmutableArray References => _references; + + public bool IsLoaded => !_references.IsDefault; + + public async Task LoadAsync() + { + if (IsLoaded) return; + + var builder = ImmutableArray.CreateBuilder(); + + // .NET 10 ref-only BCL — embedded as resources in the + // Basic.Reference.Assemblies.Net100 assembly. No HTTP needed; the + // package's own embedded resources are loaded by the normal Blazor + // assembly loader at startup. + builder.AddRange(Net100.References.All); + + // Take the union of every scenario's ReferenceAssemblies. With one + // scenario today this loads ExpressiveSharp + ExpressiveSharp.EntityFrameworkCore + // + EF Core 10 + the PlaygroundModel assembly. Future scenarios are + // additive: registering a Mongo scenario in ScenarioRegistry would + // automatically pull in MongoDB.Driver here. + var seenNames = new HashSet(StringComparer.Ordinal); + var fetchTasks = new List>(); + + foreach (var scenario in ScenarioRegistry.All) + { + foreach (var assembly in scenario.ReferenceAssemblies) + { + var name = assembly.GetName().Name; + if (name is null || !seenNames.Add(name)) continue; + fetchTasks.Add(FetchAsync(name)); + } + } + + var fetched = await Task.WhenAll(fetchTasks); + foreach (var reference in fetched) + if (reference is not null) + builder.Add(reference); + + _references = builder.ToImmutable(); + } + + private async Task FetchAsync(string assemblyName) + { + // Try the standard _framework/ path first. If Blazor's BaseAddress + // doesn't point at the playground subdirectory (e.g., the web component + // is hosted on a VitePress page), fall back to the playground/ prefix. + var url = $"_framework/{assemblyName}.dll"; + try + { + var bytes = await _http.GetByteArrayAsync(url); + return MetadataReference.CreateFromImage(bytes, filePath: assemblyName + ".dll"); + } + catch (HttpRequestException) + { + // The runtime sometimes splits an assembly into multiple package + // ones — if a logical name doesn't resolve to a file in /_framework, + // skip it. Roslyn will surface a "missing reference" diagnostic + // later if the snippet actually needs the type. + return null; + } + } +} diff --git a/src/Docs/Playground.Wasm/Services/PlaygroundRuntime.cs b/src/Docs/Playground.Wasm/Services/PlaygroundRuntime.cs new file mode 100644 index 0000000..dd864e9 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/PlaygroundRuntime.cs @@ -0,0 +1,411 @@ +// PlaygroundRuntime — singleton DI service shared by every PlaygroundHost on +// the page. Owns the reference set, the SnippetCompiler, the IntelliSense +// workspace, the lazy-loaded provider assemblies, and a per-page compile cache. +// Provider-neutral; dispatch goes through IPlaygroundScenario / IScenarioInstance. + +using ExpressiveSharp.Docs.Playground.Core.Services; +using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; +using Microsoft.AspNetCore.Components.WebAssembly.Services; + +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +internal sealed class PlaygroundRuntime : IAsyncDisposable +{ + /// Sentinel target id for the universal "show generator output" target. + public const string GeneratorTargetId = "generator"; + + // Bounded so a runaway editor session can't grow the cache forever. + // Realistic page sessions hit ~10–20 distinct snippets at most. + private const int CompileCacheMaxSize = 32; + + private readonly PlaygroundReferences _references; + private readonly LazyAssemblyLoader _lazyLoader; + private readonly SemaphoreSlim _initLock = new(1, 1); + private readonly SemaphoreSlim _lazyLoadLock = new(1, 1); + private readonly Dictionary _scenarioInstances = new(StringComparer.Ordinal); + private readonly Dictionary _compileCache = new(StringComparer.Ordinal); + private readonly Queue _compileCacheOrder = new(); + private readonly HashSet _loadedLazyAssemblies = new(StringComparer.Ordinal); + private SnippetCompiler? _compiler; + private PlaygroundLanguageServices? _languageServices; + private bool _initialized; + private string? _sharedTargetId; + + public PlaygroundRuntime(PlaygroundReferences references, LazyAssemblyLoader lazyLoader) + { + _references = references; + _lazyLoader = lazyLoader; + } + + public bool IsInitialized => _initialized; + + public PlaygroundLanguageServices LanguageServices => + _languageServices ?? throw new InvalidOperationException( + "PlaygroundRuntime.LanguageServices accessed before InitializeAsync completed."); + + /// + /// Most recent target id chosen by any PlaygroundHost on the page. Dynamic + /// instances mounting after a broadcast read this to pick up the active + /// target instead of the scenario default. + /// + public string? SharedTargetId => _sharedTargetId; + + public void SetSharedTargetId(string targetId) => _sharedTargetId = targetId; + + /// + /// One-time async init: loads reference assemblies, instantiates the + /// SnippetCompiler, prewarms the IntelliSense workspace. Idempotent and + /// safe under concurrent callers via _initLock. + /// + public async Task InitializeAsync() + { + if (_initialized) return; + await _initLock.WaitAsync(); + try + { + if (_initialized) return; + + await _references.LoadAsync(); + _compiler = new SnippetCompiler(_references); + _languageServices = new PlaygroundLanguageServices(_references); + // Prewarm forces MEF composition (and the cctor moment that would + // otherwise PNSE in WASM without WorkspaceShim) up-front, so the + // first user keystroke hits a warm cache. + await _languageServices.PrewarmAsync(); + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + /// + /// Compiles a snippet and renders it through the chosen target. Memoized + /// by (scenarioId, setup, snippet); the cache stores the snippet's Run + /// method as a callable Func so multi-provider scenarios can invoke it + /// against a different argument per render target without recompiling. + /// + public async Task RunAsync( + string snippet, + string? setup, + string targetId, + IPlaygroundScenario scenario) + { + if (!_initialized || _compiler is null) + throw new InvalidOperationException("PlaygroundRuntime is not initialized."); + + // Lazy-load before the cache check + Task.Run, because LazyAssemblyLoader + // yields to the JS event loop while fetching and the assembly must be + // resolved before any IL referencing it gets JIT-compiled. + var renderTarget = scenario.RenderTargets.FirstOrDefault(t => t.Id == targetId); + if (renderTarget?.LazyLoadAssemblies is { Count: > 0 } lazyAssemblies) + { + try + { + await EnsureLazyAssembliesLoadedAsync(lazyAssemblies); + } + catch (Exception ex) + { + return RenderResult.Exception(Unwrap(ex)); + } + } + + var cacheKey = MakeCompileCacheKey(scenario.Id, setup, snippet); + if (_compileCache.TryGetValue(cacheKey, out var cached)) + return await Task.Run(() => RenderFromCache(cached, targetId, scenario)); + + return await Task.Run(() => + { + try + { + var compileResult = _compiler.Compile(snippet, setup, scenario); + var partitioned = PartitionDiagnostics(compileResult); + + if (!compileResult.Success) + { + var failureCache = new CachedCompile( + Success: false, + GeneratedSources: compileResult.GeneratedSources, + SnippetMarkers: partitioned.snippet, + SetupErrorMessages: partitioned.setup, + FailureDiagnostics: compileResult.Diagnostics, + Invoke: null); + StoreInCache(cacheKey, failureCache); + return RenderFromCache(failureCache, targetId, scenario); + } + + if (compileResult.Assembly is null) + throw new InvalidOperationException("Compile reported success but produced no assembly."); + + var snippetType = compileResult.Assembly.GetType(SnippetCompiler.SnippetTypeFullName) + ?? throw new InvalidOperationException( + $"Snippet type '{SnippetCompiler.SnippetTypeFullName}' not found."); + var runMethod = snippetType.GetMethod("Run", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + ?? throw new InvalidOperationException("Snippet.Run method not found."); + + Func invoke = arg => + (IQueryable?)runMethod.Invoke(null, new[] { arg }) + ?? throw new InvalidOperationException("Snippet.Run returned null."); + + var successCache = new CachedCompile( + Success: true, + GeneratedSources: compileResult.GeneratedSources, + SnippetMarkers: partitioned.snippet, + SetupErrorMessages: partitioned.setup, + FailureDiagnostics: null, + Invoke: invoke); + StoreInCache(cacheKey, successCache); + return RenderFromCache(successCache, targetId, scenario); + } + catch (Exception ex) + { + return RenderResult.Exception(Unwrap(ex)); + } + }); + } + + // Null bytes between fields prevent ambiguous concatenation collisions. + // targetId is intentionally NOT in the key — the cache serves any target + // from one compile. + private static string MakeCompileCacheKey(string scenarioId, string? setup, string snippet) => + scenarioId + "\0" + (setup ?? "") + "\0" + snippet; + + // Idempotent — re-loaded assemblies are skipped via _loadedLazyAssemblies. + // The lock serializes concurrent loads from a dropdown broadcast hitting + // all 6 instances at once with the same target. + private async Task EnsureLazyAssembliesLoadedAsync(IReadOnlyList assemblyFileNames) + { + var needsLoad = false; + foreach (var name in assemblyFileNames) + if (!_loadedLazyAssemblies.Contains(name)) { needsLoad = true; break; } + if (!needsLoad) return; + + await _lazyLoadLock.WaitAsync(); + try + { + var pending = new List(assemblyFileNames.Count); + foreach (var name in assemblyFileNames) + if (!_loadedLazyAssemblies.Contains(name)) + pending.Add(name); + if (pending.Count == 0) return; + + await _lazyLoader.LoadAssembliesAsync(pending); + foreach (var name in pending) + _loadedLazyAssemblies.Add(name); + } + finally + { + _lazyLoadLock.Release(); + } + } + + private void StoreInCache(string key, CachedCompile entry) + { + if (!_compileCache.ContainsKey(key)) + { + if (_compileCache.Count >= CompileCacheMaxSize) + { + var oldest = _compileCacheOrder.Dequeue(); + _compileCache.Remove(oldest); + } + _compileCacheOrder.Enqueue(key); + } + _compileCache[key] = entry; + } + + // Looks up the render target, gets its query argument from the scenario + // instance, invokes the cached Run method, runs the target's render + // delegate. The reflection invoke is ~1ms — fast enough for dropdown + // thrash where 6 instances re-render against the same cache entry with + // different per-target arguments. + private RenderResult RenderFromCache(CachedCompile cached, string targetId, IPlaygroundScenario scenario) + { + try + { + if (!cached.Success) + { + return RenderResult.Failure( + cached.FailureDiagnostics ?? Array.Empty(), + cached.GeneratedSources, + cached.SnippetMarkers, + cached.SetupErrorMessages); + } + + if (targetId == GeneratorTargetId) + { + return RenderResult.Ok( + FormatGeneratorOutput(cached.GeneratedSources), + cached.GeneratedSources, + cached.SnippetMarkers, + cached.SetupErrorMessages); + } + + var renderTarget = scenario.RenderTargets.FirstOrDefault(t => t.Id == targetId) + ?? throw new InvalidOperationException( + $"Scenario '{scenario.Id}' does not support render target '{targetId}'."); + + var instance = GetOrCreateInstance(scenario); + var queryArgument = renderTarget.GetQueryArgument?.Invoke(instance) ?? instance.QueryArgument; + var queryable = cached.Invoke!(queryArgument); + + var output = renderTarget.Render(queryable, instance); + return RenderResult.Ok( + output, + cached.GeneratedSources, + cached.SnippetMarkers, + cached.SetupErrorMessages); + } + catch (Exception ex) + { + return RenderResult.Exception(Unwrap(ex)); + } + } + + private sealed record CachedCompile( + bool Success, + IReadOnlyList GeneratedSources, + IReadOnlyList SnippetMarkers, + IReadOnlyList SetupErrorMessages, + IReadOnlyList? FailureDiagnostics, + Func? Invoke); + + private IScenarioInstance GetOrCreateInstance(IPlaygroundScenario scenario) + { + if (_scenarioInstances.TryGetValue(scenario.Id, out var existing)) + return existing; + + var fresh = scenario.CreateInstance(); + _scenarioInstances[scenario.Id] = fresh; + return fresh; + } + + // Splits diagnostics into snippet markers (in-region; translated to user + // coordinates for Monaco squiggles) and setup messages (in-setup-region; + // shown in PlaygroundHost's error block since Monaco doesn't see setup). + // Anything anchored elsewhere in the wrapped source is dropped. + private static (List snippet, List setup) PartitionDiagnostics(CompileResult result) + { + var snippetMarkers = new List(); + var setupMessages = new List(); + + foreach (var diag in result.Diagnostics) + { + if (diag.Span is not { } span) + continue; + + if (result.Wrap.IsInSnippet(span.Start)) + { + var startRel = result.Wrap.ToSnippetRelative(span.Start); + var endRel = result.Wrap.ToSnippetRelative(span.End); + snippetMarkers.Add(new SnippetMarker( + Severity: diag.Severity, + Code: diag.Id, + Message: diag.Message, + StartLine: startRel.Line + 1, + StartColumn: startRel.Character + 1, + EndLine: endRel.Line + 1, + // Force at least one column of width so zero-width + // diagnostics still get a visible squiggle. + EndColumn: Math.Max(endRel.Character + 1, startRel.Character + 2))); + } + else if (result.Wrap.IsInSetup(span.Start)) + { + setupMessages.Add(diag.ToString()); + } + } + + return (snippetMarkers, setupMessages); + } + + // Walks reflection wrapper exceptions to surface the actual root cause — + // TargetInvocationException wraps anything thrown by a reflection-invoked + // method body, TypeInitializationException wraps cctor failures. + private static Exception Unwrap(Exception ex) + { + while (true) + { + if (ex is System.Reflection.TargetInvocationException tie && tie.InnerException is not null) + { + ex = tie.InnerException; + continue; + } + if (ex is TypeInitializationException tinit && tinit.InnerException is not null) + { + ex = tinit.InnerException; + continue; + } + return ex; + } + } + + private static string FormatGeneratorOutput(IReadOnlyList sources) + { + if (sources.Count == 0) + return "// (no generator output for this snippet)"; + + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < sources.Count; i++) + { + if (i > 0) sb.AppendLine().AppendLine(); + sb.Append("// === ").Append(sources[i].HintName).AppendLine(" ==="); + sb.Append(sources[i].Source); + } + return sb.ToString(); + } + + public async ValueTask DisposeAsync() + { + foreach (var instance in _scenarioInstances.Values) + { + try { await instance.DisposeAsync(); } + catch { /* swallow — page unload */ } + } + _scenarioInstances.Clear(); + _languageServices?.Dispose(); + _initLock.Dispose(); + _lazyLoadLock.Dispose(); + } +} + +/// +/// Diagnostic in the user's snippet region with positions already translated +/// to snippet-relative 1-based coordinates for Monaco's setModelMarkers. +/// +internal sealed record SnippetMarker( + Microsoft.CodeAnalysis.DiagnosticSeverity Severity, + string Code, + string Message, + int StartLine, + int StartColumn, + int EndLine, + int EndColumn); + +internal sealed record RenderResult( + bool Success, + string? Output, + string? ErrorMessage, + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + IReadOnlyList SnippetMarkers, + IReadOnlyList SetupErrorMessages) +{ + public static RenderResult Ok( + string output, + IReadOnlyList generatedSources, + IReadOnlyList snippetMarkers, + IReadOnlyList setupErrorMessages) + => new(true, output, null, Array.Empty(), generatedSources, snippetMarkers, setupErrorMessages); + + public static RenderResult Failure( + IReadOnlyList diagnostics, + IReadOnlyList generatedSources, + IReadOnlyList snippetMarkers, + IReadOnlyList setupErrorMessages) + => new(false, null, "Compilation failed.", diagnostics, generatedSources, snippetMarkers, setupErrorMessages); + + public static RenderResult Exception(Exception ex) + => new(false, null, ex.GetType().Name + ": " + ex.Message, + Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); +} diff --git a/src/Docs/Playground.Wasm/Services/RoslynMonacoConverters.cs b/src/Docs/Playground.Wasm/Services/RoslynMonacoConverters.cs new file mode 100644 index 0000000..e2b93d5 --- /dev/null +++ b/src/Docs/Playground.Wasm/Services/RoslynMonacoConverters.cs @@ -0,0 +1,120 @@ +using System.Collections.Immutable; +using System.Text; +using ExpressiveSharp.Docs.Playground.Core.Services; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.QuickInfo; +using Microsoft.CodeAnalysis.Tags; +using Microsoft.CodeAnalysis.Text; + +using RoslynCompletionList = Microsoft.CodeAnalysis.Completion.CompletionList; + +namespace ExpressiveSharp.Docs.Playground.Wasm.Services; + +internal static class RoslynMonacoConverters +{ + public static int ToMonacoKind(ImmutableArray tags) + { + foreach (var tag in tags) + { + switch (tag) + { + case WellKnownTags.Class: return MonacoCompletionItemKind.Class; + case WellKnownTags.Constant: return MonacoCompletionItemKind.Constant; + case WellKnownTags.Delegate: return MonacoCompletionItemKind.Method; + case WellKnownTags.Enum: return MonacoCompletionItemKind.Enum; + case WellKnownTags.EnumMember: return MonacoCompletionItemKind.EnumMember; + case WellKnownTags.Event: return MonacoCompletionItemKind.Event; + case WellKnownTags.ExtensionMethod: return MonacoCompletionItemKind.Method; + case WellKnownTags.Field: return MonacoCompletionItemKind.Field; + case WellKnownTags.Interface: return MonacoCompletionItemKind.Interface; + case WellKnownTags.Intrinsic: return MonacoCompletionItemKind.Keyword; + case WellKnownTags.Keyword: return MonacoCompletionItemKind.Keyword; + case WellKnownTags.Label: return MonacoCompletionItemKind.Text; + case WellKnownTags.Local: return MonacoCompletionItemKind.Variable; + case WellKnownTags.Method: return MonacoCompletionItemKind.Method; + case WellKnownTags.Module: return MonacoCompletionItemKind.Module; + case WellKnownTags.Namespace: return MonacoCompletionItemKind.Module; + case WellKnownTags.Operator: return MonacoCompletionItemKind.Operator; + case WellKnownTags.Parameter: return MonacoCompletionItemKind.Variable; + case WellKnownTags.Property: return MonacoCompletionItemKind.Property; + case WellKnownTags.RangeVariable: return MonacoCompletionItemKind.Variable; + case WellKnownTags.Reference: return MonacoCompletionItemKind.Reference; + case WellKnownTags.Snippet: return MonacoCompletionItemKind.Snippet; + case WellKnownTags.Structure: return MonacoCompletionItemKind.Struct; + case WellKnownTags.TypeParameter: return MonacoCompletionItemKind.TypeParameter; + } + } + return MonacoCompletionItemKind.Text; + } + + public static MonacoRange? ToMonacoRange(TextSpan span, SourceText text, SnippetWrap wrap) + { + var lineSpan = text.Lines.GetLinePositionSpan(span); + if (!wrap.IsInSnippet(lineSpan.Start)) + return null; + + var startRel = wrap.ToSnippetRelative(lineSpan.Start); + var endRel = wrap.ToSnippetRelative(lineSpan.End); + return new MonacoRange + { + StartLineNumber = startRel.Line + 1, + StartColumn = startRel.Character + 1, + EndLineNumber = endRel.Line + 1, + EndColumn = endRel.Character + 1, + }; + } + + public static MonacoCompletionList ToMonacoCompletionList( + RoslynCompletionList list, + SourceText text, + SnippetWrap wrap) + { + var suggestions = new List(list.ItemsList.Count); + foreach (var item in list.ItemsList) + { + if (item.IsComplexTextEdit) continue; + var range = ToMonacoRange(item.Span, text, wrap); + if (range is null) continue; + + suggestions.Add(new MonacoCompletionItem + { + Label = item.DisplayText, + Kind = ToMonacoKind(item.Tags), + InsertText = item.DisplayText, + SortText = item.SortText, + FilterText = item.FilterText, + Range = range, + Detail = item.InlineDescription, + }); + } + + return new MonacoCompletionList + { + Suggestions = suggestions, + Incomplete = false, + }; + } + + public static MonacoHover? ToMonacoHover(QuickInfoItem item, SourceText text, SnippetWrap wrap) + { + var range = ToMonacoRange(item.Span, text, wrap); + if (range is null) return null; + + var sb = new StringBuilder(); + for (var s = 0; s < item.Sections.Length; s++) + { + if (s > 0) sb.Append("\n\n"); + foreach (var part in item.Sections[s].TaggedParts) + sb.Append(part.Text); + } + + return new MonacoHover + { + Contents = new List + { + new() { Value = sb.ToString(), IsTrusted = false }, + }, + Range = range, + }; + } +} diff --git a/src/Docs/Playground.Wasm/_Imports.razor b/src/Docs/Playground.Wasm/_Imports.razor new file mode 100644 index 0000000..3b86ac4 --- /dev/null +++ b/src/Docs/Playground.Wasm/_Imports.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +@using ExpressiveSharp.Docs.Playground.Wasm +@using ExpressiveSharp.Docs.Playground.Wasm.Components +@using ExpressiveSharp.Docs.Playground.Wasm.Services +@using ExpressiveSharp.Docs.Playground.Core.Services +@using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios diff --git a/src/Docs/Playground.Wasm/wwwroot/app.htm b/src/Docs/Playground.Wasm/wwwroot/app.htm new file mode 100644 index 0000000..a022e4d --- /dev/null +++ b/src/Docs/Playground.Wasm/wwwroot/app.htm @@ -0,0 +1,148 @@ + + + + + + ExpressiveSharp Playground + + + + + + + + + +
+ An unhandled error has occurred. + Reload +
+ + + + + + + + + + + diff --git a/src/Docs/Playground.Wasm/wwwroot/js/monaco-interop.js b/src/Docs/Playground.Wasm/wwwroot/js/monaco-interop.js new file mode 100644 index 0000000..4fa9977 --- /dev/null +++ b/src/Docs/Playground.Wasm/wwwroot/js/monaco-interop.js @@ -0,0 +1,155 @@ +// monaco-interop.js — thin JSInterop bridge between Blazor and Monaco editor. +// Replaces BlazorMonaco by calling Monaco's API directly. This avoids the +// BlazorMonaco dependency which hardcodes document.baseURI for AMD loader +// paths, preventing the playground from being hosted as a web component on +// a page with a different base URL. + +const editors = {}; +const editorCallbacks = {}; + +window.monacoInterop = { + + // ─── Editor lifecycle ─────────────────────────────────────────── + + async create(elementId, options, dotnetRef) { + // Wait for Monaco AMD require() to finish + if (window.__monacoReady) await window.__monacoReady; + + const container = document.getElementById(elementId); + if (!container) { console.warn('[monaco-interop] container not found:', elementId); return; } + console.log('[monaco-interop] create', elementId, container.offsetWidth + 'x' + container.offsetHeight, options); + + const editor = monaco.editor.create(container, { + language: options.language || 'plaintext', + value: options.value || '', + readOnly: options.readOnly || false, + automaticLayout: true, + minimap: { enabled: false }, + fontSize: options.fontSize || 13, + scrollBeyondLastLine: false, + lineNumbers: options.lineNumbers || 'off', + folding: false, + glyphMargin: false, + lineDecorationsWidth: options.lineDecorationsWidth || 4, + wordWrap: options.wordWrap || 'off', + renderLineHighlight: options.renderLineHighlight || 'line', + scrollbar: options.scrollbar || {}, + }); + + editors[elementId] = editor; + + if (dotnetRef) { + editor.onDidChangeModelContent(() => { + dotnetRef.invokeMethodAsync('OnContentChanged'); + }); + } + + return editor.getModel()?.uri?.toString() || null; + }, + + dispose(elementId) { + const editor = editors[elementId]; + if (editor) { + editor.dispose(); + delete editors[elementId]; + } + }, + + // ─── Editor operations ────────────────────────────────────────── + + getValue(elementId) { + return editors[elementId]?.getValue() || ''; + }, + + setValue(elementId, value) { + const editor = editors[elementId]; + if (editor) editor.setValue(value); + }, + + getModelUri(elementId) { + return editors[elementId]?.getModel()?.uri?.toString() || null; + }, + + setModelLanguage(elementId, language) { + const editor = editors[elementId]; + if (editor) { + const model = editor.getModel(); + if (model) monaco.editor.setModelLanguage(model, language); + } + }, + + // ─── Markers (squiggles) ──────────────────────────────────────── + + setModelMarkers(elementId, owner, markers) { + const editor = editors[elementId]; + if (!editor) return; + const model = editor.getModel(); + if (!model) return; + monaco.editor.setModelMarkers(model, owner, markers); + }, + + // ─── Language providers ───────────────────────────────────────── + + registerCompletionProvider(dotnetRef) { + monaco.languages.registerCompletionItemProvider('csharp', { + triggerCharacters: ['.', ' '], + provideCompletionItems: async (model, position) => { + const result = await dotnetRef.invokeMethodAsync( + 'ProvideCompletionItems', + model.uri.toString(), + { lineNumber: position.lineNumber, column: position.column } + ); + if (!result) return { suggestions: [] }; + // Map the suggestions to Monaco format + return { + suggestions: result.suggestions.map(s => ({ + label: s.label, + kind: s.kind, + insertText: s.insertText, + sortText: s.sortText || s.label, + filterText: s.filterText || s.label, + detail: s.detail || '', + range: s.range ? { + startLineNumber: s.range.startLineNumber, + startColumn: s.range.startColumn, + endLineNumber: s.range.endLineNumber, + endColumn: s.range.endColumn, + } : undefined, + })), + incomplete: result.incomplete || false, + }; + }, + }); + }, + + registerHoverProvider(dotnetRef) { + monaco.languages.registerHoverProvider('csharp', { + provideHover: async (model, position) => { + const result = await dotnetRef.invokeMethodAsync( + 'ProvideHover', + model.uri.toString(), + { lineNumber: position.lineNumber, column: position.column } + ); + if (!result) return null; + return { + contents: (result.contents || []).map(c => ({ + value: c.value, + isTrusted: c.isTrusted || false, + })), + range: result.range ? { + startLineNumber: result.range.startLineNumber, + startColumn: result.range.startColumn, + endLineNumber: result.range.endLineNumber, + endColumn: result.range.endColumn, + } : undefined, + }; + }, + }); + }, + + // ─── Theme ────────────────────────────────────────────────────── + + setTheme(themeName) { + monaco.editor.setTheme(themeName); + }, +}; diff --git a/src/Docs/Playground.WasmWorkspaceShim/ExpressiveSharp.Docs.Playground.WasmWorkspaceShim.csproj b/src/Docs/Playground.WasmWorkspaceShim/ExpressiveSharp.Docs.Playground.WasmWorkspaceShim.csproj new file mode 100644 index 0000000..16b08d8 --- /dev/null +++ b/src/Docs/Playground.WasmWorkspaceShim/ExpressiveSharp.Docs.Playground.WasmWorkspaceShim.csproj @@ -0,0 +1,52 @@ + + + + + + + + net10.0 + + + Microsoft.CodeAnalysis.Workspaces.UnitTests + + + true + true + $(MSBuildThisFileDirectory)MSSharedLib1024.snk + + enable + enable + false + + + $(NoWarn);CS8002 + + + + + + + + diff --git a/src/Docs/Playground.WasmWorkspaceShim/MSSharedLib1024.snk b/src/Docs/Playground.WasmWorkspaceShim/MSSharedLib1024.snk new file mode 100644 index 0000000000000000000000000000000000000000..695f1b38774e839e5b90059bfb7f32df1dff4223 GIT binary patch literal 160 zcmV;R0AK$ABme*efB*oL000060ssI2Bme+XQ$aBR1ONa50098C{E+7Ye`kjtcRG*W zi8#m|)B?I?xgZ^2Sw5D;l4TxtPwG;3)3^j?qDHjEteSTF{rM+4WI`v zCD?tsZ^;k+S&r1&HRMb=j738S=;J$tCKNrc$@P|lZtrue, MSBuild signs the shim assembly's +metadata with the public-key fingerprint without needing the private key. + +This is the same approach used by Mono, ILSpy, and other open-source +projects that need to access Roslyn / .NET Framework internals. Microsoft +distributes this key precisely so that test/tooling projects can perform +this kind of public-key impersonation. + +Security note +------------- +This is a PUBLIC KEY ONLY. There is no security risk in checking it in. +You cannot forge a signed assembly with it — only the matching private +key (held by Microsoft) can produce real signatures. We use it solely to +let the JIT/loader/Roslyn's InternalsVisibleTo check accept our shim's +public-key fingerprint as a match. diff --git a/src/Docs/Playground.WasmWorkspaceShim/NoOpPersistentStorageConfiguration.cs b/src/Docs/Playground.WasmWorkspaceShim/NoOpPersistentStorageConfiguration.cs new file mode 100644 index 0000000..f54ecda --- /dev/null +++ b/src/Docs/Playground.WasmWorkspaceShim/NoOpPersistentStorageConfiguration.cs @@ -0,0 +1,33 @@ +// Adapted from DotNetLab/src/RoslynWorkspaceAccess/RoslynWorkspaceAccessors.cs (MIT). +// https://github.com/jjonescz/DotNetLab +// +// Why this exists: Roslyn's DefaultPersistentStorageConfiguration..cctor() calls +// Process.GetCurrentProcess(), which throws PlatformNotSupportedException on +// Blazor WebAssembly. MEF discovers it on the first CompletionService.GetService() +// call inside an AdhocWorkspace. By exporting our own IPersistentStorageConfiguration +// with ServiceLayer.Test, MEF prefers ours over the default and never instantiates +// the broken type — its cctor never runs, the PNSE is never thrown, and completions +// work in WASM. +// +// This file lives in an assembly whose AssemblyName is impersonated to +// "Microsoft.CodeAnalysis.Workspaces.UnitTests" (see csproj) so Roslyn's +// [InternalsVisibleTo] attribute lets us reference IPersistentStorageConfiguration, +// MefConstruction, ExportWorkspaceServiceAttribute, and ServiceLayer — all of +// which are `internal` in Microsoft.CodeAnalysis.Workspaces.dll. + +using System.Composition; +using Microsoft.CodeAnalysis.Host; // IPersistentStorageConfiguration +using Microsoft.CodeAnalysis.Host.Mef; // ExportWorkspaceService, ServiceLayer, MefConstruction +using Microsoft.CodeAnalysis.Storage; // SolutionKey + +namespace ExpressiveSharp.Docs.Playground.Wasm.WorkspaceShim; + +[ExportWorkspaceService(typeof(IPersistentStorageConfiguration), ServiceLayer.Test), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +public sealed class NoOpPersistentStorageConfiguration() : IPersistentStorageConfiguration +{ + public bool ThrowOnFailure => false; + + string? IPersistentStorageConfiguration.TryGetStorageLocation(SolutionKey solutionKey) => null; +} diff --git a/src/Docs/PlaygroundModel/ExpressiveSharp.Docs.PlaygroundModel.csproj b/src/Docs/PlaygroundModel/ExpressiveSharp.Docs.PlaygroundModel.csproj new file mode 100644 index 0000000..94deb60 --- /dev/null +++ b/src/Docs/PlaygroundModel/ExpressiveSharp.Docs.PlaygroundModel.csproj @@ -0,0 +1,35 @@ + + + + + + Sample data model used by the ExpressiveSharp Playground (interactive docs). + + net10.0 + false + ExpressiveSharp.Docs.PlaygroundModel + + + + + + + + + + + + + + + + diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs new file mode 100644 index 0000000..0db60b0 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/Customer.cs @@ -0,0 +1,16 @@ +// Plain data only — no [Expressive] members. Samples that demonstrate +// computed members do so with their own inline [Expressive] extension methods +// declared in the snippet's `setup` attribute, so the docs reader sees the +// definition next to the query that uses it. + +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string? Email { get; set; } + public string? Country { get; set; } + public DateTime JoinedAt { get; set; } + public ICollection Orders { get; set; } = new List(); +} diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs new file mode 100644 index 0000000..e96dc30 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs @@ -0,0 +1,18 @@ +using ExpressiveSharp; + +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +/// +/// Multi-root query context that sample snippets receive as their sole argument. +/// Each render target (EF Core SQLite/Postgres/SqlServer/Cosmos, MongoDB, in-memory +/// playground) supplies its own implementation wrapping its underlying queryables. +/// Snippets read as db.Customers.Where(...), db.Orders.SelectMany(...), +/// etc. — naturally rooted at whichever entity set the example needs. +/// +public interface IWebshopQueryRoots +{ + IExpressiveQueryable Customers { get; } + IExpressiveQueryable Orders { get; } + IExpressiveQueryable Products { get; } + IExpressiveQueryable LineItems { get; } +} diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs new file mode 100644 index 0000000..a7525f5 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/LineItem.cs @@ -0,0 +1,12 @@ +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +public class LineItem +{ + public int Id { get; set; } + public int OrderId { get; set; } + public Order Order { get; set; } = null!; + public int ProductId { get; set; } + public Product Product { get; set; } = null!; + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } +} diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs new file mode 100644 index 0000000..2df5f82 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/Order.cs @@ -0,0 +1,11 @@ +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +public class Order +{ + public int Id { get; set; } + public int CustomerId { get; set; } + public Customer Customer { get; set; } = null!; + public DateTime PlacedAt { get; set; } + public OrderStatus Status { get; set; } + public ICollection Items { get; set; } = new List(); +} diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/OrderStatus.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/OrderStatus.cs new file mode 100644 index 0000000..56b0f44 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/OrderStatus.cs @@ -0,0 +1,10 @@ +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +public enum OrderStatus +{ + Pending, + Paid, + Shipped, + Delivered, + Refunded, +} diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs new file mode 100644 index 0000000..ca44c34 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/Product.cs @@ -0,0 +1,10 @@ +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public decimal ListPrice { get; set; } + public int StockQuantity { get; set; } +} diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopDbContext.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopDbContext.cs new file mode 100644 index 0000000..5b99556 --- /dev/null +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/WebshopDbContext.cs @@ -0,0 +1,43 @@ +// WebshopDbContext — the in-memory DbContext the playground's webshop scenario +// uses to back EF Core's ToQueryString(). Constructed once per scenario instance +// in WebshopScenarioInstance; no real database connection is ever opened. + +using ExpressiveSharp.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; + +public class WebshopDbContext : DbContext +{ + public ExpressiveDbSet Customers => this.ExpressiveSet(); + public ExpressiveDbSet Orders => this.ExpressiveSet(); + public ExpressiveDbSet LineItems => this.ExpressiveSet(); + public ExpressiveDbSet Products => this.ExpressiveSet(); + + public WebshopDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity().HasKey(c => c.Id); + + b.Entity(e => + { + e.HasKey(o => o.Id); + e.HasOne(o => o.Customer).WithMany(c => c.Orders).HasForeignKey(o => o.CustomerId); + }); + + b.Entity(e => + { + e.HasKey(i => i.Id); + e.HasOne(i => i.Order).WithMany(o => o.Items).HasForeignKey(i => i.OrderId); + e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId); + e.Property(i => i.UnitPrice).HasPrecision(18, 2); + }); + + b.Entity(e => + { + e.HasKey(p => p.Id); + e.Property(p => p.ListPrice).HasPrecision(18, 2); + }); + } +} diff --git a/src/Docs/Prerenderer/ExpressiveSharp.Docs.Prerenderer.csproj b/src/Docs/Prerenderer/ExpressiveSharp.Docs.Prerenderer.csproj new file mode 100644 index 0000000..5d6e6aa --- /dev/null +++ b/src/Docs/Prerenderer/ExpressiveSharp.Docs.Prerenderer.csproj @@ -0,0 +1,29 @@ + + + + + net10.0 + Exe + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/src/Docs/Prerenderer/LocalPlaygroundReferences.cs b/src/Docs/Prerenderer/LocalPlaygroundReferences.cs new file mode 100644 index 0000000..0787b68 --- /dev/null +++ b/src/Docs/Prerenderer/LocalPlaygroundReferences.cs @@ -0,0 +1,41 @@ +using System.Collections.Immutable; +using System.Reflection; +using Basic.Reference.Assemblies; +using ExpressiveSharp.Docs.Playground.Core.Services; +using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Docs.Prerenderer; + +/// +/// Loads reference assemblies from disk (the build output of the referenced +/// projects) instead of fetching via HTTP like the WASM PlaygroundReferences. +/// +internal sealed class LocalPlaygroundReferences : IPlaygroundReferences +{ + private ImmutableArray _references; + + public ImmutableArray References => _references; + public bool IsLoaded => !_references.IsDefault; + + public void Load() + { + if (IsLoaded) return; + + var builder = ImmutableArray.CreateBuilder(); + builder.AddRange(Net100.References.All); + + var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var scenario in ScenarioRegistry.All) + { + foreach (var assembly in scenario.ReferenceAssemblies) + { + if (string.IsNullOrEmpty(assembly.Location)) continue; + if (!seenPaths.Add(assembly.Location)) continue; + builder.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + } + + _references = builder.ToImmutable(); + } +} diff --git a/src/Docs/Prerenderer/Program.cs b/src/Docs/Prerenderer/Program.cs new file mode 100644 index 0000000..fbf8fa3 --- /dev/null +++ b/src/Docs/Prerenderer/Program.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ExpressiveSharp.Docs.Playground.Core.Services; +using ExpressiveSharp.Docs.Prerenderer; + +// Doc samples use plain `new DateTime(2024, 1, 1)` literals which have +// DateTimeKind.Unspecified. Npgsql 6+ rejects those for `timestamp with time +// zone` columns at translation time. Restoring the pre-6 legacy behavior +// treats Unspecified DateTimes as UTC — which is what the samples mean, +// and keeps the docs free of `DateTime.SpecifyKind(..., Utc)` boilerplate. +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + +// Parse --docs-root argument +var docsRoot = "../../docs"; +for (var i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--docs-root") + { + docsRoot = args[i + 1]; + break; + } +} + +docsRoot = Path.GetFullPath(docsRoot); +if (!Directory.Exists(docsRoot)) +{ + Console.Error.WriteLine($"Docs root not found: {docsRoot}"); + return 1; +} + +Console.WriteLine($"Docs root: {docsRoot}"); + +// Load references and create compiler +var references = new LocalPlaygroundReferences(); +references.Load(); + +var compiler = new SnippetCompiler(references); + +// Scan all markdown files for samples. Skip VitePress build artifacts and +// node_modules so the scan doesn't pick up generated or vendored .md. +var mdFiles = Directory.GetFiles(docsRoot, "*.md", SearchOption.AllDirectories) + .Where(f => + { + var rel = Path.GetRelativePath(docsRoot, f).Replace('\\', '/'); + return !rel.StartsWith(".vitepress/dist/", StringComparison.OrdinalIgnoreCase) + && !rel.StartsWith(".vitepress/cache/", StringComparison.OrdinalIgnoreCase) + && !rel.Contains("/node_modules/", StringComparison.OrdinalIgnoreCase); + }) + .ToArray(); +var allSamples = new List<(string relativePath, DocSample sample)>(); + +foreach (var mdFile in mdFiles) +{ + var content = File.ReadAllText(mdFile); + var relativePath = Path.GetRelativePath(docsRoot, mdFile).Replace('\\', '/'); + var samples = SampleExtractor.Extract(relativePath, content); + foreach (var sample in samples) + allSamples.Add((relativePath, sample)); +} + +if (allSamples.Count == 0) +{ + Console.WriteLine("No ::: expressive-sample blocks found."); + return 0; +} + +Console.WriteLine($"Found {allSamples.Count} sample(s) across {allSamples.Select(s => s.relativePath).Distinct().Count()} file(s)."); + +// Render all samples +await using var renderer = new SampleRenderer(compiler); +var failed = 0; +var byFile = new Dictionary>(StringComparer.Ordinal); + +foreach (var (relativePath, sample) in allSamples) +{ + try + { + var rendered = renderer.Render(sample); + if (!byFile.TryGetValue(relativePath, out var list)) + { + list = new List(); + byFile[relativePath] = list; + } + list.Add(rendered); + Console.WriteLine($" OK: {relativePath} [{sample.Index}] ({sample.StableKey})"); + } + catch (Exception ex) + { + Console.Error.WriteLine($" FAIL: {relativePath} [{sample.Index}]: {ex.Message}"); + failed++; + } +} + +// Write output JSON files +var outputDir = Path.Combine(docsRoot, ".vitepress", "data", "samples"); +Directory.CreateDirectory(outputDir); + +var jsonOptions = new JsonSerializerOptions +{ + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, +}; + +foreach (var (relativePath, samples) in byFile) +{ + var jsonPath = Path.Combine(outputDir, Path.ChangeExtension(relativePath, ".json")); + Directory.CreateDirectory(Path.GetDirectoryName(jsonPath)!); + + var json = JsonSerializer.Serialize(samples, jsonOptions); + + // Only overwrite if content changed (keeps git diffs clean) + if (File.Exists(jsonPath) && File.ReadAllText(jsonPath) == json) + { + Console.WriteLine($" Unchanged: {Path.GetRelativePath(docsRoot, jsonPath)}"); + continue; + } + + File.WriteAllText(jsonPath, json); + Console.WriteLine($" Wrote: {Path.GetRelativePath(docsRoot, jsonPath)}"); +} + +if (failed > 0) +{ + Console.Error.WriteLine($"\n{failed} sample(s) failed to compile. Docs examples must compile successfully."); + return 1; +} + +Console.WriteLine($"\nPre-rendered {allSamples.Count} sample(s) successfully."); +return 0; diff --git a/src/Docs/Prerenderer/SampleExtractor.cs b/src/Docs/Prerenderer/SampleExtractor.cs new file mode 100644 index 0000000..58f2e0e --- /dev/null +++ b/src/Docs/Prerenderer/SampleExtractor.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ExpressiveSharp.Docs.Prerenderer; + +internal sealed record DocSample( + string FilePath, + int Index, + string Snippet, + string? Setup, + string ScenarioId, + string StableKey); + +internal static class SampleExtractor +{ + private const string ContainerOpen = "::: expressive-sample"; + private const string ContainerClose = ":::"; + private const string SetupSeparator = "---setup---"; + + public static List Extract(string filePath, string content) + { + var samples = new List(); + var lines = content.Split('\n'); + var i = 0; + + while (i < lines.Length) + { + var trimmed = lines[i].TrimStart(); + if (!trimmed.StartsWith(ContainerOpen, StringComparison.Ordinal)) + { + i++; + continue; + } + + // Parse optional scenario from the opening line: "::: expressive-sample webshop" + var scenarioId = "webshop"; + var afterMarker = trimmed[ContainerOpen.Length..].Trim(); + if (afterMarker.Length > 0) + scenarioId = afterMarker; + + i++; + var bodyLines = new List(); + while (i < lines.Length) + { + var closeTrimmed = lines[i].TrimStart(); + if (closeTrimmed == ContainerClose || closeTrimmed.StartsWith(ContainerClose + " ", StringComparison.Ordinal)) + break; + bodyLines.Add(lines[i]); + i++; + } + i++; // skip the closing ::: + + var body = string.Join('\n', bodyLines).Trim(); + var separatorIdx = body.IndexOf(SetupSeparator, StringComparison.Ordinal); + + string snippet; + string? setup = null; + + if (separatorIdx >= 0) + { + snippet = body[..separatorIdx].Trim(); + setup = body[(separatorIdx + SetupSeparator.Length)..].Trim(); + } + else + { + snippet = body; + } + + var key = ComputeStableKey(snippet, setup); + samples.Add(new DocSample(filePath, samples.Count, snippet, setup, scenarioId, key)); + } + + return samples; + } + + public static string ComputeStableKey(string snippet, string? setup) + { + var input = snippet + "\0" + (setup ?? ""); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash)[..12].ToLowerInvariant(); + } +} diff --git a/src/Docs/Prerenderer/SampleRenderer.cs b/src/Docs/Prerenderer/SampleRenderer.cs new file mode 100644 index 0000000..f4ee4fb --- /dev/null +++ b/src/Docs/Prerenderer/SampleRenderer.cs @@ -0,0 +1,455 @@ +using ExpressiveSharp.Docs.Playground.Core.Services; +using ExpressiveSharp.Docs.Playground.Core.Services.Scenarios; +using ExpressiveSharp.Docs.PlaygroundModel.Webshop; +using ExpressiveSharp.EntityFrameworkCore; +using ExpressiveSharp.MongoDB.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using ExpressiveSharp; + +namespace ExpressiveSharp.Docs.Prerenderer; + +internal sealed record RenderedSample( + string Key, + string Snippet, + string? Setup, + Dictionary Targets); + +internal sealed record RenderedTarget( + string Label, + string Language, + string Output, + bool IsError = false); + +internal sealed class SampleRenderer : IAsyncDisposable +{ + private readonly SnippetCompiler _compiler; + + // A fresh context per sample is essential: reusing a DbContext (or shared + // MongoDB root) across samples corrupts EF Core's query cache when one + // sample's translation fails. Downstream samples then surface errors like + // "Expression of type 'System.Object' cannot be used for return type + // 'System.String'" even though the snippet is translatable in isolation. + + private static WebshopDbContext BuildSqlServerContext() => + new(new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=playground") + .UseExpressives() + .EnableServiceProviderCaching(false) + .Options); + + private static WebshopDbContext BuildCosmosContext() => + new(new DbContextOptionsBuilder() + .UseCosmos("AccountEndpoint=https://localhost:8081/;AccountKey=dW5pdHRlc3Q=", "playground") + .UseExpressives() + .EnableServiceProviderCaching(false) + .Options); + + private static IWebshopQueryRoots BuildMongoRoots() + { + var db = new MongoClient("mongodb://localhost:27017").GetDatabase("playground"); + return new MongoRootsImpl( + db.GetCollection("customers").AsExpressive(), + db.GetCollection("orders").AsExpressive(), + db.GetCollection("products").AsExpressive(), + db.GetCollection("line_items").AsExpressive()); + } + + private sealed class DbContextRoots : IWebshopQueryRoots + { + private readonly WebshopDbContext _ctx; + public DbContextRoots(WebshopDbContext ctx) { _ctx = ctx; } + public IExpressiveQueryable Customers => _ctx.Customers; + public IExpressiveQueryable Orders => _ctx.Orders; + public IExpressiveQueryable Products => _ctx.Products; + public IExpressiveQueryable LineItems => _ctx.LineItems; + } + + private sealed class MongoRootsImpl : IWebshopQueryRoots + { + public MongoRootsImpl( + IExpressiveQueryable customers, + IExpressiveQueryable orders, + IExpressiveQueryable products, + IExpressiveQueryable lineItems) + { + Customers = customers; + Orders = orders; + Products = products; + LineItems = lineItems; + } + + public IExpressiveQueryable Customers { get; } + public IExpressiveQueryable Orders { get; } + public IExpressiveQueryable Products { get; } + public IExpressiveQueryable LineItems { get; } + } + + public SampleRenderer(SnippetCompiler compiler) + { + _compiler = compiler; + } + + public RenderedSample Render(DocSample sample) + { + var scenario = ScenarioRegistry.Resolve(sample.ScenarioId); + var formatted = FormatSnippet(sample.Snippet); + var result = _compiler.Compile(sample.Snippet, sample.Setup, scenario); + + if (!result.Success) + { + var errors = string.Join(Environment.NewLine, result.Diagnostics.Select(d => d.ToString())); + throw new InvalidOperationException( + $"Sample in {sample.FilePath} (index {sample.Index}) failed to compile:\n{errors}"); + } + + if (result.Assembly is null) + throw new InvalidOperationException( + $"Sample in {sample.FilePath} (index {sample.Index}) compiled but produced no assembly."); + + var snippetType = result.Assembly.GetType(SnippetCompiler.SnippetTypeFullName) + ?? throw new InvalidOperationException( + $"Snippet type '{SnippetCompiler.SnippetTypeFullName}' not found."); + var runMethod = snippetType.GetMethod("Run", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + ?? throw new InvalidOperationException("Snippet.Run method not found."); + + Func invoke = arg => + (IQueryable?)runMethod.Invoke(null, new[] { arg }) + ?? throw new InvalidOperationException("Snippet.Run returned null."); + + // Reset ExpressiveSharp's process-level resolver caches between samples, + // then restrict registry scanning to only the current snippet's assembly. + // Without this, previous samples' assemblies accumulate in the AppDomain, + // their [ExpressiveFor] registrations overlap with the current sample's, + // and FindExternalExpression throws "Multiple mappings found". + ExpressiveSharp.Services.ExpressiveResolver.ResetAllCaches(); + var snippetAssembly = result.Assembly; + ExpressiveSharp.Services.ExpressiveResolver.SetAssemblyScanFilter( + asm => asm == snippetAssembly + || asm == typeof(ExpressiveSharp.ExpressiveAttribute).Assembly + || asm == typeof(ExpressiveSharp.EntityFrameworkCore.ExpressiveDbSet<>).Assembly); + + // Fresh scenario instance per sample — isolates per-sample EF Core + // query-cache state so one sample's failure can't poison the next. + using var instanceScope = new ScenarioInstanceScope(scenario); + var targets = new Dictionary(); + + foreach (var renderTarget in scenario.RenderTargets) + { + try + { + var queryArgument = renderTarget.GetQueryArgument?.Invoke(instanceScope.Instance) ?? instanceScope.Instance.QueryArgument; + var queryable = invoke(queryArgument); + var output = renderTarget.Render(queryable, instanceScope.Instance); + targets[renderTarget.Id] = new RenderedTarget(renderTarget.Label, renderTarget.OutputLanguage, output); + } + catch (Exception ex) + { + targets[renderTarget.Id] = new RenderedTarget( + renderTarget.Label, + renderTarget.OutputLanguage, + FormatErrorMessage(ex), + IsError: true); + } + } + + // Prerenderer-only providers: fresh DbContexts / Mongo roots per sample. + // Their client libraries throw on WASM so they can't live in Core. + if (scenario.Id == "webshop") + { + using var sqlServer = BuildSqlServerContext(); + using var cosmos = BuildCosmosContext(); + RenderPrerendererTarget(targets, invoke, "sqlserver", "EF Core + SQL Server", "sql", + new DbContextRoots(sqlServer), static (q, _) => q.ToQueryString()); + RenderPrerendererTarget(targets, invoke, "cosmos", "EF Core + Cosmos DB", "sql", + new DbContextRoots(cosmos), static (q, _) => q.ToQueryString()); + RenderPrerendererTarget(targets, invoke, "mongodb", "MongoDB", "javascript", + BuildMongoRoots(), static (q, _) => FormatMongoOutput(q.ToString()!)); + } + + // Generator output + var generatorOutput = FormatGeneratorOutput(result.GeneratedSources); + targets["generator"] = new RenderedTarget("Generator output", "csharp", generatorOutput); + + return new RenderedSample(sample.StableKey, formatted, sample.Setup, targets); + } + + private static void RenderPrerendererTarget( + Dictionary targets, + Func invoke, + string id, string label, string language, + object queryArgument, + Func render) + { + try + { + var queryable = invoke(queryArgument); + targets[id] = new RenderedTarget(label, language, render(queryable, null)); + } + catch (Exception ex) + { + targets[id] = new RenderedTarget(label, language, + FormatErrorMessage(ex), + IsError: true); + } + } + + // Extracts a clean, readable error message from the exception chain. + // Strips EF Core's verbose LINQ expression dump and leaves just the + // "could not be translated" reason plus the fwlink hint if present. + private static string FormatErrorMessage(Exception ex) + { + // Unwrap TargetInvocationException and TypeInitializationException — + // they add no signal, the inner message is the real reason the query + // could not be translated. + while (ex is System.Reflection.TargetInvocationException or TypeInitializationException && ex.InnerException is not null) + ex = ex.InnerException; + + if (Environment.GetEnvironmentVariable("PRERENDERER_VERBOSE") == "1") + Console.Error.WriteLine($"\n--- Exception from {ex.GetType().Name} ---\n{ex}\n---"); + + var msg = ex.Message; + // EF Core often prepends "The LINQ expression '...' could not be translated." + // followed by "Additional information: ..." with the real reason. + var additionalIdx = msg.IndexOf("Additional information:", StringComparison.Ordinal); + if (additionalIdx >= 0) + { + var reason = msg[(additionalIdx + "Additional information:".Length)..].Trim(); + // Trim the fwlink footer too + var fwlinkIdx = reason.IndexOf("See https://", StringComparison.Ordinal); + if (fwlinkIdx >= 0) reason = reason[..fwlinkIdx].Trim(); + return reason; + } + return msg; + } + + // Synchronously disposes the scenario instance at end-of-scope so the + // DbContext underlying the in-scenario render targets (SQLite, Postgres + // in WebshopScenarioInstance) is torn down before the next sample runs. + private readonly struct ScenarioInstanceScope : IDisposable + { + public IScenarioInstance Instance { get; } + + public ScenarioInstanceScope(IPlaygroundScenario scenario) + { + Instance = scenario.CreateInstance(); + } + + public void Dispose() + { + try { Instance.DisposeAsync().AsTask().GetAwaiter().GetResult(); } + catch { /* best-effort */ } + } + } + + // Formats a C# expression across multiple lines: + // 1. SnippetFormatter breaks outer fluent chains (.Where().Select() → one per line) + // 2. A custom syntax rewriter breaks switch expression arms onto their own lines + // 3. Roslyn's Formatter normalizes indentation + private static string FormatSnippet(string snippet) + { + var preformatted = SnippetFormatter.Format(snippet); + var wrapped = "_ = " + preformatted + ";"; + var root = CSharpSyntaxTree.ParseText(wrapped).GetRoot(); + + // Break switch expression arms onto separate lines + root = new SwitchExpressionBreaker().Visit(root); + + using var workspace = new Microsoft.CodeAnalysis.AdhocWorkspace(); + var formatted = Formatter.Format(root, workspace).ToFullString().Trim(); + + // Collapse accidental blank lines the rewriter may introduce + formatted = System.Text.RegularExpressions.Regex.Replace(formatted, @"\n\s*\n", "\n"); + + if (formatted.StartsWith("_ = ", StringComparison.Ordinal)) + formatted = formatted[4..]; + if (formatted.EndsWith(";", StringComparison.Ordinal)) + formatted = formatted[..^1]; + return formatted.Trim(); + } + + private sealed class SwitchExpressionBreaker : Microsoft.CodeAnalysis.CSharp.CSharpSyntaxRewriter + { + public override Microsoft.CodeAnalysis.SyntaxNode? VisitSwitchExpression( + Microsoft.CodeAnalysis.CSharp.Syntax.SwitchExpressionSyntax node) + { + var visited = (Microsoft.CodeAnalysis.CSharp.Syntax.SwitchExpressionSyntax)base.VisitSwitchExpression(node)!; + var newline = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.EndOfLine("\n"); + + // Put each arm on its own line by injecting a newline before each arm's leading trivia + var newArms = visited.Arms.Select(arm => + arm.WithLeadingTrivia(arm.GetLeadingTrivia().Insert(0, newline))); + + return visited + .WithOpenBraceToken(visited.OpenBraceToken.WithTrailingTrivia(newline)) + .WithArms(Microsoft.CodeAnalysis.CSharp.SyntaxFactory.SeparatedList(newArms, visited.Arms.GetSeparators())) + .WithCloseBraceToken(visited.CloseBraceToken.WithLeadingTrivia(newline)); + } + } + + // Pretty-prints a MongoDB aggregation pipeline. Raw output is a single line: + // collection.Aggregate([{ "$match" : {...} }, { "$project" : {...} }]) + // Output expands each stage and the BSON content inside each stage. + private static string FormatMongoOutput(string raw) + { + var openIdx = raw.IndexOf("Aggregate([", StringComparison.Ordinal); + if (openIdx < 0) return raw; + var prefix = raw[..(openIdx + "Aggregate(".Length)]; + var closeIdx = raw.LastIndexOf("])", StringComparison.Ordinal); + if (closeIdx < 0) return raw; + var stagesJson = raw.Substring(openIdx + "Aggregate([".Length, closeIdx - openIdx - "Aggregate([".Length); + var suffix = raw[(closeIdx + 1)..]; + + var stages = SplitTopLevel(stagesJson); + + var sb = new System.Text.StringBuilder(); + sb.Append(prefix).AppendLine("["); + for (var i = 0; i < stages.Count; i++) + { + var stage = stages[i].Trim(); + var pretty = PrettyPrintBson(stage, indentDepth: 1); + sb.Append(" ").Append(pretty); + if (i < stages.Count - 1) sb.Append(','); + sb.AppendLine(); + } + sb.Append(']').Append(suffix); + return sb.ToString(); + } + + // Pretty-prints relaxed JSON (Mongo-style with spaces around colons). + // Breaks objects and arrays onto multiple lines when they contain nested + // objects/arrays; keeps short primitive-only objects inline. + private static string PrettyPrintBson(string json, int indentDepth) + { + var sb = new System.Text.StringBuilder(); + var depth = indentDepth; + var inString = false; + for (var i = 0; i < json.Length; i++) + { + var c = json[i]; + if (c == '"' && (i == 0 || json[i - 1] != '\\')) inString = !inString; + + if (inString) + { + sb.Append(c); + continue; + } + + if (c == '{' || c == '[') + { + sb.Append(c); + // Check if contents are simple (no nested objects/arrays) + var closing = c == '{' ? '}' : ']'; + var end = FindMatchingBrace(json, i, c, closing); + var inner = end > i ? json[(i + 1)..end] : ""; + if (end > i && !ContainsNested(inner)) + { + // Keep short object/array inline + sb.Append(inner).Append(closing); + i = end; + continue; + } + depth++; + sb.AppendLine(); + sb.Append(new string(' ', depth * 4)); + } + else if (c == '}' || c == ']') + { + depth--; + sb.AppendLine(); + sb.Append(new string(' ', depth * 4)); + sb.Append(c); + } + else if (c == ',') + { + sb.Append(','); + sb.AppendLine(); + sb.Append(new string(' ', depth * 4)); + // Skip the following space if present + if (i + 1 < json.Length && json[i + 1] == ' ') i++; + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } + + private static int FindMatchingBrace(string s, int start, char open, char close) + { + var depth = 0; + var inString = false; + for (var i = start; i < s.Length; i++) + { + var c = s[i]; + if (c == '"' && (i == 0 || s[i - 1] != '\\')) inString = !inString; + if (inString) continue; + if (c == open) depth++; + else if (c == close) + { + depth--; + if (depth == 0) return i; + } + } + return -1; + } + + private static bool ContainsNested(string s) + { + var inString = false; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (c == '"' && (i == 0 || s[i - 1] != '\\')) inString = !inString; + if (inString) continue; + if (c == '{' || c == '[') return true; + } + return false; + } + + private static List SplitTopLevel(string s) + { + var parts = new List(); + var depth = 0; + var start = 0; + var inString = false; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (c == '"' && (i == 0 || s[i - 1] != '\\')) inString = !inString; + if (inString) continue; + if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') depth--; + else if (c == ',' && depth == 0) + { + parts.Add(s[start..i]); + start = i + 1; + } + } + parts.Add(s[start..]); + return parts; + } + + private static string FormatGeneratorOutput(IReadOnlyList sources) + { + if (sources.Count == 0) + return "// (no generator output for this snippet)"; + + var sb = new System.Text.StringBuilder(); + for (var i = 0; i < sources.Count; i++) + { + if (i > 0) sb.AppendLine().AppendLine(); + sb.Append("// === ").Append(sources[i].HintName).AppendLine(" ==="); + sb.Append(sources[i].Source); + } + return sb.ToString(); + } + + // All per-sample state is now disposed in Render() itself; nothing to + // clean up at the renderer level. + public ValueTask DisposeAsync() => default; +} diff --git a/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs b/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs index 964e1fd..79bd20f 100644 --- a/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs +++ b/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs @@ -68,14 +68,26 @@ public string EnsureMethodInfo(IMethodSymbol method) // Disambiguate overloads that share name, generic arity, and parameter count // (e.g., SetProperty

(Func, P) vs SetProperty

(Func, Func)) // by checking whether each parameter is a generic type or a type parameter. + // Also pin concrete type arguments inside generic parameters (e.g., the + // `decimal` in `Func`) so we don't collide with sibling + // overloads that only differ in closed type args — this is what + // separates Sum(Func) from Sum(Func), etc. var paramChecksBuilder = new System.Text.StringBuilder(); for (int i = 0; i < originalDef.Parameters.Length; i++) { var paramType = originalDef.Parameters[i].Type; if (paramType is ITypeParameterSymbol) paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.IsGenericParameter && !m.GetParameters()[{i}].ParameterType.IsGenericType"); - else if (paramType is INamedTypeSymbol { IsGenericType: true }) + else if (paramType is INamedTypeSymbol { IsGenericType: true } genericParam) + { paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.IsGenericType && !m.GetParameters()[{i}].ParameterType.IsGenericParameter"); + // Pin each closed (non-type-parameter) type argument by index + for (int t = 0; t < genericParam.TypeArguments.Length; t++) + { + if (genericParam.TypeArguments[t] is not ITypeParameterSymbol) + paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.GetGenericArguments()[{t}] == typeof({ResolveTypeFqn(genericParam.TypeArguments[t])})"); + } + } } var paramChecks = paramChecksBuilder.ToString(); diff --git a/src/ExpressiveSharp/Services/ExpressiveResolver.cs b/src/ExpressiveSharp/Services/ExpressiveResolver.cs index 99109c1..8dfe960 100644 --- a/src/ExpressiveSharp/Services/ExpressiveResolver.cs +++ b/src/ExpressiveSharp/Services/ExpressiveResolver.cs @@ -21,6 +21,35 @@ public sealed class ExpressiveResolver : IExpressiveResolver /// private readonly static ConcurrentDictionary _expressionCache = new(); + ///

+ /// Clears all process-level caches built up by the resolver. Intended for test harnesses + /// and the docs prerenderer, where many short-lived assemblies are loaded in sequence and + /// accumulated [ExpressiveFor] registrations across them would cause false "multiple + /// mappings" errors. Not part of the public production API surface. + /// + public static void ResetAllCaches() + { + _assemblyRegistries.Clear(); + _expressionCache.Clear(); + _reflectionCache.Clear(); + _lastScannedAssemblyCount = 0; + _assemblyScanFilter = null; + } + + private static Func? _assemblyScanFilter; + + /// + /// Restricts to assemblies matching the given filter. + /// Used by the docs prerenderer to register only the currently-rendering snippet's assembly + /// instead of every previously-loaded snippet assembly still in the AppDomain. + /// Pass null to remove the filter. + /// + public static void SetAssemblyScanFilter(Func? filter) + { + _assemblyScanFilter = filter; + _lastScannedAssemblyCount = 0; + } + /// /// Caches → C#-formatted name strings, since the same parameter types /// appear repeatedly across different expressive members. @@ -97,7 +126,22 @@ public LambdaExpression FindGeneratedExpression(MemberInfo expressiveMemberInfo, if (ReferenceEquals(kvp.Value, _nullRegistry)) continue; - var result = kvp.Value(memberInfo); + LambdaExpression? result; + try + { + result = kvp.Value(memberInfo); + } + catch (TypeInitializationException) + { + // The registry's static type initializer failed — typically + // because a generated *_Expression() factory threw (e.g. a + // malformed lambda built from an unsupported IOperation). + // Mark this registry as broken so we stop probing it for + // every subsequent member lookup. + _assemblyRegistries[kvp.Key] = _nullRegistry; + continue; + } + if (result is null) continue; @@ -113,30 +157,39 @@ public LambdaExpression FindGeneratedExpression(MemberInfo expressiveMemberInfo, return found; } - private static volatile bool _allRegistriesScanned; + private static int _lastScannedAssemblyCount; private static readonly object _scanLock = new(); /// - /// Scans all loaded assemblies once to discover expression registries. - /// This is a one-time cost on the first call. + /// Scans loaded assemblies for expression registries. Rescans on demand + /// whenever new assemblies have been loaded into the AppDomain since the + /// previous scan — this matters for runtime-compiled assemblies (e.g. + /// the docs prerenderer) where the first scan happens before later + /// samples' assemblies are loaded. /// private static void EnsureAllRegistriesLoaded() { - if (_allRegistriesScanned) return; + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + if (assemblies.Length == _lastScannedAssemblyCount) return; lock (_scanLock) { - if (_allRegistriesScanned) return; + assemblies = AppDomain.CurrentDomain.GetAssemblies(); + if (assemblies.Length == _lastScannedAssemblyCount) return; - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + var filter = _assemblyScanFilter; + foreach (var assembly in assemblies) { if (assembly.IsDynamic) continue; + if (filter is not null && !filter(assembly)) + continue; + GetAssemblyRegistry(assembly); } - _allRegistriesScanned = true; + _lastScannedAssemblyCount = assemblies.Length; } } From 25e127376be7646ff43f00708f7468501a8a84eb Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 01:19:30 +0000 Subject: [PATCH 3/6] feat: install wasm-tools workload for AOT compilation in benchmarks, CI, docs, and release workflows --- .github/workflows/benchmarks.yml | 5 +++++ .github/workflows/ci.yml | 7 +++++++ .github/workflows/docs.yml | 5 +++++ .github/workflows/release.yml | 6 ++++++ 4 files changed, 23 insertions(+) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index f4412ef..cb41d14 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -45,6 +45,11 @@ jobs: restore-keys: | nuget-${{ runner.os }}- + # Solution-wide build pulls in Playground.Wasm, which needs wasm-tools + # for its Release AOT compilation step. + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + - name: Restore run: dotnet restore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1913a7..b1ecd4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,13 @@ jobs: restore-keys: | nuget-${{ runner.os }}- + # ExpressiveSharp.Docs.Playground.Wasm uses RunAOTCompilation=true in + # Release, which requires the wasm-tools workload (native toolchain + + # Emscripten). `dotnet restore` triggers the workload check before + # compilation, so install first. + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + - name: Restore run: dotnet restore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a4c9f51..886ca77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,11 @@ jobs: with: dotnet-version: '10.0.x' + # Playground.Wasm sets RunAOTCompilation=true in Release, which needs + # the wasm-tools workload (Emscripten + native WASM toolchain). + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + - name: Publish ExpressiveSharp Playground (Blazor WASM) run: | dotnet publish src/Docs/Playground.Wasm/ExpressiveSharp.Docs.Playground.Wasm.csproj \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 642debb..03ce41b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,12 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Publishing version: $VERSION" + # Solution-wide build pulls in Playground.Wasm, which needs wasm-tools + # for its Release AOT compilation step. (The release itself doesn't pack + # the Wasm project — IsPackable=false — but it's still built.) + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + - name: Restore run: dotnet restore From 392445e90f77888afca298c2691ba6e2d8c14fa6 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 01:31:21 +0000 Subject: [PATCH 4/6] eased it up on the comments --- .github/workflows/benchmarks.yml | 3 +- .github/workflows/ci.yml | 5 +- .github/workflows/docs.yml | 3 +- .github/workflows/release.yml | 4 +- .../Scenarios/WebshopScenarioInstance.cs | 8 +--- .../Scenarios/Webshop/IWebshopQueryRoots.cs | 7 +-- src/Docs/Prerenderer/Program.cs | 8 +--- src/Docs/Prerenderer/SampleRenderer.cs | 47 ++++--------------- .../Emitter/ReflectionFieldCache.cs | 9 +--- .../Services/ExpressiveResolver.cs | 19 ++------ 10 files changed, 25 insertions(+), 88 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index cb41d14..90b5045 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -45,8 +45,7 @@ jobs: restore-keys: | nuget-${{ runner.os }}- - # Solution-wide build pulls in Playground.Wasm, which needs wasm-tools - # for its Release AOT compilation step. + # Required by Playground.Wasm's Release AOT compilation. - name: Install wasm-tools workload run: dotnet workload install wasm-tools diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1ecd4a..644ad77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,10 +39,7 @@ jobs: restore-keys: | nuget-${{ runner.os }}- - # ExpressiveSharp.Docs.Playground.Wasm uses RunAOTCompilation=true in - # Release, which requires the wasm-tools workload (native toolchain + - # Emscripten). `dotnet restore` triggers the workload check before - # compilation, so install first. + # Required by Playground.Wasm's Release AOT compilation. - name: Install wasm-tools workload run: dotnet workload install wasm-tools diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 886ca77..b7a8e2d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,8 +26,7 @@ jobs: with: dotnet-version: '10.0.x' - # Playground.Wasm sets RunAOTCompilation=true in Release, which needs - # the wasm-tools workload (Emscripten + native WASM toolchain). + # Required by Playground.Wasm's Release AOT compilation. - name: Install wasm-tools workload run: dotnet workload install wasm-tools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03ce41b..1bf88ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,9 +53,7 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Publishing version: $VERSION" - # Solution-wide build pulls in Playground.Wasm, which needs wasm-tools - # for its Release AOT compilation step. (The release itself doesn't pack - # the Wasm project — IsPackable=false — but it's still built.) + # Required by Playground.Wasm's Release AOT compilation. - name: Install wasm-tools workload run: dotnet workload install wasm-tools diff --git a/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs b/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs index 501f965..cb72a07 100644 --- a/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs +++ b/src/Docs/Playground.Core/Services/Scenarios/WebshopScenarioInstance.cs @@ -12,15 +12,12 @@ public sealed class WebshopScenarioInstance : IScenarioInstance public WebshopScenarioInstance() { + // ServiceProviderCaching disabled so a failing sample's bad compiled-query + // state can't be reused by the next sample's context. _sqlite = new WebshopDbContext( new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .UseExpressives() - // Disable EF Core's process-wide internal service provider cache. - // Without this, failing queries in one sample can cache bad - // compiled-query state that breaks later samples with seemingly - // unrelated errors (e.g., "'System.Object' cannot be used for - // return type 'System.String'"). .EnableServiceProviderCaching(false) .Options); } @@ -46,7 +43,6 @@ public async ValueTask DisposeAsync() await _postgres.DisposeAsync(); } - // Adapts a WebshopDbContext to IWebshopQueryRoots. private sealed class DbContextRoots : IWebshopQueryRoots { private readonly WebshopDbContext _ctx; diff --git a/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs b/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs index e96dc30..2814027 100644 --- a/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs +++ b/src/Docs/PlaygroundModel/Scenarios/Webshop/IWebshopQueryRoots.cs @@ -3,11 +3,8 @@ namespace ExpressiveSharp.Docs.PlaygroundModel.Webshop; /// -/// Multi-root query context that sample snippets receive as their sole argument. -/// Each render target (EF Core SQLite/Postgres/SqlServer/Cosmos, MongoDB, in-memory -/// playground) supplies its own implementation wrapping its underlying queryables. -/// Snippets read as db.Customers.Where(...), db.Orders.SelectMany(...), -/// etc. — naturally rooted at whichever entity set the example needs. +/// Query context passed to sample snippets; each render target supplies its own +/// implementation (EF Core DbContext, MongoDB collections, in-memory arrays). /// public interface IWebshopQueryRoots { diff --git a/src/Docs/Prerenderer/Program.cs b/src/Docs/Prerenderer/Program.cs index fbf8fa3..2fa8ec4 100644 --- a/src/Docs/Prerenderer/Program.cs +++ b/src/Docs/Prerenderer/Program.cs @@ -3,14 +3,10 @@ using ExpressiveSharp.Docs.Playground.Core.Services; using ExpressiveSharp.Docs.Prerenderer; -// Doc samples use plain `new DateTime(2024, 1, 1)` literals which have -// DateTimeKind.Unspecified. Npgsql 6+ rejects those for `timestamp with time -// zone` columns at translation time. Restoring the pre-6 legacy behavior -// treats Unspecified DateTimes as UTC — which is what the samples mean, -// and keeps the docs free of `DateTime.SpecifyKind(..., Utc)` boilerplate. +// Treat `new DateTime(...)` literals (Kind=Unspecified) as UTC so Npgsql accepts +// them for `timestamp with time zone` columns — samples stay free of SpecifyKind. AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); -// Parse --docs-root argument var docsRoot = "../../docs"; for (var i = 0; i < args.Length - 1; i++) { diff --git a/src/Docs/Prerenderer/SampleRenderer.cs b/src/Docs/Prerenderer/SampleRenderer.cs index f4ee4fb..5ef2c2d 100644 --- a/src/Docs/Prerenderer/SampleRenderer.cs +++ b/src/Docs/Prerenderer/SampleRenderer.cs @@ -28,12 +28,8 @@ internal sealed class SampleRenderer : IAsyncDisposable { private readonly SnippetCompiler _compiler; - // A fresh context per sample is essential: reusing a DbContext (or shared - // MongoDB root) across samples corrupts EF Core's query cache when one - // sample's translation fails. Downstream samples then surface errors like - // "Expression of type 'System.Object' cannot be used for return type - // 'System.String'" even though the snippet is translatable in isolation. - + // Fresh contexts per sample — a failing sample corrupts EF Core's query cache + // and poisons subsequent samples' translations. private static WebshopDbContext BuildSqlServerContext() => new(new DbContextOptionsBuilder() .UseSqlServer("Server=.;Database=playground") @@ -120,11 +116,9 @@ public RenderedSample Render(DocSample sample) (IQueryable?)runMethod.Invoke(null, new[] { arg }) ?? throw new InvalidOperationException("Snippet.Run returned null."); - // Reset ExpressiveSharp's process-level resolver caches between samples, - // then restrict registry scanning to only the current snippet's assembly. - // Without this, previous samples' assemblies accumulate in the AppDomain, - // their [ExpressiveFor] registrations overlap with the current sample's, - // and FindExternalExpression throws "Multiple mappings found". + // Isolate each sample from prior ones: clear resolver caches and + // restrict registry scanning to the current snippet's assembly so + // accumulated [ExpressiveFor] registrations don't collide. ExpressiveSharp.Services.ExpressiveResolver.ResetAllCaches(); var snippetAssembly = result.Assembly; ExpressiveSharp.Services.ExpressiveResolver.SetAssemblyScanFilter( @@ -132,8 +126,6 @@ public RenderedSample Render(DocSample sample) || asm == typeof(ExpressiveSharp.ExpressiveAttribute).Assembly || asm == typeof(ExpressiveSharp.EntityFrameworkCore.ExpressiveDbSet<>).Assembly); - // Fresh scenario instance per sample — isolates per-sample EF Core - // query-cache state so one sample's failure can't poison the next. using var instanceScope = new ScenarioInstanceScope(scenario); var targets = new Dictionary(); @@ -156,8 +148,7 @@ public RenderedSample Render(DocSample sample) } } - // Prerenderer-only providers: fresh DbContexts / Mongo roots per sample. - // Their client libraries throw on WASM so they can't live in Core. + // Prerenderer-only providers — their client libraries throw on WASM. if (scenario.Id == "webshop") { using var sqlServer = BuildSqlServerContext(); @@ -197,14 +188,9 @@ private static void RenderPrerendererTarget( } } - // Extracts a clean, readable error message from the exception chain. - // Strips EF Core's verbose LINQ expression dump and leaves just the - // "could not be translated" reason plus the fwlink hint if present. + // Strips EF Core's verbose LINQ expression dump and returns the translation reason only. private static string FormatErrorMessage(Exception ex) { - // Unwrap TargetInvocationException and TypeInitializationException — - // they add no signal, the inner message is the real reason the query - // could not be translated. while (ex is System.Reflection.TargetInvocationException or TypeInitializationException && ex.InnerException is not null) ex = ex.InnerException; @@ -212,13 +198,10 @@ private static string FormatErrorMessage(Exception ex) Console.Error.WriteLine($"\n--- Exception from {ex.GetType().Name} ---\n{ex}\n---"); var msg = ex.Message; - // EF Core often prepends "The LINQ expression '...' could not be translated." - // followed by "Additional information: ..." with the real reason. var additionalIdx = msg.IndexOf("Additional information:", StringComparison.Ordinal); if (additionalIdx >= 0) { var reason = msg[(additionalIdx + "Additional information:".Length)..].Trim(); - // Trim the fwlink footer too var fwlinkIdx = reason.IndexOf("See https://", StringComparison.Ordinal); if (fwlinkIdx >= 0) reason = reason[..fwlinkIdx].Trim(); return reason; @@ -226,9 +209,6 @@ private static string FormatErrorMessage(Exception ex) return msg; } - // Synchronously disposes the scenario instance at end-of-scope so the - // DbContext underlying the in-scenario render targets (SQLite, Postgres - // in WebshopScenarioInstance) is torn down before the next sample runs. private readonly struct ScenarioInstanceScope : IDisposable { public IScenarioInstance Instance { get; } @@ -245,17 +225,12 @@ public void Dispose() } } - // Formats a C# expression across multiple lines: - // 1. SnippetFormatter breaks outer fluent chains (.Where().Select() → one per line) - // 2. A custom syntax rewriter breaks switch expression arms onto their own lines - // 3. Roslyn's Formatter normalizes indentation private static string FormatSnippet(string snippet) { var preformatted = SnippetFormatter.Format(snippet); var wrapped = "_ = " + preformatted + ";"; var root = CSharpSyntaxTree.ParseText(wrapped).GetRoot(); - // Break switch expression arms onto separate lines root = new SwitchExpressionBreaker().Visit(root); using var workspace = new Microsoft.CodeAnalysis.AdhocWorkspace(); @@ -290,9 +265,7 @@ private sealed class SwitchExpressionBreaker : Microsoft.CodeAnalysis.CSharp.CSh } } - // Pretty-prints a MongoDB aggregation pipeline. Raw output is a single line: - // collection.Aggregate([{ "$match" : {...} }, { "$project" : {...} }]) - // Output expands each stage and the BSON content inside each stage. + // Breaks MongoDB's single-line `Aggregate([...])` output into one stage per line. private static string FormatMongoOutput(string raw) { var openIdx = raw.IndexOf("Aggregate([", StringComparison.Ordinal); @@ -319,9 +292,7 @@ private static string FormatMongoOutput(string raw) return sb.ToString(); } - // Pretty-prints relaxed JSON (Mongo-style with spaces around colons). - // Breaks objects and arrays onto multiple lines when they contain nested - // objects/arrays; keeps short primitive-only objects inline. + // Breaks nested-object BSON across lines; keeps primitive-only objects inline. private static string PrettyPrintBson(string json, int indentDepth) { var sb = new System.Text.StringBuilder(); diff --git a/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs b/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs index 79bd20f..385f40a 100644 --- a/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs +++ b/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs @@ -66,12 +66,8 @@ public string EnsureMethodInfo(IMethodSymbol method) $"typeof({ResolveTypeFqn(t)})")); // Disambiguate overloads that share name, generic arity, and parameter count - // (e.g., SetProperty

(Func, P) vs SetProperty

(Func, Func)) - // by checking whether each parameter is a generic type or a type parameter. - // Also pin concrete type arguments inside generic parameters (e.g., the - // `decimal` in `Func`) so we don't collide with sibling - // overloads that only differ in closed type args — this is what - // separates Sum(Func) from Sum(Func), etc. + // by pinning each parameter's shape and any closed type arguments — this is + // what separates Sum(Func) from Sum(Func), etc. var paramChecksBuilder = new System.Text.StringBuilder(); for (int i = 0; i < originalDef.Parameters.Length; i++) { @@ -81,7 +77,6 @@ public string EnsureMethodInfo(IMethodSymbol method) else if (paramType is INamedTypeSymbol { IsGenericType: true } genericParam) { paramChecksBuilder.Append($" && m.GetParameters()[{i}].ParameterType.IsGenericType && !m.GetParameters()[{i}].ParameterType.IsGenericParameter"); - // Pin each closed (non-type-parameter) type argument by index for (int t = 0; t < genericParam.TypeArguments.Length; t++) { if (genericParam.TypeArguments[t] is not ITypeParameterSymbol) diff --git a/src/ExpressiveSharp/Services/ExpressiveResolver.cs b/src/ExpressiveSharp/Services/ExpressiveResolver.cs index 8dfe960..8c29820 100644 --- a/src/ExpressiveSharp/Services/ExpressiveResolver.cs +++ b/src/ExpressiveSharp/Services/ExpressiveResolver.cs @@ -23,9 +23,7 @@ public sealed class ExpressiveResolver : IExpressiveResolver ///

/// Clears all process-level caches built up by the resolver. Intended for test harnesses - /// and the docs prerenderer, where many short-lived assemblies are loaded in sequence and - /// accumulated [ExpressiveFor] registrations across them would cause false "multiple - /// mappings" errors. Not part of the public production API surface. + /// and the docs prerenderer, where many short-lived snippet assemblies are loaded in sequence. /// public static void ResetAllCaches() { @@ -40,8 +38,6 @@ public static void ResetAllCaches() /// /// Restricts to assemblies matching the given filter. - /// Used by the docs prerenderer to register only the currently-rendering snippet's assembly - /// instead of every previously-loaded snippet assembly still in the AppDomain. /// Pass null to remove the filter. /// public static void SetAssemblyScanFilter(Func? filter) @@ -133,11 +129,7 @@ public LambdaExpression FindGeneratedExpression(MemberInfo expressiveMemberInfo, } catch (TypeInitializationException) { - // The registry's static type initializer failed — typically - // because a generated *_Expression() factory threw (e.g. a - // malformed lambda built from an unsupported IOperation). - // Mark this registry as broken so we stop probing it for - // every subsequent member lookup. + // Registry's static ctor failed — mark inert so we don't re-throw on every lookup. _assemblyRegistries[kvp.Key] = _nullRegistry; continue; } @@ -161,11 +153,8 @@ public LambdaExpression FindGeneratedExpression(MemberInfo expressiveMemberInfo, private static readonly object _scanLock = new(); /// - /// Scans loaded assemblies for expression registries. Rescans on demand - /// whenever new assemblies have been loaded into the AppDomain since the - /// previous scan — this matters for runtime-compiled assemblies (e.g. - /// the docs prerenderer) where the first scan happens before later - /// samples' assemblies are loaded. + /// Scans loaded assemblies for expression registries. Rescans when new assemblies + /// have been loaded since the previous scan (matters for runtime-compiled assemblies). /// private static void EnsureAllRegistriesLoaded() { From 0fda80f413c0e2f18c23b8f1608baac7d8a40278 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 01:38:25 +0000 Subject: [PATCH 5/6] Address PR #30 review feedback - Make ResetAllCaches / SetAssemblyScanFilter internal; add InternalsVisibleTo for the prerenderer so they don't leak into the public NuGet surface. - Use Volatile.Read/Write on _lastScannedAssemblyCount for the double-checked scan path. - Fix stale "fallback to playground/ prefix" comment in PlaygroundReferences.FetchAsync. - Fix broken link ../guid./integrations/ef-core in reference/troubleshooting.md. - Register the playground-editor message handler with a stored reference and unregister it in onUnmounted so it doesn't leak on navigation. - Type the best-effort catch in ScenarioInstanceScope.Dispose as catch (Exception). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/playground-editor.md | 18 ++++++++++++------ docs/reference/troubleshooting.md | 2 +- .../Services/PlaygroundReferences.cs | 7 +------ src/Docs/Prerenderer/SampleRenderer.cs | 2 +- src/ExpressiveSharp/ExpressiveSharp.csproj | 1 + .../Services/ExpressiveResolver.cs | 12 ++++++------ 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/docs/playground-editor.md b/docs/playground-editor.md index 90dd09c..6f7ba10 100644 --- a/docs/playground-editor.md +++ b/docs/playground-editor.md @@ -24,6 +24,9 @@ function isDark() { return document.documentElement.classList.contains('dark') } +let resizeHandler = null +let observer = null + onMounted(() => { const frame = document.getElementById('playground-frame') if (!frame) return @@ -33,20 +36,23 @@ onMounted(() => { const hash = location.hash || '' frame.setAttribute('src', `${base}?theme=${theme}${hash}`) - // Auto-resize iframe to fit its content - window.addEventListener('message', (e) => { + resizeHandler = (e) => { if (e.data?.type === 'playground-resize') { frame.style.height = e.data.height + 'px' } - }) + } + window.addEventListener('message', resizeHandler) - // Sync theme toggle - const observer = new MutationObserver(() => { + observer = new MutationObserver(() => { if (frame.contentWindow) { frame.contentWindow.postMessage(isDark() ? 'theme:dark' : 'theme:light', '*') } }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) - onUnmounted(() => observer.disconnect()) +}) + +onUnmounted(() => { + if (resizeHandler) window.removeEventListener('message', resizeHandler) + if (observer) observer.disconnect() }) diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index b2bbee1..62d7a90 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -195,7 +195,7 @@ services.AddDbContext(options => .UseExpressives()); ``` -See [EF Core Integration](../guid./integrations/ef-core) for the full setup guide. +See [EF Core Integration](../guide/integrations/ef-core) for the full setup guide. --- diff --git a/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs b/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs index 2e171e6..7136055 100644 --- a/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs +++ b/src/Docs/Playground.Wasm/Services/PlaygroundReferences.cs @@ -70,9 +70,6 @@ public async Task LoadAsync() private async Task FetchAsync(string assemblyName) { - // Try the standard _framework/ path first. If Blazor's BaseAddress - // doesn't point at the playground subdirectory (e.g., the web component - // is hosted on a VitePress page), fall back to the playground/ prefix. var url = $"_framework/{assemblyName}.dll"; try { @@ -81,9 +78,7 @@ public async Task LoadAsync() } catch (HttpRequestException) { - // The runtime sometimes splits an assembly into multiple package - // ones — if a logical name doesn't resolve to a file in /_framework, - // skip it. Roslyn will surface a "missing reference" diagnostic + // Missing DLL → skip; Roslyn surfaces a "missing reference" diagnostic // later if the snippet actually needs the type. return null; } diff --git a/src/Docs/Prerenderer/SampleRenderer.cs b/src/Docs/Prerenderer/SampleRenderer.cs index 5ef2c2d..16c349f 100644 --- a/src/Docs/Prerenderer/SampleRenderer.cs +++ b/src/Docs/Prerenderer/SampleRenderer.cs @@ -221,7 +221,7 @@ public ScenarioInstanceScope(IPlaygroundScenario scenario) public void Dispose() { try { Instance.DisposeAsync().AsTask().GetAwaiter().GetResult(); } - catch { /* best-effort */ } + catch (Exception) { /* best-effort */ } } } diff --git a/src/ExpressiveSharp/ExpressiveSharp.csproj b/src/ExpressiveSharp/ExpressiveSharp.csproj index cb9c4a3..6090588 100644 --- a/src/ExpressiveSharp/ExpressiveSharp.csproj +++ b/src/ExpressiveSharp/ExpressiveSharp.csproj @@ -6,6 +6,7 @@ + diff --git a/src/ExpressiveSharp/Services/ExpressiveResolver.cs b/src/ExpressiveSharp/Services/ExpressiveResolver.cs index 8c29820..a449a1a 100644 --- a/src/ExpressiveSharp/Services/ExpressiveResolver.cs +++ b/src/ExpressiveSharp/Services/ExpressiveResolver.cs @@ -25,12 +25,12 @@ public sealed class ExpressiveResolver : IExpressiveResolver /// Clears all process-level caches built up by the resolver. Intended for test harnesses /// and the docs prerenderer, where many short-lived snippet assemblies are loaded in sequence. ///
- public static void ResetAllCaches() + internal static void ResetAllCaches() { _assemblyRegistries.Clear(); _expressionCache.Clear(); _reflectionCache.Clear(); - _lastScannedAssemblyCount = 0; + Volatile.Write(ref _lastScannedAssemblyCount, 0); _assemblyScanFilter = null; } @@ -40,10 +40,10 @@ public static void ResetAllCaches() /// Restricts to assemblies matching the given filter. /// Pass null to remove the filter. /// - public static void SetAssemblyScanFilter(Func? filter) + internal static void SetAssemblyScanFilter(Func? filter) { _assemblyScanFilter = filter; - _lastScannedAssemblyCount = 0; + Volatile.Write(ref _lastScannedAssemblyCount, 0); } /// @@ -159,7 +159,7 @@ public LambdaExpression FindGeneratedExpression(MemberInfo expressiveMemberInfo, private static void EnsureAllRegistriesLoaded() { var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - if (assemblies.Length == _lastScannedAssemblyCount) return; + if (assemblies.Length == Volatile.Read(ref _lastScannedAssemblyCount)) return; lock (_scanLock) { @@ -178,7 +178,7 @@ private static void EnsureAllRegistriesLoaded() GetAssemblyRegistry(assembly); } - _lastScannedAssemblyCount = assemblies.Length; + Volatile.Write(ref _lastScannedAssemblyCount, assemblies.Length); } } From af542e3387bffdfcdf7213465c641e668050563d Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 13 Apr 2026 01:46:55 +0000 Subject: [PATCH 6/6] Exclude docs-only code from coverage reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codecov.yml: ignore src/Docs/** alongside the existing tests/, benchmarks/, samples/, docs/ ignores. - [ExcludeFromCodeCoverage] on ExpressiveResolver.ResetAllCaches and SetAssemblyScanFilter — internal helpers that only run in the docs prerenderer harness, so they shouldn't pull down patch coverage on the core library. Co-Authored-By: Claude Opus 4.6 (1M context) --- codecov.yml | 1 + src/ExpressiveSharp/Services/ExpressiveResolver.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/codecov.yml b/codecov.yml index 2d55da6..876ef94 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,3 +14,4 @@ ignore: - "benchmarks/**" - "samples/**" - "docs/**" + - "src/Docs/**" diff --git a/src/ExpressiveSharp/Services/ExpressiveResolver.cs b/src/ExpressiveSharp/Services/ExpressiveResolver.cs index a449a1a..ce5f4d7 100644 --- a/src/ExpressiveSharp/Services/ExpressiveResolver.cs +++ b/src/ExpressiveSharp/Services/ExpressiveResolver.cs @@ -25,6 +25,7 @@ public sealed class ExpressiveResolver : IExpressiveResolver /// Clears all process-level caches built up by the resolver. Intended for test harnesses /// and the docs prerenderer, where many short-lived snippet assemblies are loaded in sequence. /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static void ResetAllCaches() { _assemblyRegistries.Clear(); @@ -40,6 +41,7 @@ internal static void ResetAllCaches() /// Restricts to assemblies matching the given filter. /// Pass null to remove the filter. /// + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal static void SetAssemblyScanFilter(Func? filter) { _assemblyScanFilter = filter;