diff --git a/.claude/skills/migrate/SKILL.md b/.claude/skills/migrate/SKILL.md deleted file mode 100644 index 4bcf23f5..00000000 --- a/.claude/skills/migrate/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: migrate -description: Run database migrations using the Migration CLI with YAML schemas. Use when asked to create databases, run migrations, or set up schema. -disable-model-invocation: true -allowed-tools: Bash(dotnet run --project *Migration*) -argument-hint: "[schema.yaml] [output.db] [provider]" ---- - -# Migrate - -Run the Migration CLI to create or update databases from YAML schema files. - -## Usage - -`/migrate` - show help -`/migrate icd10` - create ICD10 SQLite database from its schema - -## Shortcuts - -| Argument | Schema | Output | Provider | -|----------|--------|--------|----------| -| icd10 | Samples/ICD10/ICD10.Api/icd10-schema.yaml | Samples/ICD10/ICD10.Api/icd10.db | sqlite | - -## Manual usage - -```bash -dotnet run --project /Users/christianfindlay/Documents/Code/DataProvider/Migration/Migration.Cli -- \ - --schema \ - --output \ - --provider -``` - -## Notes - -- YAML schemas are the ONLY valid way to define database schema (raw SQL DDL is ILLEGAL) -- Schema files live alongside their API projects -- Supported providers: `sqlite`, `postgres` -- The Migration CLI converts YAML to SQL DDL and applies it diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md deleted file mode 100644 index 01b09c36..00000000 --- a/.claude/skills/test/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: test -description: Run tests for the DataProvider solution or specific test projects. Use when asked to run tests, verify changes, or check test results. -disable-model-invocation: true -allowed-tools: Bash(dotnet test *) -argument-hint: "[component|project-path]" ---- - -# Test - -Run tests for a specific component or the full solution. - -## Usage - -`/test` - run all tests -`/test dataprovider` - run DataProvider tests -`/test icd10` - run ICD10 API tests - -## Test projects by component - -| Argument | Test project path | -|----------|------------------| -| dataprovider | DataProvider/DataProvider.Tests | -| dataprovider-example | DataProvider/DataProvider.Example.Tests | -| lql | Lql/Lql.Tests | -| lql-cli | Lql/LqlCli.SQLite.Tests | -| migration | Migration/Migration.Tests | -| sync | Sync/Sync.Tests | -| sync-sqlite | Sync/Sync.SQLite.Tests | -| sync-postgres | Sync/Sync.Postgres.Tests | -| sync-http | Sync/Sync.Http.Tests | -| sync-integration | Sync/Sync.Integration.Tests | -| gatekeeper | Gatekeeper/Gatekeeper.Api.Tests | -| clinical | Samples/Clinical/Clinical.Api.Tests | -| scheduling | Samples/Scheduling/Scheduling.Api.Tests | -| icd10 | Samples/ICD10/ICD10.Api.Tests | -| icd10-cli | Samples/ICD10/ICD10.Cli.Tests | -| dashboard | Samples/Dashboard/Dashboard.Integration.Tests | - -## Commands - -Run a specific test project: -```bash -dotnet test --no-restore --verbosity normal -``` - -Run all tests in the solution: -```bash -dotnet test /Users/christianfindlay/Documents/Code/DataProvider/DataProvider.sln --no-restore --verbosity normal -``` - -## Notes - -- Tests use xUnit 2.9.2 -- Coverage config: `coverlet.runsettings` -- Sync and Gatekeeper tests require a running Postgres instance -- Dashboard tests use Playwright (E2E) -- NEVER skip tests - failing tests are OK, skipped tests are ILLEGAL diff --git a/.claude/skills/website-audit/SKILL.md b/.claude/skills/website-audit/SKILL.md new file mode 100644 index 00000000..9c1b57fc --- /dev/null +++ b/.claude/skills/website-audit/SKILL.md @@ -0,0 +1,179 @@ +--- +name: website-audit +description: Audits a website for SEO, AI search performance, structured data, mobile usability, broken links, and social media cards. Fixes issues found. Use when the user mentions "audit website", "SEO", "fix search ranking", "AI search", "structured data", "social media cards", or "website performance". +--- + +# Website Audit + +Performs a comprehensive website audit and fixes issues affecting search visibility and AI discoverability. + +Copy this checklist and track your progress: + +``` +Audit Progress: +- [ ] Step 1: Read guidelines +- [ ] Step 2: Audit AI search readiness +- [ ] Step 3: Audit SEO and keywords +- [ ] Step 4: Audit crawling and indexing +- [ ] Step 5: Audit broken links and canonicalization +- [ ] Step 6: Audit mobile usability +- [ ] Step 7: Audit structured data +- [ ] Step 8: Audit social media cards +- [ ] Step 9: Audit For Unsubstantiated Claims +- [ ] Step 10: Audit Design Compliance +- [ ] Step 11: Test with Playwright +- [ ] Step 12: Report findings +``` + +- Check the outputted HTML/CSS/JavaScript AFTER the website is generated by the static content generator. - Don't just check the static content before the website is generated. +- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. _site) +- Never manually edit the generated website content directly + +## Step 1 — Read guidelines + +Fetch and read each of these before auditing. These are the authoritative references for every step that follows. + +- [Google's guidance on using generative AI content](https://developers.google.com/search/docs/fundamentals/using-gen-ai-content) +- [Top ways to ensure content performs well in Google's AI experiences](https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search) +- [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) + +Take the business plan into account: +[text](../../../business_plan/business_plan.md) + +Identify the website source files in the repo. Determine the framework (static site generator, Next.js, Hugo, etc.) so you know where to find templates, metadata, and content. + +## Step 2 — Audit AI search readiness + +Apply the guidance from the AI search article. Check: + +1. **Content quality** — Is content original, expert-level, and comprehensive? Flag thin or duplicated pages. +2. **Clear structure** — Do pages use descriptive headings, lists, and concise answers to likely questions? +3. **Entity clarity** — Are key terms, products, and concepts defined clearly so AI can extract them? +4. **Freshness signals** — Are dates, update timestamps, and authorship present? + +Fix issues directly in the source files. For each fix, note what changed and why. + +## Step 3 — Audit SEO and keywords + +1. Search [Google Trends](https://trends.google.com/home) for trending keywords related to the website's content. +2. Review each page's ``, `<meta name="description">`, and `<h1>` tags. +3. Check for keyword opportunities — can trending terms be naturally inserted into headings, descriptions, or body content? +4. Verify each page has a unique, descriptive title (50-60 chars) and meta description (150-160 chars). +5. Check image `alt` attributes describe the image content and include relevant keywords where natural. + +Apply the [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) principles. Fix issues directly. + +## Step 4 — Audit crawling and indexing + +Reference: [Overview of crawling and indexing topics](https://developers.google.com/search/docs/crawling-indexing) + +1. **robots.txt** — Locate and review it. Verify it doesn't block important pages. Reference: [robots.txt spec](https://developers.google.com/search/docs/crawling-indexing/robots-txt) +2. **Sitemap** — Locate the sitemap (or sitemap index). Verify all important pages are listed and no dead URLs are included. Reference: [Sitemap guidelines](https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps) +3. **Meta robots tags** — Check for unintended `noindex` or `nofollow` directives on pages that should be indexed. + +Note: robots.txt and sitemaps are often auto-generated. If so, check the generator config rather than the output file. + +## Step 5 — Audit broken links and canonicalization + +Reference: [What is canonicalization](https://developers.google.com/search/docs/crawling-indexing/canonicalization) + +1. Check all internal links resolve to valid pages (no 404s). +2. Verify `<link rel="canonical">` tags are present and point to the correct URL. +3. Check for duplicate content accessible via multiple URLs (with/without trailing slash, www vs non-www). +4. Verify redirects use 301 (permanent) not 302 (temporary) where appropriate. + +## Step 6 — Audit mobile usability + +Reference: [Mobile-first indexing best practices](https://developers.google.com/search/docs/crawling-indexing/mobile/mobile-sites-mobile-first-indexing) + +1. Verify the `<meta name="viewport">` tag is present and correctly configured. +2. Check that content is identical between mobile and desktop (mobile-first indexing requires this). +3. Verify touch targets are adequately sized (min 48x48px). +4. Check font sizes are readable without zooming (min 16px body text). + +## Step 7 — Audit structured data + +Reference: [Structured data guidelines](https://developers.google.com/search/docs/appearance/structured-data/sd-policies) + +1. Check for existing JSON-LD `<script type="application/ld+json">` blocks. +2. Verify the structured data matches the page content (no misleading markup). +3. Add missing structured data where appropriate: + - **Organization/Person** on the homepage + - **Article/BlogPosting** on blog posts (with author, datePublished, dateModified) + - **BreadcrumbList** for navigation + - **FAQ** for pages with question/answer content +4. Validate JSON-LD syntax is correct. + +## Step 8 — Audit social media cards + +Reference: [Implementing Social Media Preview Cards](https://documentation.platformos.com/use-cases/implementing-social-media-preview-cards) + +Check every page template includes: + +**Open Graph (Facebook/LinkedIn):** +- `og:title`, `og:description`, `og:image`, `og:url`, `og:type` + +**Twitter Card:** +- `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` + +Verify `og:image` dimensions are at least 1200x630px. Fix missing or incomplete tags. + +## Step 9 - Audit For Unsubstantiated Claims + +Ensure that all claims are backed up with a link to a reputable source. As an example, this claim isn't valid as content unless it links to an authority that found this through research + +> Research shows teams with strong DevEx perform 4-5x better across speed, quality, and engagement + +Search for the authoritative URL and add a link to the URL. If it is not available, change the claim to something that can be substatiated. + +## Step 10 — Audit Design Compliance + +Read the design system docs and view the design screens in the designsystem folder. + +## Step 11 — Test with Playwright + +Build and run the website locally using `make website-run` (or the project's equivalent dev server command). + +**Desktop tests (1280x720):** + +1. Navigate to the homepage — take a screenshot. +2. Navigate to each major section — verify pages load without errors. +3. Check the browser console for JavaScript errors. +4. Verify all navigation links work. + +**Mobile tests (375x667, iPhone SE):** + +1. Resize the browser to mobile dimensions. +2. Navigate to the homepage — take a screenshot. +3. Verify the layout is responsive (no horizontal overflow, readable text). +4. Test navigation menu (hamburger menu if applicable). + +If any page fails to load or has console errors, fix the issue and retest. + +## Step 12 — Report findings + +Summarize the audit results: + +``` +## Website Audit Report + +### Fixed +- [List each issue fixed with file and line reference] + +### Warnings (manual review needed) +- [Issues that need human judgment] + +### Passed +- [Areas that passed audit with no issues] + +### Screenshots +- [Reference Playwright screenshots taken] +``` + +## Rules + +- **Fix issues directly** — don't just report them. Only flag issues as warnings when they require human judgment (e.g., content tone, keyword selection). +- **One step at a time** — complete each step before moving to the next. +- **Preserve existing content** — improve structure and metadata without rewriting the author's voice. +- **No keyword stuffing** — keywords must read naturally in context. +- **Respect the framework** — edit templates/configs, not generated output files. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09a73387..ab76b982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: - name: Build CLI tools (needed by F# Type Provider MSBuild targets) run: | - dotnet build Migration/Nimblesite.DataProvider.Migration.Cli -c Debug + dotnet build Migration/DataProviderMigrate -c Debug dotnet build DataProvider/Nimblesite.DataProvider.SQLite.Cli -c Debug - name: Test DataProvider (with coverage enforcement) @@ -142,13 +142,25 @@ jobs: ${{ runner.os }}-cargo-build- ${{ runner.os }}-cargo- + - name: Restore .NET tools (h5, csharpier, ilspycmd) + run: dotnet tool restore + - name: Build .NET run: dotnet build DataProvider.sln -c Debug - name: Build Rust run: cd Lql/lql-lsp-rust && cargo build - - name: Test LQL + Migration + Sync (with coverage enforcement) + - name: Install Playwright browsers (Reporting.Integration.Tests) + run: | + dotnet build Reporting/Nimblesite.Reporting.Integration.Tests -c Debug + PWSCRIPT=$(find Reporting/Nimblesite.Reporting.Integration.Tests/bin -name 'playwright.ps1' | head -1) + if [ -z "$PWSCRIPT" ]; then + echo "playwright.ps1 not found under Reporting.Integration.Tests bin"; exit 1 + fi + pwsh "$PWSCRIPT" install --with-deps chromium + + - name: Test LQL + Migration + Sync + Reporting (with coverage enforcement) run: >- make _test_dotnet DOTNET_TEST_PROJECTS="Lql/Nimblesite.Lql.Tests Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests @@ -157,7 +169,9 @@ jobs: Sync/Nimblesite.Sync.SQLite.Tests Sync/Nimblesite.Sync.Postgres.Tests Sync/Nimblesite.Sync.Integration.Tests - Sync/Nimblesite.Sync.Http.Tests" + Sync/Nimblesite.Sync.Http.Tests + Reporting/Nimblesite.Reporting.Tests + Reporting/Nimblesite.Reporting.Integration.Tests" env: TESTCONTAINERS_RYUK_DISABLED: false @@ -175,7 +189,21 @@ jobs: extension-tests: name: LQL Extension Tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: changeme + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -184,11 +212,67 @@ jobs: with: node-version: '20' + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + Lql/lql-lsp-rust/target + key: ${{ runner.os }}-cargo-extension-${{ hashFiles('Lql/lql-lsp-rust/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-extension- + ${{ runner.os }}-cargo- + + - name: Install VSCode runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + xvfb \ + libnss3 \ + libgbm1 \ + libdrm2 \ + libxkbcommon-x11-0 \ + libgtk-3-0 \ + libasound2t64 \ + libsecret-1-0 \ + libsndio7.0 || \ + sudo apt-get install -y --no-install-recommends \ + xvfb \ + libnss3 \ + libgbm1 \ + libdrm2 \ + libxkbcommon-x11-0 \ + libgtk-3-0 + + - name: Build lql-lsp binary (release) + run: cd Lql/lql-lsp-rust && cargo build --release -p lql-lsp + + - name: Verify lql-lsp --version + run: | + ./Lql/lql-lsp-rust/target/release/lql-lsp --version + # Confirm the printed version matches the VSIX package.json version + BIN_VERSION=$(./Lql/lql-lsp-rust/target/release/lql-lsp --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + VSIX_VERSION=$(jq -r '.version' Lql/LqlExtension/package.json) + echo "Binary version: $BIN_VERSION" + echo "VSIX version: $VSIX_VERSION" + if [ "$BIN_VERSION" != "$VSIX_VERSION" ]; then + echo "ERROR: lql-lsp --version ($BIN_VERSION) does not match VSIX version ($VSIX_VERSION)" + exit 1 + fi + - name: Install dependencies run: cd Lql/LqlExtension && npm install --no-audit --no-fund - name: Test Extension (with coverage enforcement) - run: make _test_ts + env: + POSTGRES_CONNECTION_STRING: "Host=localhost;Port=5432;Username=postgres;Password=changeme;Database=postgres" + run: | + export PATH="$PWD/Lql/lql-lsp-rust/target/release:$PATH" + make _test_ts - name: Package VSIX (dry run) run: cd Lql/LqlExtension && npm run compile && npx vsce package --no-git-tag-version --no-update-package-json diff --git a/.gitignore b/.gitignore index 4db495c5..28496d2f 100644 --- a/.gitignore +++ b/.gitignore @@ -487,4 +487,7 @@ Lql/LqlWebsite-Eleventy/_site/ -.claude/skills/website-audit/SKILL.md \ No newline at end of file +.claude/skills/website-audit/SKILL.md + +Lql/lql-lsp-rust/target/ +*.vsix diff --git a/CLAUDE.md b/CLAUDE.md index ff7ab752..06a8951f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,14 @@ -<!-- agent-pmo:d75d5c8 --> # DataProvider — Agent Instructions +⚠️ CRITICAL: **Reduce token usage.** Check file size before loading. Write less. Delete fluff and dead code. Alert user when context is loaded with pointless files. ⚠️ + > Read this entire file before writing any code. > These rules are NON-NEGOTIABLE. Violations will be rejected in review. +⚠️ NEVER KILL ANY VSCODE PROCESS ⚠️ + +<!-- agent-pmo:d75d5c8 --> + ## Project Overview DataProvider is a comprehensive .NET database access toolkit: source generation for SQL extension methods, the Lambda Query Language (LQL) transpiler, bidirectional offline-first sync, WebAuthn + RBAC auth, and an embeddable reporting platform. The LQL LSP is implemented in Rust with a VS Code extension in TypeScript. Healthcare sample applications live in a separate repo: [MelbourneDeveloper/HealthcareSamples](https://github.com/MelbourneDeveloper/HealthcareSamples). @@ -125,6 +130,9 @@ Always include these in `Directory.Build.props`: - LQL is database platform INDEPENDENT. It MUST work exactly the same on whatever platform it is transpiled to. Failure for this to happen must be logged as a GitHub issue +## LQL +- LQL is database platform INDEPENDENT. It MUST work exactly the same on whatever platform it is transpiled to. Failure for this to happen must be logged as a GitHub issue + ## CSS - **MINIMAL CSS** — Do not duplicate CSS classes @@ -182,6 +190,7 @@ make setup # post-create dev environment setup | Sync | `Sync/` | Offline-first bidirectional sync | | Gatekeeper | `Gatekeeper/` | WebAuthn + RBAC auth | | Samples | `Samples/` | Clinical, Scheduling, ICD10, Dashboard | +| Reporting | `Reporting/` | Embeddable reporting platform (SQL/LQL data sources, JSON config, React renderer) | | Website | `Website/` | Documentation site (Eleventy + DocFX) | ## Repo Structure @@ -199,6 +208,7 @@ DataProvider/ ├── Sync/ # Bidirectional sync engine ├── Gatekeeper/ # WebAuthn auth + RBAC ├── Samples/ # Healthcare samples +├── Reporting/ # Embeddable reporting platform ├── Website/ # Documentation site ├── docs/ │ ├── specs/ # Specification documents diff --git a/DataProvider.sln b/DataProvider.sln index 10601d74..7c2a2dd5 100644 --- a/DataProvider.sln +++ b/DataProvider.sln @@ -73,7 +73,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sql.Model", "Oth EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Sync.Http", "Sync\Nimblesite.Sync.Http\Nimblesite.Sync.Http.csproj", "{392C12C2-ECBA-4728-9D8D-54BD2E10F7ED}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Migration.Cli", "Migration\Nimblesite.DataProvider.Migration.Cli\Nimblesite.DataProvider.Migration.Cli.csproj", "{57572A45-33CD-4928-9C30-13480AEDB313}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataProviderMigrate", "Migration\DataProviderMigrate\DataProviderMigrate.csproj", "{57572A45-33CD-4928-9C30-13480AEDB313}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.Postgres.Cli", "DataProvider\Nimblesite.DataProvider.Postgres.Cli\Nimblesite.DataProvider.Postgres.Cli.csproj", "{A8A70E6D-1D43-437F-9971-44A4FA1BDD74}" EndProject @@ -85,12 +85,28 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Nimblesite.Lql.TypeProvider EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Lql.TypeProvider.FSharp.Tests.Data", "Lql\Nimblesite.Lql.TypeProvider.FSharp.Tests.Data\Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj", "{0D6A831B-4759-46F2-8527-51C8A9CB6F6F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.SqlServer", "DataProvider\Nimblesite.DataProvider.SqlServer\Nimblesite.DataProvider.SqlServer.csproj", "{238A9928-E501-46AD-963B-BB529A983D3E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reporting", "Reporting", "{7E3B463F-4C55-B4D7-FA79-ADF7D77E220F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.Engine", "Reporting\Nimblesite.Reporting.Engine\Nimblesite.Reporting.Engine.csproj", "{3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataProvider", "DataProvider", "{43BAF0A3-C050-BE83-B489-7FC6F9FDE235}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.DataProvider.SqlServer", "DataProvider\Nimblesite.DataProvider.SqlServer\Nimblesite.DataProvider.SqlServer.csproj", "{238A9928-E501-46AD-963B-BB529A983D3E}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lql", "Lql", "{54B846BA-A27D-B76F-8730-402A5742FF43}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Other", "Other", "{A36FAAB1-3B70-FACF-6B1E-E7138A3CFC44}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.Api", "Reporting\Nimblesite.Reporting.Api\Nimblesite.Reporting.Api.csproj", "{1B908378-2196-4396-AFB4-C2FA3E4D71F7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.Tests", "Reporting\Nimblesite.Reporting.Tests\Nimblesite.Reporting.Tests.csproj", "{99C7B597-0B71-429C-89F8-A2AF6727D5B2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Migration", "Migration", "{C7F49633-8D5E-7E19-1580-A6459B2EAE66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.Integration.Tests", "Reporting\Nimblesite.Reporting.Integration.Tests\Nimblesite.Reporting.Integration.Tests.csproj", "{6BB769C2-4164-4781-A68F-516172A048F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nimblesite.Reporting.React", "Reporting\Nimblesite.Reporting.React\Nimblesite.Reporting.React.csproj", "{79CF7074-2442-402D-8E17-E7B222B00200}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -545,6 +561,66 @@ Global {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x64.Build.0 = Release|Any CPU {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x86.ActiveCfg = Release|Any CPU {238A9928-E501-46AD-963B-BB529A983D3E}.Release|x86.Build.0 = Release|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x64.Build.0 = Debug|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Debug|x86.Build.0 = Debug|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|Any CPU.Build.0 = Release|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x64.ActiveCfg = Release|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x64.Build.0 = Release|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x86.ActiveCfg = Release|Any CPU + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81}.Release|x86.Build.0 = Release|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|x64.Build.0 = Debug|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Debug|x86.Build.0 = Debug|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Release|Any CPU.Build.0 = Release|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Release|x64.ActiveCfg = Release|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Release|x64.Build.0 = Release|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Release|x86.ActiveCfg = Release|Any CPU + {1B908378-2196-4396-AFB4-C2FA3E4D71F7}.Release|x86.Build.0 = Release|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Debug|x64.Build.0 = Debug|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Debug|x86.Build.0 = Debug|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Release|Any CPU.Build.0 = Release|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Release|x64.ActiveCfg = Release|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Release|x64.Build.0 = Release|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Release|x86.ActiveCfg = Release|Any CPU + {99C7B597-0B71-429C-89F8-A2AF6727D5B2}.Release|x86.Build.0 = Release|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Debug|x64.Build.0 = Debug|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Debug|x86.Build.0 = Debug|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Release|Any CPU.Build.0 = Release|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Release|x64.ActiveCfg = Release|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Release|x64.Build.0 = Release|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Release|x86.ActiveCfg = Release|Any CPU + {6BB769C2-4164-4781-A68F-516172A048F1}.Release|x86.Build.0 = Release|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Debug|x64.ActiveCfg = Debug|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Debug|x64.Build.0 = Debug|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Debug|x86.ActiveCfg = Debug|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Debug|x86.Build.0 = Debug|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Release|Any CPU.Build.0 = Release|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Release|x64.ActiveCfg = Release|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Release|x64.Build.0 = Release|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Release|x86.ActiveCfg = Release|Any CPU + {79CF7074-2442-402D-8E17-E7B222B00200}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -587,6 +663,11 @@ Global {B0104C42-1B46-4CA5-9E91-A5F09D7E5B92} = {54B846BA-A27D-B76F-8730-402A5742FF43} {0D6A831B-4759-46F2-8527-51C8A9CB6F6F} = {54B846BA-A27D-B76F-8730-402A5742FF43} {238A9928-E501-46AD-963B-BB529A983D3E} = {43BAF0A3-C050-BE83-B489-7FC6F9FDE235} + {3A67AEB1-3F78-4BBE-9F14-CA3350CA9F81} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} + {1B908378-2196-4396-AFB4-C2FA3E4D71F7} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} + {99C7B597-0B71-429C-89F8-A2AF6727D5B2} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} + {6BB769C2-4164-4781-A68F-516172A048F1} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} + {79CF7074-2442-402D-8E17-E7B222B00200} = {7E3B463F-4C55-B4D7-FA79-ADF7D77E220F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53128A75-E7B6-4B83-B079-A309FCC2AD9C} diff --git a/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj index c2b0da28..c3b15d80 100644 --- a/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj +++ b/DataProvider/Nimblesite.DataProvider.Example/Nimblesite.DataProvider.Example.csproj @@ -45,10 +45,10 @@ </Content> </ItemGroup> - <!-- Create database from YAML using Nimblesite.DataProvider.Migration.Cli (YAML stored in git) --> + <!-- Create database from YAML using DataProviderMigrate (YAML stored in git) --> <Target Name="CreateDatabaseSchema" BeforeTargets="TranspileLqlAndGenerateDataProvider"> <Exec - Command="dotnet run --project "$(MSBuildThisFileDirectory)../../Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj" -- --schema "$(MSBuildProjectDirectory)/example-schema.yaml" --output "$(MSBuildProjectDirectory)/invoices.db" --provider sqlite" + Command="dotnet run --project "$(MSBuildThisFileDirectory)../../Migration/DataProviderMigrate/DataProviderMigrate.csproj" -- --schema "$(MSBuildProjectDirectory)/example-schema.yaml" --output "$(MSBuildProjectDirectory)/invoices.db" --provider sqlite" WorkingDirectory="$(MSBuildProjectDirectory)" StandardOutputImportance="High" StandardErrorImportance="High" diff --git a/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj index 319ced22..0d906f36 100644 --- a/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj +++ b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Nimblesite.DataProvider.Postgres.Cli.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <EnforceExtendedAnalyzerRules>false</EnforceExtendedAnalyzerRules> @@ -10,11 +10,18 @@ <RunAnalyzersDuringLiveAnalysis>false</RunAnalyzersDuringLiveAnalysis> <TreatWarningsAsErrors>false</TreatWarningsAsErrors> <NoWarn>EPC12;CA2100</NoWarn> - <IsPackable>false</IsPackable> + <IsPackable>true</IsPackable> <PackageId>Nimblesite.DataProvider.Postgres.Cli</PackageId> <PackAsTool>true</PackAsTool> <ToolCommandName>dataprovider-postgres</ToolCommandName> + <PackageVersion>0.2.7-beta</PackageVersion> + <Version>0.2.7-beta</Version> <Description>CLI tool for generating type-safe PostgreSQL data access code</Description> + <Authors>ChristianFindlay</Authors> + <Company>MelbourneDeveloper</Company> + <PackageLicenseExpression>MIT</PackageLicenseExpression> + <RepositoryUrl>https://github.com/MelbourneDeveloper/DataProvider</RepositoryUrl> + <RepositoryType>git</RepositoryType> </PropertyGroup> <ItemGroup> <ProjectReference Include="../Nimblesite.DataProvider.Core/Nimblesite.DataProvider.Core.csproj" /> diff --git a/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs index c56e6b5e..022bdbdc 100644 --- a/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs +++ b/DataProvider/Nimblesite.DataProvider.Postgres.Cli/Program.cs @@ -73,7 +73,13 @@ public static async Task<int> Main(string[] args) schemaFile, }; root.SetHandler( - async (DirectoryInfo proj, FileInfo cfg, DirectoryInfo output, bool off, FileInfo? schema) => + async ( + DirectoryInfo proj, + FileInfo cfg, + DirectoryInfo output, + bool off, + FileInfo? schema + ) => { var exit = await RunAsync(proj, cfg, output, off, schema).ConfigureAwait(false); Environment.Exit(exit); @@ -111,9 +117,7 @@ private static async Task<int> RunAsync( var cfg = JsonSerializer.Deserialize<PostgresDataProviderConfig>(cfgText, JsonOptions); if (cfg is null) { - Console.WriteLine( - "❌ Nimblesite.DataProvider.Core.json is invalid" - ); + Console.WriteLine("❌ Nimblesite.DataProvider.Core.json is invalid"); return 1; } @@ -121,7 +125,8 @@ private static async Task<int> RunAsync( SchemaDefinition? schema = null; if (schemaFile?.Exists == true) { - var schemaYaml = await File.ReadAllTextAsync(schemaFile.FullName).ConfigureAwait(false); + var schemaYaml = await File.ReadAllTextAsync(schemaFile.FullName) + .ConfigureAwait(false); schema = SchemaSerializer.FromYaml(schemaYaml); Console.WriteLine($"📋 Loaded schema from {schemaFile.FullName}"); } @@ -171,10 +176,18 @@ private static async Task<int> RunAsync( { try { - var sql = await File.ReadAllTextAsync(sqlPath).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(sql)) + var rawSql = await File.ReadAllTextAsync(sqlPath).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(rawSql)) continue; + // Bug #23: any `AS <bareIdent>` that contains uppercase + // gets double-quoted so Postgres preserves the case in + // the column name returned by GetColumnSchema, which + // becomes the C# record field name. Without this, + // `AS CategoryTitle` is folded to `categorytitle` and + // multiple aliased Title columns collide. + var sql = QuoteAsAliases(rawSql); + var baseName = Path.GetFileNameWithoutExtension(sqlPath); if (baseName.EndsWith(".generated", StringComparison.OrdinalIgnoreCase)) { @@ -356,7 +369,8 @@ private static Result<IReadOnlyList<DatabaseColumn>, SqlError> InferColumnTypesF { var (name, sqlType) = ParseColumnDefinition(colDef, schema); var csharpType = MapPostgresTypeToCSharp(sqlType, true); - var isNullable = !sqlType.Contains("serial", StringComparison.OrdinalIgnoreCase) + var isNullable = + !sqlType.Contains("serial", StringComparison.OrdinalIgnoreCase) && !sqlType.Contains("not null", StringComparison.OrdinalIgnoreCase); columns.Add( @@ -436,11 +450,7 @@ private static (string name, string sqlType) ParseColumnDefinition( ) { // Check for AS alias - var asMatch = Regex.Match( - colDef, - @"(.+?)\s+AS\s+(\w+)", - RegexOptions.IgnoreCase - ); + var asMatch = Regex.Match(colDef, @"(.+?)\s+AS\s+(\w+)", RegexOptions.IgnoreCase); if (asMatch.Success) { @@ -542,7 +552,9 @@ string outDir if (table.ExcludeColumns.Contains(col.Name, StringComparer.OrdinalIgnoreCase)) continue; - var isPk = tableDef.PrimaryKey?.Columns.Contains(col.Name, StringComparer.OrdinalIgnoreCase) == true + var isPk = + tableDef.PrimaryKey?.Columns.Contains(col.Name, StringComparer.OrdinalIgnoreCase) + == true || table.PrimaryKeyColumns.Contains(col.Name, StringComparer.OrdinalIgnoreCase); columns.Add( @@ -554,7 +566,9 @@ string outDir IsNullable = col.IsNullable, IsPrimaryKey = isPk, IsIdentity = col.IsIdentity, - IsComputed = col.DefaultValue?.StartsWith("nextval", StringComparison.OrdinalIgnoreCase) == true, + IsComputed = + col.DefaultValue?.StartsWith("nextval", StringComparison.OrdinalIgnoreCase) + == true, } ); } @@ -567,7 +581,11 @@ string outDir } var sb = new StringBuilder(); - var pascalName = ToPascalCase(table.Name); + // Use the literal table name (e.g. `gk_user`) instead of + // PascalCasing it. The SQLite.Cli emits methods like + // `Insertgk_userAsync`, and consumers reference them by that + // exact name. PascalCasing here breaks consumer call sites. + var pascalName = table.Name; // Header _ = sb.AppendLine("// <auto-generated />"); @@ -577,6 +595,8 @@ string outDir _ = sb.AppendLine("using Outcome;"); _ = sb.AppendLine("using Nimblesite.Sql.Model;"); _ = sb.AppendLine(); + _ = sb.AppendLine("namespace Generated;"); + _ = sb.AppendLine(); // Extension class _ = sb.AppendLine("/// <summary>"); @@ -702,7 +722,11 @@ ORDER BY c.ordinal_position } var sb = new StringBuilder(); - var pascalName = ToPascalCase(table.Name); + // Use the literal table name (e.g. `gk_user`) instead of + // PascalCasing it. The SQLite.Cli emits methods like + // `Insertgk_userAsync`, and consumers reference them by that + // exact name. PascalCasing here breaks consumer call sites. + var pascalName = table.Name; // Header _ = sb.AppendLine("// <auto-generated />"); @@ -712,6 +736,8 @@ ORDER BY c.ordinal_position _ = sb.AppendLine("using Outcome;"); _ = sb.AppendLine("using Nimblesite.Sql.Model;"); _ = sb.AppendLine(); + _ = sb.AppendLine("namespace Generated;"); + _ = sb.AppendLine(); // Extension class _ = sb.AppendLine("/// <summary>"); @@ -772,12 +798,15 @@ private static void GenerateInsertMethod( string pascalName ) { - // Get insertable columns (exclude auto-generated ones) - var insertable = columns.Where(c => !c.IsIdentity && !c.IsComputed).ToList(); - var parameters = string.Join( - ", ", - insertable.Select(c => $"{c.CSharpType} {ToCamelCase(c.Name)}") - ); + // Bug #17: include ALL columns as insertable params (including id / + // identity / computed). Consumers want to be able to pass an id + // value explicitly. The previous filter excluded identity cols which + // forced consumers to refactor their call sites to drop the id arg. + // Insertable columns are written verbatim using col.Name (Bug #8 / + // #13: keep snake_case parameter names so consumer named-argument + // calls work). + var insertable = columns.Where(c => !c.IsComputed).ToList(); + var parameters = string.Join(", ", insertable.Select(c => $"{c.CSharpType} {c.Name}")); _ = sb.AppendLine(); _ = sb.AppendLine(" /// <summary>"); @@ -795,7 +824,7 @@ string pascalName _ = sb.AppendLine(" {"); var colNames = string.Join(", ", insertable.Select(c => c.Name)); - var paramNames = string.Join(", ", insertable.Select(c => $"@{ToCamelCase(c.Name)}")); + var paramNames = string.Join(", ", insertable.Select(c => $"@{c.Name}")); _ = sb.AppendLine(" const string sql = @\""); _ = sb.AppendLine( @@ -812,12 +841,12 @@ string pascalName foreach (var col in insertable) { - var paramName = ToCamelCase(col.Name); + var paramName = col.Name; if (col.IsNullable) { _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{paramName}\", {paramName} ?? (object)DBNull.Value);" + $" cmd.Parameters.AddWithValue(\"{paramName}\", (object?){paramName} ?? DBNull.Value);" ); } else @@ -844,6 +873,97 @@ string pascalName ); _ = sb.AppendLine(" }"); _ = sb.AppendLine(" }"); + + // Bug #16: also emit an IDbTransaction overload that delegates to + // the same SQL via a NpgsqlCommand bound to the transaction's + // connection. Matches the old SQLite.Cli shape so consumer code + // calling tx.Insert{T}Async(...) keeps compiling. + GenerateInsertTransactionOverload(sb, table, insertable, pascalName); + } + + /// <summary> + /// Emits an `IDbTransaction` overload of an Insert method that + /// matches the old SQLite.Cli shape. Bug #16. + /// </summary> + private static void GenerateInsertTransactionOverload( + StringBuilder sb, + TableConfigItem table, + List<DatabaseColumn> insertable, + string pascalName + ) + { + var parameters = string.Join(", ", insertable.Select(c => $"{c.CSharpType} {c.Name}")); + var colNames = string.Join(", ", insertable.Select(c => c.Name)); + var paramNames = string.Join(", ", insertable.Select(c => $"@{c.Name}")); + + _ = sb.AppendLine(); + _ = sb.AppendLine(" /// <summary>"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" /// IDbTransaction overload of Insert{pascalName}Async." + ); + _ = sb.AppendLine(" /// </summary>"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" public static async Task<Result<Guid?, SqlError>> Insert{pascalName}Async(" + ); + _ = sb.AppendLine(" this System.Data.IDbTransaction transaction,"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" const string sql = @\""); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" INSERT INTO {table.Schema}.{table.Name} ({colNames})" + ); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" VALUES ({paramNames})"); + _ = sb.AppendLine(" ON CONFLICT DO NOTHING"); + _ = sb.AppendLine(" RETURNING id\";"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" if (transaction.Connection is not NpgsqlConnection conn)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<Guid?, SqlError>.Error<Guid?, SqlError>(new SqlError(\"Transaction.Connection must be NpgsqlConnection\"));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " await using var cmd = new NpgsqlCommand(sql, conn, (NpgsqlTransaction)transaction);" + ); + foreach (var col in insertable) + { + var paramName = col.Name; + if (col.IsNullable) + { + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" cmd.Parameters.AddWithValue(\"{paramName}\", (object?){paramName} ?? DBNull.Value);" + ); + } + else + { + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" cmd.Parameters.AddWithValue(\"{paramName}\", {paramName});" + ); + } + } + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);" + ); + _ = sb.AppendLine( + " return new Result<Guid?, SqlError>.Ok<Guid?, SqlError>(result is Guid g ? g : null);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch (Exception ex)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<Guid?, SqlError>.Error<Guid?, SqlError>(SqlError.FromException(ex));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); } private static void GenerateUpdateMethod( @@ -862,11 +982,12 @@ string pascalName return; var allParams = pkCols.Concat(updateable).ToList(); - var parameters = string.Join( - ", ", - allParams.Select(c => $"{c.CSharpType} {ToCamelCase(c.Name)}") - ); + var parameters = string.Join(", ", allParams.Select(c => $"{c.CSharpType} {c.Name}")); + + var setClauses = string.Join(", ", updateable.Select(c => $"{c.Name} = @{c.Name}")); + var whereClauses = string.Join(" AND ", pkCols.Select(c => $"{c.Name} = @{c.Name}")); + // NpgsqlConnection overload _ = sb.AppendLine(); _ = sb.AppendLine(" /// <summary>"); _ = sb.AppendLine( @@ -881,16 +1002,47 @@ string pascalName _ = sb.AppendLine(" this NpgsqlConnection conn,"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); _ = sb.AppendLine(" {"); - - var setClauses = string.Join( - ", ", - updateable.Select(c => $"{c.Name} = @{ToCamelCase(c.Name)}") + _ = sb.AppendLine(" const string sql = @\""); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" UPDATE {table.Schema}.{table.Name}" + ); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" SET {setClauses}"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" WHERE {whereClauses}\";"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" await using var cmd = new NpgsqlCommand(sql, conn);"); + EmitParameterBindings(sb, allParams); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" ); - var whereClauses = string.Join( - " AND ", - pkCols.Select(c => $"{c.Name} = @{ToCamelCase(c.Name)}") + _ = sb.AppendLine(" return new Result<int, SqlError>.Ok<int, SqlError>(rows);"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch (Exception ex)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<int, SqlError>.Error<int, SqlError>(SqlError.FromException(ex));" ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + // Bug #16: IDbTransaction overload, matching old SQLite.Cli shape. + _ = sb.AppendLine(); + _ = sb.AppendLine(" /// <summary>"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" /// IDbTransaction overload of Update{pascalName}Async." + ); + _ = sb.AppendLine(" /// </summary>"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" public static async Task<Result<int, SqlError>> Update{pascalName}Async(" + ); + _ = sb.AppendLine(" this System.Data.IDbTransaction transaction,"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); + _ = sb.AppendLine(" {"); _ = sb.AppendLine(" const string sql = @\""); _ = sb.AppendLine( CultureInfo.InvariantCulture, @@ -899,18 +1051,49 @@ string pascalName _ = sb.AppendLine(CultureInfo.InvariantCulture, $" SET {setClauses}"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" WHERE {whereClauses}\";"); _ = sb.AppendLine(); + _ = sb.AppendLine(" if (transaction.Connection is not NpgsqlConnection conn)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<int, SqlError>.Error<int, SqlError>(new SqlError(\"Transaction.Connection must be NpgsqlConnection\"));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); _ = sb.AppendLine(" try"); _ = sb.AppendLine(" {"); - _ = sb.AppendLine(" await using var cmd = new NpgsqlCommand(sql, conn);"); + _ = sb.AppendLine( + " await using var cmd = new NpgsqlCommand(sql, conn, (NpgsqlTransaction)transaction);" + ); + EmitParameterBindings(sb, allParams); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" + ); + _ = sb.AppendLine(" return new Result<int, SqlError>.Ok<int, SqlError>(rows);"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch (Exception ex)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<int, SqlError>.Error<int, SqlError>(SqlError.FromException(ex));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + } - foreach (var col in allParams) + /// <summary> + /// Emits the standard `cmd.Parameters.AddWithValue(...)` lines for a + /// list of columns. Used by both NpgsqlConnection and IDbTransaction + /// overloads of Insert/Update/Delete to avoid duplication. + /// </summary> + private static void EmitParameterBindings(StringBuilder sb, List<DatabaseColumn> cols) + { + foreach (var col in cols) { - var paramName = ToCamelCase(col.Name); + var paramName = col.Name; if (col.IsNullable) { _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{paramName}\", {paramName} ?? (object)DBNull.Value);" + $" cmd.Parameters.AddWithValue(\"{paramName}\", (object?){paramName} ?? DBNull.Value);" ); } else @@ -921,20 +1104,6 @@ string pascalName ); } } - - _ = sb.AppendLine(); - _ = sb.AppendLine( - " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" - ); - _ = sb.AppendLine(" return new Result<int, SqlError>.Ok<int, SqlError>(rows);"); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" catch (Exception ex)"); - _ = sb.AppendLine(" {"); - _ = sb.AppendLine( - " return new Result<int, SqlError>.Error<int, SqlError>(SqlError.FromException(ex));" - ); - _ = sb.AppendLine(" }"); - _ = sb.AppendLine(" }"); } private static void GenerateDeleteMethod( @@ -948,11 +1117,11 @@ string pascalName if (pkCols.Count == 0) return; - var parameters = string.Join( - ", ", - pkCols.Select(c => $"{c.CSharpType} {ToCamelCase(c.Name)}") - ); + var parameters = string.Join(", ", pkCols.Select(c => $"{c.CSharpType} {c.Name}")); + var whereClauses = string.Join(" AND ", pkCols.Select(c => $"{c.Name} = @{c.Name}")); + var sqlLine = $"DELETE FROM {table.Schema}.{table.Name} WHERE {whereClauses}"; + // NpgsqlConnection overload _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// <summary>"); _ = sb.AppendLine( @@ -967,29 +1136,62 @@ string pascalName _ = sb.AppendLine(CultureInfo.InvariantCulture, $" this NpgsqlConnection conn,"); _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); _ = sb.AppendLine(" {"); - - var whereClauses = string.Join( - " AND ", - pkCols.Select(c => $"{c.Name} = @{ToCamelCase(c.Name)}") - ); - _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" const string sql = @\"DELETE FROM {table.Schema}.{table.Name} WHERE {whereClauses}\";" + $" const string sql = @\"{sqlLine}\";" ); _ = sb.AppendLine(); _ = sb.AppendLine(" try"); _ = sb.AppendLine(" {"); _ = sb.AppendLine(" await using var cmd = new NpgsqlCommand(sql, conn);"); + EmitParameterBindings(sb, pkCols); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" + ); + _ = sb.AppendLine(" return new Result<int, SqlError>.Ok<int, SqlError>(rows);"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch (Exception ex)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<int, SqlError>.Error<int, SqlError>(SqlError.FromException(ex));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); - foreach (var col in pkCols) - { - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{ToCamelCase(col.Name)}\", {ToCamelCase(col.Name)});" - ); - } - + // Bug #16: IDbTransaction overload, matching old SQLite.Cli shape. + _ = sb.AppendLine(); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// <summary>"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" /// IDbTransaction overload of Delete{pascalName}Async." + ); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// </summary>"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" public static async Task<Result<int, SqlError>> Delete{pascalName}Async(" + ); + _ = sb.AppendLine(" this System.Data.IDbTransaction transaction,"); + _ = sb.AppendLine(CultureInfo.InvariantCulture, $" {parameters})"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" const string sql = @\"{sqlLine}\";" + ); + _ = sb.AppendLine(); + _ = sb.AppendLine(" if (transaction.Connection is not NpgsqlConnection conn)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " return new Result<int, SqlError>.Error<int, SqlError>(new SqlError(\"Transaction.Connection must be NpgsqlConnection\"));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " await using var cmd = new NpgsqlCommand(sql, conn, (NpgsqlTransaction)transaction);" + ); + EmitParameterBindings(sb, pkCols); _ = sb.AppendLine(); _ = sb.AppendLine( " var rows = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);" @@ -1160,7 +1362,10 @@ string pascalName for (int i = 0; i < insertable.Count; i++) { var col = insertable[i]; - var propName = ToPascalCase(col.Name); + // Preserve the column name verbatim so generated record fields + // match the SQLite CLI output (which kept snake_case literally), + // and so consumers that reference `rec.user_id` etc. keep working. + var propName = col.Name; if (col.IsNullable) { _ = sb.AppendLine( @@ -1364,7 +1569,10 @@ string pascalName for (int i = 0; i < insertable.Count; i++) { var col = insertable[i]; - var propName = ToPascalCase(col.Name); + // Preserve the column name verbatim so generated record fields + // match the SQLite CLI output (which kept snake_case literally), + // and so consumers that reference `rec.user_id` etc. keep working. + var propName = col.Name; if (col.IsNullable) { _ = sb.AppendLine( @@ -1390,6 +1598,129 @@ string pascalName _ = sb.AppendLine(" }"); } + /// <summary> + /// Walks the input SQL and double-quotes any `AS <bareIdent>` + /// alias that contains uppercase letters, so PostgreSQL preserves + /// the case in the column name returned by reader.GetColumnSchema(). + /// Skips characters inside single-quoted string literals and + /// already-quoted aliases. Bug #23. + /// </summary> + private static string QuoteAsAliases(string sql) + { + if (string.IsNullOrEmpty(sql)) + { + return sql; + } + + var sb = new StringBuilder(sql.Length + 16); + var i = 0; + while (i < sql.Length) + { + var c = sql[i]; + + // Pass through single-quoted string literals. + if (c == '\'') + { + sb.Append(c); + i++; + while (i < sql.Length) + { + sb.Append(sql[i]); + if (sql[i] == '\'') + { + if (i + 1 < sql.Length && sql[i + 1] == '\'') + { + sb.Append(sql[i + 1]); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + // Pass through already-quoted identifiers. + if (c == '"') + { + sb.Append(c); + i++; + while (i < sql.Length) + { + sb.Append(sql[i]); + if (sql[i] == '"') + { + i++; + break; + } + i++; + } + continue; + } + + // Look for AS keyword (case-insensitive) at a word boundary. + if ( + (c == 'A' || c == 'a') + && i + 1 < sql.Length + && (sql[i + 1] == 'S' || sql[i + 1] == 's') + && (i == 0 || !char.IsLetterOrDigit(sql[i - 1]) && sql[i - 1] != '_') + && i + 2 < sql.Length + && !char.IsLetterOrDigit(sql[i + 2]) + && sql[i + 2] != '_' + ) + { + // Emit "AS" + sb.Append(sql[i]).Append(sql[i + 1]); + i += 2; + // Skip whitespace. + while (i < sql.Length && char.IsWhiteSpace(sql[i])) + { + sb.Append(sql[i]); + i++; + } + // Read alias identifier. + if (i < sql.Length && (char.IsLetter(sql[i]) || sql[i] == '_')) + { + var aliasStart = i; + i++; + while (i < sql.Length && (char.IsLetterOrDigit(sql[i]) || sql[i] == '_')) + { + i++; + } + var alias = sql[aliasStart..i]; + if (HasUppercaseAscii(alias)) + { + sb.Append('"').Append(alias).Append('"'); + } + else + { + sb.Append(alias); + } + } + continue; + } + + sb.Append(c); + i++; + } + return sb.ToString(); + } + + private static bool HasUppercaseAscii(string s) + { + for (var i = 0; i < s.Length; i++) + { + var ch = s[i]; + if (ch >= 'A' && ch <= 'Z') + { + return true; + } + } + return false; + } + private static List<string> ExtractParameters(string sql) { var parameters = new List<string>(); @@ -1439,8 +1770,21 @@ private static async Task< metaSql = metaSql.Replace($"@{param}", "NULL", StringComparison.OrdinalIgnoreCase); } - // Wrap in a CTE to get metadata without executing - var wrappedSql = $"SELECT * FROM ({metaSql}) AS _meta WHERE 1=0"; + // Strip optional trailing semicolons + whitespace so the inner + // statement parses cleanly inside a CTE wrapper. Postgres rejects + // a `;` followed by `)` inside a subquery / CTE body. + metaSql = metaSql.TrimEnd(); + while (metaSql.EndsWith(";", StringComparison.Ordinal)) + { + metaSql = metaSql[..^1].TrimEnd(); + } + + // Wrap in a CTE to get metadata without executing. CTE form + // (instead of `SELECT * FROM (<sql>) AS _meta`) is required so + // that UPDATE/INSERT/DELETE ... RETURNING statements also work, + // since Postgres only allows DML inside a `WITH` clause, not + // inside a `FROM (...)` subquery. + var wrappedSql = $"WITH _meta AS ({metaSql}) SELECT * FROM _meta WHERE 1=0"; await using var cmd = new NpgsqlCommand(wrappedSql, conn); await using var reader = await cmd.ExecuteReaderAsync( @@ -1508,7 +1852,7 @@ private static string MapPostgresTypeToCSharp(string pgType, bool isNullable) "string", "json" or "jsonb" => "string", var t when t.EndsWith("[]", StringComparison.Ordinal) => "string[]", - + // PortableType names (from schema.yaml) "uuidtype" => "Guid", "booleantype" => "bool", @@ -1535,10 +1879,19 @@ var t when t.EndsWith("[]", StringComparison.Ordinal) => "string[]", "varbinarytype" => "byte[]", "blobtype" => "byte[]", "rowversiontype" => "byte[]", - + _ => "string", }; + // Bug #14: byte[] columns can come back as null from the reader + // even when the schema marks them NOT NULL (Postgres bytea + // metadata is unreliable). Always treat byte[] as nullable so + // the generated reader expression compiles without CS8604. + if (baseType == "byte[]") + { + return "byte[]?"; + } + // Add nullable suffix for nullable types (including strings but not arrays) if (isNullable && !baseType.EndsWith("[]", StringComparison.Ordinal)) { @@ -1558,7 +1911,18 @@ List<string> parameters var sb = new StringBuilder(); var recordName = fileName; - // Header with all using statements (including type aliases) at the top + // Fully qualified Result type strings used throughout the generated + // file. Inlining these avoids per-file `using XxxOk = ...` aliases + // that conflict (CS1537) with consumer-side global aliases. + var resultType = + $"Outcome.Result<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>"; + var okType = + $"Outcome.Result<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>.Ok<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>"; + var errorType = + $"Outcome.Result<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>.Error<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>"; + + // Header with all using statements at the top, then a file-scoped + // `namespace Generated;` so consumers can `using Generated;`. _ = sb.AppendLine("// <auto-generated />"); _ = sb.AppendLine("#nullable enable"); _ = sb.AppendLine(); @@ -1567,42 +1931,40 @@ List<string> parameters _ = sb.AppendLine("using Outcome;"); _ = sb.AppendLine("using Nimblesite.Sql.Model;"); _ = sb.AppendLine(); - // Result type aliases must come after standard usings but before any type definitions - // Use fully qualified names since type aliases don't use namespace context - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $"using {fileName}Result = Outcome.Result<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>;" - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $"using {fileName}Ok = Outcome.Result<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>.Ok<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>;" - ); - _ = sb.AppendLine( - CultureInfo.InvariantCulture, - $"using {fileName}Error = Outcome.Result<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>.Error<System.Collections.Immutable.ImmutableList<{recordName}>, Nimblesite.Sql.Model.SqlError>;" - ); + _ = sb.AppendLine("namespace Generated;"); _ = sb.AppendLine(); - // Generate record type + // Generate record type. Bug #15: emit a non-positional record with + // per-property `{ get; init; }` declarations matching the old + // SQLite.Cli shape. Per-property nullability comes from the + // database column metadata (NOT NULL -> non-nullable type, + // nullable -> `T?`). _ = sb.AppendLine(CultureInfo.InvariantCulture, $"/// <summary>"); _ = sb.AppendLine( CultureInfo.InvariantCulture, $"/// Generated record for {fileName} query." ); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"/// </summary>"); - _ = sb.Append(CultureInfo.InvariantCulture, $"public sealed record {recordName}("); - - var first = true; + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"public record {recordName}"); + _ = sb.AppendLine("{"); foreach (var col in columns) { - if (!first) - _ = sb.Append(", "); - first = false; - - var propName = ToPascalCase(col.Name); - _ = sb.Append(CultureInfo.InvariantCulture, $"{col.CSharpType} {propName}"); + // Preserve the column name verbatim so generated record fields + // match the SQLite CLI output (which kept snake_case literally), + // and so consumers that reference `rec.user_id` etc. keep working. + var propName = col.Name; + // Bug #19: emit per-property XML doc so consumers with + // GenerateDocumentationFile=true don't get CS1591 warnings. + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" /// <summary>Column '{col.Name}'.</summary>" + ); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" public {col.CSharpType} {propName} {{ get; init; }}" + ); } - _ = sb.AppendLine(");"); + _ = sb.AppendLine("}"); _ = sb.AppendLine(); // Generate extension method @@ -1630,13 +1992,16 @@ List<string> parameters _ = sb.AppendLine(CultureInfo.InvariantCulture, $" /// </summary>"); _ = sb.Append( CultureInfo.InvariantCulture, - $" public static async Task<{fileName}Result> {fileName}Async(this NpgsqlConnection conn" + $" public static async Task<{resultType}> {fileName}Async(this NpgsqlConnection conn" ); foreach (var param in parameters) { - var paramType = InferParameterType(param); - _ = sb.Append(CultureInfo.InvariantCulture, $", {paramType} {ToCamelCase(param)}"); + var paramType = InferParameterType(param, columns); + // Bug #13: emit the parameter name verbatim (e.g. resource_id) + // so consumer call sites that use named arguments + // (CheckResourceGrantAsync(resource_id: ...)) keep working. + _ = sb.Append(CultureInfo.InvariantCulture, $", {paramType} {param}"); } _ = sb.AppendLine(")"); _ = sb.AppendLine(" {"); @@ -1652,7 +2017,7 @@ List<string> parameters { _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" cmd.Parameters.AddWithValue(\"{param}\", {ToCamelCase(param)});" + $" cmd.Parameters.AddWithValue(\"{param}\", {param});" ); } @@ -1670,40 +2035,43 @@ List<string> parameters _ = sb.AppendLine(); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" return new {fileName}Ok(results.ToImmutable());" + $" return new {okType}(results.ToImmutable());" ); _ = sb.AppendLine(" }"); _ = sb.AppendLine(" catch (Exception ex)"); _ = sb.AppendLine(" {"); _ = sb.AppendLine( CultureInfo.InvariantCulture, - $" return new {fileName}Error(SqlError.FromException(ex));" + $" return new {errorType}(SqlError.FromException(ex));" ); _ = sb.AppendLine(" }"); _ = sb.AppendLine(" }"); _ = sb.AppendLine(); - // Reader method + // Reader method. Bug #15: use object initializer syntax to match + // the non-positional record shape declared above. _ = sb.AppendLine( CultureInfo.InvariantCulture, $" private static {recordName} Read{recordName}(NpgsqlDataReader reader) =>" ); - _ = sb.Append(CultureInfo.InvariantCulture, $" new("); + _ = sb.AppendLine(" new()"); + _ = sb.AppendLine(" {"); - first = true; var ordinal = 0; foreach (var col in columns) { - if (!first) - _ = sb.Append(", "); - first = false; - - var propName = ToPascalCase(col.Name); + // Preserve the column name verbatim so generated record fields + // match the SQLite CLI output (which kept snake_case literally), + // and so consumers that reference `rec.user_id` etc. keep working. + var propName = col.Name; var readExpr = GetReaderExpression(col, ordinal); - _ = sb.Append(CultureInfo.InvariantCulture, $"{propName}: {readExpr}"); + _ = sb.AppendLine( + CultureInfo.InvariantCulture, + $" {propName} = {readExpr}," + ); ordinal++; } - _ = sb.AppendLine(");"); + _ = sb.AppendLine(" };"); _ = sb.AppendLine("}"); @@ -1742,6 +2110,15 @@ private static string MapPortableTypeToCSharp(PortableType type, bool isNullable _ => "string", }; + // Bug #14: byte[] columns can come back as null from the reader + // even when the schema marks them NOT NULL (Postgres bytea + // metadata is unreliable). Always treat byte[] as nullable so + // the generated reader expression compiles without CS8604. + if (baseType == "byte[]") + { + return "byte[]?"; + } + // Add nullable suffix for nullable types (including strings but not arrays) if (isNullable && !baseType.EndsWith("[]", StringComparison.Ordinal)) { @@ -1776,11 +2153,38 @@ private static string GetReaderExpression(DatabaseColumn col, int ordinal) }; } - private static string InferParameterType(string paramName) + private static string InferParameterType( + string paramName, + IReadOnlyList<DatabaseColumn>? columns = null + ) { + // 1. If we have schema columns and one matches the param name + // (case-insensitive), use the actual column C# type. Strip the + // nullable suffix because parameters are non-nullable in method + // signatures (callers pass concrete values). + if (columns is not null) + { + foreach (var col in columns) + { + if (string.Equals(col.Name, paramName, StringComparison.OrdinalIgnoreCase)) + { + var t = col.CSharpType; + if (t.EndsWith("?", StringComparison.Ordinal)) + { + t = t[..^1]; + } + return t; + } + } + } + + // 2. Fall back to name-based heuristics. Default `*id` -> string + // because Postgres `text` ids are common and a `string` argument + // round-trips correctly to both `text` and `uuid` columns (Npgsql + // handles the cast for the latter when the column type is uuid). var lower = paramName.ToLowerInvariant(); if (lower.EndsWith("id", StringComparison.Ordinal)) - return "Guid"; + return "string"; if ( lower.Contains("limit", StringComparison.Ordinal) || lower.Contains("offset", StringComparison.Ordinal) @@ -1795,6 +2199,16 @@ private static string ToPascalCase(string name) if (string.IsNullOrEmpty(name)) return name; + // If the name is already mixed case (no underscores), preserve the + // existing case and just uppercase the first letter. This keeps + // identifiers like `givenName` -> `GivenName` and `GivenName` -> + // `GivenName`, instead of destructively lowercasing the tail. + if (!name.Contains('_', StringComparison.Ordinal)) + { + return char.ToUpperInvariant(name[0]) + name[1..]; + } + + // snake_case input: split on underscore and Pascal-case each chunk. var parts = name.Split('_'); var sb = new StringBuilder(); foreach (var part in parts) @@ -1803,7 +2217,7 @@ private static string ToPascalCase(string name) { _ = sb.Append(char.ToUpperInvariant(part[0])); if (part.Length > 1) - _ = sb.Append(part[1..].ToLowerInvariant()); + _ = sb.Append(part[1..]); } } return sb.ToString(); @@ -1811,6 +2225,17 @@ private static string ToPascalCase(string name) private static string ToCamelCase(string name) { + if (string.IsNullOrEmpty(name)) + return name; + + // Preserve existing camel/Pascal input, just lowercase the first + // letter. For snake_case input, fall through to PascalCase then + // lowercase the leading letter. + if (!name.Contains('_', StringComparison.Ordinal)) + { + return char.ToLowerInvariant(name[0]) + name[1..]; + } + var pascal = ToPascalCase(name); if (string.IsNullOrEmpty(pascal)) return pascal; @@ -1912,4 +2337,4 @@ internal sealed record TableConfigItem /// Primary key columns. /// </summary> public IReadOnlyList<string> PrimaryKeyColumns { get; init; } = []; -} \ No newline at end of file +} diff --git a/Directory.Build.props b/Directory.Build.props index 0cf7000d..bed372be 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -51,11 +51,19 @@ <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <!-- SqlParserCS for SQL parsing --> - <PackageReference Include="SqlParserCS" Version="0.6.5" /> + <!-- SqlParserCS for SQL parsing (not compatible with netstandard) --> + <PackageReference + Include="SqlParserCS" + Version="0.6.5" + Condition="!$(TargetFramework.StartsWith('netstandard'))" + /> - <!-- Result types for Railway Oriented Programming --> - <PackageReference Include="Outcome" Version="1.0.0" /> + <!-- Result types for Railway Oriented Programming (not compatible with netstandard2.0) --> + <PackageReference + Include="Outcome" + Version="1.0.0" + Condition="!$(TargetFramework.StartsWith('netstandard'))" + /> <!-- Exhaustive pattern matching analyzer --> <PackageReference Include="Exhaustion" Version="1.0.0"> diff --git a/Lql/LqlExtension/.nycrc.json b/Lql/LqlExtension/.nycrc.json index ef80406f..01a5e8bc 100644 --- a/Lql/LqlExtension/.nycrc.json +++ b/Lql/LqlExtension/.nycrc.json @@ -1,7 +1,7 @@ { "all": true, - "include": ["out/**/*.js"], - "exclude": ["out/test/**"], + "include": ["src/**/*.ts"], + "exclude": ["src/test/**", "out/**"], "reporter": ["json-summary", "text"], "temp-dir": ".nyc_output" } diff --git a/Lql/LqlExtension/src/extension.ts b/Lql/LqlExtension/src/extension.ts index 79c2bfab..c197280e 100644 --- a/Lql/LqlExtension/src/extension.ts +++ b/Lql/LqlExtension/src/extension.ts @@ -2,6 +2,7 @@ import * as path from "path"; import * as fs from "fs"; import * as https from "https"; import * as http from "http"; +import { spawnSync } from "child_process"; import * as vscode from "vscode"; import { LanguageClient, @@ -74,6 +75,82 @@ function downloadFile(url: string, dest: string): Promise<void> { }); } +/** + * Run `<binary> --version` and return the parsed semver string, or undefined + * if the binary couldn't be invoked or didn't print a recognisable version. + */ +function getBinaryVersion(binary: string): string | undefined { + try { + const result = spawnSync(binary, ["--version"], { + encoding: "utf8", + timeout: 5000, + }); + if (result.status !== 0) { + return undefined; + } + const output = `${result.stdout}\n${result.stderr}`; + const match = /(\d+\.\d+\.\d+)/.exec(output); + return match === null ? undefined : match[1]; + } catch { + return undefined; + } +} + +/** + * Look for `lql-lsp` on the system PATH and return its location if its + * --version matches the extension version. Used so dev / test / CI can + * install the binary into PATH (e.g. via cargo install or copying the + * cargo build output) and have the extension use it without downloading. + */ +function findOnPathMatchingVersion(expectedVersion: string): string | undefined { + const binaryName = process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; + const pathEnv = process.env.PATH ?? ""; + const sep = process.platform === "win32" ? ";" : ":"; + for (const dir of pathEnv.split(sep)) { + if (dir === "") { + continue; + } + const candidate = path.join(dir, binaryName); + if (!fs.existsSync(candidate)) { + continue; + } + const version = getBinaryVersion(candidate); + if (version === expectedVersion) { + return candidate; + } + } + return undefined; +} + +/** + * Look for a locally-built `lql-lsp` in the Rust cargo target folder + * adjacent to the extension install path. Used by dev / test / CI runs + * where the binary is built but not installed onto PATH (or where the + * test harness strips PATH from the spawned VS Code process). + */ +function findLocalCargoBuild( + context: vscode.ExtensionContext, + expectedVersion: string, +): string | undefined { + const binaryName = process.platform === "win32" ? "lql-lsp.exe" : "lql-lsp"; + const extPath = context.extensionPath; + const candidates = [ + path.join(extPath, "..", "lql-lsp-rust", "target", "release", binaryName), + path.join(extPath, "..", "lql-lsp-rust", "target", "debug", binaryName), + path.join(extPath, "bin", binaryName), + ]; + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) { + continue; + } + const version = getBinaryVersion(candidate); + if (version === expectedVersion) { + return candidate; + } + } + return undefined; +} + /** Download the LSP binary from the GitHub release matching the extension version. */ async function downloadLspBinary( context: vscode.ExtensionContext, @@ -131,6 +208,9 @@ export async function activate( return; } + const expectedVersion = getExtensionVersion(context); + log(`Extension version: ${expectedVersion}`); + let serverBinary: string; const customPath = config.get<string>("languageServer.path") ?? ""; if (customPath !== "") { @@ -144,16 +224,33 @@ export async function activate( serverBinary = customPath; log(`LSP binary (custom): ${serverBinary}`); } else { - try { - serverBinary = await downloadLspBinary(context); - log(`LSP binary: ${serverBinary}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log(`ERROR: ${message}`); - vscode.window.showErrorMessage( - `LQL: Failed to download language server: ${message}`, - ); - return; + // 1. Prefer a binary on PATH whose --version matches the extension. + const onPath = findOnPathMatchingVersion(expectedVersion); + if (onPath !== undefined) { + serverBinary = onPath; + log(`LSP binary (PATH, version ${expectedVersion}): ${serverBinary}`); + } else { + // 2. Fall back to a locally-built cargo target adjacent to the + // extension (used by dev / test / CI runs where the binary isn't + // on PATH inside the spawned VS Code process). + const localBuild = findLocalCargoBuild(context, expectedVersion); + if (localBuild !== undefined) { + serverBinary = localBuild; + log(`LSP binary (local cargo build, version ${expectedVersion}): ${serverBinary}`); + } else { + // 3. Otherwise download the matching release into globalStorage. + try { + serverBinary = await downloadLspBinary(context); + log(`LSP binary (downloaded): ${serverBinary}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`ERROR: ${message}`); + vscode.window.showErrorMessage( + `LQL: Failed to download language server: ${message}`, + ); + return; + } + } } } @@ -190,7 +287,10 @@ export async function activate( } const clientOptions: LanguageClientOptions = { - documentSelector: [{ scheme: "file", language: "lql" }], + documentSelector: [ + { scheme: "file", language: "lql" }, + { scheme: "untitled", language: "lql" }, + ], synchronize: { fileEvents: vscode.workspace.createFileSystemWatcher("**/*.lql"), }, diff --git a/Lql/LqlExtension/src/test/suite/lsp-protocol.test.ts b/Lql/LqlExtension/src/test/suite/lsp-protocol.test.ts index 4b011c6a..2ed55b62 100644 --- a/Lql/LqlExtension/src/test/suite/lsp-protocol.test.ts +++ b/Lql/LqlExtension/src/test/suite/lsp-protocol.test.ts @@ -389,13 +389,13 @@ function readFixture(name: string): string { throw new Error(`Fixture not found: ${name}`); } -describe("LSP Protocol E2E Tests", function () { +suite("LSP Protocol E2E Tests", function () { this.timeout(30000); let client: LspClient; let lspBinary: string; - before(function () { + suiteSetup(function () { try { lspBinary = findLspBinary(); } catch { @@ -403,11 +403,11 @@ describe("LSP Protocol E2E Tests", function () { } }); - beforeEach(function () { + setup(function () { client = new LspClient(lspBinary); }); - afterEach(function () { + teardown(function () { client.kill(); }); @@ -443,7 +443,7 @@ describe("LSP Protocol E2E Tests", function () { // INITIALIZATION // ═══════════════════════════════════════════════════════════════ - it("should initialize with correct capabilities", async function () { + test("should initialize with correct capabilities", async function () { const result = await initServer(); assert.notStrictEqual(result, undefined, "Initialize result should not be null"); @@ -502,7 +502,7 @@ describe("LSP Protocol E2E Tests", function () { // COMPLETIONS (IntelliSense) // ═══════════════════════════════════════════════════════════════ - it("should provide pipeline completions after |>", async function () { + test("should provide pipeline completions after |>", async function () { await initServer(); await openDocument("file:///test/completion.lql", "users |> "); @@ -531,7 +531,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(selectItem.kind !== undefined, "select must have a kind"); }); - it("should provide aggregate function completions", async function () { + test("should provide aggregate function completions", async function () { await initServer(); await openDocument("file:///test/agg.lql", "orders |> select(c"); @@ -548,7 +548,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(labels.includes("coalesce"), "Must suggest 'coalesce'"); }); - it("should provide keyword completions", async function () { + test("should provide keyword completions", async function () { await initServer(); await openDocument("file:///test/kw.lql", "l"); @@ -568,7 +568,7 @@ describe("LSP Protocol E2E Tests", function () { // HOVER (IntelliPrompt) // ═══════════════════════════════════════════════════════════════ - it("should provide hover info for filter keyword", async function () { + test("should provide hover info for filter keyword", async function () { await initServer(); const content = "users |> filter(fn(row) => row.users.age > 18) |> select(users.name) |> limit(10)"; @@ -594,7 +594,7 @@ describe("LSP Protocol E2E Tests", function () { ); }); - it("should provide hover info for select keyword", async function () { + test("should provide hover info for select keyword", async function () { await initServer(); const content = "users |> filter(fn(row) => row.users.age > 18) |> select(users.name) |> limit(10)"; @@ -619,7 +619,7 @@ describe("LSP Protocol E2E Tests", function () { ); }); - it("should provide hover for aggregate functions", async function () { + test("should provide hover for aggregate functions", async function () { await initServer(); await openDocument( "file:///test/agg_hover.lql", @@ -656,7 +656,7 @@ describe("LSP Protocol E2E Tests", function () { ); }); - it("should return null hover for unknown identifiers", async function () { + test("should return null hover for unknown identifiers", async function () { await initServer(); await openDocument( "file:///test/no_hover.lql", @@ -679,7 +679,7 @@ describe("LSP Protocol E2E Tests", function () { // DIAGNOSTICS // ═══════════════════════════════════════════════════════════════ - it("should publish diagnostics for syntax errors", async function () { + test("should publish diagnostics for syntax errors", async function () { await initServer(); const invalidContent = readFixture("invalid_syntax.lql"); const notifications = await openDocument( @@ -704,7 +704,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(firstDiag.severity !== undefined, "Diagnostic must have severity"); }); - it("should publish clean diagnostics for valid files", async function () { + test("should publish clean diagnostics for valid files", async function () { await initServer(); const validContent = readFixture("simple_select.lql"); const notifications = await openDocument( @@ -722,7 +722,7 @@ describe("LSP Protocol E2E Tests", function () { ); }); - it("should update diagnostics on document change", async function () { + test("should update diagnostics on document change", async function () { await initServer(); await openDocument( "file:///test/change.lql", @@ -752,7 +752,7 @@ describe("LSP Protocol E2E Tests", function () { // DOCUMENT SYMBOLS // ═══════════════════════════════════════════════════════════════ - it("should return document symbols for let bindings", async function () { + test("should return document symbols for let bindings", async function () { await initServer(); const content = readFixture("complex_pipeline.lql"); await openDocument("file:///test/symbols.lql", content); @@ -788,7 +788,7 @@ describe("LSP Protocol E2E Tests", function () { // FORMATTING // ═══════════════════════════════════════════════════════════════ - it("should format LQL documents", async function () { + test("should format LQL documents", async function () { await initServer(); const uglyContent = " users |> select( users.id , users.name ) "; await openDocument("file:///test/format.lql", uglyContent); @@ -814,7 +814,7 @@ describe("LSP Protocol E2E Tests", function () { // COMPLEX REAL-WORLD SCENARIOS // ═══════════════════════════════════════════════════════════════ - it("should handle complex pipeline with all features", async function () { + test("should handle complex pipeline with all features", async function () { await initServer(); const content = readFixture("complex_pipeline.lql"); const notifications = await openDocument( @@ -840,7 +840,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(result !== null && result !== undefined, "Should provide completions in complex document"); }); - it("should handle window function documents cleanly", async function () { + test("should handle window function documents cleanly", async function () { await initServer(); const content = readFixture("window_functions.lql"); const notifications = await openDocument( @@ -854,7 +854,7 @@ describe("LSP Protocol E2E Tests", function () { assert.strictEqual(errors.length, 0, "Window function file should parse without errors"); }); - it("should handle case expression documents cleanly", async function () { + test("should handle case expression documents cleanly", async function () { await initServer(); const content = readFixture("case_expression.lql"); const notifications = await openDocument( @@ -867,7 +867,7 @@ describe("LSP Protocol E2E Tests", function () { assert.strictEqual(errors.length, 0, "Case expression should parse cleanly"); }); - it("should handle exists subquery documents cleanly", async function () { + test("should handle exists subquery documents cleanly", async function () { await initServer(); const content = readFixture("subquery_exists.lql"); const notifications = await openDocument( @@ -884,14 +884,14 @@ describe("LSP Protocol E2E Tests", function () { // LIFECYCLE // ═══════════════════════════════════════════════════════════════ - it("should handle shutdown gracefully", async function () { + test("should handle shutdown gracefully", async function () { await initServer(); const result = await client.request("shutdown"); assert.strictEqual(result, null, "Shutdown should return null"); client.notify("exit", null); }); - it("should handle multiple documents simultaneously", async function () { + test("should handle multiple documents simultaneously", async function () { await initServer(); await openDocument( "file:///test/doc1.lql", @@ -915,7 +915,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(result2 !== null && result2 !== undefined, "Doc2 should return completions"); }); - it("should handle document close and reopen", async function () { + test("should handle document close and reopen", async function () { await initServer(); await openDocument( "file:///test/reopen.lql", @@ -945,7 +945,7 @@ describe("LSP Protocol E2E Tests", function () { // INTELLISENSE PROOF — Deep completions testing (ZERO MOCKING) // ═══════════════════════════════════════════════════════════════ - it("PROOF: IntelliSense delivers context-aware completions after pipe in real multiline pipeline", async function () { + test("PROOF: IntelliSense delivers context-aware completions after pipe in real multiline pipeline", async function () { await initServer(); const content = `let active_users = users |> filter(fn(row) => row.users.status = 'active') @@ -985,7 +985,7 @@ describe("LSP Protocol E2E Tests", function () { } }); - it("PROOF: IntelliSense delivers function completions inside select() arguments", async function () { + test("PROOF: IntelliSense delivers function completions inside select() arguments", async function () { await initServer(); const content = "orders |> select("; await openDocument("file:///test/intellisense_proof2.lql", content); @@ -1024,7 +1024,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(labels.includes("dense_rank"), "Must suggest 'dense_rank' window function"); }); - it("PROOF: IntelliSense completions have correct LSP completion item kinds", async function () { + test("PROOF: IntelliSense completions have correct LSP completion item kinds", async function () { await initServer(); await openDocument("file:///test/intellisense_kinds.lql", "orders |> "); @@ -1057,7 +1057,7 @@ describe("LSP Protocol E2E Tests", function () { ); }); - it("PROOF: IntelliSense delivers prefix-filtered completions after pipe", async function () { + test("PROOF: IntelliSense delivers prefix-filtered completions after pipe", async function () { await initServer(); // Place cursor right after "|> s" — the word prefix is "s", after_pipe detected await openDocument("file:///test/prefix_filter.lql", "orders |> s"); @@ -1085,7 +1085,7 @@ describe("LSP Protocol E2E Tests", function () { // INTELLIPROMPT PROOF — Deep hover testing (ZERO MOCKING) // ═══════════════════════════════════════════════════════════════ - it("PROOF: IntelliPrompt delivers rich Markdown hover with signature for ALL pipeline ops", async function () { + test("PROOF: IntelliPrompt delivers rich Markdown hover with signature for ALL pipeline ops", async function () { await initServer(); // Build a document with all pipeline operations for hover testing const content = `users @@ -1198,7 +1198,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(offsetText.toLowerCase().includes("skip"), "Offset hover must mention skipping rows"); }); - it("PROOF: IntelliPrompt delivers hover for aggregate functions in real context", async function () { + test("PROOF: IntelliPrompt delivers hover for aggregate functions in real context", async function () { await initServer(); const content = "orders |> select(count(*) as cnt, sum(orders.total) as total_sum, avg(orders.total) as avg_total, max(orders.total) as high, min(orders.total) as low)"; await openDocument("file:///test/intelliprompt_agg.lql", content); @@ -1264,7 +1264,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(extractHoverText(minHover).toLowerCase().includes("min"), "Min hover must describe min"); }); - it("PROOF: IntelliPrompt delivers hover for string functions", async function () { + test("PROOF: IntelliPrompt delivers hover for string functions", async function () { await initServer(); const content = "users |> select(concat(users.first, users.last) as name, upper(users.email) as email_upper, trim(users.bio) as bio, length(users.bio) as bio_len)"; await openDocument("file:///test/intelliprompt_string.lql", content); @@ -1326,7 +1326,7 @@ describe("LSP Protocol E2E Tests", function () { ); }); - it("PROOF: IntelliPrompt returns null for non-LQL identifiers", async function () { + test("PROOF: IntelliPrompt returns null for non-LQL identifiers", async function () { await initServer(); await openDocument( "file:///test/intelliprompt_null.lql", @@ -1360,7 +1360,7 @@ describe("LSP Protocol E2E Tests", function () { // DIAGNOSTICS PROOF — Real error detection (ZERO MOCKING) // ═══════════════════════════════════════════════════════════════ - it("PROOF: LSP detects real syntax errors and provides actionable range info", async function () { + test("PROOF: LSP detects real syntax errors and provides actionable range info", async function () { await initServer(); // Unclosed parenthesis const notifications = await openDocument( @@ -1383,7 +1383,7 @@ describe("LSP Protocol E2E Tests", function () { assert.ok(error.source === "lql", "Error source must be 'lql'"); }); - it("PROOF: LSP reports zero errors on valid complex multiline LQL", async function () { + test("PROOF: LSP reports zero errors on valid complex multiline LQL", async function () { await initServer(); const validContent = `let completed_orders = orders |> filter(fn(row) => row.orders.status = 'completed') @@ -1411,7 +1411,7 @@ let summary = completed_orders ); }); - it("PROOF: LSP detects errors and then clears them when content is fixed", async function () { + test("PROOF: LSP detects errors and then clears them when content is fixed", async function () { await initServer(); // Open with invalid content const badNotifications = await openDocument( @@ -1448,7 +1448,7 @@ let summary = completed_orders // DOCUMENT SYMBOLS PROOF (ZERO MOCKING) // ═══════════════════════════════════════════════════════════════ - it("PROOF: LSP extracts document symbols from let bindings with correct locations", async function () { + test("PROOF: LSP extracts document symbols from let bindings with correct locations", async function () { await initServer(); const content = `let users_active = users |> filter(fn(row) => row.users.active = true) @@ -1496,7 +1496,7 @@ let final_report = users_active // FORMATTING PROOF (ZERO MOCKING) // ═══════════════════════════════════════════════════════════════ - it("PROOF: LSP formats messy multiline LQL into properly indented output", async function () { + test("PROOF: LSP formats messy multiline LQL into properly indented output", async function () { await initServer(); // Multiline with bad indentation — formatter normalizes leading whitespace per line const messy = ` users @@ -1546,7 +1546,7 @@ let final_report = users_active // REAL-WORLD COMPLEX SCENARIO PROOF (ZERO MOCKING) // ═══════════════════════════════════════════════════════════════ - it("PROOF: Full E2E workflow — open, complete, hover, diagnose, format in sequence", async function () { + test("PROOF: Full E2E workflow — open, complete, hover, diagnose, format in sequence", async function () { await initServer(); // Step 1: Open a real document @@ -1616,7 +1616,7 @@ let final_report = users_active assert.ok(newErrors.length > 0, "Broken content must produce errors"); }); - it("PROOF: IntelliSense works for LQL keywords in empty doc and lambda context", async function () { + test("PROOF: IntelliSense works for LQL keywords in empty doc and lambda context", async function () { await initServer(); // In empty document: keywords like 'let', 'fn', 'case' should be available @@ -1664,7 +1664,7 @@ let final_report = users_active assert.ok(lambdaLabels.includes("like"), "Must suggest 'like' in lambda context"); }); - it("PROOF: IntelliPrompt hover for 'let' and 'fn' keywords", async function () { + test("PROOF: IntelliPrompt hover for 'let' and 'fn' keywords", async function () { await initServer(); const content = "let result = users |> filter(fn(row) => row.users.id > 0)"; await openDocument("file:///test/kw_hover_proof.lql", content); @@ -1700,7 +1700,7 @@ let final_report = users_active ); }); - it("PROOF: Window function completions available with correct prefix filtering", async function () { + test("PROOF: Window function completions available with correct prefix filtering", async function () { await initServer(); // Prefix "ro" — should match row_number, round diff --git a/Lql/LqlWebsite-Eleventy/_site/assets/css/styles.css b/Lql/LqlWebsite-Eleventy/_site/assets/css/styles.css new file mode 100644 index 00000000..d4c5b05b --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/assets/css/styles.css @@ -0,0 +1,790 @@ +/* LQL - Design System CSS + Dark-first, high contrast, minimal, reusable classes */ + +:root { + /* Core Colors from LQL Design System */ + --volcanic: #FF4500; + --forest: #228B22; + --obsidian: #1C1C1C; + --amber: #FFA500; + --violet: #8A2BE2; + --charcoal: #36454F; + --ivory: #FFFFF0; + --dark-bg: #0F0F0F; + --darker-bg: #0A0A0A; + --card-bg: #1A1A1A; + --border: #2A2A2A; + + /* Semantic mappings (dark theme default) */ + --bg-primary: var(--dark-bg); + --bg-secondary: var(--darker-bg); + --bg-tertiary: var(--obsidian); + --text-primary: var(--ivory); + --text-secondary: #B0B0B0; + --text-muted: #808080; + --border-color: var(--border); + --code-bg: var(--darker-bg); + --accent: var(--volcanic); + --accent-hover: var(--amber); + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + + /* Type Scale */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + --text-5xl: 3rem; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + + /* Layout */ + --max-width: 1200px; + --header-height: 64px; + --sidebar-width: 260px; + --radius: 8px; + --transition: 200ms ease; +} + +/* Reset */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + var(--space-4)); + overflow-x: hidden; +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-primary); + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + width: 100%; + max-width: 100vw; +} + +/* Skip link */ +.skip-link { + position: absolute; + top: -100%; + left: var(--space-4); + padding: var(--space-2) var(--space-4); + background: var(--volcanic); + color: white; + border-radius: var(--radius); + z-index: 1000; +} +.skip-link:focus { top: var(--space-4); } + +/* Container */ +.container { + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: 0 var(--space-4); +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.25; + color: var(--text-primary); +} +h1 { font-size: var(--text-4xl); font-weight: 700; } +h2 { font-size: var(--text-3xl); } +h3 { font-size: var(--text-2xl); } +h4 { font-size: var(--text-xl); } + +p { margin-bottom: var(--space-4); color: var(--text-secondary); } + +a { color: var(--volcanic); text-decoration: none; transition: color var(--transition); } +a:hover { color: var(--amber); } + +ul, ol { margin-bottom: var(--space-4); padding-left: var(--space-6); } +li { margin-bottom: var(--space-2); color: var(--text-secondary); } + +/* Code */ +code { + font-family: var(--font-mono); + font-size: 0.9em; + padding: 0.2em 0.4em; + background: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--amber); +} + +pre { + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.7; + padding: var(--space-4); + background: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius); + overflow-x: auto; + margin-bottom: var(--space-4); +} +pre code { padding: 0; background: none; border: none; color: inherit; } + +/* Header */ +.header { + position: sticky; + top: 0; + height: var(--header-height); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + z-index: 100; + backdrop-filter: blur(10px); +} + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; +} + +.logo { + display: flex; + align-items: center; + gap: var(--space-2); +} +.logo img { height: 32px; } +.logo:hover { opacity: 0.9; } +.logo-text { + font-size: var(--text-xl); + font-weight: 800; + color: var(--amber); +} + +.nav-links { + display: flex; + align-items: center; + gap: var(--space-6); + list-style: none; + margin: 0; + padding: 0; +} + +.nav-link { + font-weight: 500; + color: var(--text-secondary); + transition: color var(--transition); +} +.nav-link:hover, .nav-link.active { color: var(--volcanic); } + +/* Site Toggle */ +.site-toggle { + display: flex; + background: var(--bg-primary); + border-radius: var(--radius); + padding: 2px; + border: 1px solid var(--border-color); +} +.site-toggle-btn { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-secondary); + border-radius: 6px; + transition: all var(--transition); +} +.site-toggle-btn:hover { color: var(--text-primary); } +.site-toggle-btn.active { + background: var(--amber); + color: var(--obsidian); +} + +.nav-actions { display: flex; align-items: center; gap: var(--space-3); } + +/* Mobile menu toggle */ +.mobile-menu-toggle { + display: none; + flex-direction: column; + gap: 4px; + padding: var(--space-2); + background: transparent; + border: none; + cursor: pointer; +} +.mobile-menu-toggle span { + display: block; + width: 24px; + height: 2px; + background: var(--text-primary); + transition: all var(--transition); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: 600; + border-radius: var(--radius); + border: none; + cursor: pointer; + transition: all var(--transition); + text-decoration: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--volcanic) 0%, var(--amber) 100%); + color: white; +} +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 69, 0, 0.4); + color: white; +} + +.btn-secondary { + background: transparent; + color: var(--forest); + border: 2px solid var(--forest); +} +.btn-secondary:hover { background: var(--forest); color: white; } + +.btn-large { padding: var(--space-4) var(--space-8); font-size: var(--text-lg); } + +/* Hero */ +.hero { + position: relative; + padding: var(--space-20) 0; + text-align: center; + background: linear-gradient(135deg, var(--darker-bg) 0%, var(--obsidian) 100%); + overflow: hidden; +} +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 30% 20%, rgba(255, 69, 0, 0.1) 0%, transparent 50%), + radial-gradient(circle at 70% 80%, rgba(34, 139, 34, 0.1) 0%, transparent 50%); + pointer-events: none; +} +.hero > * { position: relative; z-index: 1; } +.hero h1 { + font-size: var(--text-5xl); + font-weight: 800; + margin-bottom: var(--space-6); + background: linear-gradient(135deg, var(--volcanic) 0%, var(--amber) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.hero-subtitle { + font-size: var(--text-xl); + color: var(--text-secondary); + max-width: 600px; + margin: 0 auto var(--space-8); +} +.hero-buttons { display: flex; gap: var(--space-4); justify-content: center; flex-wrap: wrap; } +.hero-code { + max-width: 800px; + margin: var(--space-12) auto 0; + text-align: left; +} + +/* Code example styling */ +.code-window { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: var(--space-8); + overflow: hidden; +} +.code-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: var(--space-6); +} +.code-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} +.code-dot:nth-child(1) { background: #FF5F57; } +.code-dot:nth-child(2) { background: #FFBD2E; } +.code-dot:nth-child(3) { background: #28CA42; } +.code-title { + margin-left: var(--space-4); + color: var(--text-muted); + font-size: var(--text-sm); +} +.code-block { + font-family: var(--font-mono); + font-size: var(--text-base); + line-height: 1.8; + color: var(--text-primary); + white-space: pre-wrap; +} + +/* LQL syntax highlighting */ +.keyword { color: var(--volcanic); } +.operator { color: var(--forest); } +.function { color: var(--amber); } +.string { color: var(--violet); } +.comment { color: var(--text-muted); } +.identifier { color: var(--text-primary); } + +/* Feature cards */ +.features { padding: var(--space-16) 0; } +.features-alt { padding: var(--space-16) 0; background: var(--obsidian); } + +.section-header { + text-align: center; + margin-bottom: var(--space-12); +} +.section-header h2 { + font-size: var(--text-4xl); + font-weight: 700; + margin-bottom: var(--space-4); +} +.section-header p { + font-size: var(--text-lg); + max-width: 600px; + margin: 0 auto; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-6); +} + +.card { + padding: var(--space-6); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + transition: all 0.3s ease; +} +.card:hover { + transform: translateY(-4px); + border-color: var(--volcanic); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); +} + +.card-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--volcanic) 0%, var(--amber) 100%); + color: white; + border-radius: var(--radius); + margin-bottom: var(--space-4); + font-size: var(--text-xl); + font-weight: 700; +} +.card h3 { margin-bottom: var(--space-2); } +.card p { margin: 0; } +.card a { display: inline-block; margin-top: var(--space-3); } + +/* Examples section */ +.examples { padding: var(--space-16) 0; } +.examples-grid { display: grid; gap: var(--space-12); } + +.example { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-12); + align-items: center; +} +.example:nth-child(even) { direction: rtl; } +.example:nth-child(even) > * { direction: ltr; } + +.example-content h3 { + font-size: var(--text-2xl); + font-weight: 700; + margin-bottom: var(--space-4); +} +.example-content p { + font-size: var(--text-lg); + margin-bottom: var(--space-6); +} +.example-features { + list-style: none; + padding: 0; +} +.example-features li { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.example-features li::before { + content: '\2192'; + color: var(--forest); + font-weight: bold; +} + +.example-code { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: var(--space-8); +} + +/* F# section */ +.fsharp { padding: var(--space-16) 0; background: var(--obsidian); } +.fsharp-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-12); + align-items: center; +} +.fsharp-info h3 { + font-size: var(--text-2xl); + font-weight: 700; + margin-bottom: var(--space-4); +} +.fsharp-info p { + font-size: var(--text-lg); + margin-bottom: var(--space-6); + line-height: 1.7; +} +.fsharp-features { + list-style: none; + padding: 0; +} +.fsharp-features li { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.fsharp-features li::before { + content: '\2192'; + color: var(--violet); + font-weight: bold; +} +.fsharp-code { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: var(--space-8); +} + +/* Playground */ +.playground { padding: var(--space-16) 0; } +.playground-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-8); + margin-bottom: var(--space-8); +} +.playground-panel { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: var(--space-6); +} +.playground-panel h3 { + font-size: var(--text-lg); + font-weight: 600; + margin-bottom: var(--space-4); +} +.playground-controls { + display: flex; + gap: var(--space-4); + align-items: center; + margin-bottom: var(--space-4); +} +.dialect-selector { + background: var(--darker-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: var(--space-2) var(--space-3); + color: var(--text-primary); + font-size: var(--text-sm); +} +.dialect-selector:focus { outline: none; border-color: var(--volcanic); } +.convert-btn { + background: linear-gradient(135deg, var(--volcanic) 0%, var(--amber) 100%); + color: white; + border: none; + padding: var(--space-2) var(--space-4); + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} +.convert-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(255, 69, 0, 0.3); +} +.convert-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } +.lql-input { + width: 100%; + height: 300px; + background: var(--darker-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: var(--space-4); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--text-primary); + resize: vertical; +} +.lql-input:focus { outline: none; border-color: var(--volcanic); } +.sql-output { + background: var(--darker-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: var(--space-4); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--text-primary); + min-height: 300px; + white-space: pre-wrap; + overflow-y: auto; +} +.error-message { + background: rgba(255, 69, 0, 0.1); + border: 1px solid var(--volcanic); + border-radius: 6px; + padding: var(--space-3); + color: var(--volcanic); + font-size: var(--text-sm); + margin-top: var(--space-4); +} +.example-buttons { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; +} +.example-btn { + background: transparent; + color: var(--forest); + border: 1px solid var(--forest); + padding: var(--space-2) var(--space-4); + border-radius: 6px; + font-size: var(--text-sm); + cursor: pointer; + transition: all var(--transition); +} +.example-btn:hover { background: var(--forest); color: white; } + +/* Docs layout */ +.docs-layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + min-height: calc(100vh - var(--header-height)); +} + +.sidebar { + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + overflow-y: auto; + padding: var(--space-6); + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); +} + +.sidebar-section { margin-bottom: var(--space-6); } +.sidebar-section h4 { + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--space-3); +} +.sidebar-section ul { list-style: none; padding: 0; margin: 0; } +.sidebar-section li { margin: 0; } +.sidebar-section a { + display: block; + padding: var(--space-2) var(--space-3); + color: var(--text-secondary); + border-radius: 4px; + transition: all var(--transition); +} +.sidebar-section a:hover, .sidebar-section a.active { + background: var(--bg-tertiary); + color: var(--volcanic); +} + +.docs-content { + padding: var(--space-8); + max-width: 900px; +} +.docs-content h1 { margin-bottom: var(--space-6); } +.docs-content h2 { + margin-top: var(--space-10); + margin-bottom: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-color); +} +.docs-content h3 { margin-top: var(--space-8); margin-bottom: var(--space-3); } + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; + margin-bottom: var(--space-6); + background: var(--card-bg); + border-radius: var(--radius); + overflow: hidden; +} +th, td { + padding: var(--space-3) var(--space-4); + text-align: left; + border-bottom: 1px solid var(--border-color); +} +th { + background: var(--obsidian); + color: var(--text-primary); + font-weight: 600; + text-transform: uppercase; + font-size: var(--text-sm); + letter-spacing: 0.05em; +} +td code { + background: var(--bg-tertiary); + padding: var(--space-1) var(--space-2); + border-radius: 4px; + font-size: var(--text-sm); +} + +/* Syntax highlighting (Eleventy plugin) */ +.token.comment { color: #6B7280; } +.token.keyword { color: var(--volcanic); } +.token.string { color: var(--violet); } +.token.function, .token.class-name { color: var(--amber); } +.token.number { color: var(--amber); } +.token.operator { color: var(--forest); } + +/* Footer */ +.footer { + padding: var(--space-16) 0 var(--space-8); + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); +} +.footer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-8); + margin-bottom: var(--space-8); +} +.footer-section h3 { + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-4); +} +.footer-section ul { list-style: none; padding: 0; margin: 0; } +.footer-section li { margin-bottom: var(--space-2); } +.footer-section a { color: var(--text-secondary); font-size: var(--text-sm); } +.footer-section a:hover { color: var(--volcanic); } + +.footer-bottom { + padding-top: var(--space-8); + border-top: 1px solid var(--border-color); + text-align: center; +} +.footer-bottom p { + font-size: var(--text-sm); + color: var(--text-muted); + margin-bottom: var(--space-2); +} + +/* Responsive */ +@media (max-width: 1024px) { + .docs-layout { grid-template-columns: 1fr; } + .sidebar { + display: none; + position: fixed; + top: var(--header-height); + left: 0; + width: 100%; + height: calc(100vh - var(--header-height)); + z-index: 50; + } + .sidebar.open { display: block; } +} + +@media (max-width: 768px) { + :root { + --text-5xl: 2.25rem; + --text-4xl: 1.875rem; + --text-3xl: 1.5rem; + } + + .container { padding: 0 var(--space-3); } + + .nav-links { + display: none; + position: fixed; + top: var(--header-height); + left: 0; + width: 100%; + padding: var(--space-4); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-direction: column; + gap: var(--space-2); + } + .nav-links.open { display: flex; } + + .mobile-menu-toggle { display: flex; } + + .nav-actions .btn { display: none; } + + .hero { padding: var(--space-12) 0; } + .hero h1 { font-size: var(--text-4xl); } + .hero-subtitle { font-size: var(--text-base); } + .hero-buttons { flex-direction: column; align-items: center; width: 100%; } + .hero-buttons .btn { width: 100%; max-width: 280px; } + .hero-code { margin: var(--space-8) auto 0; } + + .example { grid-template-columns: 1fr; } + .example:nth-child(even) { direction: ltr; } + + .fsharp-content { grid-template-columns: 1fr; } + + .playground-content { grid-template-columns: 1fr; } + + .features-grid { grid-template-columns: 1fr; } + + .card { padding: var(--space-4); } + .docs-content { padding: var(--space-4); } + pre { font-size: var(--text-xs); padding: var(--space-3); } + table { display: block; overflow-x: auto; } + .footer-grid { gap: var(--space-6); } +} + +/* Utilities */ +.text-center { text-align: center; } +.mt-8 { margin-top: var(--space-8); } +.mb-8 { margin-bottom: var(--space-8); } diff --git a/Lql/LqlWebsite-Eleventy/_site/assets/images/lql-icon.png b/Lql/LqlWebsite-Eleventy/_site/assets/images/lql-icon.png new file mode 100644 index 00000000..9bc4a157 Binary files /dev/null and b/Lql/LqlWebsite-Eleventy/_site/assets/images/lql-icon.png differ diff --git a/Lql/LqlWebsite-Eleventy/_site/assets/js/main.js b/Lql/LqlWebsite-Eleventy/_site/assets/js/main.js new file mode 100644 index 00000000..d608cca8 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/assets/js/main.js @@ -0,0 +1,81 @@ +(function() { + 'use strict'; + + // Mobile menu toggle + const mobileMenuToggle = document.getElementById('mobile-menu-toggle'); + const navLinks = document.querySelector('.nav-links'); + + if (mobileMenuToggle && navLinks) { + mobileMenuToggle.addEventListener('click', () => { + navLinks.classList.toggle('open'); + mobileMenuToggle.classList.toggle('active'); + }); + + document.addEventListener('click', (e) => { + if (!navLinks.contains(e.target) && !mobileMenuToggle.contains(e.target)) { + navLinks.classList.remove('open'); + mobileMenuToggle.classList.remove('active'); + } + }); + } + + // Docs sidebar toggle (mobile) + const docsSidebar = document.getElementById('docs-sidebar'); + if (docsSidebar) { + const sidebarToggle = document.createElement('button'); + sidebarToggle.className = 'btn btn-primary'; + sidebarToggle.innerHTML = 'Menu'; + sidebarToggle.style.cssText = 'display: none; position: fixed; bottom: 1rem; right: 1rem; z-index: 60;'; + document.body.appendChild(sidebarToggle); + + const checkMobile = () => { + sidebarToggle.style.display = window.innerWidth <= 1024 ? 'block' : 'none'; + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + + sidebarToggle.addEventListener('click', () => { + docsSidebar.classList.toggle('open'); + sidebarToggle.innerHTML = docsSidebar.classList.contains('open') ? 'Close' : 'Menu'; + }); + } + + // Smooth scroll for anchor links + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function(e) { + const target = document.querySelector(this.getAttribute('href')); + if (target) { + e.preventDefault(); + target.scrollIntoView({ behavior: 'smooth' }); + } + }); + }); + + // Copy button for code blocks + document.querySelectorAll('pre').forEach(pre => { + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + pre.parentNode.insertBefore(wrapper, pre); + wrapper.appendChild(pre); + + const copyBtn = document.createElement('button'); + copyBtn.innerHTML = 'Copy'; + copyBtn.style.cssText = 'position: absolute; top: 0.5rem; right: 0.5rem; padding: 0.25rem 0.75rem; font-size: 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer; opacity: 0; transition: opacity 0.2s; color: var(--text-secondary);'; + + wrapper.appendChild(copyBtn); + wrapper.addEventListener('mouseenter', () => copyBtn.style.opacity = '1'); + wrapper.addEventListener('mouseleave', () => copyBtn.style.opacity = '0'); + + copyBtn.addEventListener('click', async () => { + const code = pre.querySelector('code'); + const text = code ? code.textContent : pre.textContent; + try { + await navigator.clipboard.writeText(text); + copyBtn.innerHTML = 'Copied!'; + setTimeout(() => copyBtn.innerHTML = 'Copy', 2000); + } catch (err) { + copyBtn.innerHTML = 'Failed'; + } + }); + }); +})(); diff --git a/Lql/LqlWebsite-Eleventy/_site/assets/js/playground.js b/Lql/LqlWebsite-Eleventy/_site/assets/js/playground.js new file mode 100644 index 00000000..65c2f148 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/assets/js/playground.js @@ -0,0 +1,80 @@ +(function() { + 'use strict'; + + const examples = { + simple: 'users |> select(users.id, users.name, users.email)', + join: 'users\n|> join(orders, on = users.id = orders.user_id)\n|> select(users.name, orders.total, orders.status)', + filter: 'employees\n|> select(employees.id, employees.name, employees.salary)\n|> filter(fn(row) => row.employees.salary > 50000 and row.employees.department = \'Engineering\')', + aggregate: 'orders\n|> group_by(orders.user_id)\n|> select(\n orders.user_id,\n count(*) as order_count,\n sum(orders.total) as total_amount,\n avg(orders.total) as avg_amount\n)\n|> having(fn(group) => count(*) > 2)\n|> order_by(total_amount desc)', + complex: '-- Complex analytics query\nlet joined =\n users\n |> join(orders, on = users.id = orders.user_id)\n |> filter(fn(row) => row.orders.status = \'completed\')\n\njoined\n|> group_by(users.id)\n|> select(\n users.name,\n count(*) as total_orders,\n sum(orders.total) as revenue,\n avg(orders.total) as avg_order_value\n)\n|> filter(fn(row) => row.revenue > 1000)\n|> order_by(revenue desc)\n|> limit(10)' + }; + + const lqlInput = document.getElementById('lql-input'); + const sqlOutput = document.getElementById('sql-output'); + const errorMessage = document.getElementById('error-message'); + const convertBtn = document.getElementById('convert-btn'); + const dialectSelector = document.getElementById('dialect-selector'); + const outputTitle = document.getElementById('output-title'); + + // Load default example + lqlInput.value = examples.simple; + + // Update output title when dialect changes + dialectSelector.addEventListener('change', function() { + outputTitle.textContent = this.value === 'SqlServer' ? 'SQL Server Output' : 'PostgreSQL Output'; + }); + + // Convert button - calls the Blazor WASM transpiler via JS interop + convertBtn.addEventListener('click', async function() { + const lql = lqlInput.value.trim(); + if (!lql) { + showError('Please enter some LQL code to convert.'); + return; + } + + convertBtn.disabled = true; + convertBtn.textContent = 'Converting...'; + errorMessage.style.display = 'none'; + sqlOutput.textContent = 'Converting...'; + + try { + // Call the Blazor WASM transpiler if available + if (window.lqlTranspile) { + const dialect = dialectSelector.value; + const result = await window.lqlTranspile(lql, dialect); + if (result.error) { + showError(result.error); + sqlOutput.textContent = ''; + } else { + sqlOutput.textContent = result.sql; + } + } else { + // Fallback: show a message that the transpiler is loading or unavailable + sqlOutput.textContent = 'The LQL transpiler is loading. Please wait a moment and try again.\n\nIf this persists, the Blazor WASM runtime may not be available.'; + } + } catch (err) { + showError('An unexpected error occurred: ' + err.message); + sqlOutput.textContent = ''; + } finally { + convertBtn.disabled = false; + convertBtn.textContent = 'Convert to SQL'; + } + }); + + // Example buttons + document.querySelectorAll('.example-btn[data-example]').forEach(function(btn) { + btn.addEventListener('click', function() { + const key = this.getAttribute('data-example'); + if (examples[key]) { + lqlInput.value = examples[key]; + errorMessage.style.display = 'none'; + sqlOutput.textContent = "Click 'Convert to SQL' to see the result."; + } + }); + }); + + function showError(msg) { + errorMessage.textContent = msg; + errorMessage.style.display = 'block'; + } +})(); diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/aggregation/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/aggregation/index.html new file mode 100644 index 00000000..b2746e62 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/aggregation/index.html @@ -0,0 +1,394 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Aggregation + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Aggregation

+

LQL provides full aggregation support with group by, aggregate functions, and having clauses.

+

Aggregate Functions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FunctionDescription
count(*)Count all rows
sum(column)Sum of values
avg(column)Average of values
min(column)Minimum value
max(column)Maximum value
+

Basic Aggregation

+
orders
+|> group_by(orders.status)
+|> select(
+    orders.status,
+    count(*) as order_count
+)
+
+

Multiple Group Columns

+
orders
+|> group_by(orders.user_id, orders.status)
+|> select(
+    orders.user_id,
+    orders.status,
+    count(*) as order_count,
+    sum(orders.total) as total_amount
+)
+
+

Having Clause

+

Filter groups after aggregation using lambda expressions:

+
orders
+|> group_by(orders.user_id)
+|> having(fn(group) => count(*) > 2)
+|> select(
+    orders.user_id,
+    count(*) as order_count,
+    sum(orders.total) as total_amount,
+    avg(orders.total) as avg_amount
+)
+
+

Complete Analytics Query

+
orders
+|> group_by(orders.user_id, orders.status)
+|> select(
+    orders.user_id,
+    orders.status,
+    count(*) as order_count,
+    sum(orders.total) as total_amount,
+    avg(orders.total) as avg_amount
+)
+|> having(fn(group) => count(*) > 2)
+|> order_by(total_amount desc)
+
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/ai-integration/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/ai-integration/index.html new file mode 100644 index 00000000..5ae3da91 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/ai-integration/index.html @@ -0,0 +1,584 @@ + + + + + + AI-Powered Completions + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

AI-Powered Completions

+

The LQL Language Server has built-in support for AI-powered code completions. Connect a local model via Ollama or use a cloud provider to get intelligent, context-aware query suggestions alongside the standard schema and keyword completions.

+

How It Works

+
graph TD
+    A[VS Code Editor] <-->|completions| B[lql-lsp]
+    B --> C["Schema\ncolumns, tables\npriority 0–4"]
+    B --> D["Keywords\nfunctions, operators\npriority 1–3"]
+    B --> E["AI Model\nasync with timeout\npriority 6"]
+

On every completion request, the LSP runs three sources in parallel:

+
    +
  1. Schema completions (priority 0-4) - Table names, column names from your database
  2. +
  3. Keyword completions (priority 1-3) - Pipeline operations, functions, keywords
  4. +
  5. AI completions (priority 6) - Intelligent suggestions from a language model
  6. +
+

All results are merged and sorted by priority. Schema and keyword completions always appear first; AI suggestions supplement them at the bottom. If the AI model is slow or unavailable, you still get instant schema and keyword completions.

+

Timeout Enforcement

+

AI completions are wrapped in a configurable timeout (default: 2000ms). If the model doesn't respond in time, the LSP silently drops the AI results and returns only schema/keyword completions. This guarantees the editor never feels sluggish, regardless of AI model latency.

+

What the AI Model Receives

+

Every completion request sends the AI model rich context about your current editing state:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ContextDescription
Full documentThe complete .lql file text
Cursor positionLine and column number
Line prefixText from the start of the line to the cursor
Word prefixThe partial word being typed
File URIPath to the current file
Table namesAll tables from the database schema
Schema descriptionCompact schema: users(id uuid PK NOT NULL, name text, email text)
+

The schema description gives the model full knowledge of your database structure, so it can suggest syntactically valid and schema-aware queries.

+ +

Ollama runs language models locally on your machine. No API keys, no cloud, no data leaves your laptop.

+

1. Install Ollama

+

Download and install from ollama.com.

+

2. Pull a Code Model

+
ollama pull qwen2.5-coder:1.5b
+

3. Configure VS Code

+

Add to your settings.json:

+
{
+  "lql.aiProvider": {
+    "provider": "ollama",
+    "endpoint": "http://localhost:11434/api/generate",
+    "model": "qwen2.5-coder:1.5b",
+    "enabled": true
+  }
+}
+

4. Start Writing LQL

+

Open any .lql file and start typing. AI suggestions appear in the completion list alongside schema and keyword completions, marked as Snippet items.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelParametersSpeedQualityBest For
qwen2.5-coder:1.5b1.5BFastGoodDaily use, quick responses
deepseek-coder:1.3b1.3BFastGoodLightweight alternative
codellama:7b7BModerateBetterComplex queries, more context
qwen2.5-coder:7b7BModerateBetterHigher quality suggestions
+

For the best experience, start with qwen2.5-coder:1.5b - it provides good suggestions with minimal latency.

+

Provider Configuration

+

The AI provider is configured via initializationOptions during the LSP handshake, which VS Code passes from your settings:

+
{
+  "lql.aiProvider": {
+    "provider": "ollama",
+    "endpoint": "http://localhost:11434/api/generate",
+    "model": "qwen2.5-coder:1.5b",
+    "apiKey": "",
+    "timeoutMs": 2000,
+    "enabled": true
+  }
+}
+

Configuration Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDefaultDescription
providerstringrequiredProvider type: ollama, openai, anthropic, custom
endpointstringrequiredFull URL of the API endpoint
modelstring"default"Model identifier (provider-specific)
apiKeystringnullAPI key for cloud providers
timeoutMsnumber2000Maximum time to wait for AI response (ms)
enabledbooleantrueEnable/disable AI completions
+

Supported Providers

+

Ollama (Local)

+

Runs entirely on your machine. The LSP calls the Ollama /api/generate endpoint and injects the LQL language reference as system context, giving the model knowledge of LQL syntax.

+
{
+  "provider": "ollama",
+  "endpoint": "http://localhost:11434/api/generate",
+  "model": "qwen2.5-coder:1.5b"
+}
+

The Ollama provider:

+
    +
  • Sends the full document, cursor position, and schema as a structured prompt
  • +
  • Uses low temperature (0.1) for deterministic, focused completions
  • +
  • Limits response to 256 tokens for fast turnaround
  • +
  • Parses the model's JSON array response into completion items
  • +
  • Handles markdown code fence wrapping in model responses
  • +
+

OpenAI / Anthropic / Custom (Cloud)

+

Configure any OpenAI-compatible or custom endpoint:

+
{
+  "provider": "openai",
+  "endpoint": "https://api.openai.com/v1/completions",
+  "model": "gpt-4",
+  "apiKey": "sk-..."
+}
+

Cloud providers require an API key. The same context (document, cursor, schema) is sent to the model.

+

Custom Providers

+

Set provider to "custom" and point endpoint to any API that accepts the same prompt format. The LSP logs the configuration on startup so you can verify it's active.

+

How AI Completions Merge

+

The completion pipeline works as follows:

+
    +
  1. Schema + keyword completions are computed synchronously (instant)
  2. +
  3. AI completions are requested asynchronously with a timeout
  4. +
  5. If AI responds within the timeout, results are appended to the list
  6. +
  7. If AI times out, only schema + keyword results are returned
  8. +
  9. All items are sorted by sort_priority before sending to the editor
  10. +
+
Priority 0: Column completions (users.id, users.name)
+Priority 1: Pipeline operations (select, filter, join)
+Priority 2: Functions (count, sum, avg, concat)
+Priority 3: Keywords (let, fn, as, and, or)
+Priority 4: Table names (users, orders, products)
+Priority 5: Let bindings (active_users, joined)
+Priority 6: AI suggestions (context-aware snippets)
+
+

This means AI suggestions never push schema completions out of view - they always appear at the bottom of the list as supplementary suggestions.

+

Disabling AI

+

To disable AI completions without removing the configuration:

+
{
+  "lql.aiProvider": {
+    "provider": "ollama",
+    "endpoint": "http://localhost:11434/api/generate",
+    "model": "qwen2.5-coder:1.5b",
+    "enabled": false
+  }
+}
+

Or simply remove the lql.aiProvider section from your settings.

+

Troubleshooting

+

No AI completions appearing

+
    +
  1. Check the LQL Language Server output channel (View > Output > LQL Language Server) for provider activation messages
  2. +
  3. Verify Ollama is running: curl http://localhost:11434/api/tags
  4. +
  5. Verify the model is pulled: ollama list
  6. +
  7. Check enabled is not set to false
  8. +
+

AI completions are slow

+
    +
  1. Try a smaller model (qwen2.5-coder:1.5b instead of codellama:7b)
  2. +
  3. Increase timeoutMs if you prefer waiting for better results
  4. +
  5. Ensure Ollama has enough RAM (1.5B models need ~2GB, 7B models need ~8GB)
  6. +
+

AI suggestions are irrelevant

+
    +
  1. Ensure your database is connected - schema context dramatically improves AI suggestions
  2. +
  3. Try a different model - qwen2.5-coder tends to produce better LQL-specific completions
  4. +
  5. The LQL reference document is automatically injected as system context for Ollama
  6. +
+

Verifying the pipeline works

+

Use the built-in test provider to confirm AI completions flow end-to-end:

+
{
+  "lql.aiProvider": {
+    "provider": "test",
+    "endpoint": "http://localhost",
+    "enabled": true
+  }
+}
+

This returns deterministic completions (like ai_suggest_filter, ai_suggest_join) without any external service, proving the full pipeline works.

+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/database-config/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/database-config/index.html new file mode 100644 index 00000000..68c6ee02 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/database-config/index.html @@ -0,0 +1,469 @@ + + + + + + Database Configuration + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Database Configuration

+

The LQL Language Server can connect to your PostgreSQL database to provide schema-aware features like column completions, table hover, and qualified column hover.

+

Why Connect a Database?

+

Without a database connection, the LSP still provides:

+
    +
  • Keyword and function completions
  • +
  • Pipeline operation suggestions
  • +
  • Parse error diagnostics
  • +
  • Hover documentation for LQL constructs
  • +
  • Document formatting and symbols
  • +
+

With a database connection, you additionally get:

+
    +
  • Column completions - Type users. and see all columns with types
  • +
  • Table name completions - See all tables with column counts
  • +
  • Table hover - Hover over a table name to see its full schema
  • +
  • Column hover - Hover over users.email to see type, nullability, and PK status
  • +
+

Connection Methods

+

The LSP resolves the database connection in this priority order:

+ +

Add a connection string to your VS Code settings.json:

+
{
+  "lql.connectionString": "host=localhost dbname=myapp user=postgres password=secret"
+}
+

This is passed to the LSP via initializationOptions.connectionString.

+

2. Environment Variable: LQL_CONNECTION_STRING

+
export LQL_CONNECTION_STRING="host=localhost dbname=myapp user=postgres password=secret"
+

3. Environment Variable: DATABASE_URL

+
export DATABASE_URL="postgres://postgres:secret@localhost/myapp"
+

Supported Connection Formats

+

The LSP accepts multiple PostgreSQL connection string formats and normalizes them automatically.

+

libpq Format

+

The native PostgreSQL format:

+
host=localhost dbname=myapp user=postgres password=secret
+
+

With port:

+
host=localhost port=5433 dbname=myapp user=postgres password=secret
+
+

Npgsql Format (.NET style)

+

Semicolon-delimited key=value pairs. These are automatically converted to libpq format:

+
Host=localhost;Database=myapp;Username=postgres;Password=secret
+
+

Mapping:

+
    +
  • Host -> host
  • +
  • Database -> dbname
  • +
  • Username -> user
  • +
  • Password -> password
  • +
  • Port -> port
  • +
+

URI Format

+

PostgreSQL connection URI:

+
postgres://postgres:secret@localhost/myapp
+postgresql://postgres:secret@localhost:5433/myapp
+
+

Schema Introspection

+

On startup (and when the connection is available), the LSP queries information_schema.columns and information_schema.key_column_usage to discover:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetadataSource
Table namesinformation_schema.columns
Column namesinformation_schema.columns
Column typesdata_type column
Nullabilityis_nullable column
Primary keysinformation_schema.key_column_usage
+

The schema is cached in memory for fast lookups. Connection timeout is 10 seconds, query timeout is 30 seconds.

+

Graceful Degradation

+

If the database is unreachable or the connection string is invalid:

+
    +
  • The LSP logs the error and continues without schema
  • +
  • All non-schema features remain fully functional
  • +
  • No error is shown to the user (check the LQL Language Server output channel for diagnostics)
  • +
+

This means you can use the extension without any database - you just won't get table/column completions.

+

Schema-Aware Features in Detail

+

Column Completions

+

When you type a table name followed by ., the LSP shows all columns for that table:

+
users.
+
+

Completion list shows:

+
id       uuid (PK) NOT NULL
+name     text NOT NULL
+email    text
+status   text
+
+

Table Completions

+

Table names appear in the completion list with metadata:

+
users    (4 columns: id, name, email, status)
+orders   (6 columns: id, user_id, total, status, ...)
+
+

Table Hover

+

Hovering over a table name shows the full schema:

+
Table: users
+
+| Column | Type | PK | Nullable |
+|--------|------|----|----------|
+| id     | uuid | Y  | N        |
+| name   | text |    | N        |
+| email  | text |    | Y        |
+| status | text |    | Y        |
+
+

Qualified Column Hover

+

Hovering over users.email shows:

+
Column: users.email
+Type: text
+Nullable: yes
+Primary Key: no
+
+

Troubleshooting

+

No schema completions

+
    +
  1. Check the LQL Language Server output channel (View > Output > LQL Language Server)
  2. +
  3. Verify your connection string is correct
  4. +
  5. Ensure PostgreSQL is running and accessible
  6. +
  7. Check firewall/network rules
  8. +
+

Connection string not picked up

+
    +
  1. VS Code settings take priority over environment variables
  2. +
  3. Restart VS Code after changing environment variables
  4. +
  5. Try the libpq format if other formats don't work
  6. +
+

Schema is stale

+

The schema is fetched once on startup. Restart the language server to refresh:

+
    +
  1. Open the command palette (Ctrl+Shift+P)
  2. +
  3. Run Developer: Reload Window
  4. +
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/fsharp-type-provider/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/fsharp-type-provider/index.html new file mode 100644 index 00000000..4be795e6 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/fsharp-type-provider/index.html @@ -0,0 +1,373 @@ + + + + + + F# Type Provider + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

F# Type Provider

+

The LQL Type Provider brings compile-time type checking to your LQL queries in F#. Write queries with IntelliSense support, catch errors before runtime, and enjoy seamless integration with your F# codebase.

+

Installation

+
<PackageReference Include="Lql.TypeProvider.FSharp" Version="*" />
+

Basic Usage

+
open Lql
+
+// Define types with validated LQL - errors caught at COMPILE TIME
+type GetUsers = LqlCommand<"Users |> select(Users.Id, Users.Name, Users.Email)">
+type ActiveUsers = LqlCommand<"Users |> filter(fn(row) => row.Status = 'active') |> select(*)">
+
+// Access generated SQL and original query
+let sql = GetUsers.Sql      // Generated SQL string
+let query = GetUsers.Query  // Original LQL string
+

What Gets Validated

+

The type provider validates your LQL at compile time and generates two properties:

+
    +
  • Query - The original LQL query string
  • +
  • Sql - The generated SQL (SQLite dialect)
  • +
+

Query Examples

+
// Select with columns
+type SelectColumns = LqlCommand<"Users |> select(Users.Id, Users.Name, Users.Email)">
+
+// Filtering with AND/OR
+type FilterComplex = LqlCommand<"Users |> filter(fn(row) => row.Users.Age > 18 and row.Users.Status = 'active') |> select(*)">
+
+// Joins
+type JoinQuery = LqlCommand<"Users |> join(Orders, on = Users.Id = Orders.UserId) |> select(Users.Name, Orders.Total)">
+type LeftJoin = LqlCommand<"Users |> left_join(Orders, on = Users.Id = Orders.UserId) |> select(*)">
+
+// Aggregations with GROUP BY and HAVING
+type GroupBy = LqlCommand<"Orders |> group_by(Orders.UserId) |> select(Orders.UserId, count(*) as order_count)">
+type Having = LqlCommand<"Orders |> group_by(Orders.UserId) |> having(fn(g) => count(*) > 5) |> select(Orders.UserId, count(*) as cnt)">
+
+// Order, limit, offset
+type Pagination = LqlCommand<"Users |> order_by(Users.Name asc) |> limit(10) |> offset(20) |> select(*)">
+
+// Arithmetic expressions
+type Calculated = LqlCommand<"Products |> select(Products.Price * Products.Quantity as total)">
+

Compile-Time Error Example

+

Invalid LQL causes a build error with line/column position:

+
// This FAILS to compile with: "Invalid LQL syntax at line 1, column 15"
+type BadQuery = LqlCommand<"Users |> selectt(*)">  // typo: 'selectt'
+

Executing Queries

+
open Microsoft.Data.Sqlite
+
+let executeQuery() =
+    use conn = new SqliteConnection("Data Source=mydb.db")
+    conn.Open()
+
+    // SQL is validated at compile time, safe to execute
+    use cmd = new SqliteCommand(GetUsers.Sql, conn)
+    use reader = cmd.ExecuteReader()
+    // ... process results
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/index.html new file mode 100644 index 00000000..5dd4a0f8 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/index.html @@ -0,0 +1,399 @@ + + + + + + Introduction + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Introduction

+

A functional pipeline-style DSL that transpiles to SQL. Write database logic once, run it anywhere.

+

The Problem

+

SQL dialects differ. PostgreSQL, SQLite, and SQL Server each have their own quirks. This creates problems:

+
    +
  • Migrations - Schema changes need different SQL for each database
  • +
  • Business Logic - Triggers, stored procedures, and constraints vary by vendor
  • +
  • Sync Logic - Offline-first apps need identical logic on client (SQLite) and server (Postgres)
  • +
  • Testing - Running tests against SQLite while production uses Postgres
  • +
+

The Solution

+

LQL is a single query language that transpiles to any SQL dialect. Write once, deploy everywhere.

+
Users
+|> filter(fn(row) => row.Age > 18 and row.Status = 'active')
+|> join(Orders, on = Users.Id = Orders.UserId)
+|> group_by(Users.Id, Users.Name)
+|> select(Users.Name, sum(Orders.Total) as TotalSpent)
+|> order_by(TotalSpent desc)
+|> limit(10)
+
+

This transpiles to correct SQL for PostgreSQL, SQLite, or SQL Server.

+

Use Cases

+

Cross-Database Migrations

+

Define schema changes in LQL. Migration.CLI generates the right SQL for your target database.

+

Cross DB Platform Business Logic With Triggers

+

Write triggers and constraints in LQL. Deploy the same logic to any database.

+

Offline-First Sync

+

Sync framework uses LQL for conflict resolution. Same logic runs on mobile (SQLite) and server (Postgres).

+

Integration Testing

+

Test against SQLite locally, deploy to Postgres in production. Same queries, same results.

+

Pipeline Operations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationDescription
select(cols...)Choose columns
filter(fn(row) => ...)Filter rows
join(table, on = ...)Join tables
left_join(table, on = ...)Left join
group_by(cols...)Group rows
having(fn(row) => ...)Filter groups
order_by(col [asc/desc])Sort results
limit(n) / offset(n)Pagination
distinct()Unique rows
union(query)Combine queries
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/installation/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/installation/index.html new file mode 100644 index 00000000..a35830f9 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/installation/index.html @@ -0,0 +1,349 @@ + + + + + + Installation + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Installation

+

NuGet Packages

+

LQL provides dialect-specific packages for each target database:

+
<!-- SQLite -->
+<PackageReference Include="Lql.SQLite" Version="*" />
+
+<!-- PostgreSQL -->
+<PackageReference Include="Lql.Postgres" Version="*" />
+
+<!-- SQL Server -->
+<PackageReference Include="Lql.SqlServer" Version="*" />
+

CLI Tool

+

Install the LQL CLI for command-line transpilation:

+
dotnet tool install -g LqlCli.SQLite
+

F# Type Provider

+

For compile-time validated LQL queries in F#:

+
<PackageReference Include="Lql.TypeProvider.FSharp" Version="*" />
+

VS Code Extension

+

Search for LQL in VS Code Extensions marketplace for:

+
    +
  • Syntax highlighting
  • +
  • IntelliSense completions
  • +
  • Real-time diagnostics
  • +
  • Hover documentation
  • +
  • Document formatting
  • +
+

Requirements

+
    +
  • .NET 9.0 or later
  • +
  • One of the supported databases: SQLite, PostgreSQL, or SQL Server
  • +
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/joins/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/joins/index.html new file mode 100644 index 00000000..b0d1905a --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/joins/index.html @@ -0,0 +1,357 @@ + + + + + + Joins + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Joins

+

LQL supports multiple join types for combining data from different tables.

+

Inner Join

+

Returns only rows that have matching values in both tables:

+
users
+|> join(orders, on = users.id = orders.user_id)
+|> select(users.name, orders.total, orders.status)
+
+

Left Join

+

Returns all rows from the left table, with matching rows from the right table (or NULL):

+
users
+|> left_join(orders, on = users.id = orders.user_id)
+|> select(users.name, orders.total)
+
+

Multiple Joins

+

Chain joins to combine more than two tables:

+
users
+|> join(orders, on = users.id = orders.user_id)
+|> join(products, on = orders.product_id = products.id)
+|> select(users.name, products.name, orders.quantity)
+
+

Join with Filter

+

Combine joins with filtering:

+
users
+|> join(orders, on = users.id = orders.user_id)
+|> filter(fn(row) => row.orders.status = 'completed')
+|> select(users.name, orders.total)
+
+

Join with Aggregation

+
users
+|> join(orders, on = users.id = orders.user_id)
+|> group_by(users.id, users.name)
+|> select(
+    users.name,
+    count(*) as total_orders,
+    sum(orders.total) as revenue
+)
+|> order_by(revenue desc)
+
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/lambdas/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/lambdas/index.html new file mode 100644 index 00000000..d1770cec --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/lambdas/index.html @@ -0,0 +1,393 @@ + + + + + + Lambda Expressions + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Lambda Expressions

+

Lambda expressions are the functional core of LQL. They provide type-safe, composable predicates for filtering and transforming data.

+

Syntax

+
fn(parameter) => expression
+
+

The parameter represents a row of data. Access columns using parameter.table.column:

+
fn(row) => row.users.age > 18
+
+

In filter

+

The most common use is filtering rows:

+
users |> filter(fn(row) => row.users.active = true)
+
+

Compound Expressions

+

Combine conditions with and and or:

+
employees |> filter(fn(row) =>
+    row.employees.salary > 50000 and
+    row.employees.salary < 100000
+)
+
+
users |> filter(fn(row) =>
+    row.users.role = 'admin' or
+    row.users.role = 'superadmin'
+)
+
+

In having

+

Lambdas also work with having to filter groups:

+
orders
+|> group_by(orders.user_id)
+|> having(fn(group) => count(*) > 5)
+|> select(orders.user_id, count(*) as order_count)
+
+

Comparison Operators

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperatorMeaning
=Equal
!=Not equal
>Greater than
<Less than
>=Greater than or equal
<=Less than or equal
+

String Comparisons

+
users |> filter(fn(row) => row.users.name = 'Alice')
+users |> filter(fn(row) => row.users.status != 'inactive')
+
+

Arithmetic in Lambdas

+
products |> filter(fn(row) =>
+    row.products.price * row.products.quantity > 1000
+)
+
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/language-server/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/language-server/index.html new file mode 100644 index 00000000..110a6fba --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/language-server/index.html @@ -0,0 +1,561 @@ + + + + + + Language Server + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Language Server

+

The LQL Language Server (lql-lsp) is a native Rust implementation that provides IDE features for .lql files. It communicates via the Language Server Protocol (LSP) over JSON-RPC on stdin/stdout.

+

Key capabilities: schema-aware completions via Database Configuration, intelligent suggestions via AI-Powered Completions, real-time diagnostics, hover documentation, and formatting.

+

Architecture

+
graph TD
+    A[VS Code Extension] <-->|JSON-RPC / stdio| B[lql-lsp - Rust]
+    B --> C[lql-parser - ANTLR]
+    B --> D[lql-analyzer]
+    B --> E[PostgreSQL Schema Cache]
+    B --> F[AI Model - Ollama / Cloud]
+

Built with:

+
    +
  • tower-lsp - LSP protocol framework (JSON-RPC, message framing)
  • +
  • antlr-rust - ANTLR4 grammar-based parser with error recovery
  • +
  • tokio - Async runtime for concurrent schema fetching and AI calls
  • +
  • tokio-postgres - PostgreSQL client for schema introspection
  • +
  • reqwest - HTTP client for AI provider communication
  • +
+

LSP Capabilities

+

The server registers these capabilities on initialization:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityDescription
textDocumentSync: FullFull document synced on every change
completionProviderTriggered by: . | > (
hoverProviderHover info for keywords, tables, columns
documentSymbolProviderlet bindings shown in outline
documentFormattingProviderFull-document formatting
+

Completion Engine

+

Completions are organized into priority layers. Lower numbers appear first:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PriorityCategoryCountRequires DB
0Column completions (table.col)Per-tableYes
1Pipeline operations14No
2Functions (aggregate, string, math, date)40+No
3Keywords30+No
4Table namesPer-schemaYes
5Variable bindings (let names)Per-documentNo
6AI completionsVariableOptional
+

Context Detection

+

The completion engine detects context to filter suggestions:

+
    +
  • After |> - Shows pipeline operations
  • +
  • After table. - Shows columns for that table
  • +
  • In argument list - Shows functions, columns, keywords
  • +
  • In lambda body - Shows row field access patterns
  • +
  • Word prefix - Filters all completions by typed prefix
  • +
+

Trigger Characters

+

Completions auto-trigger on: . (column access), | and > (pipe), ( (function args), (space after pipe).

+

AI Completion Pipeline

+

When an AI provider is configured, the LSP merges AI-generated completions with schema and keyword results on every request:

+
    +
  1. Schema + keyword completions are computed synchronously (instant)
  2. +
  3. AI completions are requested asynchronously via HTTP (e.g., Ollama /api/generate)
  4. +
  5. A configurable timeout (default 2000ms) wraps the AI call
  6. +
  7. If AI responds in time, results are appended at priority 6
  8. +
  9. If AI times out, only schema + keyword results are returned - no latency penalty
  10. +
+

The AI model receives full context: document text, cursor position, line prefix, word prefix, and a compact schema description (e.g., users(id uuid PK NOT NULL, name text, email text)). This enables schema-aware suggestions even from small local models.

+

See AI-Powered Completions for setup instructions and provider configuration.

+

Diagnostics

+

Four categories of diagnostics, published on every document change:

+

Parse Errors (ERROR)

+

From the ANTLR parser. Syntax errors with exact line/column positions:

+
-- Error: mismatched input 'selectt' expecting...
+users |> selectt(users.id)
+         ^^^^^^^^
+
+

Bracket Validation (ERROR)

+

Document-level parenthesis matching:

+
-- Error: unclosed parenthesis
+users |> select(users.id
+                       ^
+
+

Pipe Spacing (WARNING)

+

The |> operator should be surrounded by spaces:

+
-- Warning: pipe operator should be surrounded by spaces
+users|>select(*)
+     ^^
+
+

Unknown Functions (INFO)

+

Functions not in the 82-entry known function list:

+
-- Info: unknown function 'foobar'
+users |> foobar(users.id)
+         ^^^^^^
+
+

Hover Information

+

The hover database contains 50+ entries covering all LQL constructs.

+

Keyword Hover

+

Hovering over select, filter, join, etc. shows descriptions with usage patterns.

+

Schema-Aware Hover

+

With a database connection:

+
    +
  • Table name hover - Shows all columns with types, PK, and nullable indicators
  • +
  • Qualified column hover (users.email) - Shows column type, nullability, primary key status
  • +
+

Document Symbols

+

Extracts let bindings as SymbolKind::Variable for the VS Code outline and breadcrumb views:

+
let active_users = users |> filter(...)    -> Symbol: active_users
+let orders_2024 = orders |> filter(...)    -> Symbol: orders_2024
+
+

Formatting

+

The formatter applies consistent indentation rules:

+
    +
  • Pipeline continuations (|>) get 4-space indent
  • +
  • Nested parentheses increase indent level
  • +
  • Closing ) decreases indent level
  • +
  • Lines are trimmed of trailing whitespace
  • +
  • Comments and blank lines are preserved
  • +
+

Before:

+
users
+|> filter(fn(row) => row.users.active)
+|> select(
+users.id,
+users.name
+)
+
+

After:

+
users
+    |> filter(fn(row) => row.users.active)
+    |> select(
+        users.id,
+        users.name
+    )
+
+

Initialization Options

+

The server accepts configuration via initializationOptions during the LSP initialize handshake:

+
{
+  "connectionString": "host=localhost dbname=myapp user=postgres",
+  "aiProvider": {
+    "provider": "ollama",
+    "endpoint": "http://localhost:11434",
+    "model": "qwen2.5-coder:1.5b",
+    "apiKey": "",
+    "timeoutMs": 2000,
+    "enabled": true
+  }
+}
+

See Database Configuration and VS Code Extension - AI Configuration for details.

+

Crate Structure

+ + + + + + + + + + + + + + + + + + + + + +
CratePurpose
lql-parserANTLR grammar, lexer, parser, parse tree, error recovery
lql-analyzerCompletions, diagnostics, hover database, symbols, schema cache
lql-lspLSP server binary, tower-lsp integration, AI providers, DB client
+

Building from Source

+
cd Lql/lql-lsp-rust
+cargo build --release
+

The binary is at target/release/lql-lsp.

+

Running Tests

+
cargo test --workspace
+

With Coverage

+
./test-coverage.sh
+

Individual crate coverage:

+
cargo tarpaulin --packages lql-parser --engine llvm --exclude-files "*/generated/*"
+cargo tarpaulin --packages lql-analyzer --engine llvm
+cargo tarpaulin --packages lql-lsp --engine llvm
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/let-bindings/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/let-bindings/index.html new file mode 100644 index 00000000..67cfdabc --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/let-bindings/index.html @@ -0,0 +1,358 @@ + + + + + + Let Bindings + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Let Bindings

+

Let bindings allow you to name intermediate query results and reuse them, making complex queries more readable and composable.

+

Basic Syntax

+
let name = expression
+
+

Simple Example

+
let active_users = users |> filter(fn(row) => row.users.status = 'active')
+
+active_users |> select(active_users.name, active_users.email)
+
+

Building Complex Queries

+

Let bindings shine when building multi-step analytics:

+
-- Step 1: Join and filter
+let joined =
+    users
+    |> join(orders, on = users.id = orders.user_id)
+    |> filter(fn(row) => row.orders.status = 'completed')
+
+-- Step 2: Aggregate
+joined
+|> group_by(users.id)
+|> select(
+    users.name,
+    count(*) as total_orders,
+    sum(orders.total) as revenue,
+    avg(orders.total) as avg_order_value
+)
+|> filter(fn(row) => row.revenue > 1000)
+|> order_by(revenue desc)
+|> limit(10)
+
+

Reusability

+

Define a filtered dataset once and use it in multiple contexts:

+
let engineering = employees
+    |> filter(fn(row) => row.employees.department = 'Engineering')
+
+-- Use for different analyses
+engineering |> select(engineering.name, engineering.salary)
+engineering |> group_by(engineering.level) |> select(engineering.level, avg(engineering.salary) as avg_salary)
+
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/pipelines/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/pipelines/index.html new file mode 100644 index 00000000..11f0fd86 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/pipelines/index.html @@ -0,0 +1,399 @@ + + + + + + Pipeline Operators + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Pipeline Operators

+

Pipeline operators are the core of LQL. Each operation takes the result of the previous step and transforms it.

+

select

+

Choose which columns to include in the output:

+
users |> select(users.id, users.name, users.email)
+
+

Select all columns:

+
users |> select(*)
+
+

With computed columns:

+
products |> select(
+    products.name,
+    products.price * products.quantity as total_value,
+    round(products.price / 2, 2) as half_price
+)
+
+

filter

+

Filter rows using lambda expressions:

+
users |> filter(fn(row) => row.users.age > 18)
+
+

Combine conditions with and / or:

+
employees |> filter(fn(row) =>
+    row.employees.salary > 50000 and
+    row.employees.department = 'Engineering'
+)
+
+

join

+

Inner join two tables:

+
users |> join(orders, on = users.id = orders.user_id)
+
+

left_join

+

Left join preserving all rows from the left table:

+
users |> left_join(orders, on = users.id = orders.user_id)
+
+

group_by

+

Group rows by one or more columns:

+
orders |> group_by(orders.status)
+
+

Multiple grouping columns:

+
orders |> group_by(orders.user_id, orders.status)
+
+

having

+

Filter groups after aggregation:

+
orders
+|> group_by(orders.user_id)
+|> having(fn(group) => count(*) > 5)
+|> select(orders.user_id, count(*) as order_count)
+
+

order_by

+

Sort results ascending or descending:

+
users |> order_by(users.name asc)
+users |> order_by(users.created_at desc)
+
+

limit / offset

+

Pagination:

+
users |> limit(10)
+users |> limit(10) |> offset(20)
+
+

distinct

+

Remove duplicate rows:

+
orders |> select(orders.status) |> distinct()
+
+

union

+

Combine results from two queries:

+
active_users |> union(inactive_users)
+
+

Chaining Operations

+

The real power comes from chaining multiple operations:

+
users
+|> join(orders, on = users.id = orders.user_id)
+|> filter(fn(row) => row.orders.status = 'completed')
+|> group_by(users.id, users.name)
+|> select(
+    users.name,
+    count(*) as total_orders,
+    sum(orders.total) as revenue
+)
+|> having(fn(group) => count(*) > 2)
+|> order_by(revenue desc)
+|> limit(10)
+
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/quick-start/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/quick-start/index.html new file mode 100644 index 00000000..964189ba --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/quick-start/index.html @@ -0,0 +1,346 @@ + + + + + + Quick Start + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Quick Start

+

Install

+

NuGet Packages

+
<PackageReference Include="Lql.SQLite" Version="*" />
+<PackageReference Include="Lql.Postgres" Version="*" />
+<PackageReference Include="Lql.SqlServer" Version="*" />
+

CLI Tool

+
dotnet tool install -g LqlCli.SQLite
+

Your First Query

+

Write your first LQL query:

+
users |> select(users.id, users.name, users.email)
+
+

This transpiles to:

+
SELECT users.id, users.name, users.email FROM users
+

Programmatic Usage

+
using Lql;
+using Lql.SQLite;
+
+var lql = "Users |> filter(fn(row) => row.Age > 21) |> select(Name, Email)";
+var sql = LqlCodeParser.Parse(lql).ToSql(new SQLiteContext());
+

CLI Usage

+
lql --input query.lql --output query.sql
+

Next Steps

+ + + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/sql-dialects/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/sql-dialects/index.html new file mode 100644 index 00000000..3c11791a --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/sql-dialects/index.html @@ -0,0 +1,393 @@ + + + + + + SQL Dialects + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

SQL Dialects

+

LQL is database platform independent. The same query transpiles to correct SQL for each target database.

+

Supported Dialects

+ + + + + + + + + + + + + + + + + + + + + + + + + +
DialectPackageStatus
PostgreSQLLql.PostgresFull support
SQL ServerLql.SqlServerFull support
SQLiteLql.SQLiteFull support
+

Example

+

This LQL query:

+
users
+|> filter(fn(row) => row.users.age > 18)
+|> select(users.name, users.email)
+|> order_by(users.name asc)
+|> limit(10)
+
+

PostgreSQL Output

+
SELECT users.name, users.email
+FROM users
+WHERE users.age > 18
+ORDER BY users.name ASC
+LIMIT 10
+

SQL Server Output

+
SELECT TOP 10 users.name, users.email
+FROM users
+WHERE users.age > 18
+ORDER BY users.name ASC
+

SQLite Output

+
SELECT users.name, users.email
+FROM users
+WHERE users.age > 18
+ORDER BY users.name ASC
+LIMIT 10
+

Dialect Differences Handled by LQL

+

LQL abstracts away common dialect differences:

+
    +
  • LIMIT/TOP - PostgreSQL and SQLite use LIMIT, SQL Server uses TOP
  • +
  • String concatenation - || vs +
  • +
  • Boolean literals - TRUE/FALSE vs 1/0
  • +
  • ILIKE - PostgreSQL-specific case-insensitive LIKE
  • +
+

Programmatic Dialect Selection

+
using Lql;
+using Lql.Postgres;
+using Lql.SqlServer;
+using Lql.SQLite;
+
+var lql = "Users |> select(Users.Name)";
+var statement = LqlStatementConverter.ToStatement(lql);
+
+// Generate for each dialect
+var postgres = statement.ToPostgreSql();
+var sqlServer = statement.ToSqlServer();
+var sqlite = statement.ToSQLite();
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/syntax/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/syntax/index.html new file mode 100644 index 00000000..3f0e3216 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/syntax/index.html @@ -0,0 +1,372 @@ + + + + + + Syntax Overview + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

Syntax Overview

+

LQL uses a functional pipeline syntax where data flows through a series of transformations using the pipeline operator |>.

+

Basic Structure

+

Every LQL query starts with a table reference and pipes data through operations:

+
table_name |> operation1(...) |> operation2(...)
+
+

Table References

+

Simply name the table to start a query:

+
users
+employees
+orders
+
+

Pipeline Operator

+

The |> operator passes the result of the left side to the right side:

+
users |> select(users.id, users.name)
+
+

Column References

+

Columns are referenced with the table.column syntax:

+
users.id
+users.name
+orders.total
+
+

Lambda Expressions

+

Lambdas use the fn(param) => expression syntax:

+
filter(fn(row) => row.users.age > 18)
+filter(fn(row) => row.users.status = 'active' and row.users.age > 21)
+
+

Let Bindings

+

Store intermediate results with let:

+
let active_users = users |> filter(fn(row) => row.users.status = 'active')
+
+active_users |> select(active_users.name, active_users.email)
+
+

Aliases

+

Use as to rename columns in output:

+
users |> select(
+    users.name,
+    users.salary * 12 as annual_salary
+)
+
+

Comments

+

Single-line comments start with --:

+
-- Get all active users
+users |> filter(fn(row) => row.users.active)
+
+

Operators

+

Comparison

+

=, >, <, >=, <=, !=

+

Logical

+

and, or

+

Arithmetic

+

+, -, *, /

+

Sorting

+

asc, desc

+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/docs/vscode/index.html b/Lql/LqlWebsite-Eleventy/_site/docs/vscode/index.html new file mode 100644 index 00000000..70ceea22 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/docs/vscode/index.html @@ -0,0 +1,713 @@ + + + + + + VS Code Extension + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

VS Code Extension

+

The LQL VS Code extension provides a rich editing experience for .lql files, powered by a native Rust Language Server.

+

Installation

+

Search for LQL in the VS Code Extensions marketplace and click Install. The extension automatically downloads the correct LSP binary for your platform on first activation.

+

Supported platforms:

+
    +
  • Linux x64
  • +
  • macOS x64 (Intel)
  • +
  • macOS ARM64 (Apple Silicon)
  • +
  • Windows x64
  • +
+

Features

+

Syntax Highlighting

+

Full TextMate grammar with semantic colorization for all LQL constructs:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenColorExample
KeywordsOrange-redlet, fn, as, asc, desc
Pipeline operatorForest green|>
Lambda operatorViolet=>
Query functionsForest greenselect, filter, join
Aggregate functionsVioletcount, sum, avg
String literalsLime green'completed'
CommentsDark slate-- comment
Table/column namesWhite/greenusers.id
+

The extension includes a dedicated LQL Dark color theme optimized for LQL syntax.

+

IntelliSense Completions

+

Context-aware completions triggered automatically as you type. Completions are organized by priority:

+

1. Column completions - Type table. to see all columns from that table (requires database connection):

+
    +
  • Shows column type, primary key indicator, and nullability
  • +
  • Example: users. suggests id (uuid PK NOT NULL), name (text), email (text)
  • +
+

2. Pipeline operations - Suggested after |>:

+
    +
  • select, filter, join, left_join, right_join, cross_join
  • +
  • group_by, order_by, having, limit, offset
  • +
  • union, union_all, insert, distinct
  • +
+

3. Functions - Suggested in expression contexts:

+
    +
  • Aggregate: count, sum, avg, min, max, first, last, row_number, rank
  • +
  • String: concat, substring, length, trim, upper, lower, replace
  • +
  • Math: round, floor, ceil, abs, sqrt, power, mod
  • +
  • Date/Time: now, today, year, month, day, extract, date_trunc
  • +
  • Conditional: coalesce, nullif, isnull, isnotnull
  • +
+

4. Keywords - let, fn, as, and, or, not, distinct, null, case, when, etc.

+

5. Table names - From your database schema, showing column count and first 5 columns

+

6. Variable bindings - let bindings from the current document

+

7. AI completions - Optional intelligent suggestions from an AI model (see AI Configuration)

+

Snippets

+

23 built-in snippets with tab stops for fast query authoring:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PrefixDescription
selectBasic select all
selectcSelect specific columns
selectfSelect with filter
filterandFilter with AND
filterorFilter with OR
joinInner join
leftjoinLeft join
groupbyGroup by with count
groupbyhavingGroup by with having
orderbyOrder by ascending
limitLimit results
limitoffsetPagination (limit + offset)
distinctSelect distinct
unionUnion queries
letLet binding
caseCase expression
fnLambda function
pipelineFull pipeline example
+

Real-Time Diagnostics

+

Errors and warnings appear as you type with squiggly underlines:

+

Errors (red):

+
    +
  • ANTLR parse errors with line/column position
  • +
  • Unmatched closing parenthesis
  • +
  • Unclosed parenthesis at end of file
  • +
+

Warnings (yellow):

+
    +
  • Pipe operator |> not surrounded by spaces
  • +
+

Information (blue):

+
    +
  • Unknown function names (not in the built-in function list)
  • +
+

Hover Documentation

+

Hover over any LQL keyword, function, or operator to see:

+
    +
  • Description and usage
  • +
  • Syntax signature
  • +
+

With a database connection, hover also shows:

+
    +
  • Table hover: All columns with types, PK/nullable indicators
  • +
  • Column hover (e.g., users.email): Column type, nullability, primary key status
  • +
+

Document Symbols

+

The outline view shows all let bindings in your file, enabling quick navigation with Ctrl+Shift+O.

+

Document Formatting

+

Format your entire document with Shift+Alt+F or right-click and select Format LQL Document:

+
    +
  • Consistent 4-space indentation for pipeline continuations
  • +
  • Proper indentation inside parentheses
  • +
  • Trimmed whitespace
  • +
  • Preserved comments and blank lines
  • +
+

Commands

+ + + + + + + + + + + + + + + + + + + + + +
CommandDescription
Format LQL DocumentFormat the current .lql file
Validate LQL DocumentTrigger validation diagnostics
Show Compiled SQLShow the transpiled SQL output
+

Commands are available in the command palette (Ctrl+Shift+P) and the editor context menu when editing .lql files.

+

Extension Settings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingTypeDefaultDescription
lql.languageServer.enabledbooleantrueEnable/disable the language server
lql.languageServer.traceenumoffLSP trace level: off, messages, verbose
lql.validation.enabledbooleantrueEnable/disable real-time validation
lql.formatting.enabledbooleantrueEnable/disable document formatting
+

AI Configuration

+

The extension supports optional AI-powered completions via local or remote models. AI completions are merged with schema and keyword completions - they supplement, never replace.

+ +
    +
  1. Install Ollama
  2. +
  3. Pull a code model:
    ollama pull qwen2.5-coder:1.5b
    +
  4. +
  5. Add to your VS Code settings.json:
    {
    +  "lql.aiProvider": {
    +    "provider": "ollama",
    +    "endpoint": "http://localhost:11434",
    +    "model": "qwen2.5-coder:1.5b",
    +    "enabled": true
    +  }
    +}
    +
  6. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelSizeSpeedQuality
qwen2.5-coder:1.5b1.5BFastGood
deepseek-coder:1.3b1.3BFastGood
codellama:7b7BSlowerBetter
+

AI Provider Settings

+
{
+  "lql.aiProvider": {
+    "provider": "ollama",
+    "endpoint": "http://localhost:11434",
+    "model": "qwen2.5-coder:1.5b",
+    "apiKey": "",
+    "timeoutMs": 2000,
+    "enabled": true
+  }
+}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
providerProvider type: ollama, openai, anthropic, custom
endpointAPI endpoint URL
modelModel identifier (optional, provider-specific)
apiKeyAPI key (optional, for cloud providers)
timeoutMsTimeout in milliseconds (default: 2000)
enabledEnable/disable AI completions
+

What the AI Sees

+

The AI model receives full context for accurate suggestions:

+
    +
  • The complete document text
  • +
  • Cursor position (line and column)
  • +
  • Current line prefix and word prefix
  • +
  • File URI
  • +
  • Database schema (table names, column names, types)
  • +
+

Responses that exceed the timeout are silently dropped - you always get fast keyword and schema completions regardless of AI latency.

+

Language Features

+

Comment Support

+
    +
  • Line comments: -- comment
  • +
  • Block comments: /* comment */
  • +
+

Bracket Matching

+

Auto-closing and matching for (), [], {}, '', ""

+

Folding

+

Region-based folding with -- #region and -- #endregion markers.

+

LSP Binary

+

The extension bundles a native Rust language server (lql-lsp). On first activation, it searches for the binary in this order:

+
    +
  1. Bundled bin/lql-lsp in the extension directory
  2. +
  3. Local development build (target/release/lql-lsp or target/debug/lql-lsp)
  4. +
  5. Previously cached binary in VS Code global storage
  6. +
  7. Downloads from GitHub Releases matching the extension version
  8. +
  9. Falls back to lql-lsp on your system PATH
  10. +
+ + + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/index.html b/Lql/LqlWebsite-Eleventy/_site/index.html new file mode 100644 index 00000000..e50a0c7a --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/index.html @@ -0,0 +1,400 @@ + + + + + + Lambda Query Language - Functional Data Querying + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+

Lambda Query Language

+

+ Functional programming meets data querying. Write elegant, composable queries with the power of lambda expressions and pipeline operators. +

+ + +
+
+
+
+
+
+ complex_analytics.lql +
+
-- Join users + orders, filter only completed orders +let joined = + users + |> join(orders, on = users.id = orders.user_id) + |> filter(fn(row) => row.orders.status = 'completed') + +-- Aggregate and analyze +joined +|> group_by(users.id) +|> select( + users.name, + count(*) as total_orders, + sum(orders.total) as revenue +)
+
+
+
+
+ +
+
+
+

Why LQL?

+

Functional programming principles applied to data querying for cleaner, more maintainable code.

+
+ +
+
+
λ
+

Functional First

+

Built on pure functional programming principles. Immutable data transformations, lambda expressions, and composable operations make your queries predictable and testable.

+
+ +
+
|>
+

Pipeline Operators

+

Chain operations naturally with pipeline operators. Data flows from left to right, making complex transformations easy to read and understand.

+
+ +
+
TS
+

Type Safe

+

Strong typing ensures your queries are correct at compile time. No more runtime surprises from typos or schema mismatches.

+
+ +
+
fn
+

Composable

+

Build complex queries from simple, reusable components. Define once, use everywhere with let bindings and function composition.

+
+ +
+
SQL
+

SQL Compatible

+

Compiles to optimized SQL for your target database. Get the performance of SQL with the elegance of functional programming.

+
+ +
+
DX
+

Developer Focused

+

Designed by developers, for developers. Excellent tooling support with VS Code extension, LSP, and clear error messages.

+
+
+
+
+ +
+
+
+

See LQL in Action

+

Real examples showing the power and elegance of functional data querying.

+
+ +
+
+
+

Simple Selection

+

Clean, readable syntax for basic data selection. No verbose SELECT statements or complex syntax.

+
    +
  • Pipeline operator for natural flow
  • +
  • Clear column specification
  • +
  • Type-safe field access
  • +
+
+
+
users |> select( + users.id, + users.name, + users.email +)
+
+
+ +
+
+

Advanced Filtering

+

Lambda expressions provide powerful, type-safe filtering with full access to row data.

+
    +
  • Lambda function syntax
  • +
  • Logical operators (and, or)
  • +
  • Range filtering
  • +
+
+
+
employees +|> select( + employees.id, + employees.name, + employees.salary +) +|> filter(fn(row) => + row.employees.salary > 50000 and + row.employees.salary < 100000 +)
+
+
+ +
+
+

Arithmetic & Functions

+

Rich expression support with mathematical operations and built-in functions for complex calculations.

+
    +
  • Mathematical expressions
  • +
  • Column aliases with 'as'
  • +
  • Function composition
  • +
+
+
+
products +|> select( + products.name, + products.price * products.quantity as total_value, + products.price + 10 as price_plus_ten, + round(products.price / 2, 2) as half_price +) +|> filter(fn(row) => + row.products.price > 0 +)
+
+
+ +
+
+

Aggregation & Grouping

+

Powerful aggregation functions with group by operations and having clauses for complex analytics.

+
    +
  • Group by multiple columns
  • +
  • Aggregate functions (count, sum, avg)
  • +
  • Having clause with lambda filters
  • +
+
+
+
orders +|> group_by(orders.user_id, orders.status) +|> select( + orders.user_id, + orders.status, + count(*) as order_count, + sum(orders.total) as total_amount, + avg(orders.total) as avg_amount +) +|> having(fn(group) => count(*) > 2) +|> order_by(total_amount desc)
+
+
+
+
+
+ +
+
+
+

F# Type Provider

+

Use LQL with full type safety in F# through our native type provider.

+
+ +
+
+

Type-Safe LQL in F#

+

The LQL Type Provider brings compile-time type checking to your LQL queries in F#. Write queries with IntelliSense support, catch errors before runtime, and enjoy seamless integration with your F# codebase.

+
    +
  • Compile-time query validation
  • +
  • Full IntelliSense support for table and column names
  • +
  • Automatic SQL generation for PostgreSQL and SQL Server
  • +
  • Strongly-typed result sets
  • +
+
+
+
+
+
+
+ Program.fs +
+
open Lql + +// Define types with validated LQL +type GetUsers = + LqlCommand<"Users |> select(*)"> + +// Access generated SQL +let sql = GetUsers.Sql +let query = GetUsers.Query
+
+
+
+
+ +
+
+
+

Get Started in Minutes

+
+ +
+
# Install the LQL NuGet package
+dotnet add package Lql
+
+# Write your first LQL query
+users |> select(users.id, users.name, users.email)
+
+# Transpiles to SQL:
+# SELECT users.id, users.name, users.email FROM users
+
+ + +
+
+ +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/playground/index.html b/Lql/LqlWebsite-Eleventy/_site/playground/index.html new file mode 100644 index 00000000..967cecd2 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/playground/index.html @@ -0,0 +1,198 @@ + + + + + + LQL Playground - Interactive Transpiler + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+
+

LQL Playground

+

Try Lambda Query Language and see how it transpiles to PostgreSQL or SQL Server

+
+ +
+
+
+

LQL Input

+
+ + +
+ +
+ +
+

PostgreSQL Output

+
Enter LQL code and click 'Convert to SQL' to see the result.
+ +
+
+ +
+

Example Queries

+

Click any example to load it into the editor:

+
+ + + + + +
+
+
+
+
+ + + +
+ + + + + + + diff --git a/Lql/LqlWebsite-Eleventy/_site/robots.txt b/Lql/LqlWebsite-Eleventy/_site/robots.txt new file mode 100644 index 00000000..32c0e406 --- /dev/null +++ b/Lql/LqlWebsite-Eleventy/_site/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://lql.dev/sitemap.xml diff --git a/Lql/Nimblesite.Lql.Cli.Postgres/Nimblesite.Lql.Cli.Postgres.csproj b/Lql/Nimblesite.Lql.Cli.Postgres/Nimblesite.Lql.Cli.Postgres.csproj new file mode 100644 index 00000000..04572dd6 --- /dev/null +++ b/Lql/Nimblesite.Lql.Cli.Postgres/Nimblesite.Lql.Cli.Postgres.csproj @@ -0,0 +1,30 @@ + + + Exe + false + true + lql-postgres + true + Nimblesite.Lql.Cli.Postgres + true + lql-postgres + 0.1.8-beta + 0.1.8-beta + LQL to PostgreSQL transpiler CLI tool + ChristianFindlay + MelbourneDeveloper + MIT + https://github.com/MelbourneDeveloper/DataProvider + git + + $(NoWarn);RS1035 + + + + + + + + + + diff --git a/Lql/Nimblesite.Lql.Cli.Postgres/Program.cs b/Lql/Nimblesite.Lql.Cli.Postgres/Program.cs new file mode 100644 index 00000000..eea0e764 --- /dev/null +++ b/Lql/Nimblesite.Lql.Cli.Postgres/Program.cs @@ -0,0 +1,222 @@ +using System.CommandLine; +using Nimblesite.Lql.Core; +using Nimblesite.Lql.Postgres; +using Nimblesite.Sql.Model; +using LqlStatementError = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Error; +using LqlStatementOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; +using StringSqlError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError +>; +using StringSqlOk = Outcome.Result.Ok< + string, + Nimblesite.Sql.Model.SqlError +>; + +namespace Nimblesite.Lql.Cli.Postgres; + +/// +/// LQL to PostgreSQL CLI transpiler +/// +internal static class Program +{ + /// + /// Main entry point for the CLI application + /// + /// Command line arguments + /// Exit code + public static async Task Main(string[] args) + { + var inputOption = new Option( + name: "--input", + description: "Input LQL file to transpile" + ) + { + IsRequired = true, + }; + inputOption.AddAlias("-i"); + + var outputOption = new Option( + name: "--output", + description: "Output PostgreSQL SQL file (optional - prints to console if not specified)" + ) + { + IsRequired = false, + }; + outputOption.AddAlias("-o"); + + var validateOption = new Option( + name: "--validate", + description: "Validate the LQL syntax without generating output", + getDefaultValue: () => false + ); + validateOption.AddAlias("-v"); + + var rootCommand = new RootCommand("LQL to PostgreSQL SQL transpiler") + { + inputOption, + outputOption, + validateOption, + }; + + rootCommand.SetHandler( + async (inputFile, outputFile, validate) => + { + var result = await TranspileLqlToPostgres(inputFile!, outputFile, validate) + .ConfigureAwait(false); + Environment.Exit(result); + }, + inputOption, + outputOption, + validateOption + ); + + return await rootCommand.InvokeAsync(args).ConfigureAwait(false); + } + + /// + /// Transpiles LQL file to PostgreSQL SQL + /// + /// Input LQL file + /// Optional output file + /// Whether to only validate syntax + /// Exit code (0 = success, 1 = error) + private static async Task TranspileLqlToPostgres( + FileInfo inputFile, + FileInfo? outputFile, + bool validate + ) + { + try + { + if (!inputFile.Exists) + { + Console.WriteLine($"❌ Error: Input file '{inputFile.FullName}' does not exist."); + return 1; + } + + var lqlContent = await File.ReadAllTextAsync(inputFile.FullName).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(lqlContent)) + { + Console.WriteLine($"❌ Error: Input file '{inputFile.FullName}' is empty."); + return 1; + } + + Console.WriteLine($"📖 Reading LQL from: {inputFile.FullName}"); + + // Parse the LQL using Nimblesite.Lql.Core + var parseResult = LqlStatementConverter.ToStatement(lqlContent); + + return parseResult switch + { + LqlStatementOk success => await ProcessSuccessfulParse( + success.Value, + outputFile, + validate, + inputFile.FullName + ) + .ConfigureAwait(false), + LqlStatementError failure => HandleParseError(failure.Value), + }; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Unexpected error: {ex}"); + return 1; + } + } + + /// + /// Processes a successfully parsed LQL statement + /// + private static async Task ProcessSuccessfulParse( + LqlStatement statement, + FileInfo? outputFile, + bool validate, + string inputFileName + ) + { + if (validate) + { + Console.WriteLine($"✅ LQL syntax is valid in: {inputFileName}"); + return 0; + } + + // Convert to PostgreSQL + var pgResult = statement.ToPostgreSql(); + + return pgResult switch + { + StringSqlOk success => await OutputSql(success.Value, outputFile).ConfigureAwait(false), + StringSqlError failure => HandleTranspilationError(failure.Value), + }; + } + + /// + /// Outputs the generated SQL + /// + private static async Task OutputSql(string sql, FileInfo? outputFile) + { + var finalSql = sql; + + if (outputFile != null) + { + var directory = outputFile.Directory; + if (directory != null && !directory.Exists) + { + directory.Create(); + } + + await File.WriteAllTextAsync(outputFile.FullName, finalSql).ConfigureAwait(false); + Console.WriteLine($"✅ PostgreSQL SQL written to: {outputFile.FullName}"); + } + else + { + Console.WriteLine("\n🔄 Generated PostgreSQL SQL:"); + Console.WriteLine("─".PadRight(50, '─')); + Console.WriteLine(finalSql); + Console.WriteLine("─".PadRight(50, '─')); + } + + return 0; + } + + /// + /// Handles parse errors + /// + private static int HandleParseError(SqlError error) + { + Console.WriteLine($"❌ LQL Parse Error: {error.FormattedMessage}"); + if ( + !string.IsNullOrEmpty(error.DetailedMessage) + && error.DetailedMessage != error.FormattedMessage + ) + { + Console.WriteLine($" Details: {error.DetailedMessage}"); + } + return 1; + } + + /// + /// Handles transpilation errors + /// + private static int HandleTranspilationError(SqlError error) + { + Console.WriteLine($"❌ PostgreSQL Transpilation Error: {error.FormattedMessage}"); + if ( + !string.IsNullOrEmpty(error.DetailedMessage) + && error.DetailedMessage != error.FormattedMessage + ) + { + Console.WriteLine($" Details: {error.DetailedMessage}"); + } + return 1; + } +} diff --git a/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs b/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs index 2b31335b..2670eb53 100644 --- a/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs +++ b/Lql/Nimblesite.Lql.Postgres/PostgreSqlContext.cs @@ -13,6 +13,7 @@ public sealed class PostgreSqlContext : ISqlContext private readonly IFunctionMappingProvider _functionMappingProvider; #pragma warning restore IDE0052 // Remove unread private members private readonly SelectStatementBuilder _builder = new(); + private readonly HashSet _usedAliases = new(StringComparer.Ordinal); private string? _baseTable; private string? _baseAlias; @@ -79,7 +80,7 @@ public void AddJoin(string joinType, string tableName, string? condition) /// /// The subquery SQL /// The generated alias - private static string ExtractSubqueryAlias(string subquerySql) + private string ExtractSubqueryAlias(string subquerySql) { // Try to find the FROM clause and extract the table name var upperSql = subquerySql.ToUpperInvariant(); @@ -231,7 +232,144 @@ private string GenerateSelectSQL(SelectStatement statement) ); } - return sql.ToString(); + // Final pass: walk the entire generated SQL and quote any inline + // `alias.Ident` substring whose tail contains uppercase. This + // catches WHERE / JOIN ON / GROUP BY / HAVING / ORDER BY paths + // where the raw expression text was emitted verbatim. The walker + // skips characters inside string literals and already-quoted + // identifiers so it's safe to apply once at the end. + var result = QuoteInlineQualifiedIdents(sql.ToString()); + + // Bug #22: when joins are present, the FROM/JOIN clauses emit + // table aliases (`FROM users u INNER JOIN orders o ON ...`) but + // the column refs in SELECT/WHERE/etc use the full table name + // (`users.name`). Postgres rejects this — once you alias a + // table, the original name is no longer a valid qualifier. + // Build a map of table -> alias from the FROM/JOIN portion of + // the generated SQL, then rewrite the rest of the body to use + // aliases consistently. + if (statement.HasJoins) + { + result = RewriteFullTableRefsToAliases(result, statement); + } + + return result; + } + + /// + /// Walks the generated SQL and replaces any `tableName."Col"` or + /// `tableName.col` reference with the corresponding aliased form + /// using the alias declared in the FROM/JOIN clauses. Skips + /// string literals and the FROM/JOIN-clause text itself (the + /// declaration `FROM tableName alias` and `JOIN tableName alias` + /// must keep the table name). + /// + private static string RewriteFullTableRefsToAliases(string sql, SelectStatement statement) + { + // Build name -> alias map from the statement's table list. + var aliasMap = new Dictionary(StringComparer.Ordinal); + foreach (var t in statement.Tables) + { + if (!string.IsNullOrEmpty(t.Alias) && !string.IsNullOrEmpty(t.Name)) + { + aliasMap[t.Name] = t.Alias; + } + } + if (aliasMap.Count == 0) + { + return sql; + } + + var sb = new StringBuilder(sql.Length); + var i = 0; + while (i < sql.Length) + { + var c = sql[i]; + + // Skip string literals. + if (c == '\'') + { + sb.Append(c); + i++; + while (i < sql.Length) + { + sb.Append(sql[i]); + if (sql[i] == '\'') + { + if (i + 1 < sql.Length && sql[i + 1] == '\'') + { + sb.Append(sql[i + 1]); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + // Quoted identifier `"foo"`. If followed by `.` it's a + // `"fhir_Slot".` qualifier and can be rewritten to its alias. + // Otherwise (followed by whitespace/end) it's the FROM/JOIN + // declaration form `"fhir_Slot" f` and must be left alone. + if (c == '"') + { + var quoteStart = i; + i++; + while (i < sql.Length && sql[i] != '"') + { + i++; + } + if (i < sql.Length) + { + i++; // consume closing quote + } + var quotedIdent = sql[quoteStart..i]; + var inner = quotedIdent.Length >= 2 ? quotedIdent[1..^1] : string.Empty; + if (aliasMap.TryGetValue(inner, out var qAlias) && i < sql.Length && sql[i] == '.') + { + sb.Append(qAlias); + continue; + } + sb.Append(quotedIdent); + continue; + } + + // Identifier candidate. + if (char.IsLetter(c) || c == '_') + { + var start = i; + i++; + while (i < sql.Length && (char.IsLetterOrDigit(sql[i]) || sql[i] == '_')) + { + i++; + } + var ident = sql[start..i]; + + // Skip if this identifier is the FROM/JOIN declaration of + // the same table. We detect this by looking back for + // FROM/JOIN keyword and checking that what follows is + // . Easier heuristic: if the next non- + // whitespace token after this ident is the alias literal + // (a single word matching the alias map value), AND the + // previous keyword was FROM/JOIN, treat as declaration. + if (aliasMap.TryGetValue(ident, out var alias) && i < sql.Length && sql[i] == '.') + { + // It's a `tableName.` reference — rewrite to alias. + sb.Append(alias); + continue; + } + + sb.Append(ident); + continue; + } + + sb.Append(c); + i++; + } + return sb.ToString(); } /// @@ -377,7 +515,11 @@ private static string GenerateWhereConditionSql(WhereCondition condition) => }; /// - /// Generates SQL for a ColumnInfo + /// Generates SQL for a ColumnInfo. Bare column names get double-quoted + /// so PostgreSQL preserves their case (matches the FormatTableName + /// behaviour above). ExpressionColumn values pass through + /// QuoteIdentifier as well so qualified references like + /// `icd10_chapter.Id` get the trailing component quoted. /// /// The column info /// The SQL string for the column @@ -385,14 +527,37 @@ private static string GenerateColumnSql(ColumnInfo columnInfo) => columnInfo switch { NamedColumn n => string.IsNullOrEmpty(n.TableAlias) - ? n.Name - : $"{n.TableAlias}.{n.Name}", - WildcardColumn w => string.IsNullOrEmpty(w.TableAlias) ? "*" : $"{w.TableAlias}.*", - ExpressionColumn e => e.Expression, + ? QuoteIdentifier(n.Name) + // Bug #24: also quote the TableAlias if it needs it. + // When the FROM clause uses a quoted mixed-case table name + // (e.g. `FROM "fhir_Patient"`), the column qualifier must + // also be quoted to look up the same table. + : $"{QuoteBareIdentifier(n.TableAlias)}.{QuoteIdentifier(n.Name)}", + WildcardColumn w => string.IsNullOrEmpty(w.TableAlias) + ? "*" + : $"{QuoteBareIdentifier(w.TableAlias)}.*", + ExpressionColumn e => QuoteIdentifier(e.Expression), SubQueryColumn s => $"({s.SubQuery})", _ => "/*UNKNOWN_COLUMN*/", }; + /// + /// Quotes a bare identifier (no dots) when its case requires it. + /// Identifiers that are already quoted are passed through. + /// + private static string QuoteBareIdentifier(string identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + return identifier; + } + if (identifier.StartsWith('"')) + { + return identifier; + } + return NeedsQuoting(identifier) ? $"\"{identifier}\"" : identifier; + } + /// /// Generates SQL for a ColumnInfo with alias if present /// @@ -405,21 +570,300 @@ private static string GenerateColumnSqlWithAlias(ColumnInfo columnInfo) } /// - /// Generates a table alias from a table name + /// Generates a table alias from a table name. Bug #20: tracks used + /// aliases per-context and appends a digit suffix on collision so + /// two tables starting with the same letter (e.g. account + address) + /// don't end up with the same alias. /// /// The table name /// The generated alias - private static string GenerateTableAlias(string tableName) + private string GenerateTableAlias(string tableName) { ArgumentNullException.ThrowIfNull(tableName); - // Use first letter of the table name (to match expected test output) - return tableName.Length > 0 ? tableName[0].ToString().ToLowerInvariant() : "t"; + var baseAlias = tableName.Length > 0 ? tableName[0].ToString().ToLowerInvariant() : "t"; + + if (_usedAliases.Add(baseAlias)) + { + return baseAlias; + } + + // Collision: append a numeric suffix until we find a free slot. + var suffix = 2; + while (true) + { + var candidate = + baseAlias + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (_usedAliases.Add(candidate)) + { + return candidate; + } + suffix++; + } + } + + /// + /// Formats a table name for PostgreSQL. PostgreSQL folds unquoted + /// identifiers to lower case, so any identifier that contains an + /// uppercase character (e.g. `fhir_Patient`) MUST be double-quoted + /// to survive a round-trip. Identifiers that are already lowercase + /// (the previous behaviour) are emitted unquoted to preserve the + /// existing test fixture output. + /// + private static string FormatTableName(string tableName) => + NeedsQuoting(tableName) ? $"\"{tableName}\"" : tableName; + + /// + /// Quotes a bare identifier (column name) when it contains characters + /// that PostgreSQL would fold (uppercase letters). For complex + /// expressions (anything with whitespace, parentheses, operators + /// or keywords) we walk the string and quote each `prefix.Tail` + /// substring where the tail is a bare ident containing uppercase. + /// + private static string QuoteIdentifier(string identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + return identifier; + } + if (identifier.StartsWith('"')) + { + return identifier; + } + + // Simple bare identifier (no `.`, no whitespace, no operators). + if (IsSimpleBareIdent(identifier)) + { + return NeedsQuoting(identifier) ? $"\"{identifier}\"" : identifier; + } + + // Simple qualified reference `prefix.tail` where both halves + // are bare idents. + if (IsSimpleQualifiedIdent(identifier, out var prefix, out var tail)) + { + // Quote either side that needs it. Bug #24: PG folds unquoted + // table-name qualifiers, so a quoted FROM table requires the + // qualifier on the column ref to also be quoted. + var prefixNeeds = NeedsQuoting(prefix); + var tailNeeds = tail != "*" && NeedsQuoting(tail); + if (!prefixNeeds && !tailNeeds) + { + return identifier; + } + var quotedPrefix = prefixNeeds ? $"\"{prefix}\"" : prefix; + var quotedTail = tail == "*" ? "*" : (tailNeeds ? $"\"{tail}\"" : tail); + return $"{quotedPrefix}.{quotedTail}"; + } + + // Complex expression: walk and quote inline `alias.Ident` + // substrings where Ident contains uppercase. + return QuoteInlineQualifiedIdents(identifier); + } + + /// + /// True when is a single bare identifier: + /// only ASCII letters, digits, and underscore, starting with a letter + /// or underscore. + /// + private static bool IsSimpleBareIdent(string s) + { + if (string.IsNullOrEmpty(s)) + { + return false; + } + var first = s[0]; + if (!(char.IsLetter(first) || first == '_')) + { + return false; + } + for (var i = 1; i < s.Length; i++) + { + var c = s[i]; + if (!(char.IsLetterOrDigit(c) || c == '_')) + { + return false; + } + } + return true; } /// - /// Formats a table name for PostgreSQL by lowercasing. + /// True when is exactly `prefix.tail` with both + /// halves being bare identifiers (or `*` as the tail). /// - private static string FormatTableName(string tableName) => tableName.ToLowerInvariant(); + private static bool IsSimpleQualifiedIdent(string s, out string prefix, out string tail) + { + prefix = string.Empty; + tail = string.Empty; + var dot = s.IndexOf('.', StringComparison.Ordinal); + if (dot <= 0 || dot == s.Length - 1) + { + return false; + } + var first = s[..dot]; + var second = s[(dot + 1)..]; + if (second.Contains('.', StringComparison.Ordinal)) + { + return false; + } + if (!IsSimpleBareIdent(first)) + { + return false; + } + if (second != "*" && !IsSimpleBareIdent(second)) + { + return false; + } + prefix = first; + tail = second; + return true; + } + + /// + /// Walks an arbitrary expression string and rewrites any inline + /// substring matching `alias.Ident` (where Ident contains an + /// uppercase letter) into `alias."Ident"`. Skips characters inside + /// single-quoted string literals or already-quoted identifiers. + /// + private static string QuoteInlineQualifiedIdents(string expression) + { + var sb = new StringBuilder(expression.Length + 8); + var i = 0; + while (i < expression.Length) + { + var c = expression[i]; + + if (c == '\'') + { + sb.Append(c); + i++; + while (i < expression.Length) + { + sb.Append(expression[i]); + if (expression[i] == '\'') + { + if (i + 1 < expression.Length && expression[i + 1] == '\'') + { + sb.Append(expression[i + 1]); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + if (c == '"') + { + sb.Append(c); + i++; + while (i < expression.Length) + { + sb.Append(expression[i]); + if (expression[i] == '"') + { + i++; + break; + } + i++; + } + continue; + } + + if (char.IsLetter(c) || c == '_') + { + var start = i; + i++; + while ( + i < expression.Length + && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_') + ) + { + i++; + } + var firstIdent = expression[start..i]; + + if (i < expression.Length && expression[i] == '.') + { + var tailStart = i + 1; + if ( + tailStart < expression.Length + && (char.IsLetter(expression[tailStart]) || expression[tailStart] == '_') + ) + { + var tailEnd = tailStart + 1; + while ( + tailEnd < expression.Length + && ( + char.IsLetterOrDigit(expression[tailEnd]) + || expression[tailEnd] == '_' + ) + ) + { + tailEnd++; + } + var tailIdent = expression[tailStart..tailEnd]; + + // Bug #24: also quote the prefix when it needs + // quoting (e.g. `fhir_Patient.Id` -> + // `"fhir_Patient"."Id"`). PG folds the unquoted + // table-name qualifier to lower case otherwise + // and the lookup against a quoted FROM table + // ("fhir_Patient") fails. + if (NeedsQuoting(firstIdent)) + { + sb.Append('"').Append(firstIdent).Append('"'); + } + else + { + sb.Append(firstIdent); + } + sb.Append('.'); + if (NeedsQuoting(tailIdent)) + { + sb.Append('"').Append(tailIdent).Append('"'); + } + else + { + sb.Append(tailIdent); + } + i = tailEnd; + continue; + } + } + + sb.Append(firstIdent); + continue; + } + + sb.Append(c); + i++; + } + return sb.ToString(); + } + + /// + /// Returns true when an identifier contains an uppercase ASCII letter, + /// meaning Postgres would fold it to lower case if left unquoted. + /// + private static bool NeedsQuoting(string identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + return false; + } + for (var i = 0; i < identifier.Length; i++) + { + var c = identifier[i]; + if (c >= 'A' && c <= 'Z') + { + return true; + } + } + return false; + } /// /// Generates the GROUP BY clause diff --git a/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs b/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs index 34496de6..f146a178 100644 --- a/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs +++ b/Lql/Nimblesite.Lql.Postgres/SqlStatementExtensionsPostgreSQL.cs @@ -38,7 +38,7 @@ public static Result ToPostgreSql(this LqlStatement statement) } var unknownSql = statement.AstNode is Identifier identifier - ? $"SELECT *\nFROM {identifier.Name}" + ? $"SELECT *\nFROM {FormatBareIdentifier(identifier.Name)}" : "-- Unknown AST node type"; return new Result.Ok(unknownSql); } @@ -59,6 +59,29 @@ private static string ConvertPipelineToPostgreSQL(Pipeline pipeline) return PipelineProcessor.ConvertPipelineToSql(pipeline, context, ProcessColumnReferences); } + /// + /// Wraps a bare identifier in double quotes only when it contains + /// uppercase ASCII (which Postgres would otherwise fold). Lower-case + /// identifiers are passed through to preserve existing test fixture + /// output and SQL readability. + /// + private static string FormatBareIdentifier(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (c >= 'A' && c <= 'Z') + { + return $"\"{name}\""; + } + } + return name; + } + /// /// Processes column references in a condition string to use proper table aliases /// diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql index 0916b778..408ea524 100644 --- a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/complex_join_union.sql @@ -1,10 +1,10 @@ INSERT INTO report_table (id, name) (SELECT - users.id, - users.name + u.id, + u.name FROM users u -INNER JOIN orders o ON users.id = orders.user_id -WHERE orders.status = 'completed' +INNER JOIN orders o ON u.id = o.user_id +WHERE o.status = 'completed' UNION SELECT a.archived_users.id, a.archived_users.name FROM archived_users a) \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql index 9823f635..872c6208 100644 --- a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_left.sql @@ -1,5 +1,5 @@ SELECT - users.name, - orders.total + u.name, + o.total FROM users u -LEFT JOIN orders o ON users.id = orders.user_id \ No newline at end of file +LEFT JOIN orders o ON u.id = o.user_id \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql index eaf59f43..ab914673 100644 --- a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_multiple.sql @@ -1,7 +1,7 @@ SELECT - users.name, - orders.total, - products.name + u.name, + o.total, + p.name FROM users u -INNER JOIN orders o ON users.id = orders.user_id -INNER JOIN products p ON orders.product_id = products.id \ No newline at end of file +INNER JOIN orders o ON u.id = o.user_id +INNER JOIN products p ON o.product_id = p.id \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql index 43e08641..15de0992 100644 --- a/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql +++ b/Lql/Nimblesite.Lql.Tests/TestData/ExpectedSql/PostgreSql/join_simple.sql @@ -1,5 +1,5 @@ SELECT - users.name, - orders.total + u.name, + o.total FROM users u -INNER JOIN orders o ON users.id = orders.user_id \ No newline at end of file +INNER JOIN orders o ON u.id = o.user_id \ No newline at end of file diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj index ff4c9f58..1c7f1c9c 100644 --- a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data/Nimblesite.Lql.TypeProvider.FSharp.Tests.Data.csproj @@ -34,10 +34,10 @@ - + + @@ -44,9 +45,9 @@ - + - + diff --git a/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs new file mode 100644 index 00000000..b2468d84 --- /dev/null +++ b/Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests/RuntimeTranspilerTests.fs @@ -0,0 +1,261 @@ +module Nimblesite.Lql.Core.TypeProvider.RuntimeTranspilerTests + +open Xunit +open Nimblesite.Lql.Core +open Nimblesite.Lql.SQLite +open Nimblesite.Sql.Model +open Outcome + +// ============================================================================= +// RUNTIME PARSER + TRANSPILER COVERAGE TESTS +// +// The compile-time tests in TypeProviderE2ETests.fs only read precomputed Sql +// strings baked by the F# Type Provider at compile time, so coverlet sees 0% +// coverage of the Lql.Core / Lql.SQLite / Sql.Model assemblies. These tests +// invoke LqlStatementConverter.ToStatement(...) and SqlStatementExtensionsSQLite +// .ToSQLite(...) directly at runtime so coverlet captures them. +// ============================================================================= + +let private transpile (lql: string) : string = + let stmtResult = LqlStatementConverter.ToStatement(lql) + match stmtResult with + | :? Result.Ok as ok -> + let sqlResult = ok.Value.ToSQLite() + match sqlResult with + | :? Result.Ok as sqlOk -> sqlOk.Value + | :? Result.Error as sqlErr -> + failwithf "ToSQLite failed for %s: %s" lql (sqlErr.Value.ToString()) + | _ -> failwithf "Unexpected ToSQLite result for %s" lql + | :? Result.Error as err -> + failwithf "Parse failed for %s: %s" lql (err.Value.ToString()) + | _ -> failwithf "Unexpected ToStatement result for %s" lql + +let private assertContainsCi (needle: string) (haystack: string) = + Assert.Contains(needle.ToUpperInvariant(), haystack.ToUpperInvariant()) + +[] +type RuntimeSelectTests() = + + [] + member _.``Runtime: select * generates SELECT *``() = + let sql = transpile "Customer |> select(*)" + assertContainsCi "SELECT" sql + assertContainsCi "CUSTOMER" sql + + [] + member _.``Runtime: select named columns``() = + let sql = transpile "Users |> select(Users.Id, Users.Name, Users.Email)" + assertContainsCi "SELECT" sql + assertContainsCi "ID" sql + assertContainsCi "NAME" sql + assertContainsCi "EMAIL" sql + + [] + member _.``Runtime: select with column alias``() = + let sql = transpile "Users |> select(Users.Id, Users.Name as username)" + assertContainsCi "AS" sql + + [] + member _.``Runtime: select multiple tables produces qualified columns``() = + let sql = transpile "Users |> select(Users.Id, Users.Name)" + assertContainsCi "USERS" sql + +[] +type RuntimeFilterTests() = + + [] + member _.``Runtime: simple filter generates WHERE``() = + let sql = transpile "Users |> filter(fn(row) => row.Users.Age > 18) |> select(Users.Name)" + assertContainsCi "WHERE" sql + assertContainsCi ">" sql + + [] + member _.``Runtime: filter with AND``() = + let sql = + transpile + "Users |> filter(fn(row) => row.Users.Age > 18 and row.Users.Status = 'active') |> select(*)" + assertContainsCi "WHERE" sql + assertContainsCi "AND" sql + + [] + member _.``Runtime: filter with OR``() = + let sql = + transpile + "Users |> filter(fn(row) => row.Users.Age < 18 or row.Users.Role = 'admin') |> select(*)" + assertContainsCi "WHERE" sql + assertContainsCi "OR" sql + + [] + member _.``Runtime: filter with equality on string literal``() = + let sql = transpile "Users |> filter(fn(row) => row.Users.Status = 'active') |> select(*)" + assertContainsCi "WHERE" sql + assertContainsCi "ACTIVE" sql + + [] + member _.``Runtime: filter with less-than-or-equal``() = + let sql = transpile "Users |> filter(fn(row) => row.Users.Age <= 65) |> select(*)" + assertContainsCi "WHERE" sql + + [] + member _.``Runtime: filter with greater-than-or-equal``() = + let sql = transpile "Users |> filter(fn(row) => row.Users.Age >= 21) |> select(*)" + assertContainsCi "WHERE" sql + +[] +type RuntimeJoinTests() = + + [] + member _.``Runtime: inner join``() = + let sql = + transpile + "Users |> join(Orders, on = Users.Id = Orders.UserId) |> select(Users.Name, Orders.Total)" + assertContainsCi "JOIN" sql + assertContainsCi "ON" sql + + [] + member _.``Runtime: left join``() = + let sql = + transpile + "Users |> left_join(Orders, on = Users.Id = Orders.UserId) |> select(Users.Name, Orders.Total)" + assertContainsCi "LEFT" sql + assertContainsCi "JOIN" sql + + [] + member _.``Runtime: multiple joins chained``() = + let sql = + transpile + "Users |> join(Orders, on = Users.Id = Orders.UserId) |> join(Products, on = Orders.ProductId = Products.Id) |> select(Users.Name, Products.Name)" + let upper = sql.ToUpperInvariant() + let joinCount = + upper.Split([| "JOIN" |], System.StringSplitOptions.None).Length - 1 + Assert.True(joinCount >= 2, sprintf "Expected at least 2 JOINs, got SQL: %s" sql) + +[] +type RuntimeAggregationTests() = + + [] + member _.``Runtime: group by``() = + let sql = + transpile "Orders |> group_by(Orders.UserId) |> select(Orders.UserId, count(*) as order_count)" + assertContainsCi "GROUP BY" sql + assertContainsCi "COUNT" sql + + [] + member _.``Runtime: group by with sum and avg``() = + let sql = + transpile + "Orders |> group_by(Orders.Status) |> select(Orders.Status, sum(Orders.Total) as total_sum, avg(Orders.Total) as avg_total)" + assertContainsCi "GROUP BY" sql + assertContainsCi "SUM" sql + assertContainsCi "AVG" sql + + [] + member _.``Runtime: having clause``() = + let sql = + transpile + "Orders |> group_by(Orders.UserId) |> having(fn(g) => count(*) > 5) |> select(Orders.UserId, count(*) as cnt)" + assertContainsCi "HAVING" sql + + [] + member _.``Runtime: count star``() = + let sql = transpile "Users |> select(count(*) as total)" + assertContainsCi "COUNT" sql + +[] +type RuntimeOrderingTests() = + + [] + member _.``Runtime: order by ascending``() = + let sql = transpile "Users |> order_by(Users.Name asc) |> select(*)" + assertContainsCi "ORDER BY" sql + + [] + member _.``Runtime: order by descending``() = + let sql = transpile "Users |> order_by(Users.CreatedAt desc) |> select(*)" + assertContainsCi "ORDER BY" sql + assertContainsCi "DESC" sql + + [] + member _.``Runtime: limit``() = + let sql = transpile "Users |> order_by(Users.Id) |> limit(10) |> select(*)" + assertContainsCi "LIMIT" sql + + [] + member _.``Runtime: limit with offset``() = + let sql = transpile "Users |> order_by(Users.Id) |> limit(10) |> offset(20) |> select(*)" + assertContainsCi "LIMIT" sql + assertContainsCi "OFFSET" sql + +[] +type RuntimeArithmeticTests() = + + [] + member _.``Runtime: multiplication in select``() = + let sql = transpile "Products |> select(Products.Price * Products.Quantity as total)" + Assert.Contains("*", sql) + + [] + member _.``Runtime: addition and subtraction in select``() = + let sql = + transpile "Orders |> select(Orders.Subtotal + Orders.Tax - Orders.Discount as final_total)" + Assert.Contains("+", sql) + Assert.Contains("-", sql) + + [] + member _.``Runtime: division``() = + let sql = transpile "Orders |> select(Orders.Total / Orders.Quantity as unit_price)" + Assert.Contains("/", sql) + +[] +type RuntimeComplexPipelineTests() = + + [] + member _.``Runtime: filter then select then order then limit``() = + let sql = + transpile + "Users |> filter(fn(row) => row.Users.Age > 18) |> order_by(Users.Name asc) |> limit(50) |> select(Users.Id, Users.Name)" + assertContainsCi "WHERE" sql + assertContainsCi "ORDER BY" sql + assertContainsCi "LIMIT" sql + + [] + member _.``Runtime: join + filter + group + having + order``() = + let sql = + transpile + "Users |> join(Orders, on = Users.Id = Orders.UserId) |> filter(fn(row) => row.Orders.Status = 'paid') |> group_by(Users.Id) |> having(fn(g) => sum(Orders.Total) > 100) |> order_by(Users.Id asc) |> select(Users.Id, sum(Orders.Total) as revenue)" + assertContainsCi "JOIN" sql + assertContainsCi "WHERE" sql + assertContainsCi "GROUP BY" sql + assertContainsCi "HAVING" sql + assertContainsCi "ORDER BY" sql + + [] + member _.``Runtime: group by with multiple aggregates``() = + let sql = + transpile + "Orders |> group_by(Orders.UserId) |> select(Orders.UserId, count(*) as orders, sum(Orders.Total) as revenue, avg(Orders.Total) as avg_order)" + assertContainsCi "GROUP BY" sql + assertContainsCi "COUNT" sql + assertContainsCi "SUM" sql + assertContainsCi "AVG" sql + +[] +type RuntimeParseErrorTests() = + + [] + member _.``Runtime: invalid LQL returns parse error``() = + let result = LqlStatementConverter.ToStatement("this is not valid lql @@@") + match result with + | :? Result.Error -> () + | :? Result.Ok -> + failwith "Expected parse error for invalid LQL" + | _ -> failwith "Unexpected result type" + + [] + member _.``Runtime: empty input returns parse error``() = + let result = LqlStatementConverter.ToStatement("") + match result with + | :? Result.Error -> () + | :? Result.Ok -> + failwith "Expected parse error for empty input" + | _ -> failwith "Unexpected result type" diff --git a/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs b/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs index f1f063bc..1185d5e2 100644 --- a/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs +++ b/Lql/lql-lsp-rust/crates/lql-lsp/src/main.rs @@ -632,6 +632,15 @@ fn format_lql(source: &str) -> String { #[tokio::main] async fn main() { + // Handle `--version` (and `-V`) for the VS Code extension's pre-flight + // version check, so it can detect a matching binary on PATH without + // downloading from GitHub releases. + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("lql-lsp {}", env!("CARGO_PKG_VERSION")); + return; + } + let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); diff --git a/Makefile b/Makefile index f8362920..fdaf05ac 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,9 @@ ifeq ($(OS),Windows_NT) MKDIR = New-Item -ItemType Directory -Force HOME ?= $(USERPROFILE) else + # Force bash for non-Windows. Some recipes use bash-only constructs + # (e.g. ${PIPESTATUS[0]}, [[ ... ]]), and Ubuntu's default /bin/sh is dash. + SHELL := /bin/bash RM = rm -rf MKDIR = mkdir -p endif @@ -34,7 +37,9 @@ DOTNET_TEST_PROJECTS = \ Sync/Nimblesite.Sync.SQLite.Tests \ Sync/Nimblesite.Sync.Postgres.Tests \ Sync/Nimblesite.Sync.Integration.Tests \ - Sync/Nimblesite.Sync.Http.Tests + Sync/Nimblesite.Sync.Http.Tests \ + Reporting/Nimblesite.Reporting.Tests \ + Reporting/Nimblesite.Reporting.Integration.Tests # ============================================================================= # PRIMARY TARGETS (uniform interface — do not rename) @@ -138,6 +143,8 @@ _test_dotnet: "Sync/Nimblesite.Sync.Postgres") ;; \ "Sync/Nimblesite.Sync.Integration") ;; \ "Sync/Nimblesite.Sync.Http") ;; \ + "Reporting/Nimblesite.Reporting") ;; \ + "Reporting/Nimblesite.Reporting.Integration") ;; \ esac; \ THRESHOLD=$$(jq -r ".projects[\"$$SRC_KEY\"].threshold // .default_threshold" coverage-thresholds.json); \ INCLUDE=$$(jq -r ".projects[\"$$SRC_KEY\"].include // empty" coverage-thresholds.json); \ @@ -240,7 +247,10 @@ _test_rust: echo "============================================================"; \ echo "==> Testing Lql/lql-lsp-rust (threshold: $$THRESHOLD%)"; \ echo "============================================================"; \ - cd Lql/lql-lsp-rust && cargo tarpaulin --workspace --skip-clean 2>&1 | tee /tmp/_dp_tarpaulin_out.txt; \ + cd Lql/lql-lsp-rust && cargo tarpaulin --workspace --skip-clean \ + --exclude-files 'crates/lql-parser/src/generated/*' \ + --exclude-files 'crates/lql-lsp/tests/*' \ + 2>&1 | tee /tmp/_dp_tarpaulin_out.txt; \ TARP_EXIT=$${PIPESTATUS[0]}; \ if [ $$TARP_EXIT -ne 0 ]; then \ echo "FAIL [Lql/lql-lsp-rust]: cargo tarpaulin failed"; \ @@ -283,14 +293,29 @@ _clean_rust: _build_ts: cd Lql/LqlExtension && npm install --no-audit --no-fund && npm run compile -_test_ts: +# Ensure the lql-lsp binary exists before running VSIX tests, so the +# extension can find a matching --version on PATH and start the LSP +# without trying to download a release from GitHub. +_ensure_lql_lsp_on_path: + @if [ ! -x "$(CURDIR)/Lql/lql-lsp-rust/target/release/lql-lsp" ] && \ + [ ! -x "$(CURDIR)/Lql/lql-lsp-rust/target/debug/lql-lsp" ]; then \ + echo "==> Building lql-lsp (release) for VSIX tests"; \ + cd Lql/lql-lsp-rust && cargo build --release -p lql-lsp; \ + fi + +_test_ts: _ensure_lql_lsp_on_path @THRESHOLD=$$(jq -r '.projects["Lql/LqlExtension"].threshold // .default_threshold' coverage-thresholds.json); \ echo ""; \ echo "============================================================"; \ echo "==> Testing Lql/LqlExtension (threshold: $$THRESHOLD%)"; \ echo "============================================================"; \ - cd Lql/LqlExtension && npm run compile && \ - rm -rf out-cov && npx nyc instrument out out-cov && rm -rf out && mv out-cov out && \ + export PATH="$(CURDIR)/Lql/lql-lsp-rust/target/release:$(CURDIR)/Lql/lql-lsp-rust/target/debug:$$PATH"; \ + unset ELECTRON_RUN_AS_NODE; \ + echo " lql-lsp on PATH: $$(command -v lql-lsp || echo 'NOT FOUND')"; \ + echo " lql-lsp --version: $$(lql-lsp --version 2>&1 || echo 'failed')"; \ + cd Lql/LqlExtension && \ + npx vsce package --no-git-tag-version --no-update-package-json && \ + rm -rf out-cov && npx nyc instrument --include='out/**/*.js' --exclude='out/test/**' --no-all out out-cov && cp -R out-cov/. out/ && rm -rf out-cov && \ if command -v xvfb-run >/dev/null 2>&1; then \ xvfb-run -a node ./out/test/runTest.js; \ else \ @@ -301,15 +326,16 @@ _test_ts: echo "FAIL [Lql/LqlExtension]: Extension tests failed"; \ exit 1; \ fi; \ - SUMMARY="Lql/LqlExtension/coverage/coverage-summary.json"; \ + SUMMARY="$(CURDIR)/Lql/LqlExtension/coverage/coverage-summary.json"; \ if [ ! -f "$$SUMMARY" ]; then \ - SUMMARY="Lql/LqlExtension/.nyc_output/coverage-summary.json"; \ + SUMMARY="$(CURDIR)/Lql/LqlExtension/.nyc_output/coverage-summary.json"; \ fi; \ if [ ! -f "$$SUMMARY" ]; then \ - echo "FAIL [Lql/LqlExtension]: No coverage summary produced"; \ - exit 1; \ + echo " WARN [Lql/LqlExtension]: No coverage summary produced (cross-process instrumentation skipped); treating as 0%"; \ + COVERAGE=0; \ + else \ + COVERAGE=$$(jq -r '.total.lines.pct' "$$SUMMARY"); \ fi; \ - COVERAGE=$$(jq -r '.total.lines.pct' "$$SUMMARY"); \ echo ""; \ echo " [Lql/LqlExtension] Coverage: $$COVERAGE% | Threshold: $$THRESHOLD%"; \ BELOW=$$(echo "$$COVERAGE < $$THRESHOLD" | bc -l); \ @@ -321,7 +347,7 @@ _test_ts: if [ "$$ABOVE" = "1" ]; then \ NEW=$$(echo "$$COVERAGE" | awk '{print int($$1)}'); \ echo " Ratcheting threshold: $$THRESHOLD% -> $$NEW%"; \ - jq '.projects["Lql/LqlExtension"].threshold = '"$$NEW" coverage-thresholds.json > coverage-thresholds.json.tmp && mv coverage-thresholds.json.tmp coverage-thresholds.json; \ + jq '.projects["Lql/LqlExtension"].threshold = '"$$NEW" "$(CURDIR)/coverage-thresholds.json" > "$(CURDIR)/coverage-thresholds.json.tmp" && mv "$(CURDIR)/coverage-thresholds.json.tmp" "$(CURDIR)/coverage-thresholds.json"; \ fi; \ echo " PASS [Lql/LqlExtension]" diff --git a/Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj b/Migration/DataProviderMigrate/DataProviderMigrate.csproj similarity index 53% rename from Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj rename to Migration/DataProviderMigrate/DataProviderMigrate.csproj index b786025f..54572b96 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Cli/Nimblesite.DataProvider.Migration.Cli.csproj +++ b/Migration/DataProviderMigrate/DataProviderMigrate.csproj @@ -1,13 +1,19 @@ Exe - Nimblesite.DataProvider.Migration.Cli + DataProviderMigrate $(NoWarn);CA2254;CA1515;RS1035;CA2100 - false - Nimblesite.DataProvider.Migration.Cli + true + DataProviderMigrate true - migration-cli - CLI tool for database schema migrations + DataProviderMigrate + CLI tool for DataProvider database schema migrations from YAML (SQLite + PostgreSQL). + ChristianFindlay + MelbourneDeveloper + MIT + https://github.com/MelbourneDeveloper/DataProvider + git + dataprovider;migration;schema;yaml;sqlite;postgres;dotnet-tool diff --git a/Migration/Nimblesite.DataProvider.Migration.Cli/Program.cs b/Migration/DataProviderMigrate/Program.cs similarity index 92% rename from Migration/Nimblesite.DataProvider.Migration.Cli/Program.cs rename to Migration/DataProviderMigrate/Program.cs index 33db966c..61d5b203 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Cli/Program.cs +++ b/Migration/DataProviderMigrate/Program.cs @@ -5,7 +5,7 @@ using Nimblesite.DataProvider.Migration.SQLite; using Npgsql; -namespace Nimblesite.DataProvider.Migration.Cli; +namespace DataProviderMigrate; /// /// CLI tool for database schema operations: migrate from YAML and export C# schemas to YAML. @@ -68,7 +68,7 @@ private static int ExecuteMigration(MigrateParseResult.Success args) { Console.WriteLine( $""" - Nimblesite.DataProvider.Migration.Cli - Database Schema Tool + DataProviderMigrate - Database Schema Tool Schema: {args.SchemaPath} Output: {args.OutputPath} Provider: {args.Provider} @@ -175,7 +175,11 @@ private static int CreatePostgresDatabase(SchemaDefinition schema, string connec Console.WriteLine($" {error}"); } - return result.TablesCreated > 0 ? 0 : 1; + // Bug #11: ANY failed table is a hard failure for CI / make. + // Previously this returned 0 if at least one table succeeded, + // which let downstream targets (like codegen) run against an + // incomplete schema and produce confusing follow-on errors. + return 1; } catch (Exception ex) { @@ -198,7 +202,7 @@ private static int ExecuteExport(ExportParseResult.Success args) { Console.WriteLine( $""" - Nimblesite.DataProvider.Migration.Cli - Export C# Schema to YAML + DataProviderMigrate - Export C# Schema to YAML Assembly: {args.AssemblyPath} Type: {args.TypeName} Output: {args.OutputPath} @@ -283,17 +287,17 @@ private static int ShowTopLevelUsage() { Console.WriteLine( """ - Nimblesite.DataProvider.Migration.Cli - Database Schema Tool + DataProviderMigrate - Database Schema Tool Commands: migrate Create database from YAML schema definition export Export C# schema class to YAML file Usage: - migration-cli migrate --schema schema.yaml --output database.db [--provider sqlite|postgres] - migration-cli export --assembly assembly.dll --type Namespace.SchemaClass --output schema.yaml + DataProviderMigrate migrate --schema schema.yaml --output database.db [--provider sqlite|postgres] + DataProviderMigrate export --assembly assembly.dll --type Namespace.SchemaClass --output schema.yaml - Run 'migration-cli --help' for command-specific options. + Run 'DataProviderMigrate --help' for command-specific options. """ ); return 1; @@ -315,7 +319,7 @@ private static int ShowMigrateUsage() { Console.WriteLine( """ - Usage: migration-cli migrate [options] + Usage: DataProviderMigrate migrate [options] Options: --schema, -s Path to YAML schema definition file (required) @@ -323,8 +327,8 @@ private static int ShowMigrateUsage() --provider, -p Database provider: sqlite or postgres (default: sqlite) Examples: - migration-cli migrate --schema my-schema.yaml --output ./build.db --provider sqlite - migration-cli migrate --schema my-schema.yaml --output "Host=localhost;Database=mydb;Username=user;Password=pass" --provider postgres + DataProviderMigrate migrate --schema my-schema.yaml --output ./build.db --provider sqlite + DataProviderMigrate migrate --schema my-schema.yaml --output "Host=localhost;Database=mydb;Username=user;Password=pass" --provider postgres """ ); return 1; @@ -340,7 +344,7 @@ private static int ShowExportUsage() { Console.WriteLine( """ - Usage: migration-cli export [options] + Usage: DataProviderMigrate export [options] Options: --assembly, -a Path to compiled assembly containing schema class (required) @@ -348,7 +352,7 @@ private static int ShowExportUsage() --output, -o Path to output YAML file (required) Examples: - migration-cli export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml + DataProviderMigrate export -a bin/Debug/net10.0/MyProject.dll -t MyNamespace.MySchema -o schema.yaml Schema Class Requirements: - Static property 'Definition' returning SchemaDefinition, OR diff --git a/Migration/Nimblesite.DataProvider.Migration.Cli/example-schema.yaml b/Migration/DataProviderMigrate/example-schema.yaml similarity index 100% rename from Migration/Nimblesite.DataProvider.Migration.Cli/example-schema.yaml rename to Migration/DataProviderMigrate/example-schema.yaml diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs b/Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs index fb2909d0..8f5b927c 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Core/LqlDefaultTranslator.cs @@ -64,18 +64,18 @@ public static string ToSqlite(string lqlExpression) return normalized switch { // Timestamp functions - SQLite uses datetime/date/time functions - "NOW()" => "(datetime('now'))", - "CURRENT_TIMESTAMP()" => "CURRENT_TIMESTAMP", - "CURRENT_DATE()" => "(date('now'))", - "CURRENT_TIME()" => "(time('now'))", + "now()" => "(datetime('now'))", + "current_timestamp()" => "CURRENT_TIMESTAMP", + "current_date()" => "(date('now'))", + "current_time()" => "(time('now'))", // UUID generation - SQLite needs manual UUID v4 construction "gen_uuid()" => UuidV4SqliteExpression, "uuid()" => UuidV4SqliteExpression, // Boolean literals - SQLite uses 0/1 - "TRUE" => "1", - "FALSE" => "0", + "true" => "1", + "false" => "0", // Numeric literals (pass through) var n when int.TryParse(n, out _) => n, diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs index 68e2930c..db561085 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs @@ -73,13 +73,12 @@ public static string Generate(SchemaOperation operation) => AddCheckConstraintOperation op => GenerateAddCheckConstraint(op), AddUniqueConstraintOperation op => GenerateAddUniqueConstraint(op), DropTableOperation op => - $"DROP TABLE IF EXISTS \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" CASCADE", + $"DROP TABLE IF EXISTS \"{op.Schema}\".\"{op.TableName}\" CASCADE", DropColumnOperation op => - $"ALTER TABLE \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" DROP COLUMN \"{op.ColumnName}\"", - DropIndexOperation op => - $"DROP INDEX IF EXISTS \"{op.Schema.ToLowerInvariant()}\".\"{op.IndexName.ToLowerInvariant()}\"", + $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" DROP COLUMN \"{op.ColumnName}\"", + DropIndexOperation op => $"DROP INDEX IF EXISTS \"{op.Schema}\".\"{op.IndexName}\"", DropForeignKeyOperation op => - $"ALTER TABLE \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" DROP CONSTRAINT \"{op.ConstraintName.ToLowerInvariant()}\"", + $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" DROP CONSTRAINT \"{op.ConstraintName}\"", _ => throw new NotSupportedException( $"Unknown operation type: {operation.GetType().Name}" ), @@ -88,8 +87,8 @@ public static string Generate(SchemaOperation operation) => private static string GenerateCreateTable(TableDefinition table) { var sb = new StringBuilder(); - var tableName = table.Name.ToLowerInvariant(); - var schemaName = table.Schema.ToLowerInvariant(); + var tableName = table.Name; + var schemaName = table.Schema; sb.Append( CultureInfo.InvariantCulture, $"CREATE TABLE IF NOT EXISTS \"{schemaName}\".\"{tableName}\" (" @@ -105,29 +104,21 @@ private static string GenerateCreateTable(TableDefinition table) // Add primary key constraint if (table.PrimaryKey is not null && table.PrimaryKey.Columns.Count > 0) { - var pkName = (table.PrimaryKey.Name ?? $"PK_{table.Name}").ToLowerInvariant(); - var pkCols = string.Join( - ", ", - table.PrimaryKey.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"") - ); + var pkName = (table.PrimaryKey.Name ?? $"PK_{table.Name}"); + var pkCols = string.Join(", ", table.PrimaryKey.Columns.Select(c => $"\"{c}\"")); columnDefs.Add($"CONSTRAINT \"{pkName}\" PRIMARY KEY ({pkCols})"); } // Add foreign key constraints foreach (var fk in table.ForeignKeys) { - var fkName = ( - fk.Name ?? $"FK_{table.Name}_{string.Join("_", fk.Columns)}" - ).ToLowerInvariant(); - var fkCols = string.Join(", ", fk.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"")); - var refCols = string.Join( - ", ", - fk.ReferencedColumns.Select(c => $"\"{c.ToLowerInvariant()}\"") - ); + var fkName = (fk.Name ?? $"FK_{table.Name}_{string.Join("_", fk.Columns)}"); + var fkCols = string.Join(", ", fk.Columns.Select(c => $"\"{c}\"")); + var refCols = string.Join(", ", fk.ReferencedColumns.Select(c => $"\"{c}\"")); var onDelete = ForeignKeyActionToSql(fk.OnDelete); var onUpdate = ForeignKeyActionToSql(fk.OnUpdate); - var refTable = fk.ReferencedTable.ToLowerInvariant(); - var refSchema = fk.ReferencedSchema.ToLowerInvariant(); + var refTable = fk.ReferencedTable; + var refSchema = fk.ReferencedSchema; columnDefs.Add( $"CONSTRAINT \"{fkName}\" FOREIGN KEY ({fkCols}) REFERENCES \"{refSchema}\".\"{refTable}\" ({refCols}) ON DELETE {onDelete} ON UPDATE {onUpdate}" @@ -137,17 +128,20 @@ private static string GenerateCreateTable(TableDefinition table) // Add unique constraints foreach (var uc in table.UniqueConstraints) { - var ucName = ( - uc.Name ?? $"UQ_{table.Name}_{string.Join("_", uc.Columns)}" - ).ToLowerInvariant(); - var ucCols = string.Join(", ", uc.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"")); + var ucName = (uc.Name ?? $"UQ_{table.Name}_{string.Join("_", uc.Columns)}"); + var ucCols = string.Join(", ", uc.Columns.Select(c => $"\"{c}\"")); columnDefs.Add($"CONSTRAINT \"{ucName}\" UNIQUE ({ucCols})"); } - // Add check constraints + // Add check constraints. Auto-quote bare identifiers in the + // expression that match a column name on this table, so a + // mixed-case column like "Status" survives the round-trip + // (Postgres folds unquoted identifiers to lower case otherwise). + var columnNames = table.Columns.Select(c => c.Name).ToHashSet(StringComparer.Ordinal); foreach (var cc in table.CheckConstraints) { - columnDefs.Add($"CONSTRAINT \"{cc.Name.ToLowerInvariant()}\" CHECK ({cc.Expression})"); + var quotedExpr = QuoteIdentifiersInExpression(cc.Expression, columnNames); + columnDefs.Add($"CONSTRAINT \"{cc.Name}\" CHECK ({quotedExpr})"); } sb.Append(string.Join(", ", columnDefs)); @@ -162,9 +156,9 @@ private static string GenerateCreateTable(TableDefinition table) var indexItems = index.Expressions.Count > 0 ? string.Join(", ", index.Expressions) - : string.Join(", ", index.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"")); + : string.Join(", ", index.Columns.Select(c => $"\"{c}\"")); var filter = index.Filter is not null ? $" WHERE {index.Filter}" : ""; - var indexName = index.Name.ToLowerInvariant(); + var indexName = index.Name; sb.Append( CultureInfo.InvariantCulture, $"CREATE {unique}INDEX IF NOT EXISTS \"{indexName}\" ON \"{schemaName}\".\"{tableName}\" ({indexItems}){filter}" @@ -177,7 +171,7 @@ private static string GenerateCreateTable(TableDefinition table) private static string GenerateColumnDef(ColumnDefinition column) { var sb = new StringBuilder(); - sb.Append(CultureInfo.InvariantCulture, $"\"{column.Name.ToLowerInvariant()}\" "); + sb.Append(CultureInfo.InvariantCulture, $"\"{column.Name}\" "); // Handle identity columns if (column.IsIdentity) @@ -215,7 +209,11 @@ private static string GenerateColumnDef(ColumnDefinition column) if (column.CheckConstraint is not null) { - sb.Append(CultureInfo.InvariantCulture, $" CHECK ({column.CheckConstraint})"); + // Auto-quote the column's own name in its CHECK expression so + // mixed-case columns survive without manual quoting in YAML. + var ownNames = new HashSet(StringComparer.Ordinal) { column.Name }; + var quotedExpr = QuoteIdentifiersInExpression(column.CheckConstraint, ownNames); + sb.Append(CultureInfo.InvariantCulture, $" CHECK ({quotedExpr})"); } return sb.ToString(); @@ -224,7 +222,7 @@ private static string GenerateColumnDef(ColumnDefinition column) private static string GenerateAddColumn(AddColumnOperation op) { var colDef = GenerateColumnDef(op.Column); - return $"ALTER TABLE \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" ADD COLUMN {colDef}"; + return $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" ADD COLUMN {colDef}"; } private static string GenerateCreateIndex(CreateIndexOperation op) @@ -234,40 +232,33 @@ private static string GenerateCreateIndex(CreateIndexOperation op) var indexItems = op.Index.Expressions.Count > 0 ? string.Join(", ", op.Index.Expressions) - : string.Join(", ", op.Index.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"")); + : string.Join(", ", op.Index.Columns.Select(c => $"\"{c}\"")); var filter = op.Index.Filter is not null ? $" WHERE {op.Index.Filter}" : ""; - return $"CREATE {unique}INDEX IF NOT EXISTS \"{op.Index.Name.ToLowerInvariant()}\" ON \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" ({indexItems}){filter}"; + return $"CREATE {unique}INDEX IF NOT EXISTS \"{op.Index.Name}\" ON \"{op.Schema}\".\"{op.TableName}\" ({indexItems}){filter}"; } private static string GenerateAddForeignKey(AddForeignKeyOperation op) { var fk = op.ForeignKey; - var fkName = ( - fk.Name ?? $"FK_{op.TableName}_{string.Join("_", fk.Columns)}" - ).ToLowerInvariant(); - var fkCols = string.Join(", ", fk.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"")); - var refCols = string.Join( - ", ", - fk.ReferencedColumns.Select(c => $"\"{c.ToLowerInvariant()}\"") - ); + var fkName = (fk.Name ?? $"FK_{op.TableName}_{string.Join("_", fk.Columns)}"); + var fkCols = string.Join(", ", fk.Columns.Select(c => $"\"{c}\"")); + var refCols = string.Join(", ", fk.ReferencedColumns.Select(c => $"\"{c}\"")); var onDelete = ForeignKeyActionToSql(fk.OnDelete); var onUpdate = ForeignKeyActionToSql(fk.OnUpdate); - return $"ALTER TABLE \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" ADD CONSTRAINT \"{fkName}\" FOREIGN KEY ({fkCols}) REFERENCES \"{fk.ReferencedSchema.ToLowerInvariant()}\".\"{fk.ReferencedTable.ToLowerInvariant()}\" ({refCols}) ON DELETE {onDelete} ON UPDATE {onUpdate}"; + return $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" ADD CONSTRAINT \"{fkName}\" FOREIGN KEY ({fkCols}) REFERENCES \"{fk.ReferencedSchema}\".\"{fk.ReferencedTable}\" ({refCols}) ON DELETE {onDelete} ON UPDATE {onUpdate}"; } private static string GenerateAddCheckConstraint(AddCheckConstraintOperation op) => - $"ALTER TABLE \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" ADD CONSTRAINT \"{op.CheckConstraint.Name.ToLowerInvariant()}\" CHECK ({op.CheckConstraint.Expression})"; + $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" ADD CONSTRAINT \"{op.CheckConstraint.Name}\" CHECK ({op.CheckConstraint.Expression})"; private static string GenerateAddUniqueConstraint(AddUniqueConstraintOperation op) { var uc = op.UniqueConstraint; - var ucName = ( - uc.Name ?? $"UQ_{op.TableName}_{string.Join("_", uc.Columns)}" - ).ToLowerInvariant(); - var ucCols = string.Join(", ", uc.Columns.Select(c => $"\"{c.ToLowerInvariant()}\"")); - return $"ALTER TABLE \"{op.Schema.ToLowerInvariant()}\".\"{op.TableName.ToLowerInvariant()}\" ADD CONSTRAINT \"{ucName}\" UNIQUE ({ucCols})"; + var ucName = (uc.Name ?? $"UQ_{op.TableName}_{string.Join("_", uc.Columns)}"); + var ucCols = string.Join(", ", uc.Columns.Select(c => $"\"{c}\"")); + return $"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" ADD CONSTRAINT \"{ucName}\" UNIQUE ({ucCols})"; } /// @@ -331,4 +322,112 @@ private static string ForeignKeyActionToSql(ForeignKeyAction action) => ForeignKeyAction.Restrict => "RESTRICT", _ => "NO ACTION", }; + + /// + /// Wraps any bare identifier in that exactly + /// matches a name in with double-quotes, + /// so PostgreSQL preserves case (unquoted identifiers are folded to + /// lower case). Skips identifiers that are already quoted, inside + /// single-quoted string literals, or that are prefixed by a `.` (which + /// indicates they're already qualified, e.g. table.column). + /// + /// + /// Hand-rolled tokenizer (not regex) so we can correctly skip string + /// literals and existing quoted identifiers. + /// + internal static string QuoteIdentifiersInExpression(string expression, ISet columnNames) + { + if (string.IsNullOrEmpty(expression) || columnNames.Count == 0) + { + return expression; + } + + var sb = new StringBuilder(expression.Length + 16); + var i = 0; + while (i < expression.Length) + { + var c = expression[i]; + + // Single-quoted string literal — copy verbatim until closing quote. + if (c == '\'') + { + sb.Append(c); + i++; + while (i < expression.Length) + { + sb.Append(expression[i]); + if (expression[i] == '\'') + { + // Postgres '' is an escaped single quote inside a literal. + if (i + 1 < expression.Length && expression[i + 1] == '\'') + { + sb.Append(expression[i + 1]); + i += 2; + continue; + } + i++; + break; + } + i++; + } + continue; + } + + // Double-quoted identifier — copy verbatim, already quoted. + if (c == '"') + { + sb.Append(c); + i++; + while (i < expression.Length) + { + sb.Append(expression[i]); + if (expression[i] == '"') + { + i++; + break; + } + i++; + } + continue; + } + + // Identifier candidate (letter or underscore start, then [a-zA-Z0-9_]). + if (char.IsLetter(c) || c == '_') + { + var start = i; + i++; + while ( + i < expression.Length + && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_') + ) + { + i++; + } + var word = expression[start..i]; + + // Don't quote if the previous non-whitespace char is `.` — + // it's already a qualified reference like `tbl.col`. + var prevIdx = start - 1; + while (prevIdx >= 0 && char.IsWhiteSpace(expression[prevIdx])) + { + prevIdx--; + } + var qualified = prevIdx >= 0 && expression[prevIdx] == '.'; + + if (!qualified && columnNames.Contains(word)) + { + sb.Append('"').Append(word).Append('"'); + } + else + { + sb.Append(word); + } + continue; + } + + sb.Append(c); + i++; + } + return sb.ToString(); + } } diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs index 2849b990..3c05eaf9 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/GlobalUsings.cs @@ -4,8 +4,8 @@ global using Nimblesite.DataProvider.Migration.Core; global using Nimblesite.DataProvider.Migration.Postgres; global using Nimblesite.DataProvider.Migration.SQLite; +global using Nimblesite.TestSupport; global using Npgsql; -global using Testcontainers.PostgreSql; global using Xunit; global using MigrationApplyResultError = Outcome.Result< bool, diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs index 7a1a8a8b..ef572b00 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/LqlDefaultsTests.cs @@ -7,10 +7,11 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// 1. The same schema definition works on both platforms /// 2. Default values are properly applied when inserting without explicit values /// 3. The resulting data is semantically equivalent across platforms +/// Uses the shared postgres container; each test gets its own database. /// -public sealed class LqlDefaultsTests : IAsyncLifetime +[Collection(PostgresTestSuite.Name)] +public sealed class LqlDefaultsTests(PostgresContainerFixture fixture) : IAsyncLifetime { - private PostgreSqlContainer _postgres = null!; private NpgsqlConnection _pgConnection = null!; private SqliteConnection _sqliteConnection = null!; private string _sqliteDbPath = null!; @@ -18,18 +19,7 @@ public sealed class LqlDefaultsTests : IAsyncLifetime public async Task InitializeAsync() { - // Setup PostgreSQL - _postgres = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .WithDatabase("lql_test") - .WithUsername("test") - .WithPassword("test") - .Build(); - - await _postgres.StartAsync().ConfigureAwait(false); - - _pgConnection = new NpgsqlConnection(_postgres.GetConnectionString()); - await _pgConnection.OpenAsync().ConfigureAwait(false); + _pgConnection = await fixture.CreateDatabaseAsync("lql_test").ConfigureAwait(false); // Setup SQLite with file-based database _sqliteDbPath = Path.Combine(Path.GetTempPath(), $"lql_defaults_{Guid.NewGuid():N}.db"); @@ -40,7 +30,6 @@ public async Task InitializeAsync() public async Task DisposeAsync() { await _pgConnection.DisposeAsync().ConfigureAwait(false); - await _postgres.DisposeAsync().ConfigureAwait(false); _sqliteConnection.Dispose(); if (File.Exists(_sqliteDbPath)) { diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs index 0e2fac03..714676ea 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/MigrateSchemaTests.cs @@ -3,36 +3,28 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// /// Tests for PostgresDdlGenerator.MigrateSchema() method. /// Covers: drop schema, fresh migration, partial upgrade scenarios. +/// Uses the shared postgres container; each test gets its own database. /// +[Collection(PostgresTestSuite.Name)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed via IAsyncLifetime.DisposeAsync" )] -public sealed class MigrateSchemaTests : IAsyncLifetime +public sealed class MigrateSchemaTests(PostgresContainerFixture fixture) : IAsyncLifetime { - private PostgreSqlContainer _postgres = null!; private NpgsqlConnection _connection = null!; public async Task InitializeAsync() { - _postgres = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .WithDatabase("migrate_schema_test") - .WithUsername("test") - .WithPassword("test") - .Build(); - - await _postgres.StartAsync().ConfigureAwait(false); - - _connection = new NpgsqlConnection(_postgres.GetConnectionString()); - await _connection.OpenAsync().ConfigureAwait(false); + _connection = await fixture + .CreateDatabaseAsync("migrate_schema_test") + .ConfigureAwait(false); } public async Task DisposeAsync() { await _connection.DisposeAsync().ConfigureAwait(false); - await _postgres.DisposeAsync().ConfigureAwait(false); } /// diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj index 051d08cf..5f0576ed 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj @@ -29,4 +29,15 @@ + + + + + diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs index c9fc80de..4a4dc6fc 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresEdgeCaseTests.cs @@ -3,37 +3,27 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// /// PostgreSQL-specific edge case tests for migrations. /// Tests PostgreSQL-specific types, behaviors, and edge cases. +/// Uses the shared postgres container; each test gets its own database. /// +[Collection(PostgresTestSuite.Name)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed via IAsyncLifetime.DisposeAsync" )] -public sealed class PostgresEdgeCaseTests : IAsyncLifetime +public sealed class PostgresEdgeCaseTests(PostgresContainerFixture fixture) : IAsyncLifetime { - private PostgreSqlContainer _postgres = null!; private NpgsqlConnection _connection = null!; private readonly ILogger _logger = NullLogger.Instance; public async Task InitializeAsync() { - _postgres = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .WithDatabase("edge_test") - .WithUsername("test") - .WithPassword("test") - .Build(); - - await _postgres.StartAsync().ConfigureAwait(false); - - _connection = new NpgsqlConnection(_postgres.GetConnectionString()); - await _connection.OpenAsync().ConfigureAwait(false); + _connection = await fixture.CreateDatabaseAsync("edge_test").ConfigureAwait(false); } public async Task DisposeAsync() { await _connection.DisposeAsync().ConfigureAwait(false); - await _postgres.DisposeAsync().ConfigureAwait(false); } #region Nullable Column Edge Cases diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs index a72528be..00bf2a02 100644 --- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs +++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs @@ -1,38 +1,28 @@ namespace Nimblesite.DataProvider.Migration.Tests; /// -/// E2E tests for PostgreSQL migrations using Testcontainers. +/// E2E tests for PostgreSQL migrations using a shared Testcontainers postgres. +/// Each test gets its own database within the shared container. /// +[Collection(PostgresTestSuite.Name)] [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed via IAsyncLifetime.DisposeAsync" )] -public sealed class PostgresMigrationTests : IAsyncLifetime +public sealed class PostgresMigrationTests(PostgresContainerFixture fixture) : IAsyncLifetime { - private PostgreSqlContainer _postgres = null!; private NpgsqlConnection _connection = null!; private readonly ILogger _logger = NullLogger.Instance; public async Task InitializeAsync() { - _postgres = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .WithDatabase("migration_test") - .WithUsername("test") - .WithPassword("test") - .Build(); - - await _postgres.StartAsync().ConfigureAwait(false); - - _connection = new NpgsqlConnection(_postgres.GetConnectionString()); - await _connection.OpenAsync().ConfigureAwait(false); + _connection = await fixture.CreateDatabaseAsync("migration_test").ConfigureAwait(false); } public async Task DisposeAsync() { await _connection.DisposeAsync().ConfigureAwait(false); - await _postgres.DisposeAsync().ConfigureAwait(false); } [Fact] diff --git a/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj b/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj new file mode 100644 index 00000000..d075849e --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Api/Nimblesite.Reporting.Api.csproj @@ -0,0 +1,27 @@ + + + Exe + false + CA1515;CA2100 + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/Reporting/Nimblesite.Reporting.Api/Program.cs b/Reporting/Nimblesite.Reporting.Api/Program.cs new file mode 100644 index 00000000..dcc98373 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Api/Program.cs @@ -0,0 +1,226 @@ +using System.Collections.Immutable; +using System.Data; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Data.Sqlite; +using Nimblesite.Lql.Core; +using Nimblesite.Lql.SQLite; +using Nimblesite.Reporting.Engine; +using Nimblesite.Sql.Model; +using ConnError = Outcome.Result.Error< + System.Data.IDbConnection, + Nimblesite.Sql.Model.SqlError +>; +using ConnOk = Outcome.Result.Ok< + System.Data.IDbConnection, + Nimblesite.Sql.Model.SqlError +>; +using ConnResult = Outcome.Result; +using EngineError = Outcome.Result< + Nimblesite.Reporting.Engine.ReportExecutionResult, + Nimblesite.Sql.Model.SqlError +>.Error; +using EngineOk = Outcome.Result< + Nimblesite.Reporting.Engine.ReportExecutionResult, + Nimblesite.Sql.Model.SqlError +>.Ok; +using LoadDirError = Outcome.Result< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>.Error< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>; +using LoadDirOk = Outcome.Result< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>.Ok< + System.Collections.Immutable.ImmutableArray, + Nimblesite.Sql.Model.SqlError +>; +using LqlParseError = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Error; +using LqlParseOk = Outcome.Result< + Nimblesite.Lql.Core.LqlStatement, + Nimblesite.Sql.Model.SqlError +>.Ok; +using TranspileError = Outcome.Result.Error< + string, + Nimblesite.Sql.Model.SqlError +>; +using TranspileResult = Outcome.Result; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(options => +{ + options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; +}); + +builder.Services.AddCors(options => +{ + options.AddPolicy( + "ReportViewer", + policy => + { + policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); + } + ); +}); + +var app = builder.Build(); + +app.UseCors("ReportViewer"); + +// Serve static files for the React renderer +app.UseDefaultFiles(); +app.UseStaticFiles(); + +// Load report definitions from the Reports directory +var reportsDir = + app.Configuration["ReportsDirectory"] ?? Path.Combine(AppContext.BaseDirectory, "Reports"); + +var loadResult = ReportConfigLoader.LoadFromDirectory( + directoryPath: reportsDir, + logger: app.Logger +); + +var reports = loadResult switch +{ + LoadDirOk ok => ok.Value.ToImmutableDictionary(r => r.Id), + LoadDirError => ImmutableDictionary.Empty, +}; + +app.Logger.LogInformation("Loaded {Count} report definitions", reports.Count); + +// Connection registry from config +var connectionStrings = app + .Configuration.GetSection("ConnectionStrings") + .GetChildren() + .ToImmutableDictionary(c => c.Key, c => c.Value ?? ""); + +ConnResult CreateConnection(string connectionRef) +{ + if (!connectionStrings.TryGetValue(connectionRef, out var connStr)) + { + return new ConnError(SqlError.Create($"Connection '{connectionRef}' not found")); + } + + try + { + var connection = new SqliteConnection(connStr); + connection.Open(); + return new ConnOk(connection); + } + catch (Exception ex) + { + return new ConnError(SqlError.FromException(ex)); + } +} + +TranspileResult TranspileLql(string lqlCode) +{ + return LqlStatementConverter.ToStatement(lqlCode) switch + { + LqlParseError stmtErr => new TranspileError(stmtErr.Value), + LqlParseOk stmtOk => stmtOk.Value.ToSQLite(), + }; +} + +static IResult FormatExportResult( + ReportExecutionResult executionResult, + ReportDefinition report, + string? datasource, + string? format +) +{ + var targetDs = datasource ?? report.DataSources.FirstOrDefault()?.Id; + if (targetDs is null || !executionResult.DataSources.TryGetValue(targetDs, out var dsResult)) + { + return Results.NotFound(new { Error = $"Data source '{targetDs}' not found" }); + } + + if (format == "csv") + { + var csv = FormatAdapter.ToCsv(dsResult); + return Results.Text(csv, contentType: "text/csv"); + } + + return Results.Ok(dsResult); +} + +// --- API Endpoints --- + +var reportGroup = app.MapGroup("/api/reports").WithTags("Reports"); + +reportGroup.MapGet( + "/", + () => Results.Ok(reports.Values.Select(ReportMetadataMapper.ToMetadata).ToImmutableArray()) +); + +reportGroup.MapGet( + "/{id}", + (string id) => + reports.TryGetValue(id, out var report) + ? Results.Ok(ReportMetadataMapper.ToMetadata(report)) + : Results.NotFound(new { Error = $"Report '{id}' not found" }) +); + +reportGroup.MapPost( + "/{id}/execute", + (string id, ReportExecuteRequest request) => + { + if (!reports.TryGetValue(id, out var report)) + { + return Results.NotFound(new { Error = $"Report '{id}' not found" }); + } + + return ReportEngine.Execute( + report: report, + parameters: request.Parameters, + connectionFactory: CreateConnection, + lqlTranspiler: TranspileLql, + logger: app.Logger + ) switch + { + EngineOk ok => Results.Ok(ok.Value), + EngineError err => Results.Problem(err.Value.Message), + }; + } +); + +reportGroup.MapGet( + "/{id}/export", + (string id, string? datasource, string? format) => + { + if (!reports.TryGetValue(id, out var report)) + { + return Results.NotFound(new { Error = $"Report '{id}' not found" }); + } + + return ReportEngine.Execute( + report: report, + parameters: ImmutableDictionary.Empty, + connectionFactory: CreateConnection, + lqlTranspiler: TranspileLql, + logger: app.Logger + ) switch + { + EngineError err => Results.Problem(err.Value.Message), + EngineOk ok => FormatExportResult( + executionResult: ok.Value, + report: report, + datasource: datasource, + format: format + ), + }; + } +); + +app.Run(); + +/// +/// Partial class to allow test access via WebApplicationFactory. +/// +public partial class Program { } diff --git a/Reporting/Nimblesite.Reporting.Api/Reports/sample-inventory.report.json b/Reporting/Nimblesite.Reporting.Api/Reports/sample-inventory.report.json new file mode 100644 index 00000000..1378256c --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Api/Reports/sample-inventory.report.json @@ -0,0 +1,126 @@ +{ + "id": "sample-inventory", + "title": "Product Inventory Report", + "parameters": [ + { "name": "category", "type": "String", "label": "Category", "required": false, "default": "all" } + ], + "dataSources": [ + { + "id": "products", + "type": "Sql", + "connectionRef": "reporting-db", + "query": "SELECT id, name, category, price, stock FROM products ORDER BY name", + "parameters": [] + }, + { + "id": "categorySummary", + "type": "Sql", + "connectionRef": "reporting-db", + "query": "SELECT category, COUNT(*) as product_count, SUM(stock) as total_stock, ROUND(AVG(price), 2) as avg_price FROM products GROUP BY category ORDER BY product_count DESC", + "parameters": [] + }, + { + "id": "totalValue", + "type": "Sql", + "connectionRef": "reporting-db", + "query": "SELECT COUNT(*) as total_products, SUM(stock) as total_stock, ROUND(SUM(price * stock), 2) as total_value FROM products", + "parameters": [] + } + ], + "layout": { + "columns": 12, + "rows": [ + { + "cells": [ + { + "colSpan": 4, + "component": { + "type": "Metric", + "dataSource": "totalValue", + "title": "Total Products", + "value": "total_products", + "format": "number" + } + }, + { + "colSpan": 4, + "component": { + "type": "Metric", + "dataSource": "totalValue", + "title": "Total Stock", + "value": "total_stock", + "format": "number" + } + }, + { + "colSpan": 4, + "component": { + "type": "Metric", + "dataSource": "totalValue", + "title": "Total Inventory Value", + "value": "total_value", + "format": "currency" + } + } + ] + }, + { + "cells": [ + { + "colSpan": 6, + "component": { + "type": "Chart", + "chartType": "Bar", + "dataSource": "categorySummary", + "title": "Products by Category", + "xAxis": { "field": "category" }, + "yAxis": { "field": "product_count", "label": "Count" } + } + }, + { + "colSpan": 6, + "component": { + "type": "Chart", + "chartType": "Bar", + "dataSource": "categorySummary", + "title": "Average Price by Category", + "xAxis": { "field": "category" }, + "yAxis": { "field": "avg_price", "label": "Avg Price ($)" } + } + } + ] + }, + { + "cells": [ + { + "colSpan": 12, + "component": { + "type": "Table", + "dataSource": "products", + "title": "Product Details", + "columns": [ + { "field": "name", "header": "Product" }, + { "field": "category", "header": "Category" }, + { "field": "price", "header": "Price" }, + { "field": "stock", "header": "Stock" } + ], + "pageSize": 10 + } + } + ] + }, + { + "cells": [ + { + "colSpan": 12, + "component": { + "type": "Text", + "content": "Report generated from the sample inventory database. Filter by category to narrow results.", + "style": "caption" + } + } + ] + } + ] + } +} diff --git a/Reporting/Nimblesite.Reporting.Engine/ConnectionRegistry.cs b/Reporting/Nimblesite.Reporting.Engine/ConnectionRegistry.cs new file mode 100644 index 00000000..11d63f59 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/ConnectionRegistry.cs @@ -0,0 +1,33 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Nimblesite.Reporting.Engine; + +/// +/// Server-side connection configuration. Never exposed to clients. +/// +public sealed record ConnectionRegistry( + ImmutableDictionary Connections, + ImmutableDictionary ApiEndpoints +); + +/// +/// Configuration for a database connection. +/// +public sealed record ConnectionConfig(DatabaseProvider Provider, string ConnectionString); + +/// +/// Supported database providers. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DatabaseProvider +{ + /// SQLite database. + Sqlite, + + /// PostgreSQL database. + Postgres, + + /// SQL Server database. + SqlServer, +} diff --git a/Reporting/Nimblesite.Reporting.Engine/DataSourceDefinition.cs b/Reporting/Nimblesite.Reporting.Engine/DataSourceDefinition.cs new file mode 100644 index 00000000..dd3b769a --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/DataSourceDefinition.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Nimblesite.Reporting.Engine; + +/// +/// Defines a data source within a report (SQL, LQL, or API). +/// +public sealed record DataSourceDefinition( + string Id, + DataSourceType Type, + string? ConnectionRef, + string? Query, + Uri? Url, + string? Method, + ImmutableDictionary? Headers, + ImmutableArray Parameters +); + +/// +/// The type of data source. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DataSourceType +{ + /// Raw SQL query executed against a database connection. + Sql, + + /// LQL expression transpiled to SQL then executed. + Lql, + + /// REST API call returning JSON. + Api, +} diff --git a/Reporting/Nimblesite.Reporting.Engine/DataSourceResult.cs b/Reporting/Nimblesite.Reporting.Engine/DataSourceResult.cs new file mode 100644 index 00000000..e7c2f3e4 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/DataSourceResult.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; + +namespace Nimblesite.Reporting.Engine; + +/// +/// Result of executing a data source query. +/// +public sealed record DataSourceResult( + ImmutableArray ColumnNames, + ImmutableArray> Rows, + int TotalRows +); + +/// +/// Result of executing an entire report. +/// +public sealed record ReportExecutionResult( + string ReportId, + DateTimeOffset ExecutedAt, + ImmutableDictionary DataSources +); + +/// +/// Request to execute a report with parameter values. +/// +public sealed record ReportExecuteRequest( + ImmutableDictionary Parameters, + string Format +); diff --git a/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs b/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs new file mode 100644 index 00000000..9afe3df8 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/FormatAdapter.cs @@ -0,0 +1,63 @@ +using System.Text.Json; + +namespace Nimblesite.Reporting.Engine; + +/// +/// Serializes report execution results to various output formats. +/// +public static class FormatAdapter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + /// + /// Serializes a report execution result to JSON. + /// + /// The execution result to serialize. + /// JSON string representation of the result. + public static string ToJson(ReportExecutionResult result) => + JsonSerializer.Serialize(value: result, options: JsonOptions); + + /// + /// Serializes a report execution result to CSV for a specific data source. + /// + /// The data source result to serialize. + /// CSV string representation. + public static string ToCsv(DataSourceResult result) + { + var lines = new List(capacity: result.TotalRows + 1) + { + string.Join(",", result.ColumnNames), + }; + + foreach (var row in result.Rows) + { + lines.Add(string.Join(",", row.Select(EscapeCsvValue))); + } + + return string.Join("\n", lines); + } + + private static string EscapeCsvValue(object? value) + { + if (value is null) + { + return ""; + } + + var str = value.ToString() ?? ""; + if ( + str.Contains(',', StringComparison.Ordinal) + || str.Contains('"', StringComparison.Ordinal) + || str.Contains('\n', StringComparison.Ordinal) + ) + { + return $"\"{str.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; + } + + return str; + } +} diff --git a/Reporting/Nimblesite.Reporting.Engine/LayoutDefinition.cs b/Reporting/Nimblesite.Reporting.Engine/LayoutDefinition.cs new file mode 100644 index 00000000..20bd72c3 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/LayoutDefinition.cs @@ -0,0 +1,91 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Nimblesite.Reporting.Engine; + +/// +/// Grid-based layout for the report. +/// +public sealed record LayoutDefinition(int Columns, ImmutableArray Rows); + +/// +/// A row of cells in the report grid. +/// +public sealed record LayoutRow(ImmutableArray Cells); + +/// +/// A cell in the report grid containing a component. +/// +public sealed record LayoutCell( + int ColSpan, + ComponentDefinition Component, + string? CssClass = null +); + +/// +/// A visual component that renders data. +/// +public sealed record ComponentDefinition( + ComponentType Type, + string? DataSource, + string? Title, + string? Value, + string? Format, + ChartType? ChartType, + AxisDefinition? XAxis, + AxisDefinition? YAxis, + ImmutableArray? Columns, + int? PageSize, + string? Content, + string? Style, + string? CssClass = null, + ImmutableDictionary? CssStyle = null +); + +/// +/// Supported component types. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ComponentType +{ + /// Single KPI metric value. + Metric, + + /// Chart visualization. + Chart, + + /// Data table with rows and columns. + Table, + + /// Static or templated text block. + Text, +} + +/// +/// Supported chart types. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ChartType +{ + /// Bar chart. + Bar, + + /// Line chart. + Line, + + /// Pie chart. + Pie, + + /// Area chart. + Area, +} + +/// +/// Axis definition for charts. +/// +public sealed record AxisDefinition(string Field, string? Label, string? Format); + +/// +/// Column definition for table components. +/// +public sealed record ColumnDefinition(string Field, string Header); diff --git a/Reporting/Nimblesite.Reporting.Engine/Nimblesite.Reporting.Engine.csproj b/Reporting/Nimblesite.Reporting.Engine/Nimblesite.Reporting.Engine.csproj new file mode 100644 index 00000000..2426d130 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/Nimblesite.Reporting.Engine.csproj @@ -0,0 +1,21 @@ + + + Library + Nimblesite.Reporting.Engine + Embeddable reporting engine for Nimblesite.DataProvider.Core: JSON-defined report configs, SQL/LQL data source execution, and CSV/JSON format adapters. + CA1515;CA1720 + false + + + + + + + + + + + + + + diff --git a/Reporting/Nimblesite.Reporting.Engine/ReportConfigLoader.cs b/Reporting/Nimblesite.Reporting.Engine/ReportConfigLoader.cs new file mode 100644 index 00000000..47f1f89c --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/ReportConfigLoader.cs @@ -0,0 +1,137 @@ +using System.Collections.Immutable; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.Reporting.Engine; + +using LoadError = Result.Error; +using LoadOk = Result.Ok; +using LoadResult = Result; + +/// +/// Loads report definitions from JSON files. +/// +public static class ReportConfigLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Loads a report definition from a JSON file path. + /// + /// Absolute path to the report JSON file. + /// Logger for diagnostics. + /// Result containing the parsed report definition or an error. + public static LoadResult LoadFromFile(string filePath, ILogger logger) + { + logger.LogInformation("Loading report from {FilePath}", filePath); + + try + { + var json = File.ReadAllText(filePath); + return LoadFromJson(json: json, logger: logger); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to read report file {FilePath}", filePath); + return new LoadError(SqlError.FromException(ex)); + } + } + + /// + /// Loads a report definition from a JSON string. + /// + /// JSON string containing the report definition. + /// Logger for diagnostics. + /// Result containing the parsed report definition or an error. + public static LoadResult LoadFromJson(string json, ILogger logger) + { + try + { + var report = JsonSerializer.Deserialize(json, JsonOptions); + if (report is null) + { + return new LoadError( + SqlError.Create("Failed to deserialize report: result was null") + ); + } + + logger.LogInformation( + "Loaded report {ReportId} with {DsCount} data sources", + report.Id, + report.DataSources.Length + ); + + return new LoadOk(report); + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to parse report JSON"); + return new LoadError(SqlError.FromException(ex)); + } + } + + /// + /// Loads all report definitions from a directory. + /// + /// Path to the directory containing report JSON files. + /// Logger for diagnostics. + /// Result containing all loaded reports or an error. + public static Result, SqlError> LoadFromDirectory( + string directoryPath, + ILogger logger + ) + { + logger.LogInformation("Loading reports from directory {DirectoryPath}", directoryPath); + + try + { + if (!Directory.Exists(directoryPath)) + { + return new Result, SqlError>.Error< + ImmutableArray, + SqlError + >(SqlError.Create($"Report directory not found: {directoryPath}")); + } + + var reports = ImmutableArray.CreateBuilder(); + + foreach (var file in Directory.GetFiles(directoryPath, "*.report.json")) + { + switch (LoadFromFile(filePath: file, logger: logger)) + { + case LoadOk ok: + reports.Add(ok.Value); + break; + case LoadError err: + logger.LogWarning( + "Skipping invalid report file {FilePath}: {Error}", + file, + err.Value.Message + ); + break; + } + } + + logger.LogInformation("Loaded {Count} reports from directory", reports.Count); + + return new Result, SqlError>.Ok< + ImmutableArray, + SqlError + >(reports.ToImmutable()); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load reports from directory"); + return new Result, SqlError>.Error< + ImmutableArray, + SqlError + >(SqlError.FromException(ex)); + } + } +} diff --git a/Reporting/Nimblesite.Reporting.Engine/ReportDefinition.cs b/Reporting/Nimblesite.Reporting.Engine/ReportDefinition.cs new file mode 100644 index 00000000..14415e52 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/ReportDefinition.cs @@ -0,0 +1,49 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +namespace Nimblesite.Reporting.Engine; + +/// +/// A complete report definition including data sources and layout. +/// +public sealed record ReportDefinition( + string Id, + string Title, + ImmutableArray Parameters, + ImmutableArray DataSources, + LayoutDefinition Layout, + string? CustomCss = null +); + +/// +/// A parameter that can be passed to data source queries. +/// +public sealed record ReportParameter( + string Name, + ParameterType Type, + string Label, + bool Required, + string? Default +); + +/// +/// Supported parameter types. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ParameterType +{ + /// String parameter. + String, + + /// Date parameter. + Date, + + /// Integer parameter. + Integer, + + /// Decimal parameter. + Decimal, + + /// Boolean parameter. + Boolean, +} diff --git a/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs b/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs new file mode 100644 index 00000000..b30190cd --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/ReportEngine.cs @@ -0,0 +1,271 @@ +using System.Collections.Immutable; +using System.Data; +using Microsoft.Extensions.Logging; +using Nimblesite.Sql.Model; +using Outcome; + +namespace Nimblesite.Reporting.Engine; + +using ConnError = Result.Error; +using ConnOk = Result.Ok; +using DsError = Result.Error; +using DsOk = Result.Ok; +using DsResult = Result; +using EngineError = Result.Error; +using EngineOk = Result.Ok; +using EngineResult = Result; +using TranspileError = Result.Error; +using TranspileOk = Result.Ok; + +/// +/// Executes report data sources and assembles results. +/// +public static class ReportEngine +{ + /// + /// Executes all data sources in a report definition and returns assembled results. + /// + /// The report definition to execute. + /// Parameter values provided by the user. + /// Factory that creates open IDbConnection from a connection ref name. + /// Function that transpiles LQL to SQL for the target database. + /// Logger for diagnostics. + /// Result containing all data source results or an error. + public static EngineResult Execute( + ReportDefinition report, + ImmutableDictionary parameters, + Func> connectionFactory, + Func> lqlTranspiler, + ILogger logger + ) + { + logger.LogInformation( + "Executing report {ReportId} with {ParamCount} parameters", + report.Id, + parameters.Count + ); + + var results = ImmutableDictionary.CreateBuilder(); + + foreach (var ds in report.DataSources) + { + logger.LogInformation( + "Executing data source {DataSourceId} (type: {Type})", + ds.Id, + ds.Type + ); + + var dsResult = ExecuteDataSource( + dataSource: ds, + parameters: parameters, + connectionFactory: connectionFactory, + lqlTranspiler: lqlTranspiler, + logger: logger + ); + + switch (dsResult) + { + case DsError dsErr: + logger.LogError( + "Data source {DataSourceId} failed: {Error}", + ds.Id, + dsErr.Value.Message + ); + return new EngineError(dsErr.Value); + case DsOk dsOk: + results.Add(ds.Id, dsOk.Value); + logger.LogInformation( + "Data source {DataSourceId} returned {RowCount} rows", + ds.Id, + dsOk.Value.TotalRows + ); + break; + } + } + + return new EngineOk( + new ReportExecutionResult( + ReportId: report.Id, + ExecutedAt: DateTimeOffset.UtcNow, + DataSources: results.ToImmutable() + ) + ); + } + + /// + /// Executes a single data source and returns its result. + /// + internal static DsResult ExecuteDataSource( + DataSourceDefinition dataSource, + ImmutableDictionary parameters, + Func> connectionFactory, + Func> lqlTranspiler, + ILogger logger + ) + { + return dataSource.Type switch + { + DataSourceType.Sql => ExecuteSql( + dataSource: dataSource, + parameters: parameters, + connectionFactory: connectionFactory, + logger: logger + ), + DataSourceType.Lql => ExecuteLql( + dataSource: dataSource, + parameters: parameters, + connectionFactory: connectionFactory, + lqlTranspiler: lqlTranspiler, + logger: logger + ), + DataSourceType.Api => new DsError( + SqlError.Create("API data sources are not yet supported") + ), + _ => new DsError(SqlError.Create($"Unknown data source type: {dataSource.Type}")), + }; + } + + private static DsResult ExecuteSql( + DataSourceDefinition dataSource, + ImmutableDictionary parameters, + Func> connectionFactory, + ILogger logger + ) + { + if (string.IsNullOrWhiteSpace(dataSource.Query)) + { + return new DsError(SqlError.Create("SQL data source has no query")); + } + + if (string.IsNullOrWhiteSpace(dataSource.ConnectionRef)) + { + return new DsError(SqlError.Create("SQL data source has no connection reference")); + } + + return connectionFactory(dataSource.ConnectionRef) switch + { + ConnError connErr => new DsError(connErr.Value), + ConnOk connOk => ExecuteQueryOnConnection( + connection: connOk.Value, + sql: dataSource.Query, + parameterNames: dataSource.Parameters, + parameterValues: parameters, + logger: logger + ), + }; + } + + private static DsResult ExecuteLql( + DataSourceDefinition dataSource, + ImmutableDictionary parameters, + Func> connectionFactory, + Func> lqlTranspiler, + ILogger logger + ) + { + if (string.IsNullOrWhiteSpace(dataSource.Query)) + { + return new DsError(SqlError.Create("LQL data source has no query")); + } + + if (string.IsNullOrWhiteSpace(dataSource.ConnectionRef)) + { + return new DsError(SqlError.Create("LQL data source has no connection reference")); + } + + return lqlTranspiler(dataSource.Query) switch + { + TranspileError transpileErr => new DsError(transpileErr.Value), + TranspileOk transpileOk => ExecuteTranspiledSql( + sql: transpileOk.Value, + connectionRef: dataSource.ConnectionRef, + parameterNames: dataSource.Parameters, + parameterValues: parameters, + connectionFactory: connectionFactory, + logger: logger + ), + }; + } + + private static DsResult ExecuteTranspiledSql( + string sql, + string connectionRef, + ImmutableArray parameterNames, + ImmutableDictionary parameterValues, + Func> connectionFactory, + ILogger logger + ) + { + logger.LogInformation("LQL transpiled to SQL: {Sql}", sql); + + return connectionFactory(connectionRef) switch + { + ConnError connErr => new DsError(connErr.Value), + ConnOk connOk => ExecuteQueryOnConnection( + connection: connOk.Value, + sql: sql, + parameterNames: parameterNames, + parameterValues: parameterValues, + logger: logger + ), + }; + } + + private static DsResult ExecuteQueryOnConnection( + IDbConnection connection, + string sql, + ImmutableArray parameterNames, + ImmutableDictionary parameterValues, + ILogger logger + ) + { + try + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var paramName in parameterNames) + { + if (parameterValues.TryGetValue(paramName, out var value)) + { + var param = command.CreateParameter(); + param.ParameterName = $"@{paramName}"; + param.Value = value; + command.Parameters.Add(param); + } + } + + using var reader = command.ExecuteReader(); + + var columnNames = ImmutableArray.CreateBuilder(); + for (var i = 0; i < reader.FieldCount; i++) + { + columnNames.Add(reader.GetName(i)); + } + + var rows = ImmutableArray.CreateBuilder>(); + while (reader.Read()) + { + var row = ImmutableArray.CreateBuilder(); + for (var i = 0; i < reader.FieldCount; i++) + { + row.Add(reader.IsDBNull(i) ? null : reader.GetValue(i)); + } + rows.Add(row.ToImmutable()); + } + + return new DsOk( + new DataSourceResult( + ColumnNames: columnNames.ToImmutable(), + Rows: rows.ToImmutable(), + TotalRows: rows.Count + ) + ); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to execute query"); + return new DsError(SqlError.FromException(ex)); + } + } +} diff --git a/Reporting/Nimblesite.Reporting.Engine/ReportMetadata.cs b/Reporting/Nimblesite.Reporting.Engine/ReportMetadata.cs new file mode 100644 index 00000000..320b2874 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Engine/ReportMetadata.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; + +namespace Nimblesite.Reporting.Engine; + +/// +/// Client-safe report metadata. No connection strings or secrets. +/// +public sealed record ReportMetadata( + string Id, + string Title, + ImmutableArray Parameters, + ImmutableArray DataSourceIds, + LayoutDefinition Layout, + string? CustomCss = null +); + +/// +/// Converts full report definitions to client-safe metadata. +/// +public static class ReportMetadataMapper +{ + /// + /// Strips sensitive information from a report definition. + /// + /// Full report definition with connection details. + /// Client-safe report metadata. + public static ReportMetadata ToMetadata(ReportDefinition report) => + new( + Id: report.Id, + Title: report.Title, + Parameters: report.Parameters, + DataSourceIds: [.. report.DataSources.Select(ds => ds.Id)], + Layout: report.Layout, + CustomCss: report.CustomCss + ); +} diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj b/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj new file mode 100644 index 00000000..00215786 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/Nimblesite.Reporting.Integration.Tests.csproj @@ -0,0 +1,104 @@ + + + net10.0 + Library + true + false + enable + enable + Nimblesite.Reporting.Integration.Tests + CA1515;CA2100;CS1591;CA1707;CA1056 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_OwnedReportNames + Include="@(Content->'%(Filename)%(Extension)')" + Condition="'%(Extension)' == '.json'" + /> + <_AllBinReports Include="$(OutDir)Reports\*.report.json" /> + <_PropagatedReports + Include="@(_AllBinReports)" + Exclude="@(_OwnedReportNames->'$(OutDir)Reports\%(Identity)')" + /> + + + + + + + $(MSBuildProjectDirectory)\..\Nimblesite.Reporting.React\wwwroot\js\vendor + + + + + + + + + diff --git a/Reporting/Nimblesite.Reporting.Integration.Tests/ReportBrowserE2ETests.cs b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportBrowserE2ETests.cs new file mode 100644 index 00000000..ab832b40 --- /dev/null +++ b/Reporting/Nimblesite.Reporting.Integration.Tests/ReportBrowserE2ETests.cs @@ -0,0 +1,594 @@ +using Microsoft.Playwright; +using Xunit; + +namespace Nimblesite.Reporting.Integration.Tests; + +/// +/// Full-stack browser E2E tests: real SQLite DB -> real Reporting.Api -> React renderer -> Playwright Chromium. +/// No mocks. No fakes. No shortcuts. Data flows from disk to DOM. +/// +[Collection("Reporting E2E")] +[Trait("Category", "E2E")] +public sealed class ReportBrowserE2ETests +{ + private readonly ReportingE2EFixture _fixture; + + public ReportBrowserE2ETests(ReportingE2EFixture fixture) => _fixture = fixture; + + /// + /// Navigate to report viewer. React loads, fetches report list from real API. + /// Verifies the report list is rendered in the DOM. + /// + [Fact] + public async Task ReportViewer_LoadsAndShowsReportList() + { + var page = await _fixture.CreateReportPageAsync(); + + // Wait for React to render the report list + await page.WaitForSelectorAsync( + ".report-viewer-list, .report-list-item, .report-container", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + // Should show available reports or auto-load + var pageContent = await page.ContentAsync(); + Assert.True( + pageContent.Contains("E2E Product Report", StringComparison.Ordinal) + || pageContent.Contains("Available Reports", StringComparison.Ordinal) + || pageContent.Contains("report", StringComparison.Ordinal), + "Page should display report content or report list" + ); + + await page.CloseAsync(); + } + + /// + /// Navigate directly to a specific report. Verifies the report title renders. + /// Full path: SQLite query -> API response -> React component -> DOM text. + /// + [Fact] + public async Task Report_DirectLoad_ShowsReportTitle() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + // Wait for report to render (title appears when data is loaded) + await page.WaitForSelectorAsync( + ".report-title, .report-container", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + var titleElement = await page.QuerySelectorAsync(".report-title"); + Assert.NotNull(titleElement); + var titleText = await titleElement.TextContentAsync(); + Assert.Equal("E2E Product Report", titleText?.Trim()); + + // Verify report container exists + var container = await page.QuerySelectorAsync(".report-container"); + Assert.NotNull(container); + + await page.CloseAsync(); + } + + /// + /// Verify metric cards are rendered with real data from the database. + /// The totals data source queries: COUNT(*), SUM(Stock), SUM(Price * Stock). + /// These values come from the 6 seeded products in SQLite. + /// + [Fact] + public async Task Report_MetricCards_ShowRealDatabaseValues() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + await page.WaitForSelectorAsync( + ".report-metric", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + var metrics = await page.QuerySelectorAllAsync(".report-metric"); + Assert.True(metrics.Count >= 3, $"Expected at least 3 metric cards, got {metrics.Count}"); + + // Extract metric values - they should contain real numbers from the DB + var metricValues = new List(); + foreach (var metric in metrics) + { + var valueEl = await metric.QuerySelectorAsync(".report-metric-value"); + if (valueEl is not null) + { + var text = await valueEl.TextContentAsync(); + metricValues.Add(text ?? ""); + } + } + + // At least one metric should have a non-empty, non-dash value + Assert.True( + metricValues.Any(v => v != "—" && v.Length > 0), + $"Metric values should contain real data. Got: [{string.Join(", ", metricValues)}]" + ); + + // Verify metric titles are present + var titles = await page.QuerySelectorAllAsync(".report-metric-title"); + Assert.True(titles.Count >= 3, "Should have metric titles"); + + var titleTexts = new List(); + foreach (var title in titles) + { + titleTexts.Add(await title.TextContentAsync() ?? ""); + } + + Assert.Contains(titleTexts, t => t.Contains("Total Products", StringComparison.Ordinal)); + Assert.Contains(titleTexts, t => t.Contains("Total Stock", StringComparison.Ordinal)); + Assert.Contains(titleTexts, t => t.Contains("Total Value", StringComparison.Ordinal)); + + await page.CloseAsync(); + } + + /// + /// Verify bar charts are rendered as SVG with real data. + /// The categorySummary data source groups products by category. + /// Bars should be present in the SVG for each category. + /// + [Fact] + public async Task Report_BarCharts_RenderSvgWithRealData() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + await page.WaitForSelectorAsync( + ".report-chart", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + var charts = await page.QuerySelectorAllAsync(".report-chart"); + Assert.True( + charts.Count >= 4, + $"Expected at least 4 charts (LQL-powered), got {charts.Count}" + ); + + // Verify SVG elements exist (real bar chart rendering) + var svgs = await page.QuerySelectorAllAsync(".report-bar-chart"); + Assert.True(svgs.Count >= 1, "Should have SVG chart elements"); + + // Verify bars exist inside the SVG (rect elements are the bars) + var bars = await page.QuerySelectorAllAsync(".report-bar-chart rect"); + Assert.True( + bars.Count >= 3, + $"Should have at least 3 bars (one per category: Widgets, Gadgets, Doohickeys), got {bars.Count}" + ); + + // Verify chart titles + var chartTitles = await page.QuerySelectorAllAsync(".report-chart .report-component-title"); + Assert.True(chartTitles.Count >= 4, "Should have chart titles for all 4 LQL charts"); + + var chartTitleTexts = new List(); + foreach (var title in chartTitles) + { + chartTitleTexts.Add(await title.TextContentAsync() ?? ""); + } + + Assert.Contains( + chartTitleTexts, + t => t.Contains("Products by Category", StringComparison.Ordinal) + ); + Assert.Contains( + chartTitleTexts, + t => t.Contains("Avg Price by Category", StringComparison.Ordinal) + ); + Assert.Contains( + chartTitleTexts, + t => t.Contains("Stock Distribution by Category", StringComparison.Ordinal) + ); + Assert.Contains( + chartTitleTexts, + t => t.Contains("High-Value Product Inventory", StringComparison.Ordinal) + ); + + // Verify axis labels exist in SVG + var svgTexts = await page.QuerySelectorAllAsync(".report-bar-chart text"); + Assert.True(svgTexts.Count > 0, "SVG should contain axis labels and value labels"); + + // Verify SVG contains actual category labels in text elements + var allSvgText = ""; + foreach (var svgText in svgTexts) + { + allSvgText += await svgText.TextContentAsync() + " "; + } + + Assert.Contains("Widgets", allSvgText, StringComparison.Ordinal); + + await page.CloseAsync(); + } + + /// + /// Verify the data table renders with real product data from SQLite. + /// Checks column headers, row count, and actual cell values. + /// + [Fact] + public async Task Report_DataTable_ShowsRealProductData() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + await page.WaitForSelectorAsync( + ".report-table", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + // Verify table header + var headerCells = await page.QuerySelectorAllAsync(".report-table-th"); + Assert.True(headerCells.Count >= 4, $"Expected 4 table headers, got {headerCells.Count}"); + + var headers = new List(); + foreach (var th in headerCells) + { + headers.Add(await th.TextContentAsync() ?? ""); + } + + Assert.Contains("Product", headers); + Assert.Contains("Category", headers); + Assert.Contains("Price", headers); + Assert.Contains("Stock", headers); + + // Verify data rows (6 products seeded) + var dataRows = await page.QuerySelectorAllAsync(".report-table-row"); + Assert.True( + dataRows.Count >= 6, + $"Expected at least 6 data rows (6 seeded products), got {dataRows.Count}" + ); + + // Verify actual product names appear in the table + var tableContent = await page.InnerTextAsync(".report-table"); + Assert.Contains("Alpha Widget", tableContent, StringComparison.Ordinal); + Assert.Contains("Beta Gadget", tableContent, StringComparison.Ordinal); + Assert.Contains("Gamma Widget", tableContent, StringComparison.Ordinal); + Assert.Contains("Delta Gadget", tableContent, StringComparison.Ordinal); + Assert.Contains("Epsilon Doohickey", tableContent, StringComparison.Ordinal); + Assert.Contains("Zeta Widget", tableContent, StringComparison.Ordinal); + + // Verify table title + var tableTitle = await page.QuerySelectorAsync( + ".report-table-container .report-component-title" + ); + Assert.NotNull(tableTitle); + var titleText = await tableTitle.TextContentAsync(); + Assert.Contains("All Products", titleText ?? "", StringComparison.Ordinal); + + await page.CloseAsync(); + } + + /// + /// Verify text components render with their content. + /// + [Fact] + public async Task Report_TextComponent_RendersCaption() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + await page.WaitForSelectorAsync( + ".report-text-caption", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + var caption = await page.QuerySelectorAsync(".report-text-caption"); + Assert.NotNull(caption); + var text = await caption.TextContentAsync(); + Assert.Contains("LQL", text ?? "", StringComparison.Ordinal); + Assert.Contains("validates full stack rendering", text ?? "", StringComparison.Ordinal); + Assert.Contains("custom CSS styling", text ?? "", StringComparison.Ordinal); + Assert.Contains("cssClass", text ?? "", StringComparison.Ordinal); + + // Caption should also have the text-banner custom class applied + var captionClass = await caption.GetAttributeAsync("class"); + Assert.Contains("text-banner", captionClass ?? "", StringComparison.Ordinal); + Assert.Contains("report-text-caption", captionClass ?? "", StringComparison.Ordinal); + + await page.CloseAsync(); + } + + /// + /// Verify the grid layout renders cells with correct structure. + /// Report has 6 rows of varying cell configurations (all LQL-powered). + /// + [Fact] + public async Task Report_GridLayout_RendersRowsAndCells() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + await page.WaitForSelectorAsync( + ".report-row", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + var rows = await page.QuerySelectorAllAsync(".report-row"); + Assert.True( + rows.Count >= 6, + $"Expected at least 6 layout rows (LQL report), got {rows.Count}" + ); + + // First row should have 3 metric cells (colSpan 4 each) + var firstRowCells = await rows[0].QuerySelectorAllAsync(".report-cell"); + Assert.True( + firstRowCells.Count >= 3, + $"First row should have 3 cells, got {firstRowCells.Count}" + ); + + // Second row should have 2 chart cells (colSpan 6 each) + var secondRowCells = await rows[1].QuerySelectorAllAsync(".report-cell"); + Assert.True( + secondRowCells.Count >= 2, + $"Second row should have 2 cells, got {secondRowCells.Count}" + ); + + // Third row should have 2 chart cells (colSpan 6 each) + var thirdRowCells = await rows[2].QuerySelectorAllAsync(".report-cell"); + Assert.True( + thirdRowCells.Count >= 2, + $"Third row should have 2 chart cells, got {thirdRowCells.Count}" + ); + + // Verify colSpan classes are applied: first row has 3 x report-cell-4 + var cell4s = await rows[0].QuerySelectorAllAsync("[class*='report-cell-4']"); + Assert.Equal(3, cell4s.Count); + + // Second row has 2 x report-cell-6 + var cell6s = await rows[1].QuerySelectorAllAsync("[class*='report-cell-6']"); + Assert.Equal(2, cell6s.Count); + + // Fourth row is full-width table (report-cell-12) + var fourthRowCells = await rows[3].QuerySelectorAllAsync("[class*='report-cell-12']"); + Assert.True(fourthRowCells.Count >= 1, "Fourth row should have a full-width cell"); + + await page.CloseAsync(); + } + + /// + /// Verify the full rendering pipeline by checking that ALL component types + /// appear on a single rendered report page. This is the ultimate E2E test: + /// database -> API -> React -> metrics + charts + table + text all visible. + /// + [Fact] + public async Task Report_FullPipeline_AllComponentTypesRendered() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + // Wait for the slowest component (table with data) + await page.WaitForSelectorAsync( + ".report-table-row", + new PageWaitForSelectorOptions { Timeout = 20000 } + ); + + // Metrics rendered + var metrics = await page.QuerySelectorAllAsync(".report-metric"); + Assert.True(metrics.Count > 0, "Metrics should be rendered"); + + // Charts rendered with SVG + var svgCharts = await page.QuerySelectorAllAsync(".report-bar-chart"); + Assert.True(svgCharts.Count > 0, "SVG bar charts should be rendered"); + + // Table rendered with data + var tableRows = await page.QuerySelectorAllAsync(".report-table-row"); + Assert.True(tableRows.Count > 0, "Table rows should be rendered"); + + // Text rendered + var text = await page.QuerySelectorAllAsync(".report-text-caption"); + Assert.True(text.Count > 0, "Text caption should be rendered"); + + // Report container with title + var container = await page.QuerySelectorAsync(".report-container"); + Assert.NotNull(container); + var title = await page.QuerySelectorAsync(".report-title"); + Assert.NotNull(title); + var titleText = await title.TextContentAsync(); + Assert.Equal("E2E Product Report", titleText?.Trim()); + + // Verify no error states visible + var errors = await page.QuerySelectorAllAsync(".report-error, .report-viewer-error"); + Assert.True( + errors.Count == 0, + "No error elements should be visible on a successful render" + ); + + // Verify no unknown component types + var unknowns = await page.QuerySelectorAllAsync(".report-unknown-component"); + Assert.True(unknowns.Count == 0, "No unknown component elements should be rendered"); + + // Verify custom CSS is also applied in the full pipeline + var styledMetrics = await page.QuerySelectorAllAsync(".report-metric.metric-highlight"); + Assert.True( + styledMetrics.Count >= 2, + "Custom CSS classes should be applied in full pipeline" + ); + + await page.CloseAsync(); + } + + /// + /// Verify that report-level customCss is injected as a style tag in the DOM. + /// The e2e report defines custom classes (metric-highlight, chart-dark-theme, etc.) + /// and these must be present in an injected style element. + /// + [Fact] + public async Task Report_CustomCss_InjectsStyleTag() + { + var page = await _fixture.CreateReportPageAsync(reportId: "e2e-products"); + + await page.WaitForSelectorAsync( + ".report-container", + new PageWaitForSelectorOptions { Timeout = 15000 } + ); + + // Verify the injected