diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ada44a9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AI Agent Policy for Rezoom and Rezoom.SQL projects + +The Rezoom and Rezoom.SQL library code shall be 100% human-written. + +This is not a statement about the quality of AI code. + +AI code is getting better all the time, but there is no level of quality that would alter the policy for this specific project. + +It is an explicit goal of this project to remain a human-designed, human-written creative work. + +Many hours of my time have been spent on this and my fingerprints are all over it. + +To wipe them away with an onslaught of AI code now would be like taking a CNC machine to a hand-carved relief. + +The same rule applies just as strongly for the documentation. + +Humans who take the time to read documentation deserve another human taking the time to write it. + +# What AI agents *can* be used for + +Agents are extremely useful! + +AI agents may be used to *critique* code and docs, give feedback, bounce ideas around, research questions, and to try to repro/hunt down bugs. + +They can be used to create build / packaging / CI scripts. They can be used to assist with infrastructure housekeeping e.g. "Update all the project files to add a .NET 12.0 packaging target". + +I *do* allow AI-written unit tests and sample projects (TypeProviderUsers, aka TPUs) that exercise the code. This saves tons of human effort in an area that is more chore than creativity. + +This includes generating test fixture data. + +I do not consider this a compromise of the policy. The tests are "just" tools we use to confirm the library's correctness. + +However, the user's prompt to create tests should be fairly precise on what invariants they want to test for. Not a lazy open-ended "add tests for the new feature". + +This should hopefully keep each test meaningful and not clutter with a bunch of do-nothing-important tests. + +AI-written tests should keep comments to a minimum. The test name in F# can be nearly a full sentence; this is often adequate for self-documentation of the +test's purpose and does not need a big redundant comment block. + +If a comment *is* deemed valuable, AI agents should remember: + +* DO NOT over-explain. Less than 20 words is a good goal for a comment. +* DO NOT use Unicode characters. Keep it to 7-bit ASCII! +* DO NOT refer to chat-local ephemeral context like "regression for bug found in pass 2" where a future reader will go "WTF are you talking about?" + +In the absence of specific requests like "write a test for xyz", agents should default to discussion mode. + +# Projects where AI is NOT allowed to write nor directly suggest "paste this in" code + +* Rezoom +* Rezoom.SQL.Annotations +* Rezoom.SQL.Mapping +* Rezoom.SQL.Compiler +* Rezoom.SQL.Provider + +# Projects where AI is allowed to write code directly, at the user's request + +* Rezoom.Test - tests of the Rezoom core library (plans, errands, caching, batching) +* Rezoom.SQL.Test - tests of the runtime and compiler at the library-level +* Rezoom.SQL.Test.UserTypes - fixtures for the UserTypes feature +* Rezoom.SQL.Provider.Test - smoke tests for the type provider's codegen paths +* The projects under the src/TypeProviderUsers/ folder - also called TPUs for short, these are real e2e tests of the type provider in action + +The TPUs require using build/pack-dev.ps1 and can be run with src/TypeProviderUsers/test-tp-users.ps1 and +src/TypeProviderUsers/test-vs-build.ps1. Running both scripts is advised because they exercise two code paths, +the TP hosted in "dotnet build" and the TP hosted in Visual Studio's msbuild. Sometimes TP code will work +in one but blow up in the other due to cross-compiling headaches, loading .NET Core assemblies in a .NET Framework host. + +# MD file litter + +Agents should not litter the repository with their own .md files / working notes. + +If the programmer requests to save a conceptual note or roadmap of something they are working on, and that file will be +agent-generated, it should be stored above the repository so it doesn't end up littered into git history by mistake. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 60879da..8a699cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,22 @@ # Changelog -## [Unreleased] +## [1.1.0] - 2026-06-23 -## [1.0.0] - 2026-XX-XX +Central feature: "UserTypes", which allows you to map table columns and query parameters to your own custom .NET types. + +### Added +- "usertypes" setting in rzsql.json +- Can define custom "wrapper" primitives like single-case DU +- Can override default handling of DateTime, Guid, etc +- Can map to underlying database types RZSQL is not natively aware of +- Row results can implement user-defined interfaces so row-processing code can be reused + +### Changed +- CommandEffect.OfSQL() API in Rezoom.SQL.Compiler is now deprecated +- New UserModel.CommandEffect() API replaces it, or pass a UserTypeLibrary explicitly to OfSQL +- These changes only affect consumers who were using the Compiler library to do SQL static analysis at runtime + +## [1.0.0] - 2026-05-16 The Rip Van Winkle release. diff --git a/DEVELOPER_README.md b/DEVELOPER_README.md index a1c51d0..8e9eaf6 100644 --- a/DEVELOPER_README.md +++ b/DEVELOPER_README.md @@ -5,13 +5,12 @@ Notes for working on this repo. ## Prerequisites - .NET SDK 10 or newer -- PowerShell 5.1+ on Windows; PowerShell 7+ on Linux/macOS (install: ). The build scripts (`build/pack-dev.ps1`, `build/pack-release.ps1`) are pwsh-only. +- PowerShell 5.1+ on Windows; PowerShell 7+ on Linux/macOS (install: ). The build scripts (`build/pack-dev.ps1`, `build/pack-release.ps1`) use PowerShell. ## Repo layout assumption -The dev workflow assumes you have this repo cloned alongside its sibling -repos (most importantly [Rezoom](https://github.com/rspeele/Rezoom)) under a -common parent directory. The parent's name doesn't matter, but the +The dev workflow assumes you have this repo cloned alongside its sibling repos (most importantly +[Rezoom](https://github.com/rspeele/Rezoom)) under a common parent directory. The parent's name doesn't matter, but the siblings need to be together. ``` @@ -38,39 +37,22 @@ You need a `NuGet.config` at the parent level so package restore finds ``` -If you're only working on Rezoom.SQL and don't need to iterate on Rezoom, -you can use the latest published Rezoom from nuget.org. If you're changing -both, pack Rezoom into the same `.localfeed` first using whatever script that -repo provides. +This allows pushing local "updates" to the NuGet packages so the TypeProviderUser tests can reference them just like a +real downstream user. -## Why the unusual setup - -Rezoom.SQL has two artifacts that interact awkwardly during dev: - -- The **type provider** (`Rezoom.SQL.Provider`) is loaded by `fsc` at compile - time, not at the consumer's runtime. To test a TP change, a project has to - reference a NuGet-installed version of the provider, not a project-reference - to its source. (Project references don't trigger the same TP loading path.) - -- The **TP user smoke tests** (in `src/TypeProviderUsers/`) exercise the TP - exactly the way a consumer would — they `PackageReference` the wrapper - meta-packages (e.g. `Rezoom.SQL.Provider.SQLite`) which transitively bring - in `Rezoom.SQL.Provider`. So testing a TP change means packing your changes, - then letting the TPUs restore the new package and rebuild. - -The local feed + version-bump dance encodes this. Run `build/pack-dev.ps1` -after a Provider change; the TPUs (and any consumer in the repo) automatically -restore the new version on their next build. +If you're only working on Rezoom.SQL and don't need to make changes to Rezoom, you can use the latest published Rezoom +from nuget.org. If you're changing both, pack Rezoom into the same `.localfeed` first using whatever script that repo +provides. ## Versioning during dev -All Rezoom.SQL packages share a single version. Two files compose it: +All Rezoom.SQL packages share a single version. Two files define it: - `version.props` (committed): `RezoomSqlVersion = 0.13.0`, represents the upcoming or current release version. Bumped only at actual releases. - `version.local.props` (gitignored): written by `pack-dev.ps1`, contains `RezoomSqlVersionSuffix = dev.N`. The combined version becomes - `0.13.0-dev.N`. Bumped extremely frequently during development, every time we have to smoke-test the TPUs. + `0.13.0-dev.N`. Bumped extremely frequently during development, every time we have to "pack-dev.ps1" and smoke-test the TPUs. Every package and consumer reads these via `Directory.Build.props`. Wrapper csprojs and TPU / demo fsprojs reference our packages as @@ -81,9 +63,8 @@ release version automatically. ### `build/pack-dev.ps1` -Bumps the dev counter (one above the highest existing prerelease in the local -feed), writes `version.local.props`, packs all six packages. Run after any -change you want the TPUs or demos to see. +Bumps the dev counter (one above the highest existing prerelease in the local feed), writes `version.local.props`, packs +all six packages. Run after any change you want the TPUs or demos to see. ```powershell ./build/pack-dev.ps1 @@ -91,9 +72,8 @@ change you want the TPUs or demos to see. ### `build/pack-release.ps1` -Deletes `version.local.props` so the build has no prerelease suffix, then -packs all six packages at the release version. Errors out if the working -tree is dirty (override with `-Force`). After it succeeds, tag and push: +Deletes `version.local.props` so the build has no prerelease suffix, then packs all six packages at the release version. +Errors out if the working tree is dirty (override with `-Force`). After it succeeds, tag and push: ```powershell ./build/pack-release.ps1 @@ -101,37 +81,28 @@ git tag v1.0.0 git push origin v1.0.0 ``` -Drop the `.nupkg`s into wherever your nuget.org push lives. - ### `build/pack-parents.ps1` -Packs the three parent libs (FParsec-Pipes, LicenseToCIL, Rezoom) from -their sibling repos at the versions declared in each fsproj. Use this when -you've edited a parent and want Rezoom.SQL to pick up the fresh bits. +Packs the three parent libs (FParsec-Pipes, LicenseToCIL, Rezoom) from their sibling repos at the versions declared in +each fsproj. Use this when you've edited a parent and want Rezoom.SQL to pick up the new dev version. ```powershell ./build/pack-parents.ps1 # all three ./build/pack-parents.ps1 -Only Rezoom # one at a time ``` -The parents don't participate in the centralized `version.props` mechanism -(they change rarely; their versions are bumped manually). If you're -publishing parent changes, bump the parent's `` first, then run -this script. +The parents don't participate in the centralized `version.props` mechanism (they change rarely; their versions are +bumped manually). If you're publishing parent changes, bump the parent's `` first, then run this script. ### `build/regen-doc-nav.ps1` -Rewrites the breadcrumb + prev/next nav blocks at the top and bottom of -every doc page listed in `SUMMARY.md`. Run after editing `SUMMARY.md` -(adding pages, reordering, renaming) to bring all nav links back into sync. +Rewrites the breadcrumb + prev/next nav blocks at the top and bottom of every doc page listed in `SUMMARY.md`. Run after +editing `SUMMARY.md` (adding pages, reordering, renaming) to bring all nav links back into sync. ```powershell ./build/regen-doc-nav.ps1 ``` -Each rewritten block is fenced by HTML comment markers (`` / -``) so the script can rerun cleanly. - ### `src/TypeProviderUsers/test-tp-users.ps1` Runs `dotnet test` on both TPU projects. SQLite can make its own DB file, but Postgres auto- @@ -139,27 +110,20 @@ skips when no server is reachable. Either set up your local Postgres like mine, `rz` user and password `testtest`, or use `REZOOM_TPU_POSTGRES` to override the connection string. -## Why the TPUs aren't in the main sln - -The TPUs reference the wrapper packages via NuGet, not via project reference. -Putting them in the same sln as `Rezoom.SQL.Provider` is confusing: opening -the sln in VS suggests project-ref semantics, but the TPUs actually consume -whatever's currently packed into `.localfeed`. Changing Provider source -without running `pack-dev.ps1` would silently leave the TPUs on the old -version. +### `src/TypeProviderUsers/test-vs-build.ps1` -They live as standalone fsprojs under `src/TypeProviderUsers/`. Open them -individually (or via the test runner script) when you need to verify TP -behavior end-to-end. +Confirms the build still works in VS/MSBuild. It's very easy to write generative TP code that works in `dotnet build` +but breaks in MSBuild. ## Edit-rebuild loop for TP work 1. Edit something in `src/Rezoom.SQL.Provider/`, `Rezoom.SQL.Compiler/`, or `Rezoom.SQL.Mapping/`. -2. Run `./build/pack-dev.ps1`. New version is `0.13.0-dev.`. -3. Run `./src/TypeProviderUsers/test-tp-users.ps1` (or `dotnet test` the specific one +2. Make sure it passes the easy tests in Rezoom.SQL.Test and Rezoom.SQL.Provider.Test. +3. Run `./build/pack-dev.ps1` to generate local dev packages. +4. Run `./src/TypeProviderUsers/test-tp-users.ps1` (or `dotnet test` the specific one you care about). Restore picks up the new dev version automatically. -4. Iterate. +5. Also run `./src/TypeProviderUsers/test-vs-build.ps1` to confirm the TP works in a VS/msbuild environment too. If something doesn't update as expected, the usual suspect is a stale entry in `~/.nuget/packages///`. `pack-dev.ps1` clears those for the @@ -173,5 +137,5 @@ the feed, you may need to clear by hand. 3. Update `CHANGELOG.md` (when one exists) and any docs that mention versions. 4. Commit. Working tree should be clean. 5. Run `./build/pack-release.ps1`. Verify the resulting `.nupkg`s in the feed. -6. Push to nuget.org via your usual mechanism. +6. Push to nuget.org. 7. Tag `v` and push the tag. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..da33b0e --- /dev/null +++ b/Gemfile @@ -0,0 +1,18 @@ +# Gem manifest for the GitHub-Pages Jekyll build of the Rezoom.SQL docs. +# +# The github-pages metagem pins every Jekyll dependency to the exact +# version GitHub's Pages builder runs in production, so what you see +# locally with `bundle exec jekyll serve` matches what gets published. +# +# Just-the-Docs is consumed as a remote theme (configured in _config.yml), +# not as a gem dependency here — jekyll-remote-theme is included in +# github-pages and fetches it at build time. + +source "https://rubygems.org" + +gem "github-pages", group: :jekyll_plugins + +# Local-preview prerequisites. Harmless on the GitHub builder; needed on +# Windows / macOS dev machines. +gem "wdm", ">= 0.1.0" if Gem.win_platform? +gem "tzinfo-data", platforms: [:mingw, :x64_mingw, :mswin, :jruby] diff --git a/README.md b/README.md index 0c7c60d..6cf2c4c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -**Documentation:** [Tutorial](doc/Tutorial/README.md) | [Using Rezoom](doc/Rezoom/README.md) | [Configuration](doc/Configuration/README.md) | [Language](doc/Language/README.md) | [API](doc/API/README.md) +**Documentation:** [Tutorial](https://fsprojects.github.io/Rezoom.SQL/doc/Tutorial/README.html) | [Using Rezoom](https://fsprojects.github.io/Rezoom.SQL/doc/Rezoom/README.html) | [Configuration](https://fsprojects.github.io/Rezoom.SQL/doc/Configuration/README.html) | [Language](https://fsprojects.github.io/Rezoom.SQL/doc/Language/README.html) | [UserTypes](https://fsprojects.github.io/Rezoom.SQL/doc/UserTypes/README.html) | [API](https://fsprojects.github.io/Rezoom.SQL/doc/API/README.html) [Query playground -- try out the SQL dialect live!](https://rzsql.com/#1F854F9945C2061389778AE5DB98238E21D3A62B) -# Statically typed SQL for F# # +# Statically typed SQL for F#, new and improved for 2026 # Rezoom.SQL is an F# ORM for SQL databases. diff --git a/SUMMARY.md b/SUMMARY.md index f2fc4d6..17573eb 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -36,7 +36,12 @@ * [TSQL](doc/Language/Quirks/TSQLQuirks.md) * [Postgres](doc/Language/Quirks/PostgresQuirks.md) * [Dynamic SQL](doc/Language/DynamicSQL.md) - * [What's missing?](doc/Language/MissingFeatures.md) + * [Language Omissions](doc/Language/MissingFeatures.md) +* [UserTypes](doc/UserTypes/README.md) + * [Field lengths and storage type](doc/UserTypes/FieldLengthsAndStorage.md) + * [Advanced primitive mapping](doc/UserTypes/AdvancedMapping.md) + * [Annotation attributes reference](doc/UserTypes/AttributesReference.md) + * [Pitfalls and limitations](doc/UserTypes/Pitfalls.md) * [API](doc/API/README.md) * [Rezoom.SQL](doc/API/RezoomSQL.md) * [Rezoom.SQL.Synchronous](doc/API/RezoomSQLSynchronous.md) diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..27ce981 --- /dev/null +++ b/_config.yml @@ -0,0 +1,72 @@ +# Jekyll + Just-the-Docs configuration. Pages this site builds are +# every .md file in the repo NOT excluded below, with frontmatter +# generated by build/regen-doc-nav.ps1 from SUMMARY.md. + +title: Rezoom.SQL +description: F# type provider for compile-time-checked SQL queries with built-in async batching. +# TODO: replace with the real GH Pages URL once Pages is configured for +# the preview branch (Settings -> Pages -> source). Format is typically +# https://.github.io/ +url: https://example.github.io +baseurl: /Rezoom.SQL + +# Just-the-Docs is consumed as a remote theme so the site builds on the +# stock GitHub-Pages Jekyll runner with no Actions workflow needed. The +# github-pages metagem (declared in Gemfile) supplies jekyll-remote-theme. +remote_theme: just-the-docs/just-the-docs + +plugins: + - jekyll-remote-theme + - jekyll-readme-index + +# --- Theme options ------------------------------------------------------- + +# Built-in client-side search index built at site-build time. +search_enabled: true +search: + heading_level: 3 + previews: 2 + preview_words_before: 3 + preview_words_after: 3 + tokenizer_separator: /[\s/]+/ + +# Auto-generate anchor links on each heading. +heading_anchors: true + +color_scheme: light + +# Top-right links rendered in the theme header. Edit the URL to match +# wherever this repo actually lives. +aux_links: + "View on GitHub": + - "https://github.com/rspeele/Rezoom.SQL" +aux_links_new_tab: true + +# Theme-rendered footer text (per-page). +footer_content: 'Rezoom.SQL documentation. Source on GitHub.' + +# --- Build excludes ------------------------------------------------------ +# Jekyll otherwise tries to render every file in the repo. Keep this list +# tight: only docs (.md files referenced from SUMMARY.md) should reach the +# generated site. + +exclude: + - Gemfile + - Gemfile.lock + - vendor/ + - .bundle/ + - build/ + - src/ + - tests/ + - test-tp-users.ps1 + - LICENSE* + - CONTRIBUTING* + - "*.sln" + - "*.fsproj" + - "*.csproj" + - "*.fs" + - "*.cs" + - "*.gv" + - SUMMARY.md + - DEVELOPER_README.md + - CHANGELOG.md diff --git a/_sass/custom/setup.scss b/_sass/custom/setup.scss new file mode 100644 index 0000000..306afb7 --- /dev/null +++ b/_sass/custom/setup.scss @@ -0,0 +1,8 @@ +// SCSS variable overrides for the Just-the-Docs theme. +// +// Wider than default 800px +$content-width: 1000px; + +// Slightly tighten the sidebar. 232 fits all the current SUMMARY.md titles comfortably with room for future additions. +$nav-width: 232px; +$nav-width-md: 232px; diff --git a/build/regen-doc-nav.ps1 b/build/regen-doc-nav.ps1 index fc680b6..d2d45be 100644 --- a/build/regen-doc-nav.ps1 +++ b/build/regen-doc-nav.ps1 @@ -1,24 +1,32 @@ #requires -Version 5 <# .SYNOPSIS - Regenerate breadcrumb + prev/next navigation blocks in every doc page. + Regenerate breadcrumb + prev/next navigation blocks in every doc page, + and emit Just-the-Docs YAML frontmatter for the gh-pages-built sidebar. .DESCRIPTION Parses SUMMARY.md at the repo root, walks its tree depth-first to build a - linear reading order, and rewrites a marker-fenced nav block at the top - and bottom of every .md file referenced. + linear reading order, and rewrites three sections of every .md file + referenced: - The top block is breadcrumbs (Home > Section > Page) followed by a - prev / next bar. The bottom block is a horizontal rule and a prev / next - bar. Both are wrapped in HTML comment markers (`` / - ``) so the script can rerun and rewrite cleanly when - SUMMARY.md changes. + 1. YAML frontmatter at the very top of the file driving the + Just-the-Docs sidebar nav (`title`, `parent`, `grand_parent`, + `nav_order`, `has_children`). + 2. A marker-fenced nav block (``) below the frontmatter + with breadcrumbs (Home > Section > Page) and a prev/next bar. + 3. A marker-fenced bottom nav block (``) with a + horizontal rule and another prev/next bar. + + All three are idempotent: rerunning the script after SUMMARY.md changes + cleanly strips and rewrites without accumulating cruft. Also strips the legacy "(this page is part of...)" preamble lines from Tutorial pages, since the new breadcrumb supersedes them. .NOTES Re-run after editing SUMMARY.md (adding pages, reordering, renaming). + The frontmatter is consumed by Jekyll + just-the-docs; the marker-fenced + nav blocks serve readers viewing the markdown raw on github.com. #> [CmdletBinding()] param() @@ -80,6 +88,38 @@ for ($i = 0; $i -lt $entries.Count; $i++) { $entries[$i] | Add-Member -NotePropertyName Next -NotePropertyValue $next } +# ---- Compute SiblingOrder + HasChildren (for Just-the-Docs frontmatter) - + +# SiblingOrder: 1-based position among entries sharing the same immediate +# parent. Top-level entries (depth 0) are siblings of each other under a +# synthetic '' key. +$siblingCounter = @{} +foreach ($e in $entries) { + $parentKey = + if ($e.Parents.Count -gt 0) { + $e.Parents[$e.Parents.Count - 1].RelPath + } else { '' } + if (-not $siblingCounter.ContainsKey($parentKey)) { + $siblingCounter[$parentKey] = 0 + } + $siblingCounter[$parentKey]++ + $e | Add-Member -NotePropertyName SiblingOrder -NotePropertyValue $siblingCounter[$parentKey] +} + +# HasChildren: true if any other entry's immediate parent is this entry. +# Just-the-Docs uses this to expand the page as a parent node in the sidebar. +foreach ($e in $entries) { + $hasChildren = $false + foreach ($other in $entries) { + if ($other.Parents.Count -gt 0 -and + $other.Parents[$other.Parents.Count - 1].RelPath -eq $e.RelPath) { + $hasChildren = $true + break + } + } + $e | Add-Member -NotePropertyName HasChildren -NotePropertyValue $hasChildren +} + # ---- Helpers ---- function Get-Rel($fromFile, $toFile) { @@ -116,6 +156,34 @@ function Build-Breadcrumb($entry) { return ($parts -join ' > ') } +function Build-Frontmatter($entry, $isRoot) { + # Just-the-Docs YAML frontmatter. Drives the sidebar nav structure. + # On rerun, the strip regex below removes any existing frontmatter + # block at the top of the file and this writes a fresh one. + if ($isRoot) { + # The root README is the site landing page. Just-the-Docs treats + # it as the home; nav_order: 0 keeps it first if it ever ends up + # rendered in the sidebar. + return "---`ntitle: $($entry.Title)`nnav_order: 0`n---" + } + $lines = @("title: $($entry.Title)") + if ($entry.Parents.Count -ge 1) { + $immediate = $entry.Parents[$entry.Parents.Count - 1] + $lines += "parent: $($immediate.Title)" + } + if ($entry.Parents.Count -ge 2) { + # Just-the-Docs requires grand_parent for any page nested three + # levels deep so the sidebar knows where to slot it. + $grand = $entry.Parents[0] + $lines += "grand_parent: $($grand.Title)" + } + $lines += "nav_order: $($entry.SiblingOrder)" + if ($entry.HasChildren) { + $lines += "has_children: true" + } + return "---`n" + ($lines -join "`n") + "`n---" +} + function Build-PrevNext($entry) { $prevPart = if ($entry.Prev) { @@ -157,6 +225,15 @@ $legacyPreamble = "^\(this page is part of \[[^\]]+\]\([^)]+\)\)\s*\r?\n" # the bottom-regex started consuming them. Allows blank lines between adjacent # orphans (which is exactly the pattern previous buggy runs produced). $trailingOrphanSep = "(?:\r?\n\s*---[ \t]*)+\s*$" +# Existing YAML frontmatter at the very top of the file. The keyed-content +# requirement (`(?:[a-zA-Z_]\w*: [^\r\n]*\r?\n)+` between the delimiters) +# keeps the regex from misfiring on a file that genuinely opens with a bare +# `---` horizontal rule and no frontmatter, AND uses [^\r\n]* (not .*) so the +# value portion can't span newlines — important because on the second run +# the file does have frontmatter, and a greedy .* would otherwise extend +# down to the next `---` separator and consume the entire body. Trailing +# blank lines are also consumed so successive runs don't drift the body. +$frontmatterRegex = "\A---\r?\n(?:[a-zA-Z_]\w*: [^\r\n]*\r?\n)+---\r?\n\r?\n?" $touched = 0 foreach ($e in $entries) { @@ -167,6 +244,10 @@ foreach ($e in $entries) { $path = $e.AbsPath.Path $body = [System.IO.File]::ReadAllText($path) + # Strip existing frontmatter at the very top of the file FIRST so the + # nav-top strip below sees the actual `` marker at the + # head of the body. + $body = [regex]::Replace($body, $frontmatterRegex, '') # Strip existing nav blocks (anywhere in the file, idempotent). $body = [regex]::Replace($body, $navTopRegex, '') $body = [regex]::Replace($body, $navBottomRegex, '') @@ -199,7 +280,8 @@ foreach ($e in $entries) { # Trim leading blank lines that may have been left by the strip, and # trailing whitespace, so the output is tidy. $body = $body.TrimStart("`r","`n"," ","`t").TrimEnd() - $newBody = $topBlock + $body + $bottomBlock + "`n" + $frontmatter = Build-Frontmatter $e ($e -eq $rootEntry) + $newBody = "$frontmatter`n`n$topBlock$body$bottomBlock`n" [System.IO.File]::WriteAllText($path, $newBody, [System.Text.UTF8Encoding]::new($false)) $touched++ diff --git a/demos/SQLFiddle/.claude/launch.json b/demos/SQLFiddle/.claude/launch.json deleted file mode 100644 index 31a19bc..0000000 --- a/demos/SQLFiddle/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "sqlfiddle", - "runtimeExecutable": "dotnet", - "runtimeArgs": ["run", "--no-launch-profile", "--urls", "http://localhost:5050", "--project", "SQLFiddle.Website"], - "port": 5050 - } - ] -} diff --git a/demos/SQLFiddle/SQLFiddle/Domain.fs b/demos/SQLFiddle/SQLFiddle/Domain.fs index 3737590..f1ad1fc 100644 --- a/demos/SQLFiddle/SQLFiddle/Domain.fs +++ b/demos/SQLFiddle/SQLFiddle/Domain.fs @@ -89,11 +89,11 @@ let private validate (input : FiddleInput) = let backend = backendOf input.Backend let initialModel = backend.InitialModel try - let modelEffect = CommandEffect.OfSQL(initialModel, "Model", input.Model) + let modelEffect = CommandEffect.OfSQL(initialModel, "Model", input.Model, Rezoom.SQL.Mapping.UserTypeLibrary.Empty) let model = defaultArg modelEffect.ModelChange initialModel try - let commandEffect = CommandEffect.OfSQL(model, "Command", input.Command) + let commandEffect = CommandEffect.OfSQL(model, "Command", input.Command, Rezoom.SQL.Mapping.UserTypeLibrary.Empty) FiddleValid (typeInfoFrom backend modelEffect commandEffect) with | :? SQLCompilerException as exn -> diff --git a/doc/API/README.md b/doc/API/README.md index a0e3da3..98406fc 100644 --- a/doc/API/README.md +++ b/doc/API/README.md @@ -1,7 +1,13 @@ +--- +title: API +nav_order: 7 +has_children: true +--- + [Home](../../README.md) > API -[← What's missing?](../Language/MissingFeatures.md) | [Rezoom.SQL →](RezoomSQL.md) +[← Pitfalls and limitations](../UserTypes/Pitfalls.md) | [Rezoom.SQL →](RezoomSQL.md) # API @@ -38,6 +44,6 @@ outside the lines and minor version releases may break the API. --- -[← What's missing?](../Language/MissingFeatures.md) | [Rezoom.SQL →](RezoomSQL.md) +[← Pitfalls and limitations](../UserTypes/Pitfalls.md) | [Rezoom.SQL →](RezoomSQL.md) diff --git a/doc/API/RezoomSQL.md b/doc/API/RezoomSQL.md index 1a16c45..9601b7d 100644 --- a/doc/API/RezoomSQL.md +++ b/doc/API/RezoomSQL.md @@ -1,3 +1,9 @@ +--- +title: Rezoom.SQL +parent: API +nav_order: 1 +--- + [Home](../../README.md) > [API](README.md) > Rezoom.SQL diff --git a/doc/API/RezoomSQLAsynchronous.md b/doc/API/RezoomSQLAsynchronous.md index c152ac9..1c026c3 100644 --- a/doc/API/RezoomSQLAsynchronous.md +++ b/doc/API/RezoomSQLAsynchronous.md @@ -1,3 +1,9 @@ +--- +title: Rezoom.SQL.Asynchronous +parent: API +nav_order: 3 +--- + [Home](../../README.md) > [API](README.md) > Rezoom.SQL.Asynchronous diff --git a/doc/API/RezoomSQLMigrations.md b/doc/API/RezoomSQLMigrations.md index 465f97f..2baebc4 100644 --- a/doc/API/RezoomSQLMigrations.md +++ b/doc/API/RezoomSQLMigrations.md @@ -1,3 +1,9 @@ +--- +title: Rezoom.SQL.Migrations +parent: API +nav_order: 5 +--- + [Home](../../README.md) > [API](README.md) > Rezoom.SQL.Migrations diff --git a/doc/API/RezoomSQLPlans.md b/doc/API/RezoomSQLPlans.md index 08b180f..4c4b71b 100644 --- a/doc/API/RezoomSQLPlans.md +++ b/doc/API/RezoomSQLPlans.md @@ -1,3 +1,9 @@ +--- +title: Rezoom.SQL.Plans +parent: API +nav_order: 4 +--- + [Home](../../README.md) > [API](README.md) > Rezoom.SQL.Plans diff --git a/doc/API/RezoomSQLSynchronous.md b/doc/API/RezoomSQLSynchronous.md index da2c7e3..7b89994 100644 --- a/doc/API/RezoomSQLSynchronous.md +++ b/doc/API/RezoomSQLSynchronous.md @@ -1,3 +1,9 @@ +--- +title: Rezoom.SQL.Synchronous +parent: API +nav_order: 2 +--- + [Home](../../README.md) > [API](README.md) > Rezoom.SQL.Synchronous diff --git a/doc/Configuration/Configuration.md b/doc/Configuration/Configuration.md index 3ceb9e7..11d9c92 100644 --- a/doc/Configuration/Configuration.md +++ b/doc/Configuration/Configuration.md @@ -1,3 +1,9 @@ +--- +title: Runtime configuration +parent: Configuration +nav_order: 2 +--- + [Home](../../README.md) > [Configuration](README.md) > Runtime configuration diff --git a/doc/Configuration/Json.md b/doc/Configuration/Json.md index c5e9c91..6a005d3 100644 --- a/doc/Configuration/Json.md +++ b/doc/Configuration/Json.md @@ -1,3 +1,9 @@ +--- +title: rzsql.json +parent: Configuration +nav_order: 1 +--- + [Home](../../README.md) > [Configuration](README.md) > rzsql.json @@ -113,104 +119,23 @@ _default: `[]`_ This optional setting allows you to bring your own types into RZSQL's type system. -I like to use a lot of little wrapper types in my domain layer. - -Instead of `string`, I might have an `EmailAddress` type. Instead of passing around `int` IDs, I like to have `UserId` and `GroupId` and `CompanyId` and so on. - -This allows validation rules to live in the type's constructor, it makes methods self-documenting, -and it creates a compiler error if I accidentally call `service.AddUser(userId, companyId)` when that method is supposed to take `(companyId, userId)`. - -By default though, RZSQL only understands the handful of built-in SQL primitives described in [Language/Data Types](../Language/DataTypes.md). - -If we put our user type definitions in a separate assembly, reference that assembly from the project where our SQL -model+queries live, and add the assembly name to the `"usertypes"` list in rzsql.json, Rezoom SQL can use any user type that wraps a supported underlying primitive. - Reference the assembly full name in rzsql.json like so: ```javascript "usertypes": ["MyProduct.MyCustomTypesAssembly"] ``` -You must also reference the MyProduct.MyCustomTypesAssembly project from your F# project where you're using Rezoom.SQL.Provider. - -The type provider will search the listed assemblies for user types with mappings to primitive types. - -A "primitive type" means any of the .NET types listed in the table at the top of [Language/Data Types](../Language/DataTypes.md). - -A user type is: - -* Any F# single-case union that wraps a primitive type, such as `type UserId = UserId of Guid`. `[]` unions are also supported. -* Or, any type `T` for which we find a class with static `ToPrimitive` and `FromPrimitive` methods mapping `T` to and from a primitive type. -* Or, any type `T` for which we find F#-style extension methods `member this.ToPrimitive()` and `static member FromPrimitive(x)` mapping `T` to and from a primitive type. - -In the latter two cases, it should be noted that `T` does not HAVE to be a type that you own. - -For example, you can write ToPrimitive and FromPrimitive extension methods for `System.TimeOnly` in your UserTypes assembly, and then use `TimeOnly` in your SQL schema. - -User-type code: - -```fsharp -// simple DU -type UserId = UserId of System.Guid - -// custom mapping for a type defined elsewhere -module TimeOnlyMapping = - type System.TimeOnly with - member this.ToPrimitive() = - this.ToString("o") - static member FromPrimitive(s : string) = - System.TimeOnly.ParseExact(s, "o") -``` - -SQL schema: - -```sql -create table Employees -( Id UserId primary key -, Name string(80) -, ShiftStarts TimeOnly -, ShiftEnds TimeOnly -); -``` - -The actual SQL that runs on your database will treat ShiftStarts and ShiftEnds as if you'd written `string`. - -Selecting from this table will give you a `TimeOnly` property in your result set rows from the type provider. - -Comparing to a parameter will cause that parameter to get a `TimeOnly` type inferred: - - -```fsharp -type MyQuery = SQL<""" -select * from Employees where ShiftStarts = @t -"""> - -let usage = - MyQuery.Command(t = TimeOnly(0, 0, 0)) -``` - -### Caveats and limitations - -It is not supported to define a custom primitive that is backed by multiple columns. ToPrimitive and FromPrimitive must convert to a single primitive object! There are no plans to add multi-column primitives in the future. - -It is not supported to define custom primitive mappings for a generic type. You cannot have `Id` and `Id`. All custom-mapped types must be simple non-generic types. - -When implementing ToPrimitive you should not return a null, and when implementing FromPrimitive you do not need to handle nulls. -You are defining the mapping for a non-null object. The mapping for a null / None object is always assumed to be null / None and cannot be overridden. - -The ToPrimitive and FromPrimitive methods for any single UserType must be defined in the same class. You can't have -`ToPrimitive(x : Foo) : int` in one class and `FromPrimitive(x : int) : Foo` in another class and have it work -- the -assembly search will not detect `Foo` as a mapped UserType. +Your F# project using the type provider must also reference the MyProduct.MyCustomTypesAssembly project. -You must be aware of the underlying representation for usertypes when writing your SQL. For example, your custom type -may override comparison operators, but SQL doesn't know about that, and indeed doesn't know about your custom type at -all. It is erased to the underlying primitive at F# compile-time. +Read [the full UserTypes feature documentation here](../UserTypes/README.md). -If you write `where ShiftStarts < @t and ShiftEnds > @t`, that comparison will be done on the underlying *string -representation*! With the above example ToPrimitive and FromPrimitive methods using the "o" format string this will -actually work fine, but a different representation might not hold up so well. So choose your underlying representation -carefully and be mindful of how your queries are actually working. +You can reference multiple assemblies in this list. For example, you could have your primitive mappings in one assembly +and your row interfaces in another. +It is also supported to reference a path to a .dll file for the assembly. Paths will be resolved relative to the folder +containing rzsql.json. However, it is generally better to use a project reference and just name the project, without +path elements or the ".dll" extension. Otherwise you'll have issues where, for example, you're pointing at the path to +the DLL under "debug", and your build breaks or pulls an outdated assembly when you're building in "release" mode. --- diff --git a/doc/Configuration/MigrationTrees.md b/doc/Configuration/MigrationTrees.md index b5cd9be..7684434 100644 --- a/doc/Configuration/MigrationTrees.md +++ b/doc/Configuration/MigrationTrees.md @@ -1,3 +1,9 @@ +--- +title: Migration Trees +parent: Configuration +nav_order: 3 +--- + [Home](../../README.md) > [Configuration](README.md) > Migration Trees diff --git a/doc/Configuration/README.md b/doc/Configuration/README.md index fce0a80..5f7e815 100644 --- a/doc/Configuration/README.md +++ b/doc/Configuration/README.md @@ -1,3 +1,9 @@ +--- +title: Configuration +nav_order: 4 +has_children: true +--- + [Home](../../README.md) > Configuration diff --git a/doc/Language/AlterTableStmt.md b/doc/Language/AlterTableStmt.md index 0d0aa94..fc12221 100644 --- a/doc/Language/AlterTableStmt.md +++ b/doc/Language/AlterTableStmt.md @@ -1,3 +1,9 @@ +--- +title: Alter table statements +parent: Language +nav_order: 12 +--- + [Home](../../README.md) > [Language](README.md) > Alter table statements diff --git a/doc/Language/CommonTableExpression.md b/doc/Language/CommonTableExpression.md index a9dce25..aabec92 100644 --- a/doc/Language/CommonTableExpression.md +++ b/doc/Language/CommonTableExpression.md @@ -1,3 +1,9 @@ +--- +title: Common table expressions +parent: Language +nav_order: 9 +--- + [Home](../../README.md) > [Language](README.md) > Common table expressions diff --git a/doc/Language/CreateTableStmt.md b/doc/Language/CreateTableStmt.md index 4fb18bc..b39e09a 100644 --- a/doc/Language/CreateTableStmt.md +++ b/doc/Language/CreateTableStmt.md @@ -1,3 +1,9 @@ +--- +title: Create table statements +parent: Language +nav_order: 10 +--- + [Home](../../README.md) > [Language](README.md) > Create table statements diff --git a/doc/Language/CreateViewStmt.md b/doc/Language/CreateViewStmt.md index c910b81..b0fecc2 100644 --- a/doc/Language/CreateViewStmt.md +++ b/doc/Language/CreateViewStmt.md @@ -1,3 +1,9 @@ +--- +title: Create view statements +parent: Language +nav_order: 11 +--- + [Home](../../README.md) > [Language](README.md) > Create view statements diff --git a/doc/Language/DataTypes.md b/doc/Language/DataTypes.md index c811f3a..aac6635 100644 --- a/doc/Language/DataTypes.md +++ b/doc/Language/DataTypes.md @@ -1,3 +1,9 @@ +--- +title: Data types +parent: Language +nav_order: 2 +--- + [Home](../../README.md) > [Language](README.md) > Data types @@ -43,9 +49,9 @@ an `int64` to an `int32`. When the typechecker encounters an expression such as `a` and `b` have the same types or one is an ancestor of the other's type. A type variable such as `@x` may be unified with types in many places. The most -specific type always wins, so in the expression `@x >= someInt32 and @x < -someInt16`, @x is inferred to have type `int32`, because it is lower in the type -hierarchy than `int16`. +specific type always wins, so in the expression +`@x >= someInt32 and @x < someInt16`, @x is inferred to have type `int32`, +because it is lower in the type hierarchy than `int16`. Here is the current type hierarchy. Notice that there are some types in the hierarchy that do not appear in the above table. These exist just as constraints @@ -64,10 +70,10 @@ show up in the corresponding column. Most expressions (like `a * b`) are assumed to be potentially null if _either_ of their inputs are potentially null. When an expression uses a function, the -nullability depends on the function. For example, in TSQL, the `power(base, -power)` function has "infectious" arguments in that if either of them is -nullable, the output is inferred to be nullable. However, the `coalesce` -function's output is only nullable if its last argument is nullable. +nullability depends on the function. For example, in TSQL, the +`power(base, power)` function has "infectious" arguments in that if either of +them is nullable, the output is inferred to be nullable. However, the +`coalesce` function's output is only nullable if its last argument is nullable. When a query is parameterized, RZSQL must also figure out which parameters should be nullable. To this end, it assumes some expressions must be nullable. @@ -121,8 +127,8 @@ In the simplest case, as seen above, the expression is a parameter, like nullable. This also works for somewhat more complex expressions. For example, it could be -a binary operation like `@count + 1`, or even a scalar sub-query, like `(select -@name as n)`. In both these cases. the typechecker concludes that the whole +a binary operation like `@count + 1`, or even a scalar sub-query, like +`(select @name as n)`. In both these cases. the typechecker concludes that the whole subquery's result is nullable if-and-only-if the parameter in question is nullable, so again, it makes the parameter's type nullable to satisfy the constraint. diff --git a/doc/Language/DeleteStmt.md b/doc/Language/DeleteStmt.md index 16b76d7..45ad63a 100644 --- a/doc/Language/DeleteStmt.md +++ b/doc/Language/DeleteStmt.md @@ -1,3 +1,9 @@ +--- +title: Delete statements +parent: Language +nav_order: 8 +--- + [Home](../../README.md) > [Language](README.md) > Delete statements diff --git a/doc/Language/DropStmt.md b/doc/Language/DropStmt.md index bcc180c..c91c26b 100644 --- a/doc/Language/DropStmt.md +++ b/doc/Language/DropStmt.md @@ -1,3 +1,9 @@ +--- +title: Drop object statements +parent: Language +nav_order: 13 +--- + [Home](../../README.md) > [Language](README.md) > Drop object statements diff --git a/doc/Language/DynamicSQL.md b/doc/Language/DynamicSQL.md index 7de0137..d2d0302 100644 --- a/doc/Language/DynamicSQL.md +++ b/doc/Language/DynamicSQL.md @@ -1,7 +1,13 @@ +--- +title: Dynamic SQL +parent: Language +nav_order: 18 +--- + [Home](../../README.md) > [Language](README.md) > Dynamic SQL -[← Postgres](Quirks/PostgresQuirks.md) | [What's missing? →](MissingFeatures.md) +[← Postgres](Quirks/PostgresQuirks.md) | [Language Omissions →](MissingFeatures.md) # Dynamic SQL @@ -97,6 +103,6 @@ let exampleCommand (nameSearch : string) = --- -[← Postgres](Quirks/PostgresQuirks.md) | [What's missing? →](MissingFeatures.md) +[← Postgres](Quirks/PostgresQuirks.md) | [Language Omissions →](MissingFeatures.md) diff --git a/doc/Language/Expr.md b/doc/Language/Expr.md index 5267bd0..fa17dbf 100644 --- a/doc/Language/Expr.md +++ b/doc/Language/Expr.md @@ -1,3 +1,9 @@ +--- +title: Expressions +parent: Language +nav_order: 4 +--- + [Home](../../README.md) > [Language](README.md) > Expressions diff --git a/doc/Language/Functions/PostgresFunctions.md b/doc/Language/Functions/PostgresFunctions.md index 4e19403..14c2e7c 100644 --- a/doc/Language/Functions/PostgresFunctions.md +++ b/doc/Language/Functions/PostgresFunctions.md @@ -1,3 +1,10 @@ +--- +title: Postgres +parent: Functions +grand_parent: Language +nav_order: 3 +--- + [Home](../../../README.md) > [Language](../README.md) > [Functions](README.md) > Postgres diff --git a/doc/Language/Functions/README.md b/doc/Language/Functions/README.md index 6e8f802..90e706b 100644 --- a/doc/Language/Functions/README.md +++ b/doc/Language/Functions/README.md @@ -1,3 +1,10 @@ +--- +title: Functions +parent: Language +nav_order: 16 +has_children: true +--- + [Home](../../../README.md) > [Language](../README.md) > Functions diff --git a/doc/Language/Functions/SQLiteFunctions.md b/doc/Language/Functions/SQLiteFunctions.md index 1e1ba04..7dc82a2 100644 --- a/doc/Language/Functions/SQLiteFunctions.md +++ b/doc/Language/Functions/SQLiteFunctions.md @@ -1,3 +1,10 @@ +--- +title: SQLite +parent: Functions +grand_parent: Language +nav_order: 1 +--- + [Home](../../../README.md) > [Language](../README.md) > [Functions](README.md) > SQLite diff --git a/doc/Language/Functions/TSQLFunctions.md b/doc/Language/Functions/TSQLFunctions.md index b570e7d..ddb6134 100644 --- a/doc/Language/Functions/TSQLFunctions.md +++ b/doc/Language/Functions/TSQLFunctions.md @@ -1,3 +1,10 @@ +--- +title: TSQL +parent: Functions +grand_parent: Language +nav_order: 2 +--- + [Home](../../../README.md) > [Language](../README.md) > [Functions](README.md) > TSQL diff --git a/doc/Language/InsertStmt.md b/doc/Language/InsertStmt.md index c98a253..ce18204 100644 --- a/doc/Language/InsertStmt.md +++ b/doc/Language/InsertStmt.md @@ -1,3 +1,9 @@ +--- +title: Insert statements +parent: Language +nav_order: 6 +--- + [Home](../../README.md) > [Language](README.md) > Insert statements diff --git a/doc/Language/Literal.md b/doc/Language/Literal.md index 31642d6..31acc4f 100644 --- a/doc/Language/Literal.md +++ b/doc/Language/Literal.md @@ -1,3 +1,9 @@ +--- +title: Literals +parent: Language +nav_order: 3 +--- + [Home](../../README.md) > [Language](README.md) > Literals diff --git a/doc/Language/MissingFeatures.md b/doc/Language/MissingFeatures.md index dbb98c8..0b1b81d 100644 --- a/doc/Language/MissingFeatures.md +++ b/doc/Language/MissingFeatures.md @@ -1,10 +1,16 @@ +--- +title: Language Omissions +parent: Language +nav_order: 19 +--- + -[Home](../../README.md) > [Language](README.md) > What's missing? +[Home](../../README.md) > [Language](README.md) > Language Omissions -[← Dynamic SQL](DynamicSQL.md) | [API →](../API/README.md) +[← Dynamic SQL](DynamicSQL.md) | [UserTypes →](../UserTypes/README.md) -# What's missing? +# Language Omissions If you are already familiar with a SQL dialect, here are some features you may miss that are not currently supported by RZSQL. @@ -19,6 +25,7 @@ goals and would make sense to add eventually. Pull requests would of course be appreciated! * Support for mixing dynamic SQL into static queries, especially `ORDER BY` clause +* Tools to generate an RZSQL migration file from an existing DB * Window functions * Table-valued functions * User-defined functions @@ -36,7 +43,6 @@ work on this project [a long time](https://www.youtube.com/watch?v=izQB2-Kmiic): * Interpreted in-memory implementation of each DB backend, for easy unit testing * Linq-ish query builder using the type provider's database model * Statically typed (w/ schema) JSON/XML access -* Custom data types (e.g. Postgres range types) ## In my nightmares @@ -48,6 +54,6 @@ though! --- -[← Dynamic SQL](DynamicSQL.md) | [API →](../API/README.md) +[← Dynamic SQL](DynamicSQL.md) | [UserTypes →](../UserTypes/README.md) diff --git a/doc/Language/Name.md b/doc/Language/Name.md index 846a7a2..18aa132 100644 --- a/doc/Language/Name.md +++ b/doc/Language/Name.md @@ -1,3 +1,9 @@ +--- +title: Names +parent: Language +nav_order: 1 +--- + [Home](../../README.md) > [Language](README.md) > Names diff --git a/doc/Language/NavigationProperties.md b/doc/Language/NavigationProperties.md index 7928fa7..e7b70ec 100644 --- a/doc/Language/NavigationProperties.md +++ b/doc/Language/NavigationProperties.md @@ -1,3 +1,9 @@ +--- +title: Navigation properties +parent: Language +nav_order: 14 +--- + [Home](../../README.md) > [Language](README.md) > Navigation properties diff --git a/doc/Language/Quirks/PostgresQuirks.md b/doc/Language/Quirks/PostgresQuirks.md index c4fae8f..a2267b6 100644 --- a/doc/Language/Quirks/PostgresQuirks.md +++ b/doc/Language/Quirks/PostgresQuirks.md @@ -1,3 +1,10 @@ +--- +title: Postgres +parent: Quirks +grand_parent: Language +nav_order: 3 +--- + [Home](../../../README.md) > [Language](../README.md) > [Quirks](README.md) > Postgres @@ -14,9 +21,9 @@ There is no data type in Postgres that stores a date, time, and timezone offset. You would think that `TIMESTAMP WITH TIME ZONE` would do that, but it does not. It stores a UTC timestamp, and annoyingly converts it to different local times -in various situations such as during conversion to `TIMESTAMP WITHOUT TIME -ZONE`. It is baffling to me why anybody would ever want a database to be aware -of anybody's local time, but I'm not the database guy. +in various situations such as during conversion to +`TIMESTAMP WITHOUT TIME ZONE`. It is baffling to me why anybody would ever +want a database to be aware of anybody's local time, but I'm not the database guy. When using RZSQL, both the `DateTime` and `DateTimeOffset` types are mapped to `TIMESTAMP WITH TIME ZONE`. With both types, querying the DB will give you a UTC diff --git a/doc/Language/Quirks/README.md b/doc/Language/Quirks/README.md index d56a868..ba9b075 100644 --- a/doc/Language/Quirks/README.md +++ b/doc/Language/Quirks/README.md @@ -1,3 +1,10 @@ +--- +title: Quirks +parent: Language +nav_order: 17 +has_children: true +--- + [Home](../../../README.md) > [Language](../README.md) > Quirks diff --git a/doc/Language/Quirks/SQLiteQuirks.md b/doc/Language/Quirks/SQLiteQuirks.md index 8abe617..ef0f06a 100644 --- a/doc/Language/Quirks/SQLiteQuirks.md +++ b/doc/Language/Quirks/SQLiteQuirks.md @@ -1,3 +1,10 @@ +--- +title: SQLite +parent: Quirks +grand_parent: Language +nav_order: 1 +--- + [Home](../../../README.md) > [Language](../README.md) > [Quirks](README.md) > SQLite diff --git a/doc/Language/Quirks/TSQLQuirks.md b/doc/Language/Quirks/TSQLQuirks.md index 0193a1a..d25086b 100644 --- a/doc/Language/Quirks/TSQLQuirks.md +++ b/doc/Language/Quirks/TSQLQuirks.md @@ -1,3 +1,10 @@ +--- +title: TSQL +parent: Quirks +grand_parent: Language +nav_order: 2 +--- + [Home](../../../README.md) > [Language](../README.md) > [Quirks](README.md) > TSQL @@ -19,8 +26,8 @@ work for comparison with a literal `NULL` -- you can't, for example, compare two columns this way. When faced with a usage of `IS` or `IS NOT` that doesn't have a literal `NULL` -as its right-hand side, RZSQL uses this idiom for `LeftSideExpr IS -RightSideExpr`: +as its right-hand side, RZSQL uses this idiom for +`LeftSideExpr IS RightSideExpr`: ```sql EXISTS(SELECT LeftSideExpr INTERSECT SELECT RightSideExpr); @@ -62,8 +69,8 @@ whenever a "fake boolean" is used in a boolean clause: SELECT * FROM SomeTable WHERE (SomeBitColumn<>0) ``` -Conversely, it adds a `CASE` expression whenever a boolean expression such as `x -< y` is used where a scalar value is needed: +Conversely, it adds a `CASE` expression whenever a boolean expression such as +`x < y` is used where a scalar value is needed: ```sql SELECT @@ -122,8 +129,8 @@ T-SQL models default values as constraints, and refuses to let you drop a column while it has constraints referencing it, even if the constraints will become completely pointless once the column is gone. -Therefore, you must `ALTER TABLE DROP DEFAULT FOR ColumnName` before you `ALTER -TABLE DROP COLUMN ColumnName`, if the column has a default value. +Therefore, you must `ALTER TABLE DROP DEFAULT FOR ColumnName` before you +`ALTER TABLE DROP COLUMN ColumnName`, if the column has a default value. You'll get informed of this at compile time. diff --git a/doc/Language/README.md b/doc/Language/README.md index bff128c..e41de17 100644 --- a/doc/Language/README.md +++ b/doc/Language/README.md @@ -1,3 +1,9 @@ +--- +title: Language +nav_order: 5 +has_children: true +--- + [Home](../../README.md) > Language diff --git a/doc/Language/SelectStmt.md b/doc/Language/SelectStmt.md index c9c1ef4..720fa03 100644 --- a/doc/Language/SelectStmt.md +++ b/doc/Language/SelectStmt.md @@ -1,3 +1,9 @@ +--- +title: Select statements +parent: Language +nav_order: 5 +--- + [Home](../../README.md) > [Language](README.md) > Select statements @@ -249,8 +255,8 @@ The four compound operators supported by RZSQL are: * `EXCEPT`: the result set contains all rows from the left compound term **not found in** the right compound term. -Compound operators are all left-associative, meaning that `x except y union all -z` groups like `(x except y) union all z`. The parentheses here are purely for +Compound operators are all left-associative, meaning that +`x except y union all z` groups like `(x except y) union all z`. The parentheses here are purely for illustration. In RZSQL, you _cannot_ use parentheses around compound exprs to override their associativity. If you need an associativity other than left-to-right, you'll need to use subqueries instead. diff --git a/doc/Language/UpdateStmt.md b/doc/Language/UpdateStmt.md index d56e954..7351a5e 100644 --- a/doc/Language/UpdateStmt.md +++ b/doc/Language/UpdateStmt.md @@ -1,3 +1,9 @@ +--- +title: Update statements +parent: Language +nav_order: 7 +--- + [Home](../../README.md) > [Language](README.md) > Update statements diff --git a/doc/Language/VendorStatements.md b/doc/Language/VendorStatements.md index 903564e..1400420 100644 --- a/doc/Language/VendorStatements.md +++ b/doc/Language/VendorStatements.md @@ -1,3 +1,9 @@ +--- +title: Vendor statements +parent: Language +nav_order: 15 +--- + [Home](../../README.md) > [Language](README.md) > Vendor statements diff --git a/doc/Rezoom/README.md b/doc/Rezoom/README.md index ceee409..ecf8809 100644 --- a/doc/Rezoom/README.md +++ b/doc/Rezoom/README.md @@ -1,3 +1,8 @@ +--- +title: Using Rezoom +nav_order: 3 +--- + [Home](../../README.md) > Using Rezoom diff --git a/doc/Tutorial/AddingMigrations.md b/doc/Tutorial/AddingMigrations.md index 6aa7bdb..d44a688 100644 --- a/doc/Tutorial/AddingMigrations.md +++ b/doc/Tutorial/AddingMigrations.md @@ -1,3 +1,9 @@ +--- +title: Adding migrations +parent: Tutorial +nav_order: 1 +--- + [Home](../../README.md) > [Tutorial](README.md) > Adding migrations diff --git a/doc/Tutorial/Async.md b/doc/Tutorial/Async.md index fff6985..dad7d34 100644 --- a/doc/Tutorial/Async.md +++ b/doc/Tutorial/Async.md @@ -1,3 +1,9 @@ +--- +title: Asynchronous programming +parent: Tutorial +nav_order: 4 +--- + [Home](../../README.md) > [Tutorial](README.md) > Asynchronous programming diff --git a/doc/Tutorial/LoadingNestedObjects.md b/doc/Tutorial/LoadingNestedObjects.md index 42ef3dc..5e98050 100644 --- a/doc/Tutorial/LoadingNestedObjects.md +++ b/doc/Tutorial/LoadingNestedObjects.md @@ -1,3 +1,9 @@ +--- +title: Loading nested objects +parent: Tutorial +nav_order: 3 +--- + [Home](../../README.md) > [Tutorial](README.md) > Loading nested objects @@ -54,8 +60,8 @@ users. Behind the scenes, it is getting the same old flat result set from SQL, but it processes it into a nested collection of objects in memory. In order to do this, it must have some way to de-duplicate the repeated user information. By default, this is done by comparing all the columns at the user level of the -query that are selected from primary key columns -- in this case, just `u.Id as -UserId`. +query that are selected from primary key columns -- in this case, just +`u.Id as UserId`. You can read more about this feature on the [Navigation Properties](../Language/NavigationProperties.md) page. diff --git a/doc/Tutorial/README.md b/doc/Tutorial/README.md index e6d70ae..ebf0969 100644 --- a/doc/Tutorial/README.md +++ b/doc/Tutorial/README.md @@ -1,3 +1,9 @@ +--- +title: Tutorial +nav_order: 2 +has_children: true +--- + [Home](../../README.md) > Tutorial diff --git a/doc/Tutorial/SwitchBackends.md b/doc/Tutorial/SwitchBackends.md index 275e0b0..c6ff680 100644 --- a/doc/Tutorial/SwitchBackends.md +++ b/doc/Tutorial/SwitchBackends.md @@ -1,3 +1,9 @@ +--- +title: Using TSQL or Postgres +parent: Tutorial +nav_order: 2 +--- + [Home](../../README.md) > [Tutorial](README.md) > Using TSQL or Postgres diff --git a/doc/UserTypes/AdvancedMapping.md b/doc/UserTypes/AdvancedMapping.md new file mode 100644 index 0000000..5a32d86 --- /dev/null +++ b/doc/UserTypes/AdvancedMapping.md @@ -0,0 +1,130 @@ +--- +title: Advanced primitive mapping +parent: UserTypes +nav_order: 2 +--- + + +[Home](../../README.md) > [UserTypes](README.md) > Advanced primitive mapping + +[← Field lengths and storage type](FieldLengthsAndStorage.md) | [Annotation attributes reference →](AttributesReference.md) + + +# Advanced primitive mapping + +## Re-mapping a builtin type + +You can also use UserTypes to change how an already-supported RZSQL primitive type is stored. + +This would primarily be useful on SQLite, where the database itself has very few types, so RZSQL made opinionated +decisions on storing GUIDs (as binary BLOBs) and DateTimes (as ISO8601 strings). + +If you don't feel those decisions fit your project you can change them the same way you'd override any other UserType: + +```fsharp +module ExampleOverrides = + // change DateTime to store as a unix time instead of an ISO string + let unixEpoch = DateTime(1970,1,1) + type System.DateTime with + member this.ToPrimitive() : int64 = int64 (this - unixEpoch).TotalSeconds + static member FromPrimitive(i : int64) = unixEpoch + TimeSpan.FromSeconds(float i) + + // change Guid to store as a string instead of a byte[] blob + type System.Guid with + member this.ToPrimitive() : string = this.ToString() + static member FromPrimitive(str : string) = Guid.Parse(str) +``` + +These overrides will apply everywhere your SQL queries reference the `datetime` and `guid` builtin types. Unlike most +UserTypes, when it's a builtin type you've re-mapped, you *don't* have to be case-sensitive to use it in your schema and +queries. That would just be far too confusing if typing `DateTime` applied your overridden methods but `datetime` +didn't! + +### Supporting Decimal and DateTimeOffset on SQLite + +Custom mapping can help you with the SQLite backend if you'd like to use `decimal` or `DateTimeOffset`. By default these +types will throw an exception if used with a SQLite backend because I couldn't think of an acceptable *default* way to +support them. For decimal, if we mapped to `REAL` you would lose the precision and base-10 math of `decimal`. The only +lossless way to store and retrieve a `decimal` value would be in a SQLite `BLOB` or `TEXT` column, but then mathematical +operators would break or silently decay to binary floating point. + +Likewise with `DateTimeOffset`, the obvious choice would be to use `.ToString("o")` like we do with DateTime, but then +comparisons and equality would produce unexpected results. The below expression evaluates FALSE in SQLite using string +comparison, but should be TRUE comparing the actual moment in time two `DateTimeOffset` types represent. The UTC+0 +one is a minute before the UTC-4 one. + +`'2026-06-08T01:15:00.0000000+00:00' < '2026-06-07T21:16:00.0000000-04:00'` + +If you understand the problem space and have chosen storage format where the tradeoffs work for *your needs*, mapping +these types can be the right call. + +## Mapping to vendor-specific database column types + +In addition to the aforementioned [built-in primitive](../Language/DataTypes.md) datatypes, your `ToPrimitive` and +`FromPrimitive` methods can map a UserType to `System.Object`. + +This allows you to store and retrieve *anything* your underlying ADO.NET provider can handle. + +For example, you can map to the `point` type in `Postgres` like so: + +```fsharp +[] +[] +type Point2D = + { X : double + Y : double + } + static member ToPrimitive(p : Point2D) : System.Object = + box (NpgsqlTypes.NpgsqlPoint(p.X, p.Y)) + static member FromPrimitive(o : System.Object) : Point2D = + let pt = o :?> NpgsqlTypes.NpgsqlPoint + { X = pt.X; Y = pt.Y } +``` + +When mapping to `System.Object`, the `RawBackendSQLType` attribute is **required**. + +Otherwise RZSQL would have no clue what underlying datatype to use on a `Point2D` column! + +You'll also notice a new attribute on the above example, `[]`. + +This is used when you write a query that takes a `Point2D` as a *parameter*. + +When the RZSQL runtime executes a query with UserType parameters, it first converts them to their underlying +representation via `ToPrimitive`. The output of that `ToPrimitive()` call becomes the +[dbParam.Value](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter.value?view=net-10.0). + +By default, +[dbParam.DbType](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter.dbtype?view=net-10.0) +is set based on the underlying type being mapped to. For example, if you mapped to int, RZSQL will assume +`DbType.Int32` is appropriate. + +Usually that is fine. + +However, if you are mapping to `System.Object` to represent a custom type, RZSQL's guess of `DbType.Object` might not work with your ADO.NET provider. + +In this case the correct thing to do, knowing that the `DbParameter` is specifically an instance of +[NpgsqlParameter](https://www.npgsql.org/doc/api/Npgsql.NpgsqlParameter.html), is to set `dbParam.NpgsqlDbType <- NpgsqlDbType.Point`. + +The attribute here gives the runtime the information it needs to do that via reflection. The runtime doesn't carry an +Npgsql dependency and doesn't directly know about those data types, but it essentially does this: + +```fsharp +let prop = dbParam.GetType().GetProperty(propName, BindingFlags.Instance|||BindingFlags.Public) +prop.SetValue(dbParam, Enum.ToObject(prop.PropertyType, intValue)) +``` + +In the above snippet, `propName` and `intValue` come from the `[]` attribute, 15 +being the integer value of NpgsqlDbType.Point. + +### Writing SQL dealing with backend-specific types + +The above example helped you store a `point` and retrieve it, but you still can't do much with it in your database +queries. RZSQL doesn't know what operations `point` supports, and doesn't have type signatures for Postgres's geometric +functions, because they don't fit into its default backend-agnostic type hierarchy. For doing more than just CRUD +storage and retrieval, you'll want to get familiar with [VENDOR statements](../Language/VendorStatements.md). + +--- + +[← Field lengths and storage type](FieldLengthsAndStorage.md) | [Annotation attributes reference →](AttributesReference.md) + + diff --git a/doc/UserTypes/AttributesReference.md b/doc/UserTypes/AttributesReference.md new file mode 100644 index 0000000..c9e1e6b --- /dev/null +++ b/doc/UserTypes/AttributesReference.md @@ -0,0 +1,55 @@ +--- +title: Annotation attributes reference +parent: UserTypes +nav_order: 3 +--- + + +[Home](../../README.md) > [UserTypes](README.md) > Annotation attributes reference + +[← Advanced primitive mapping](AdvancedMapping.md) | [Pitfalls and limitations →](Pitfalls.md) + + +# Annotation attributes reference + +## RawBackendSQLType + +Usage: `[]` + +Specifies the literal type RZSQL should use for columns storing this UserType and in typename-carrying expressions like `CAST(x AS MyUserType)`. +This allows you to override the default storage format RZSQL would use for the underlying primitive type. + +You SHOULD include the length specifier, if one is needed, in the string such as `"varchar(50)"`. + +You SHOULD NOT include nullability information like `"varchar(50) NOT NULL"` in the string. RZSQL will already add +nullability annotations where appropriate so this would generate redundant, invalid syntax. + +The `RawBackendSQLType` attribute is REQUIRED if the data type you map To/From is `System.Object`. + +## SQLTypeLength + +Usage: `[]` + +Specifies the maximum length for a UserType mapped to string (`nvarchar(N)`) or byte[] (`varbinary(N)`). + +Not valid to combine this with `RawBackendSQLType`, since that already includes a length. + +## SQLParameterDbType + +Constructor 1: `[]` + +Constructor 2: `[]` + +Specifies the `DbType` to use when this UserType is passed into a query as a parameter. + +You can change to a different `DbType` using the first constructor, like `[]`. + +For advanced use cases where the standard `DbType` set is not sufficient and you need to set a different integer-valued +property on the ADO.NET provider's implementation of `DbParameter`, you can use the second constructor. The property +will be resolved by name at runtime and set to the specified integer value. + +--- + +[← Advanced primitive mapping](AdvancedMapping.md) | [Pitfalls and limitations →](Pitfalls.md) + + diff --git a/doc/UserTypes/FieldLengthsAndStorage.md b/doc/UserTypes/FieldLengthsAndStorage.md new file mode 100644 index 0000000..64b38e8 --- /dev/null +++ b/doc/UserTypes/FieldLengthsAndStorage.md @@ -0,0 +1,65 @@ +--- +title: Field lengths and storage type +parent: UserTypes +nav_order: 1 +--- + + +[Home](../../README.md) > [UserTypes](README.md) > Field lengths and storage type + +[← UserTypes](README.md) | [Advanced primitive mapping →](AdvancedMapping.md) + + +# Field lengths and storage type + +In most SQL databases string and binary columns can (and should) have a max length specified. + +But when you map a UserType to a `string` or a `byte[]`, by default it will come through without a length specifier. + +This means the above examples like the `DateOnly` mapping or the `EmailAddress` mapping would be stored as +`nvarchar(max)` in TSQL. + +You can override this by using the `SQLTypeLength` attribute from the `Rezoom.SQL.Annotations` NuGet package. +The attribute can go on the type being mapped... + +```fsharp +open Rezoom.SQL.Annotations + +[] // store as nvarchar(255) +type EmailAddress(rawEmail : string) = + ... + +``` + +...Or on one of the methods doing the mapping: + +```fsharp +module MyCustomMappings = + type DateOnly with + [] // store as nvarchar(10) + member this.ToPrimitive() = this.ToString("o") + static member FromPrimitive(str : string) = DateOnly.ParseExact(str, "o") +``` + +A more heavy-handed alternative is to override the entire type name used on the backend. +For example, if you want more compact storage for the 10-char `DateOnly` type, you could make it a `char(10)` instead of `nvarchar`. +This is done with the `RawBackendSQLType` attribute. + +```fsharp + type DateOnly with + [] + member this.ToPrimitive() = this.ToString("o") + static member FromPrimitive(str : string) = DateOnly.ParseExact(str.Trim(), "o") +``` + +Note that `RawBackendSQLType` and `SQLTypeLength` cannot be specified on the same type, because the former completely +overrides the latter and makes it redundant. + +The string passed to `RawBackendSQLType` is opaque to RZSQL and not type-checked. It is your responsibility to ensure +that it's syntactically valid and that it can store the data you're mapping into it. + +--- + +[← UserTypes](README.md) | [Advanced primitive mapping →](AdvancedMapping.md) + + diff --git a/doc/UserTypes/Pitfalls.md b/doc/UserTypes/Pitfalls.md new file mode 100644 index 0000000..883b263 --- /dev/null +++ b/doc/UserTypes/Pitfalls.md @@ -0,0 +1,75 @@ +--- +title: Pitfalls and limitations +parent: UserTypes +nav_order: 4 +--- + + +[Home](../../README.md) > [UserTypes](README.md) > Pitfalls and limitations + +[← Annotation attributes reference](AttributesReference.md) | [API →](../API/README.md) + + +# Pitfalls and limitations + +## Must map each specific type, not just a base type + +If you have ToPrimitive and FromPrimitive defined on a base class, that does *not* automatically make all its subclasses valid UserTypes. Each one needs its own mapping. + +## No chaining primitives + +Your `ToPrimitive` and `FromPrimitive` must map *directly* to a builtin primitive type, not *another* wrapper type. + +You can't have `Foo` mapped to underlying type `Bar`, `Bar` mapped to `Baz` and `Baz` mapped to `int`. Even though it +would be possible to follow that chain of wrappers and unwrappers to convert between `Foo` and `int`, RZSQL does not do +this. It would require additional error-checking to prevent cyclical paths and it would just make the mapping of `Foo` +harder to follow for any reader of your code. + +## No generics + +You cannot map a .NET generic type as a UserType. For example, maybe every entity in your domain has a Guid PK. You +might wish to write a single `type Id<'a> = Id of Guid` and then use `Id`, `Id`, etc. instead of defining +individual types for each one. This is not supported. You'll have to use +`type UserId = UserId of Guid` and `type GroupId = GroupId of Guid` and so on. + +## Changes affecting schema + +When you change your UserTypes library, RZSQL has no way of knowing about the history. + +Suppose for a long time you had `System.TimeOnly` mapped to a `string` (hh:mm:ss) and you have decided to change it to map +to an `int` (seconds since midnight). It's a small task to change your .NET assembly to replace the `ToPrimitive` and +`FromPrimitive` methods, but there is still data in your DB with the old string type. + +As far as RZSQL is concerned, it has no idea. One of your migrations from a year ago said `create table Foo(TimeOfDay TimeOnly)`. +That migration created an `nvarchar` column in your SQL Server database and there's live data in there. + +Now that you've changed the `TimeOnly` mapping, RZSQL thinks that old migration made an `int` column and always has. The +existence of the `nvarchar` column has been memory-holed: we have always been at war with Eastasia. Your queries will +fail at runtime because RZSQL's idea of your database model no longer matches reality. + +The solution is to write a new migration and use a [VENDOR statement](../Language/VendorStatements.md) to port data over from the old +format to the new. The vendor statement will allow you to bypass RZSQL's outdated conception of the data types and work +on the real data in the table. Something like: + +```sql +// migration to handle changing storage format from string to int +VENDOR tsql { + // create a new column + ALTER TABLE dbo.Foo ADD [NewTime] INT; + // port the data over from the old format + UPDATE dbo.Foo SET [NewTime] = DATEDIFF(SECOND, 0, CAST([TimeOfDay] AS TIME)); + // drop the old column and swap in the new one + ALTER TABLE dbo.Foo DROP COLUMN [TimeOfDay]; + EXEC sp_rename 'dbo.Foo.NewTime', 'TimeOfDay', 'COLUMN'; +} IMAGINE { + // nothing here, so the typechecker thinks nothing happened +} +``` + +The key thing to remember here is it is up to you to be disciplined about changing your storage representation! + +--- + +[← Annotation attributes reference](AttributesReference.md) | [API →](../API/README.md) + + diff --git a/doc/UserTypes/README.md b/doc/UserTypes/README.md new file mode 100644 index 0000000..c64de2b --- /dev/null +++ b/doc/UserTypes/README.md @@ -0,0 +1,217 @@ +--- +title: UserTypes +nav_order: 6 +has_children: true +--- + + +[Home](../../README.md) > UserTypes + +[← Language Omissions](../Language/MissingFeatures.md) | [Field lengths and storage type →](FieldLengthsAndStorage.md) + + +# UserTypes + +The UserTypes feature allows you to bring custom .NET data types into RZSQL by pointing the type provider at your own assemblies. + +This allows you to: + +1. Model your domain better, getting columns typed as `EmailAddress` instead of `string`, `UserId` instead of `Guid`, and so on. +2. Make query result row types implement your interfaces. All your queries against the `User` table can return rows implementing an `IUser` interface you define, so you can write consumer code that works on all of them. +3. Remap built-in types to other storage formats. For example, RZSQL's default handling for DateTime in SQLite is to store an ISO8601 string. If you prefer to store it as an integer Unix time, you can do that with a UserType mapping. +4. Store and retrieve data from backend-specific column types RZSQL doesn't natively support, like Postgres `point` or TSQL `geography`. + +## The layout + +This is how an example solution with UserTypes is arranged: + +![Solution has YourProject.SQLQueries.fsproj referencing Rezoom.SQL.Provider and YourProject.UserTypes.fsproj referencing Rezoom.SQL.Annotations. There is a project reference from YourProject.SQLQueries to YourProject.UserTypes.](SolutionLayout.gv.svg) + +Your UserTypes MUST be in a separate assembly from your SQL queries, and must build first. + +The type provider cannot "see" types defined in the same assembly it's trying to compile. They don't exist yet! + +The fsproj where you're using Rezoom.SQL.Provider must have a project reference to your UserType project(s). It must +**also** name those projects in [rzsql.json's](../Configuration/Json.md) `"usertypes"` list. This tells the type +provider to search the listed assemblies at design-time to find your custom types. + +Referencing Rezoom.SQL.Annotations is optional. This is a lightweight package that only defines attributes. +Those attributes give you more control over how your custom-mapped UserTypes are translated to SQL. + +## Mapping your own primitive types + +It's a good practice to model your domain tightly with types. This helps make code self-documenting and allows the +compiler to catch errors where function arguments are passed out-of-order. For example, if you have a function in your domain: + +```fsharp +let addUserToGroup (userId : int) (groupId : int) = + // do stuff +``` + +It's very easy to accidentally call `addUserToGroup group.Id user.Id` and miss the mistake. + +If you have wrapper types and your function signature changes to: + +```fsharp +let addUserToGroup (userId : UserId) (groupId : GroupId) = + // do stuff +``` + +Then you can't make that mixup without the compiler catching it. + +However, implementing a domain model with those wrapper types on top of vanilla RZSQL would be frustrating. You'd +constantly have to convert the raw primitive `int` or `string` or `Guid` values that come out of your SQL query results +to your domain types, and unpack your domain types back to primitives to pass them in as query parameters. + +With UserTypes you can solve this. A user-mapped primitive type can take either of the following forms: + +### Single-case union + +This is the simplest case. Any F# union type with a single case that wraps an underlying [built-in +primitive](../Language/DataTypes.md) will automatically be detected as a valid UserType without needing further annotations or +methods. + +```fsharp +// typical single-case DU wrapper pattern +type UserId = UserId of System.Guid + +// struct DUs work fine too +[] +type FileHash = FileHash of byte[] +``` + +### ToPrimitive/FromPrimitive static wrappers + +This is a more advanced case. Perhaps your type is a little more complicated than a single-case DU wrapper. That's fine, +you can define the mapping directly. + +```fsharp +type EmailAddress(rawEmail : string) = + do + if isNull rawEmail || not(rawEmail.Contains("@")) then + invalidArg (nameof rawEmail) "Email must be non-null and contain @" + + override this.ToString() = rawEmail + + static member ToPrimitive(email : EmailAddress) : string = email.ToString() + static member FromPrimitive(raw : string) : EmailAddress = EmailAddress(raw) +``` + +`EmailAddress` will be detected as a valid UserType because of the ToPrimitive and FromPrimitive methods mapping it to +string. + +If you don't like having those static methods littering your domain, or you can't add them because the type you're +trying to map is from another library you can't edit, that's not a problem! + +ToPrimitive and FromPrimitive **do not have to be** declared by the same type that they are mapping. + +For example, you can map the BCL type `System.DateOnly` by declaring a static class: + +```fsharp +type DateOnlyMapping() = + static member ToPrimitive(date : DateOnly) : string = date.ToString("o") + static member FromPrimitive(str : string) : DateOnly = DateOnly.ParseExact(str, "o") +``` + +Or even a module: + +```fsharp +module DateOnlyMapping = + let ToPrimitive (date : DateOnly) = date.ToString("o") + let FromPrimitive (str : string) = DateOnly.ParseExact(str, "o") +``` + +Or my personal preference, F# extension methods: + +```fsharp +module MyCustomMappings = + type DateOnly with + member this.ToPrimitive() = this.ToString("o") + static member FromPrimitive(str : string) = DateOnly.ParseExact(str, "o") +``` + +You can have as many classes as you want defining static custom mappings. But you can't split the mapping for a +*single UserType* across multiple classes. `ToPrimitive : Foo -> string` has to be defined in the *same* class as +`FromPrimitive : string -> Foo` for the mapping to be valid. + +## Using the mapped types + +Once you've got your UserTypes assembly plugged in via [rzsql.json](../Configuration/Json.md), you can use your +domain types in your database model. Instead of writing `create table Users(Id guid primary key)`, write `create table Users(Id UserId primary key)`. + +**Note that while built-in types in RZSQL are case-insensitive, when you reference a UserType you *must* match its .NET type name exactly, case-sensitively!** + +When you `select` from that table, you'll get the `Id` column back out in your F# code as a `UserId`, not just a plain `System.Guid`. + +And when your query uses a parameter that you compare with the `Id` column, that parameter will be inferred as a `UserId` as well. + +```fsharp +type MyQuery = SQL<"select * from Users where Id = @id"> + +let someGuid = Guid.Parse("6f626f4e-7964-6957-6c6c-526561644974") + +plan { + // command requires a UserId parameter + let! row = MyQuery.Command(id = UserId someGuid).ExactlyOne() + let id = row.Id // type is UserId + let email = row.Email // type is EmailAddress + return id, email +} +``` + +## Row interfaces + +Another problem the UserTypes features solves is that RZSQL generates a new row type for *every* SQL query you write. + +```fsharp +type QueryUserById = SQL<"select * from Users where Id = @id"> +type QueryUserByEmail = SQL<"select * from Users where Email = @email"> +``` + +The above two queries both select all columns from the `Users` table, but they have two different row types, +`QueryUserById.Row` and `QueryUserByEmail.Row`. + +Those types are *structurally identical* but they are *nominally different*, so you can't easily write code that works on both. + +Unfortunately, there is no good way for the provider to make these return the same row type. Each `SQL<...>` invocation +can only generate types *nested under* itself. + +However, with UserTypes we can do the next best thing. We can make the generated types implement *the same interface*. + +In your UserTypes assembly, write an interface matching the shape of the columns in the query: + +```fsharp +type IUserRow = + abstract member Id : UserId + abstract member Email : EmailAddress + // ... etc +``` + +Now in your queries, you can specify that you want the resulting row type to implement your `IUserRow` interface. +This is done by changing the `select` to `select`. + +```fsharp +type QueryUserById = SQL<"select * from Users where Id = @id"> +type QueryUserByEmail = SQL<"select * from Users where Email = @email"> +``` + +As long as the columns specified in the `IUserRow` interface are found in the result set, both `QueryUserById.Row` and +`QueryUserByEmail.Row` will implement `IUserRow`. + +Now you can write downstream code to consume that interface, such as mapping `IUserRow` to a DTO type that your web API +returns to clients. You no longer have to deal with duplicating boilerplate mapping code on a bunch of different +basically-identical row types. + +If the columns needed to implement the interface are *not* present, you'll get an error **at compile-time**. + +You can also declare a query implements *multiple* interfaces by separating with commas: + +```sql +select * from Users +``` + +--- + +[← Language Omissions](../Language/MissingFeatures.md) | [Field lengths and storage type →](FieldLengthsAndStorage.md) + + diff --git a/doc/UserTypes/SolutionLayout.gv b/doc/UserTypes/SolutionLayout.gv new file mode 100644 index 0000000..aeccf29 --- /dev/null +++ b/doc/UserTypes/SolutionLayout.gv @@ -0,0 +1,47 @@ +// dot -Tsvg -O SolutionLayout.gv +digraph "Solution Layout" { + rankdir=BT + node[shape=plaintext,fontname=Consolas] + + rzsql[label=< + + + +
NuGet
Rezoom.SQL.Provider
+ >] + + annot[label=< + + + +
NuGet
Rezoom.SQL.Annotations
+ >] + + { rank=same; rzsql; annot; } + + utl[label=< + + + + + + +
YourProject.UserTypes.fsproj
type IUser = abstract member Id : UserId...
type UserId = UserId of Guid
[<SQLTypeLength(254)>]
type EmailAddress = EmailAddress of string
type System.TimeOnly with member this.ToPrimitive() = this.ToString("o")...
+ >] + + tpu[label=< + + + + + + + + +
YourProject.SQLQueries.fsproj
rzsql.json{ "usertypes": ["YourProject.UserTypes"] }
V1.model.sqlcreate table Users(Id UserId primary key, Email EmailAddress, ...)
Query.fstype MyQuery = SQL<"select<IUser> * from Users where Id = @id">
let! rows = MyQuery.Command(id = UserId(myGuid)).Plan()
let user = rows.[0] :> IUser
+ >] + + tpu -> utl + tpu -> rzsql + utl -> annot +} diff --git a/doc/UserTypes/SolutionLayout.gv.svg b/doc/UserTypes/SolutionLayout.gv.svg new file mode 100644 index 0000000..56e0eb1 --- /dev/null +++ b/doc/UserTypes/SolutionLayout.gv.svg @@ -0,0 +1,90 @@ + + + + + + +Solution Layout + + + +rzsql + + +NuGet + +Rezoom.SQL.Provider + + + + +annot + + +NuGet + +Rezoom.SQL.Annotations + + + + +utl + +YourProject.UserTypes.fsproj + +type IUser = abstract member Id : UserId... + +type UserId = UserId of Guid + +[<SQLTypeLength(254)>] +type EmailAddress = EmailAddress of string + +type System.TimeOnly with member this.ToPrimitive() = this.ToString("o")... + + + + +utl->annot + + + + + +tpu + +YourProject.SQLQueries.fsproj + +rzsql.json + +{ "usertypes": ["YourProject.UserTypes"] } + +V1.model.sql + +create table Users(Id UserId primary key, Email EmailAddress, ...) + +Query.fs + +type MyQuery = SQL<"select<IUser> * from Users where Id = @id"> + +let! rows = MyQuery.Command(id = UserId(myGuid)).Plan() + +let user = rows.[0] :> IUser + + + + +tpu->rzsql + + + + + +tpu->utl + + + + + diff --git a/src/Rezoom.SQL.Annotations/README.md b/src/Rezoom.SQL.Annotations/README.md new file mode 100644 index 0000000..79e1770 --- /dev/null +++ b/src/Rezoom.SQL.Annotations/README.md @@ -0,0 +1,5 @@ +# Rezoom.SQL.Annotations + +Provides attributes for annotating user-defined types for use in Rezoom.SQL's type system. + +Allows you to override the underlying SQL data type, length, and/or parameter DbType. \ No newline at end of file diff --git a/src/Rezoom.SQL.Annotations/Rezoom.SQL.Annotations.csproj b/src/Rezoom.SQL.Annotations/Rezoom.SQL.Annotations.csproj index 89e17f4..a6987c0 100644 --- a/src/Rezoom.SQL.Annotations/Rezoom.SQL.Annotations.csproj +++ b/src/Rezoom.SQL.Annotations/Rezoom.SQL.Annotations.csproj @@ -10,7 +10,16 @@ MIT https://github.com/rspeele/Rezoom.SQL https://github.com/rspeele/Rezoom.SQL + README.md rezoom sql annotations attributes - 1.0.0: initial release. RawBackendSQLType and SQLTypeLength attributes for annotating Rezoom.SQL user primitive types. + 1.1.0: RawBackendSQLType, SQLParameterDbType, and SQLTypeLength attributes. + + + + + + Never + + diff --git a/src/Rezoom.SQL.Annotations/SQLParameterDbTypeAttribute.cs b/src/Rezoom.SQL.Annotations/SQLParameterDbTypeAttribute.cs new file mode 100644 index 0000000..a39fd74 --- /dev/null +++ b/src/Rezoom.SQL.Annotations/SQLParameterDbTypeAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Data; +using System.Data.Common; +namespace Rezoom.SQL.Annotations; + +/// +/// Overrides the DbType to use when this user-primitive is passed as a parameter to a command. +/// Particularly useful when the user-primitive is mapped to System.Object, so we can't automatically infer +/// an appropriate DbType for the parameter. +/// +/// Can be placed on the mapped type or on either of the FromPrimitive/ToPrimitive methods. +/// +[AttributeUsage + ( AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method + , AllowMultiple = false + , Inherited = false + )] +public sealed class SQLParameterDbTypeAttribute : Attribute +{ + public SQLParameterDbTypeAttribute(DbType dbType) : this(nameof(DbParameter.DbType), (int)dbType) + { + } + /// + /// Use this constructor when your backend has its own special DbParameter type like "NpgsqlDbType" that's found outside of System.Data. + /// + /// + /// + public SQLParameterDbTypeAttribute(string dbParameterPropertyName, int value) + { + DbParameterPropertyName = dbParameterPropertyName; + Value = value; + } + public string DbParameterPropertyName { get; } + + public int Value { get; } +} diff --git a/src/Rezoom.SQL.Annotations/SQLTypeLengthAttribute.cs b/src/Rezoom.SQL.Annotations/SQLTypeLengthAttribute.cs index 821a006..aeff6fe 100644 --- a/src/Rezoom.SQL.Annotations/SQLTypeLengthAttribute.cs +++ b/src/Rezoom.SQL.Annotations/SQLTypeLengthAttribute.cs @@ -1,5 +1,4 @@ using System; - namespace Rezoom.SQL.Annotations; /// diff --git a/src/Rezoom.SQL.Compiler/AST.fs b/src/Rezoom.SQL.Compiler/AST.fs index b272b18..c9373dd 100644 --- a/src/Rezoom.SQL.Compiler/AST.fs +++ b/src/Rezoom.SQL.Compiler/AST.fs @@ -1,19 +1,24 @@ // Abstract syntax tree for our generic SQL dialect that we can parse and translate to different backends. -// "Last edited 9 years ago". My God. I was so ambitious and smart and utterly foolish then. I am less of all three now. -// I thought this code was self-documenting. -// Coming back to it after so long, I must admit it took me some head scratching to understand: // When a type in the AST takes generic parameters <'t, 'e>, 't means table type info and 'e means expression type info. // Well, technically 't could be info about something other than a table, like a view, but basically it's a table-ish thing in the DB. -// AST members carry these info properties around so they can have metadata attached about what their type is. + +// AST members carry these info properties around so they can have metadata attached as we proceed through typechecking. + // A string goes through the parser and makes an AST because we initially don't know jack about what it refers to. // When we start typechecking it against a user model (SQL schema, tables and views etc) in TypeChecker.fs, we produce // an AST of , ExprInfo>. + // Those types are aliased in InferredTypes.fs. +// Depending on how far we've gotten through typechecking, an InferredType can be anything from "no idea", to +// "not sure yet but it's a non-null version of whatever this sub-select's first column turns out to be", to +// "definitely an int32". + // When we are done processing type inference, we turn our conclusions about each type into a finalized // AST of , ExprInfo>. // Those types are aliased in ExprInfo.fs. -// Finally, that fully typechecked AST is what gets fed into the backend for translation to CommandFragments. -// And that's the way it is. + +// Finally, that fully typechecked AST is what gets fed into the backend for translation to CommandFragments +// which are raw SQL strings and parameter references. namespace Rezoom.SQL.Compiler open System diff --git a/src/Rezoom.SQL.Compiler/Backend.fs b/src/Rezoom.SQL.Compiler/Backend.fs index 29709b2..c16f722 100644 --- a/src/Rezoom.SQL.Compiler/Backend.fs +++ b/src/Rezoom.SQL.Compiler/Backend.fs @@ -16,11 +16,32 @@ type IParameterIndexer = [] [] type ParameterTransform = - { ParameterType : DbType + { ParameterType : XDbType ValueTransform : Quotations.Expr -> Quotations.Expr } - static member Default(columnType : ColumnType) = ParameterTransform.Default(columnType, fun t -> { ParameterType = t.DbType; ValueTransform = fun e -> e }) - static member Default(columnType : ColumnType, interiorPrimitiveTransform : ColumnType -> ParameterTransform) = + +type IBackend = + abstract member InitialModel : Model + abstract member MigrationBackend : Quotations.Expr IMigrationBackend> + abstract member ParameterTransform + : columnType : ColumnType -> ParameterTransform + abstract member ToCommandFragments + : indexer : IParameterIndexer * stmts : TTotalStmts -> CommandFragment IReadOnlyList + +[] +type BackendBase() = + abstract member InitialModel : Model + abstract member MigrationBackend : Quotations.Expr IMigrationBackend> + abstract member ParameterTransform + : columnType : ColumnType -> ParameterTransform + abstract member ToCommandFragments + : indexer : IParameterIndexer * stmts : TTotalStmts -> CommandFragment IReadOnlyList + abstract member InteriorPrimitiveTransform : builtInColumnType : ColumnType -> ParameterTransform + abstract member SQLTypeString : TypeName -> string + abstract member AlwaysUseCustomDbType : bool + default this.AlwaysUseCustomDbType = false + default this.InteriorPrimitiveTransform(builtInColumnType) = { ParameterType = StdDbType builtInColumnType.DbType; ValueTransform = fun e -> e } + default this.ParameterTransform(columnType) = // Null/None -> DBNull, else continue. (For non-user types only; a // user-typed parameter does its own Option unwrap inside the runtime // converter below.) Surprisingly we don't need this for Nullable: a @@ -43,10 +64,31 @@ type ParameterTransform = // still runs at design time because it operates on the underlying // runtime primitive that comes back out of the converter. let underlyingColumn = { Nullable = false; Type = underlying } - let interior = interiorPrimitiveTransform underlyingColumn + let interior = this.InteriorPrimitiveTransform underlyingColumn let underlyingClr = underlyingColumn.CLRType(false) let fdExpr = FreezeDry.FreezeDriedUserPrimitiveType.Of(userTy).Quote() - { ParameterType = interior.ParameterType + let finalParamType = + match userTy.SQLParameterDbType, userTy.RawBackendSQLType with + | Some (overrideProp, overrideVal), Some overrideType -> + CustomDbType + { DbTypePropertyName = overrideProp + DbTypeValue = overrideVal + SQLTypeName = overrideType + } + | None, Some overrideType -> + interior.ParameterType.WithSQLTypeName(overrideType) + | Some (overrideProp, overrideVal), None -> + CustomDbType + { DbTypePropertyName = overrideProp + DbTypeValue = overrideVal + SQLTypeName = this.SQLTypeString(underlying.ApproximateTypeName()) + } + | None, None -> + if this.AlwaysUseCustomDbType then + interior.ParameterType.WithSQLTypeName(this.SQLTypeString(underlying.ApproximateTypeName())) + else + interior.ParameterType + { ParameterType = finalParamType ValueTransform = fun e -> let asObj = Expr.Coerce(e, typeof) let underlyingObj = @@ -62,17 +104,13 @@ type ParameterTransform = // The fundamental underlying primitive could still be one the backend // doesn't *really* support (e.g. SQLite fakes DateTime as a string), // so the backend gets to intercept via the interior transform. - let interior = interiorPrimitiveTransform { columnType with Nullable = false } + let interior = this.InteriorPrimitiveTransform { columnType with Nullable = false } { ParameterType = interior.ParameterType ValueTransform = fun e -> optionalsToDbNull e (fun next -> interior.ValueTransform next) } - - -type IBackend = - abstract member InitialModel : Model - abstract member MigrationBackend : Quotations.Expr IMigrationBackend> - abstract member ParameterTransform - : columnType : ColumnType -> ParameterTransform - abstract member ToCommandFragments - : indexer : IParameterIndexer * stmts : TTotalStmts -> CommandFragment IReadOnlyList + interface IBackend with + member this.InitialModel = this.InitialModel + member this.MigrationBackend = this.MigrationBackend + member this.ParameterTransform (columnType : ColumnType) = this.ParameterTransform(columnType) + member this.ToCommandFragments (indexer: IParameterIndexer, stmts: TTotalStmts) = this.ToCommandFragments(indexer, stmts) diff --git a/src/Rezoom.SQL.Compiler/CommandEffect.fs b/src/Rezoom.SQL.Compiler/CommandEffect.fs index 888cdcc..efd2182 100644 --- a/src/Rezoom.SQL.Compiler/CommandEffect.fs +++ b/src/Rezoom.SQL.Compiler/CommandEffect.fs @@ -31,6 +31,7 @@ type CommandEffect = |> Seq.map (fun s -> s.Value.Info.Table.Query) static member ParseSQL(descr: string, sql : string) : TotalStmts = Parser.parseStatements descr sql |> toReadOnlyList + [] static member OfSQL(model : Model, stmts : TotalStmts) = CommandEffect.OfSQL(model, stmts, Mapping.UserTypeLibrary.Empty) static member OfSQL(model : Model, stmts : TotalStmts, userTypes : Mapping.UserTypeLibrary) = let builder = CommandEffectBuilder(model) @@ -39,6 +40,7 @@ type CommandEffect = let stmt = typeResolution.TotalStmt(stmt) builder.AddTotalStmt(stmt) builder.CommandEffect() + [] static member OfSQL(model : Model, descr : string, sql : string) = CommandEffect.OfSQL(model, descr, sql, Mapping.UserTypeLibrary.Empty) static member OfSQL(model : Model, descr : string, sql : string, userTypes : Mapping.UserTypeLibrary) = diff --git a/src/Rezoom.SQL.Compiler/DefaultBackend.fs b/src/Rezoom.SQL.Compiler/DefaultBackend.fs index a7ed199..1f6ba69 100644 --- a/src/Rezoom.SQL.Compiler/DefaultBackend.fs +++ b/src/Rezoom.SQL.Compiler/DefaultBackend.fs @@ -10,6 +10,7 @@ open Rezoom.SQL.Mapping open Rezoom.SQL.Migrations type DefaultBackend() = + inherit BackendBase() static let initialModel = let main, temp = Name("main"), Name("temp") { Schemas = @@ -25,18 +26,17 @@ type DefaultBackend() = { CanDropColumnWithDefaultValue = true } } - - interface IBackend with - member this.MigrationBackend = - <@ fun settings -> - new DefaultMigrationBackend(settings) :> IMigrationBackend - @> - member this.InitialModel = initialModel - member this.ParameterTransform(columnType) = ParameterTransform.Default(columnType) - member this.ToCommandFragments(indexer, stmts) = - let translator = DefaultStatementTranslator(Name("RZSQL"), indexer) - translator.TotalStatements(stmts) - |> BackendUtilities.simplifyFragments - |> ResizeArray - :> _ IReadOnlyList + override this.MigrationBackend = + <@ fun settings -> + new DefaultMigrationBackend(settings) :> IMigrationBackend + @> + override this.InitialModel = initialModel + override this.ToCommandFragments(indexer, stmts) = + let translator = DefaultStatementTranslator(Name("RZSQL"), indexer) + translator.TotalStatements(stmts) + |> BackendUtilities.simplifyFragments + |> ResizeArray + :> _ IReadOnlyList + override this.SQLTypeString (tyName : TypeName) = + DefaultSQLTypeString.typeNameFor tyName \ No newline at end of file diff --git a/src/Rezoom.SQL.Compiler/DefaultExprTranslator.fs b/src/Rezoom.SQL.Compiler/DefaultExprTranslator.fs index 36fd9fd..e6aeacd 100644 --- a/src/Rezoom.SQL.Compiler/DefaultExprTranslator.fs +++ b/src/Rezoom.SQL.Compiler/DefaultExprTranslator.fs @@ -3,6 +3,26 @@ open Rezoom.SQL.Compiler open Rezoom.SQL.Compiler.BackendUtilities open Rezoom.SQL.Mapping +module DefaultSQLTypeString = + let rec typeNameFor name = + match name with + | BooleanTypeName -> "BOOL" + | GuidTypeName -> "GUID" + | IntegerTypeName Integer16 -> "INT16" + | IntegerTypeName Integer32 -> "INT32" + | IntegerTypeName Integer64 -> "INT64" + | FloatTypeName Float32 -> "FLOAT32" + | FloatTypeName Float64 -> "FLOAT64" + | StringTypeName(Some size) -> "STRING(" + string size + ")" + | StringTypeName(None) -> "STRING" + | BinaryTypeName(Some size) -> "BINARY(" + string size + ")" + | BinaryTypeName(None) -> "BINARY" + | DecimalTypeName -> "DECIMAL" + | DateTimeTypeName -> "DATETIME" + | DateTimeOffsetTypeName -> "DATETIMEOFFSET" + | UnresolvedTypeName t -> bug <| sprintf "Unresolved UserType %s beyond resolution layer" t + | ResolvedUserType r -> r.RawBackendSQLType |> Option.defaultWith (fun () -> typeNameFor r.UnderlyingSQLTypeName) + type DefaultExprTranslator(statement : StatementTranslator, indexer : IParameterIndexer) = inherit ExprTranslator() override __.Literal = upcast DefaultLiteralTranslator() @@ -11,25 +31,7 @@ type DefaultExprTranslator(statement : StatementTranslator, indexer : IParameter |> text override this.CollationName(name) = this.Name(name) override __.TypeName(name, _) = - let rec tyName name = - match name with - | BooleanTypeName -> "BOOL" - | GuidTypeName -> "GUID" - | IntegerTypeName Integer16 -> "INT16" - | IntegerTypeName Integer32 -> "INT32" - | IntegerTypeName Integer64 -> "INT64" - | FloatTypeName Float32 -> "FLOAT32" - | FloatTypeName Float64 -> "FLOAT64" - | StringTypeName(Some size) -> "STRING(" + string size + ")" - | StringTypeName(None) -> "STRING" - | BinaryTypeName(Some size) -> "BINARY(" + string size + ")" - | BinaryTypeName(None) -> "BINARY" - | DecimalTypeName -> "DECIMAL" - | DateTimeTypeName -> "DATETIME" - | DateTimeOffsetTypeName -> "DATETIMEOFFSET" - | UnresolvedTypeName t -> bug <| sprintf "Unresolved UserType %s beyond resolution layer" t - | ResolvedUserType r -> r.RawBackendSQLType |> Option.defaultWith (fun () -> tyName r.UnderlyingSQLTypeName) - tyName name |> text |> Seq.singleton + DefaultSQLTypeString.typeNameFor name |> text |> Seq.singleton override __.BinaryOperator op = CommandText <| diff --git a/src/Rezoom.SQL.Compiler/Postgres.fs b/src/Rezoom.SQL.Compiler/Postgres.fs index 674aec3..69145e3 100644 --- a/src/Rezoom.SQL.Compiler/Postgres.fs +++ b/src/Rezoom.SQL.Compiler/Postgres.fs @@ -33,7 +33,7 @@ type private PostgresExpression(statement : StatementTranslator, indexer) = override __.CollationName(name) = // no ToLower, use as-is "\"" + name.Value.Replace("\"", "\"\"") + "\"" |> text - override __.TypeName(name, autoIncrement) = + static member PostgresTypeString(name : TypeName, autoIncrement : bool) = let rec tyName name = match name with | BooleanTypeName -> "BOOLEAN" @@ -54,7 +54,9 @@ type private PostgresExpression(statement : StatementTranslator, indexer) = | DateTimeOffsetTypeName -> "TIMESTAMPTZ" | UnresolvedTypeName t -> bug <| sprintf "Unresolved UserType %s beyond resolution layer" t | ResolvedUserType r -> r.RawBackendSQLType |> Option.defaultWith (fun () -> tyName r.UnderlyingSQLTypeName) - tyName name |> text |> Seq.singleton + tyName name + override __.TypeName(name, autoIncrement) = + PostgresExpression.PostgresTypeString(name, autoIncrement) |> text |> Seq.singleton override this.ObjectName name = seq { match name.SchemaName with @@ -192,6 +194,7 @@ type private PostgresStatement(indexer : IParameterIndexer) as this = } type PostgresBackend() = + inherit BackendBase() static let initialModel = let main, temp = Name("public"), Name("temp") { Schemas = @@ -207,13 +210,16 @@ type PostgresBackend() = { CanDropColumnWithDefaultValue = true } } - interface IBackend with - member this.MigrationBackend = <@ fun conn -> new PostgresMigrationBackend(conn) :> IMigrationBackend @> - member this.InitialModel = initialModel - member this.ParameterTransform(columnType) = ParameterTransform.Default(columnType) - member this.ToCommandFragments(indexer, stmts) = - let translator = PostgresStatement(indexer) - translator.TotalStatements(stmts) - |> BackendUtilities.simplifyFragments - |> ResizeArray - :> _ IReadOnlyList \ No newline at end of file + override this.MigrationBackend = <@ fun conn -> new PostgresMigrationBackend(conn) :> IMigrationBackend @> + override this.InitialModel = initialModel + override this.ToCommandFragments(indexer, stmts) = + let translator = PostgresStatement(indexer) + translator.TotalStatements(stmts) + |> BackendUtilities.simplifyFragments + |> ResizeArray + :> _ IReadOnlyList + /// Have to do this because the Postgres runtime needs parameters with SQL column types, + /// for generating IN (empty param list) as IN (select null::typename where false). + /// See CommandBatch.fs in runtime. + override this.AlwaysUseCustomDbType = true + override this.SQLTypeString (typeName : TypeName) = PostgresExpression.PostgresTypeString(typeName, false) \ No newline at end of file diff --git a/src/Rezoom.SQL.Compiler/Rezoom.SQL.Compiler.fsproj b/src/Rezoom.SQL.Compiler/Rezoom.SQL.Compiler.fsproj index 0544472..edd51a4 100644 --- a/src/Rezoom.SQL.Compiler/Rezoom.SQL.Compiler.fsproj +++ b/src/Rezoom.SQL.Compiler/Rezoom.SQL.Compiler.fsproj @@ -7,7 +7,7 @@ MIT https://github.com/rspeele/Rezoom.SQL https://github.com/rspeele/Rezoom.SQL - 1.0.0: ConnectionProvider returns to its minimal abstract shape (Open / BeginTransaction). ConnectionInfo and configuration concerns move to ConfigurationConnectionProvider. Commands now carry their compile-time dialect as a typed Backend DU (RzSQL / SQLite / TSQL / Postgres) rather than a string, parsed at the rzsql.json boundary and threaded through everywhere else. Breaking change for custom ConnectionProvider subclasses: Open now takes (connectionName : string, backend : Backend). See CHANGELOG.md for the full 1.0.0 story. + 1.1.0: adds support for custom user defined types. README.md true diff --git a/src/Rezoom.SQL.Compiler/SQLite.fs b/src/Rezoom.SQL.Compiler/SQLite.fs index 92d7b3f..e1f1129 100644 --- a/src/Rezoom.SQL.Compiler/SQLite.fs +++ b/src/Rezoom.SQL.Compiler/SQLite.fs @@ -36,7 +36,7 @@ type private SQLiteExpression(statement : StatementTranslator, indexer) = inherit DefaultExprTranslator(statement, indexer) let literal = SQLiteLiteral() override __.Literal = upcast literal - override __.TypeName(name, autoIncrement) = + static member SQLiteTypeString(name, autoIncrement) = let rec tyName name = match name with | BooleanTypeName @@ -53,7 +53,9 @@ type private SQLiteExpression(statement : StatementTranslator, indexer) = | DateTimeOffsetTypeName -> fail <| sprintf "Unsupported type ``%A``" name | UnresolvedTypeName t -> bug <| sprintf "Unresolved UserType %s beyond resolution layer" t | ResolvedUserType r -> r.RawBackendSQLType |> Option.defaultWith (fun () -> tyName r.UnderlyingSQLTypeName) - tyName name |> text |> Seq.singleton + tyName name + override __.TypeName(name, autoIncrement) = + SQLiteExpression.SQLiteTypeString(name, autoIncrement) |> text |> Seq.singleton type private SQLiteStatement(indexer : IParameterIndexer) as this = inherit DefaultStatementTranslator(Name("SQLITE"), indexer) @@ -84,6 +86,7 @@ type SQLiteMigrationBackend(info : ConnectionInfo) = base.Initialize() type SQLiteBackend() = + inherit BackendBase() static let initialModel = let main, temp = Name("main"), Name("temp") { Schemas = @@ -99,29 +102,26 @@ type SQLiteBackend() = { CanDropColumnWithDefaultValue = true } } - interface IBackend with - member this.MigrationBackend = <@ fun settings -> new SQLiteMigrationBackend(settings) :> IMigrationBackend @> - member this.InitialModel = initialModel - member this.ParameterTransform(columnType) = - ParameterTransform.Default(columnType, fun columnType -> - match columnType.Type with - | DateTimeType -> - { ParameterType = DbType.String - ValueTransform = fun expr -> - Expr.Call(typeof.GetMethod(nameof SQLiteParamConversions.DateTimeToString), [ expr ]) - } - | GuidType -> - { ParameterType = DbType.Binary - ValueTransform = fun expr -> - Expr.Call(typeof.GetMethod(nameof SQLiteParamConversions.GuidToBytes), [ expr ]) - } - | _ -> { ParameterType = columnType.DbType; ValueTransform = fun e -> e } - ) - - member this.ToCommandFragments(indexer, stmts) = - let translator = SQLiteStatement(indexer) - translator.TotalStatements(stmts) - |> BackendUtilities.simplifyFragments - |> ResizeArray - :> _ IReadOnlyList - \ No newline at end of file + override this.MigrationBackend = <@ fun settings -> new SQLiteMigrationBackend(settings) :> IMigrationBackend @> + override this.InitialModel = initialModel + override this.InteriorPrimitiveTransform (columnType: ColumnType): ParameterTransform = + match columnType.Type with + | DateTimeType -> + { ParameterType = StdDbType DbType.String + ValueTransform = fun expr -> + Expr.Call(typeof.GetMethod(nameof SQLiteParamConversions.DateTimeToString), [ expr ]) + } + | GuidType -> + { ParameterType = StdDbType DbType.Binary + ValueTransform = fun expr -> + Expr.Call(typeof.GetMethod(nameof SQLiteParamConversions.GuidToBytes), [ expr ]) + } + | _ -> { ParameterType = StdDbType columnType.DbType; ValueTransform = fun e -> e } + override this.ToCommandFragments(indexer, stmts) = + let translator = SQLiteStatement(indexer) + translator.TotalStatements(stmts) + |> BackendUtilities.simplifyFragments + |> ResizeArray + :> _ IReadOnlyList + override this.SQLTypeString (tyName : TypeName) = + SQLiteExpression.SQLiteTypeString(tyName, false) \ No newline at end of file diff --git a/src/Rezoom.SQL.Compiler/TSQL.Expression.fs b/src/Rezoom.SQL.Compiler/TSQL.Expression.fs index 1c8606c..dab00ea 100644 --- a/src/Rezoom.SQL.Compiler/TSQL.Expression.fs +++ b/src/Rezoom.SQL.Compiler/TSQL.Expression.fs @@ -27,7 +27,7 @@ type private TSQLExpression(statement : StatementTranslator, indexer) = "[" + name.Value.Replace("]", "]]") + "]" |> text override __.CollationName(name) = text name.Value - override __.TypeName(name, _) = + static member TSQLTypeString(name) = let rec tyName name = match name with | BooleanTypeName -> "BIT" @@ -46,7 +46,9 @@ type private TSQLExpression(statement : StatementTranslator, indexer) = | DateTimeOffsetTypeName -> "DATETIMEOFFSET" | UnresolvedTypeName t -> bug <| sprintf "Unresolved UserType %s beyond resolution layer" t | ResolvedUserType r -> r.RawBackendSQLType |> Option.defaultWith (fun () -> tyName r.UnderlyingSQLTypeName) - tyName name |> text |> Seq.singleton + tyName name + override __.TypeName(name, _) = + TSQLExpression.TSQLTypeString(name) |> text |> Seq.singleton override this.ObjectName name = seq { match name.SchemaName with diff --git a/src/Rezoom.SQL.Compiler/TSQL.fs b/src/Rezoom.SQL.Compiler/TSQL.fs index 5a4dc6e..bb3aa0a 100644 --- a/src/Rezoom.SQL.Compiler/TSQL.fs +++ b/src/Rezoom.SQL.Compiler/TSQL.fs @@ -1,11 +1,10 @@ namespace Rezoom.SQL.Compiler.TSQL open System.Collections.Generic open Rezoom.SQL.Compiler -open Rezoom.SQL.Compiler.BackendUtilities -open Rezoom.SQL.Compiler.Translators open Rezoom.SQL.Migrations type TSQLBackend() = + inherit BackendBase() static let initialModel = let main, temp = Name("dbo"), Name("temp") { Schemas = @@ -21,14 +20,13 @@ type TSQLBackend() = { CanDropColumnWithDefaultValue = false } } - interface IBackend with - member this.MigrationBackend = <@ fun conn -> new TSQLMigrationBackend(conn) :> IMigrationBackend @> - member this.InitialModel = initialModel - member this.ParameterTransform(columnType) = ParameterTransform.Default(columnType) - member this.ToCommandFragments(indexer, stmts) = - let translator = TSQLStatement(indexer) - translator.TotalStatements(stmts) - |> BackendUtilities.simplifyFragments - |> ResizeArray - :> _ IReadOnlyList - \ No newline at end of file + override this.MigrationBackend = <@ fun conn -> new TSQLMigrationBackend(conn) :> IMigrationBackend @> + override this.InitialModel = initialModel + override this.ToCommandFragments(indexer, stmts) = + let translator = TSQLStatement(indexer) + translator.TotalStatements(stmts) + |> BackendUtilities.simplifyFragments + |> ResizeArray + :> _ IReadOnlyList + override this.SQLTypeString (tyName : TypeName) = + TSQLExpression.TSQLTypeString(tyName) \ No newline at end of file diff --git a/src/Rezoom.SQL.Compiler/TypeNameForCLRType.fs b/src/Rezoom.SQL.Compiler/TypeNameForCLRType.fs index c311f27..372f522 100644 --- a/src/Rezoom.SQL.Compiler/TypeNameForCLRType.fs +++ b/src/Rezoom.SQL.Compiler/TypeNameForCLRType.fs @@ -26,7 +26,10 @@ let clrTypeDict = typeof, fun _ -> IntegerTypeName Integer32 typeof, fun _ -> IntegerTypeName Integer64 typeof, fun _ -> IntegerTypeName Integer64 + // No mapping for System.Object which is the only thing mapped in PrimitiveConverters.Converters + // that is NOT here. It gets special handling in TypeSystem.fs, CoreColumnType.OfTypeName. |] |> Array.map (fun (t, f) -> (t.FullName, f)) |> dict + type UserPrimitiveType with member this.UnderlyingSQLTypeName = let succ, found = clrTypeDict.TryGetValue(this.UnderlyingCLRType.FullName) diff --git a/src/Rezoom.SQL.Compiler/TypeSystem.fs b/src/Rezoom.SQL.Compiler/TypeSystem.fs index 0205b30..91b33b7 100644 --- a/src/Rezoom.SQL.Compiler/TypeSystem.fs +++ b/src/Rezoom.SQL.Compiler/TypeSystem.fs @@ -125,7 +125,14 @@ type CoreColumnType = | UnresolvedTypeName name -> bug <| sprintf "User type %s hit the type checker before the UserTypeResolution pass." name | ResolvedUserType t -> - UserTypeBasedOn (t, CoreColumnType.OfTypeName(t.UnderlyingSQLTypeName)) + // Special case of usertypes that map to obj: they subclass underneath ScalarTypeClass in the hierarchy + // and we don't really know of functions or operators that work on them specifically. + // You can do truly generic stuff like comparisons and coalesce(), + // but other than that, you're on your own. + if t.UnderlyingCLRType.FullName = typeof.FullName then + UserTypeBasedOn (t, ScalarTypeClass) + else + UserTypeBasedOn (t, CoreColumnType.OfTypeName(t.UnderlyingSQLTypeName)) type ColumnType = { Type : CoreColumnType diff --git a/src/Rezoom.SQL.Compiler/UserModel.fs b/src/Rezoom.SQL.Compiler/UserModel.fs index d535fe3..e04d76e 100644 --- a/src/Rezoom.SQL.Compiler/UserModel.fs +++ b/src/Rezoom.SQL.Compiler/UserModel.fs @@ -149,6 +149,10 @@ type UserModel = Migrations : string MigrationTree IReadOnlyList UserTypeLibrary : UserTypeLibrary } + member this.CommandEffect(statements : TotalStmts) = + CommandEffect.OfSQL(this.Model, statements, this.UserTypeLibrary) + member this.CommandEffect(descr : string, sql : string) = + CommandEffect.OfSQL(this.Model, descr, sql, this.UserTypeLibrary) static member ConfigFileName = "rzsql.json" static member Load(resolutionFolder : string, modelPath : string) = UserModel.Load(resolutionFolder, modelPath, Seq.empty) diff --git a/src/Rezoom.SQL.Mapping/CommandBatch.fs b/src/Rezoom.SQL.Mapping/CommandBatch.fs index 4001310..234ce80 100644 --- a/src/Rezoom.SQL.Mapping/CommandBatch.fs +++ b/src/Rezoom.SQL.Mapping/CommandBatch.fs @@ -6,6 +6,7 @@ open System.Collections.Generic open System.Text open System.Threading open System.Threading.Tasks +open System.Reflection open Rezoom.SQL type private CommandBatchRuntimeBackend = @@ -60,11 +61,17 @@ type private CommandBatchRuntimeBackend = | DbType.DateTime2 | DbType.DateTimeOffset -> "timestamptz" | _ -> "unknown" - member this.EmptyInList(ty : DbType) = + member this.EmptyInList(ty : XDbType) = match this with | Postgres -> + let typeSpecifier = + match ty with + // This fallback should no longer be needed for non-dynamic command situations, + // since our compiler backend now emits CustomDbType for ALL statically known PG parameters. + | StdDbType ty -> CommandBatchRuntimeBackend.PgType(ty) + | CustomDbType t -> t.SQLTypeName // PG has to be difficult and demand a type specifier matching the input - "(SELECT NULL::" + CommandBatchRuntimeBackend.PgType(ty) + " WHERE FALSE)" + "(SELECT NULL::" + typeSpecifier + " WHERE FALSE)" | SQLite -> // SQLite is cool and accepts the simple approach. This might be faster than the empty subquery. "()" @@ -85,12 +92,26 @@ type private CommandBatchBuilder(conn : DbConnection, tran : DbTransaction) = let mutable parameterCount = 0 let mutable evaluating = false + let applyXDbType (dbParam : DbParameter) (dbType : XDbType) = + match dbType with + | StdDbType dbType -> dbParam.DbType <- dbType + | CustomDbType { DbTypePropertyName = propName; DbTypeValue = intValue } -> + let prop = dbParam.GetType().GetProperty(propName, BindingFlags.Instance|||BindingFlags.Public) + if isNull prop then failwithf "Specified DbType property %s was not found" propName + if not prop.CanWrite then failwithf "Specified DbType property %s is not writable" propName + if prop.PropertyType = typeof then + prop.SetValue(dbParam, intValue) + elif prop.PropertyType.IsEnum && prop.PropertyType.GetEnumUnderlyingType() = typeof then + prop.SetValue(dbParam, Enum.ToObject(prop.PropertyType, intValue)) + else + failwithf "Specified DbType property %s is not a supported int32 or int32 enum type" propName + let addCommand (builder : StringBuilder) (dbCommand : DbCommand) (commandIndex : int) (command : Command) = let parameterOffset = dbCommand.Parameters.Count - let addParam name dbType (value : obj) = + let addParam name (dbType : XDbType) (value : obj) = let dbParam = dbCommand.CreateParameter() dbParam.ParameterName <- name - dbParam.DbType <- dbType + applyXDbType dbParam dbType dbParam.Value <- if isNull value then box DBNull.Value else value ignore <| dbCommand.Parameters.Add(dbParam) for i, parameter in command.Parameters |> Seq.indexed do diff --git a/src/Rezoom.SQL.Mapping/CommandParts.fs b/src/Rezoom.SQL.Mapping/CommandParts.fs index 3e43656..d7dcaf9 100644 --- a/src/Rezoom.SQL.Mapping/CommandParts.fs +++ b/src/Rezoom.SQL.Mapping/CommandParts.fs @@ -1,9 +1,35 @@ namespace Rezoom.SQL.Mapping +open FSharp.Quotations open System open System.Data open System.Collections.Generic open Rezoom + +[] +type CustomDbTypeInfo = + { DbTypePropertyName : string + SQLTypeName : string + DbTypeValue : int + } + +/// Extended DbType: can either be a true DbType enum value in the simple case, +/// or can be a driver-specific property to set via reflection for e.g. NpgsqlDbType. +[] +[] +type XDbType = + | StdDbType of dbType : DbType + | CustomDbType of CustomDbTypeInfo + member this.WithSQLTypeName(sqlTypeName : string) = + match this with + | StdDbType d -> CustomDbType { DbTypePropertyName = nameof(DbType); SQLTypeName = sqlTypeName; DbTypeValue = int d } + | CustomDbType c -> CustomDbType { c with SQLTypeName = sqlTypeName } + member this.Quote() = + match this with + | StdDbType d -> <@@ StdDbType (%%Expr.Value(d)) @@> + | CustomDbType { DbTypePropertyName = dp; SQLTypeName = st; DbTypeValue = dt } -> + <@@ CustomDbType { DbTypePropertyName = %%Expr.Value(dp); SQLTypeName = %%Expr.Value(st); DbTypeValue = %%Expr.Value(dt) } @@> + [] type CommandFragment = /// A name which should be localized to this command for batching. @@ -15,7 +41,7 @@ type CommandFragment = /// References parameter by index. | Parameter of int /// Directly specifies parameter value. - | InlineParameter of DbType * obj + | InlineParameter of XDbType * obj /// At least one unit of whitespace. | Whitespace /// Whitespace, preferably a line break. @@ -96,8 +122,8 @@ type CommandCategory = CommandCategory of connectionName : string [] [] type CommandParameter = - | ListParameter of DbType * Array - | ScalarParameter of DbType * obj + | ListParameter of XDbType * Array + | ScalarParameter of XDbType * obj | RawSQLParameter of CommandFragment array member this.Equals(other : CommandParameter) = match this, other with diff --git a/src/Rezoom.SQL.Mapping/FreezeDry.fs b/src/Rezoom.SQL.Mapping/FreezeDry.fs index 9ec6093..77ff8a6 100644 --- a/src/Rezoom.SQL.Mapping/FreezeDry.fs +++ b/src/Rezoom.SQL.Mapping/FreezeDry.fs @@ -107,6 +107,7 @@ let rehydrate (freezeDried : FreezeDriedUserTypeLibrary) : UserTypeLibrary = UnderlyingCLRType = toPrim.ReturnType RawBackendSQLType = None // don't need at runtime SQLTypeLength = None // don't need at runtime + SQLParameterDbType = None // don't currently use at runtime, but note: may eventually need to upgrade, when we start doing dynamic filtering RuntimeMapping = { FromPrimitiveMethod = fromPrim; ToPrimitiveMethod = toPrim } // Freeze-dried libraries do not include auto-implementations since the runtime would rederive them anyway // so it would be code bloat. diff --git a/src/Rezoom.SQL.Mapping/PrimitiveConverters.fs b/src/Rezoom.SQL.Mapping/PrimitiveConverters.fs index 72a89f9..8718910 100644 --- a/src/Rezoom.SQL.Mapping/PrimitiveConverters.fs +++ b/src/Rezoom.SQL.Mapping/PrimitiveConverters.fs @@ -427,12 +427,13 @@ and findSingleCaseDU (publicType : Type) : UserPrimitiveType ValueOption = && hasCompilationMappingKind p SourceConstructFlags.Field) match candidates with | [| singleProp |] -> - let raw, len = UserTypeAnnotations.resolveType publicType + let annotations = UserTypeAnnotations.resolveType publicType ValueSome { UserCLRType = publicType UnderlyingCLRType = singleParam.ParameterType - RawBackendSQLType = raw - SQLTypeLength = len + RawBackendSQLType = annotations.RawType + SQLTypeLength = annotations.Length + SQLParameterDbType = annotations.ParameterDbType RuntimeMapping = { FromPrimitiveMethod = singleCaseCtor ToPrimitiveMethod = singleProp.GetMethod diff --git a/src/Rezoom.SQL.Mapping/README.md b/src/Rezoom.SQL.Mapping/README.md index 856693d..6b1c562 100644 --- a/src/Rezoom.SQL.Mapping/README.md +++ b/src/Rezoom.SQL.Mapping/README.md @@ -2,33 +2,6 @@ Runtime code for executing SQL queries. -## Entity Readers - -The bulk of this project is loader code that enables us to run a query and materialize the result sets as strongly typed objects. -Does reflection on the result row type it's going to load, then generates CIL to make a super fast loader from DbDataReader. - -In other words, basically reimplements a lot of what Dapper does. But we need special features to handle our -MANY(...) construct and primitive type conversions, so that's why it's implemented in-house vs. pulling in Dapper to do this job. - -## Batching and Rezoom integration - -Additionally, this project defines how a Command<'a> can be turned into an Errand<'a> for use in a Rezoom `plan {...}` block. - -This plugs into the Rezoom system and lets us support automatic batching and caching. - -The actual batching code is in CommandBatch.fs, and the Rezoom integration that coordinates errand execution is in Plans.fs. - -## Migrations - -Defines the logic for figuring out a migration tree from a set of source files, and running the -migrations on a database. - -## Side Note on Dependency Order - -Rezoom.SQL.Compiler depends on this project, for types like CommandFragment and MigrationTree. - -A slightly cleaner way to structure this would be a shared base library with those types, referenced by both -Rezoom.SQL.Compiler and Rezoom.SQL.Mapping. Then you wouldn't have to pull in whole runtime layer if you -really wanted just the compiler layer to do some analysis and translation on SQL queries. +# Rezoom.SQL.Mapping -But practically speaking it's fine this way. +Runtime library for Rezoom.SQL. Contains library for executing Rezoom.SQL Commands as Tasks or Plans. \ No newline at end of file diff --git a/src/Rezoom.SQL.Mapping/README_DEV.md b/src/Rezoom.SQL.Mapping/README_DEV.md new file mode 100644 index 0000000..4060125 --- /dev/null +++ b/src/Rezoom.SQL.Mapping/README_DEV.md @@ -0,0 +1,30 @@ +## Entity Readers + +The bulk of this project is loader code that enables us to run a query and materialize the result sets as strongly typed objects. +Does reflection on the result row type it's going to load, then generates CIL to make a super fast loader from DbDataReader. + +In other words, basically reimplements a lot of what Dapper does. But we need special features to handle our +MANY(...) construct and primitive type conversions, so that's why it's implemented in-house vs. pulling in Dapper to do this job. + +## Batching and Rezoom integration + +Additionally, this project defines how a Command<'a> can be turned into an Errand<'a> for use in a Rezoom `plan {...}` block. + +This plugs into the Rezoom system and lets us support automatic batching and caching. + +The actual batching code is in CommandBatch.fs, and the Rezoom integration that coordinates errand execution is in Plans.fs. + +## Migrations + +Defines the logic for figuring out a migration tree from a set of source files, and running the +migrations on a database. + +## Side Note on Dependency Order + +Rezoom.SQL.Compiler depends on this project, for types like CommandFragment and MigrationTree. + +A slightly cleaner way to structure this would be a shared base library with those types, referenced by both +Rezoom.SQL.Compiler and Rezoom.SQL.Mapping. Then you wouldn't have to pull in whole runtime layer if you +really wanted just the compiler layer to do some analysis and translation on SQL queries. + +But practically speaking it's fine this way. diff --git a/src/Rezoom.SQL.Mapping/Raw.fs b/src/Rezoom.SQL.Mapping/Raw.fs index 9353c1c..152dbaf 100644 --- a/src/Rezoom.SQL.Mapping/Raw.fs +++ b/src/Rezoom.SQL.Mapping/Raw.fs @@ -6,7 +6,6 @@ module Rezoom.SQL.Raw open System open System.Data open Rezoom.SQL.Mapping -open System.Collections.Generic let sql text = CommandText text @@ -40,4 +39,4 @@ let arg (o : obj) = let dbType = if isNull o then DbType.Object else guessDbType (o.GetType()) - argOfType dbType o + argOfType (StdDbType dbType) o diff --git a/src/Rezoom.SQL.Mapping/Rezoom.SQL.Mapping.fsproj b/src/Rezoom.SQL.Mapping/Rezoom.SQL.Mapping.fsproj index 8798283..86aa1fb 100644 --- a/src/Rezoom.SQL.Mapping/Rezoom.SQL.Mapping.fsproj +++ b/src/Rezoom.SQL.Mapping/Rezoom.SQL.Mapping.fsproj @@ -1,4 +1,4 @@ - + netstandard2.0;net8.0;net10.0 Robert Peele @@ -6,11 +6,12 @@ MIT https://github.com/rspeele/Rezoom.SQL https://github.com/rspeele/Rezoom.SQL - 1.0.0: multi-target netstandard2.0 / net8.0 / net10.0. Drop TaskBuilder.fs. Modernize configuration to IConfiguration instead of the old ConfigurationManager. See the Rezoom.SQL CHANGELOG.md for the full 1.0.0 story. + 1.1.0: adds support for custom user defined types. README.md true + diff --git a/src/Rezoom.SQL.Mapping/UserTypeAnnotations.fs b/src/Rezoom.SQL.Mapping/UserTypeAnnotations.fs index ad6e893..fe5bd64 100644 --- a/src/Rezoom.SQL.Mapping/UserTypeAnnotations.fs +++ b/src/Rezoom.SQL.Mapping/UserTypeAnnotations.fs @@ -20,60 +20,82 @@ let private rawBackendSqlTypeName = let private sqlTypeLengthName = "Rezoom.SQL.Annotations.SQLTypeLengthAttribute" -/// Read both annotation attributes from a single member. -/// Returns (rawBackendSqlType, sqlTypeLength). -let readMember (m : MemberInfo) = - let mutable raw = None - let mutable len = None - for attr in m.GetCustomAttributesData() do - let fullName = attr.AttributeType.FullName - if fullName = rawBackendSqlTypeName - && attr.ConstructorArguments.Count >= 1 then - match attr.ConstructorArguments.[0].Value with - | :? string as v -> raw <- Some v - | _ -> () - elif fullName = sqlTypeLengthName - && attr.ConstructorArguments.Count >= 1 then - match attr.ConstructorArguments.[0].Value with - | :? int as v -> len <- Some v - | _ -> () - raw, len +/// Attribute name from Rezoom.SQL.Annotations assembly. +let private sqlParameterDbTypeName = + "Rezoom.SQL.Annotations.SQLParameterDbTypeAttribute" -let private validateExclusive (label : string) (raw, len) = - match raw, len with - | Some _, Some _ -> - failwithf - "User primitive %s has both [] and [] applied. They are mutually exclusive — RawBackendSQLType already specifies the complete SQL type string including any length parameter." - label - | _ -> raw, len +type AnnotationsForMember = + { TypeName : string + RawType : string option + Length : int option + ParameterDbType : (string * int) option + } + member this.ValidateExclusive() = + match this.RawType, this.Length with + | Some _, Some _ -> + failwithf + "User primitive %s has both [] and [] applied. They are mutually exclusive — RawBackendSQLType already specifies the complete SQL type string including any length parameter." + this.TypeName + | _ -> this + member this.Merge(other : AnnotationsForMember) = + let tName = this.TypeName + let inline agree attrLabel l r = + match l, r with + | Some lv, Some rv when lv = rv -> l + | Some lv, Some rv -> + failwithf + "User primitive %s has conflicting [<%s>] attributes." + tName attrLabel + | Some _, None -> l + | None, Some _ -> r + | None, None -> None + { TypeName = this.TypeName + RawType = agree rawBackendSqlTypeName this.RawType other.RawType + Length = agree sqlTypeLengthName this.Length other.Length + ParameterDbType = agree sqlParameterDbTypeName this.ParameterDbType other.ParameterDbType + } -// If both methods set the attribute they must set the same -// value; if only one does, that one wins. -let private agreeOnMethods (attrLabel : string) (label : string) (a : 'a option) (b : 'a option) = - match a, b with - | None, x | x, None -> x - | Some av, Some bv when av = bv -> Some av - | Some av, Some bv -> - failwithf - "User primitive %s has conflicting [<%s>] attributes on its ToPrimitive (%A) and FromPrimitive (%A) methods." - label attrLabel av bv +let readMember (typeName : string) (m : MemberInfo) : AnnotationsForMember = + let mutable acc = { TypeName = typeName; RawType = None; Length = None; ParameterDbType = None } + for attr in m.GetCustomAttributesData() do + acc <- + let fullName = attr.AttributeType.FullName + if fullName = rawBackendSqlTypeName + && attr.ConstructorArguments.Count >= 1 then + match attr.ConstructorArguments.[0].Value with + | :? string as v -> acc.Merge({ acc with RawType = Some v }).ValidateExclusive() + | _ -> acc + elif fullName = sqlTypeLengthName + && attr.ConstructorArguments.Count >= 1 then + match attr.ConstructorArguments.[0].Value with + | :? int as v -> acc.Merge({ acc with Length = Some v }).ValidateExclusive() + | _ -> acc + elif fullName = sqlParameterDbTypeName + && attr.ConstructorArguments.Count >= 1 then + match attr.ConstructorArguments.Count with + | 1 -> + // single-arg ctor is the DbType-only version + match attr.ConstructorArguments.[0].Value with + | :? int as v -> acc.Merge({ acc with ParameterDbType = Some ("DbType", v) }) + | _ -> acc + | _ -> + match attr.ConstructorArguments.[0].Value, attr.ConstructorArguments.[1].Value with + | (:? string as propName), (:? int as dbType) -> + acc.Merge({ acc with ParameterDbType = Some (propName, dbType) }) + | _ -> acc + else acc + acc /// Resolve attributes for an explicit ToPrimitive/FromPrimitive /// user primitive. `declaring` is the wrapper class that holds /// type-level attributes; the two methods may also be annotated. -/// Method-level wins over type-level. -let resolveExplicit (label : string) (declaring : Type) (toPrim : MethodInfo) (fromPrim : MethodInfo) = - let typeRaw, typeLen = readMember (declaring :> MemberInfo) - let toRaw, toLen = readMember (toPrim :> MemberInfo) - let fromRaw, fromLen = readMember (fromPrim :> MemberInfo) - let methodRaw = agreeOnMethods "RawBackendSQLType" label toRaw fromRaw - let methodLen = agreeOnMethods "SQLTypeLength" label toLen fromLen - let raw = methodRaw |> Option.orElse typeRaw - let len = methodLen |> Option.orElse typeLen - validateExclusive label (raw, len) +let resolveExplicit (declaring : Type) (toPrim : MethodInfo) (fromPrim : MethodInfo) = + let typeAttrs = readMember declaring.Name (declaring :> MemberInfo) + let toPrimAttrs = readMember declaring.Name (toPrim :> MemberInfo) + let fromPrimAttrs = readMember declaring.Name (fromPrim :> MemberInfo) + typeAttrs.Merge(toPrimAttrs).Merge(fromPrimAttrs).ValidateExclusive() /// Resolve attributes for the auto-DU path where there is just one /// type (the DU itself) to inspect. let resolveType (typ : Type) = - readMember (typ :> MemberInfo) - |> validateExclusive typ.FullName + readMember typ.Name (typ :> MemberInfo) diff --git a/src/Rezoom.SQL.Mapping/UserTypeLibrary.fs b/src/Rezoom.SQL.Mapping/UserTypeLibrary.fs index 415e835..60e4b13 100644 --- a/src/Rezoom.SQL.Mapping/UserTypeLibrary.fs +++ b/src/Rezoom.SQL.Mapping/UserTypeLibrary.fs @@ -36,6 +36,9 @@ type UserPrimitiveType = /// If our underlying type is string, we may want to specify a max length in SQL. /// Ignored if RawBackendSQLType is Some, in which case we trust it directly. SQLTypeLength : int option + /// If specified, this is a property name to set on the DbParameter plus an integer value to set it to. + /// Used to set DbType, or SqlDbType, or NpgsqlDbType properties. + SQLParameterDbType : (string * int) option RuntimeMapping : RuntimeMapping /// True if this is an implementation we derived automatically, such as for an F# /// single-case DU, as opposed to one the user specified with their own ToPrimitive diff --git a/src/Rezoom.SQL.Mapping/UserTypeLibraryLoader.fs b/src/Rezoom.SQL.Mapping/UserTypeLibraryLoader.fs index 224588e..f5134f5 100644 --- a/src/Rezoom.SQL.Mapping/UserTypeLibraryLoader.fs +++ b/src/Rezoom.SQL.Mapping/UserTypeLibraryLoader.fs @@ -92,29 +92,40 @@ let private findCustomMappingsInType (publicType : Type) : RuntimeMapping seq = publicType.FullName } +/// Called during assembly search to check for invalid/impossible to support mappings. +/// Not validated at runtime because we may not actually have all the info available at runtime due to +/// FreezeDry excluding metadata runtime doesn't need. +let private designTimeValidate (userPrim : UserPrimitiveType) = + if userPrim.UnderlyingCLRType.FullName = typeof.FullName + && Option.isNone userPrim.RawBackendSQLType then + failwithf + "User primitive %s is mapped to System.Object. The attribute [] must be applied so we know what column type to use in SQL." + userPrim.Name + userPrim + let private findUserTypesInAssembly (asm : Assembly) : UserPrimitiveType seq = seq { for publicType in asm.GetExportedTypes() do for customMapping in findCustomMappingsInType publicType do let userCLRType = customMapping.FromPrimitiveMethod.ReturnType - let raw, len = + let annotations = UserTypeAnnotations.resolveExplicit - userCLRType.FullName publicType customMapping.ToPrimitiveMethod customMapping.FromPrimitiveMethod yield { UserCLRType = userCLRType UnderlyingCLRType = customMapping.ToPrimitiveMethod.ReturnType - RawBackendSQLType = raw - SQLTypeLength = len + RawBackendSQLType = annotations.RawType + SQLTypeLength = annotations.Length + SQLParameterDbType = annotations.ParameterDbType RuntimeMapping = customMapping IsAutomaticImplementation = false } match PrimitiveConverters.findSingleCaseDU publicType with | ValueNone -> () | ValueSome singleCase -> yield singleCase - } + } |> Seq.map designTimeValidate let private findRowTypesInAssembly (asm : Assembly) : UserRowType seq = seq { diff --git a/src/Rezoom.SQL.Provider/README.md b/src/Rezoom.SQL.Provider/README.md index 6ca1c1c..ff37b6c 100644 --- a/src/Rezoom.SQL.Provider/README.md +++ b/src/Rezoom.SQL.Provider/README.md @@ -1,8 +1,5 @@ # Rezoom.SQL.Provider -The F# type provider for Rezoom.SQL. Not a massive amount of code, but it's confusing because writing type providers is hard. +F# type provider for Rezoom.SQL. Typechecks SQL queries at compile time and provided a statically typed interface to run them and read results. -This is a generative type provider, not erased. It outputs real .NET classes that can be also be consumed from C#/VB code. - -The heavy lifting is done by Rezoom.SQL.Compiler (compile-time) and Rezoom.SQL.Mapping (runtime) -so this just generates the row types and some thin wrapper classes around the runtime API. \ No newline at end of file +See https://github.com/fsprojects/Rezoom.SQL \ No newline at end of file diff --git a/src/Rezoom.SQL.Provider/Rezoom.SQL.Provider.fsproj b/src/Rezoom.SQL.Provider/Rezoom.SQL.Provider.fsproj index 6e62f56..fc419bd 100644 --- a/src/Rezoom.SQL.Provider/Rezoom.SQL.Provider.fsproj +++ b/src/Rezoom.SQL.Provider/Rezoom.SQL.Provider.fsproj @@ -7,7 +7,7 @@ MIT https://github.com/rspeele/Rezoom.SQL https://github.com/rspeele/Rezoom.SQL - 1.0.0: ALC-aware assembly resolution for .NET 8+ TP loading. Tracks modernized Rezoom.SQL.Compiler / Mapping. See the Rezoom.SQL CHANGELOG.md for the full 1.0.0 story. + 1.1.0: adds support for custom user defined types. README.md true diff --git a/src/Rezoom.SQL.Provider/TypeGeneration.fs b/src/Rezoom.SQL.Provider/TypeGeneration.fs index eeedde8..31c42a1 100644 --- a/src/Rezoom.SQL.Provider/TypeGeneration.fs +++ b/src/Rezoom.SQL.Provider/TypeGeneration.fs @@ -262,7 +262,7 @@ let private generateCommandMethod | ListType elemTy -> let elemColType = { ty with Type = elemTy } let tx = backend.ParameterTransform(elemColType) - let dbType = Quotations.Expr.Value(tx.ParameterType) + let dbType = tx.ParameterType.Quote() let inputArr = Expr.Coerce(ex, typeof) // Coerce each element to the actual element CLR type, // so backend always sees a typed Expr like it does @@ -279,7 +279,7 @@ let private generateCommandMethod <@@ RawSQLParameter %%ex @@> | _ -> let tx = backend.ParameterTransform(ty) - let dbType = Quotations.Expr.Value(tx.ParameterType) + let dbType = tx.ParameterType.Quote() <@@ ScalarParameter(%%dbType, %%tx.ValueTransform ex) @@>) ) Expr.CallUnchecked(callMeth, [ commandData; arr ])) @@ -299,13 +299,7 @@ let validateSQLCommand (generate : GenerateType) (effect : CommandEffect) = fail <| Error.commandChangesSchema let generateSQLType (generate : GenerateType) (sql : string) = - let commandEffect = - CommandEffect.OfSQL - ( generate.UserModel.Model - , generate.TypeName - , sql - , generate.UserModel.UserTypeLibrary - ) + let commandEffect = generate.UserModel.CommandEffect(generate.TypeName, sql) validateSQLCommand generate commandEffect let commandCtor = typeof let cmd (r : Type) = typedefof<_ Command>.MakeGenericType(r) diff --git a/src/Rezoom.SQL.Test.UserTypes/UserPrimitives.fs b/src/Rezoom.SQL.Test.UserTypes/UserPrimitives.fs index 2fdbae3..c9ae508 100644 --- a/src/Rezoom.SQL.Test.UserTypes/UserPrimitives.fs +++ b/src/Rezoom.SQL.Test.UserTypes/UserPrimitives.fs @@ -93,4 +93,25 @@ type FileHash = FileHash of byte[] /// Single-case DU over byte[] with [] acts like /// BinaryTypeName(Some 32). [] -type ShortHash = ShortHash of byte[] \ No newline at end of file +type ShortHash = ShortHash of byte[] + +// --- SQLParameterDbType fixtures -------------------------------------- +// These exist purely so the loader-inspection tests in +// Rezoom.SQL.Test.TestUserTypeAnnotations can assert the +// SQLParameterDbType attribute round-trips into UserPrimitiveType. The +// types themselves are never bound at runtime in the Rezoom.SQL.Test +// suite; the assertions read the loaded UserTypeLibrary directly. + +/// Exercises the single-arg constructor — SQLParameterDbType(DbType). +/// Expected to surface as ("DbType", int DbType.AnsiString) on the +/// loaded UserPrimitiveType.SQLParameterDbType. +[] +type AnsiLabel = AnsiLabel of string + +/// Exercises the two-arg escape-hatch constructor for provider-specific +/// enums — SQLParameterDbType(propertyName, int). The values here are +/// deliberately not bound at runtime; we only care that the metadata +/// round-trips. (36 happens to be NpgsqlDbType.Jsonb but no +/// Postgres-specific assembly is referenced from this fixture.) +[] +type OpaqueDbTypeProbe = OpaqueDbTypeProbe of string \ No newline at end of file diff --git a/src/Rezoom.SQL.Test/Environment.fs b/src/Rezoom.SQL.Test/Environment.fs index b79eda8..48044b8 100644 --- a/src/Rezoom.SQL.Test/Environment.fs +++ b/src/Rezoom.SQL.Test/Environment.fs @@ -22,7 +22,7 @@ let userModel2() = userModelByName "user-model-2" let expectErrorWithModel (mkMsg : Model -> string) (sql : string) = let userModel = userModel1() try - ignore <| CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + ignore <| userModel.CommandEffect("anonymous", sql) failwith "Should've thrown an exception!" with | :? SourceException as exn -> diff --git a/src/Rezoom.SQL.Test/TestNavProperties.fs b/src/Rezoom.SQL.Test/TestNavProperties.fs index c186331..b5cc8e4 100644 --- a/src/Rezoom.SQL.Test/TestNavProperties.fs +++ b/src/Rezoom.SQL.Test/TestNavProperties.fs @@ -7,7 +7,7 @@ open Rezoom.SQL.Mapping let columns (sql : string) expected = let userModel = userModel1() - let parsed = CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + let parsed = userModel.CommandEffect("anonymous", sql) let sets = parsed.ResultSets() |> Seq.toArray if sets.Length <> 1 then fail "expected 1 result set" let cols = sets.[0].Columns |> Seq.map (fun c -> c.ColumnName.Value, c.Expr.Info.Type.ToString()) |> Seq.toList diff --git a/src/Rezoom.SQL.Test/TestNullInference.fs b/src/Rezoom.SQL.Test/TestNullInference.fs index c47d134..ad80e91 100644 --- a/src/Rezoom.SQL.Test/TestNullInference.fs +++ b/src/Rezoom.SQL.Test/TestNullInference.fs @@ -7,7 +7,7 @@ open Rezoom.SQL.Mapping let expect (sql : string) expectedColumns expectedParams = let userModel = userModel1() - let parsed = CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + let parsed = userModel.CommandEffect("anonymous", sql) let sets = parsed.ResultSets() |> Seq.toArray if sets.Length <> 1 then failwith "expected 1 result set" let cols = sets.[0].Columns |> Seq.map (fun c -> c.ColumnName.Value, c.Expr.Info.Type.ToString()) |> Seq.toList diff --git a/src/Rezoom.SQL.Test/TestRoundTrip.fs b/src/Rezoom.SQL.Test/TestRoundTrip.fs index 85e0a21..7c96bbc 100644 --- a/src/Rezoom.SQL.Test/TestRoundTrip.fs +++ b/src/Rezoom.SQL.Test/TestRoundTrip.fs @@ -7,13 +7,13 @@ open Rezoom.SQL.Mapping let roundtrip (sql : string) = let userModel = userModel1() - let parsed = CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + let parsed = userModel.CommandEffect("anonymous", sql) let indexer = { new IParameterIndexer with member __.ParameterIndex(_) = 0 } let backend = DefaultBackend() :> IBackend let fragments = backend.ToCommandFragments(indexer, parsed.Statements) let str = CommandFragment.Stringize(fragments) Console.WriteLine(str) - let parsedBack = CommandEffect.OfSQL(userModel.Model, "readback", str) + let parsedBack = userModel.CommandEffect("readback", str) let fragmentsBack = backend.ToCommandFragments(indexer, parsedBack.Statements) let strBack = CommandFragment.Stringize(fragmentsBack) Console.WriteLine(String('-', 80)) diff --git a/src/Rezoom.SQL.Test/TestStaticRowCount.fs b/src/Rezoom.SQL.Test/TestStaticRowCount.fs index a5dd2bd..54ea844 100644 --- a/src/Rezoom.SQL.Test/TestStaticRowCount.fs +++ b/src/Rezoom.SQL.Test/TestStaticRowCount.fs @@ -7,7 +7,7 @@ open Rezoom.SQL.Mapping let private resultSets expected sql = let userModel = userModel1() - let effect = CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + let effect = userModel.CommandEffect("anonymous", sql) let resultSet = effect.ResultSets() |> Seq.exactlyOne Assert.AreEqual(expected, resultSet.StaticRowCount) diff --git a/src/Rezoom.SQL.Test/TestTSQL.fs b/src/Rezoom.SQL.Test/TestTSQL.fs index 484abbd..3cd59f4 100644 --- a/src/Rezoom.SQL.Test/TestTSQL.fs +++ b/src/Rezoom.SQL.Test/TestTSQL.fs @@ -13,7 +13,7 @@ let translate (sql : string) (expectedTSQL : string) = Backend = backend Model = { userModel.Model with Builtin = backend.InitialModel.Builtin } } - let parsed = CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + let parsed = userModel.CommandEffect("anonymous", sql) let indexer = { new IParameterIndexer with member __.ParameterIndex(_) = 0 } let fragments = userModel.Backend.ToCommandFragments(indexer, parsed.Statements) let str = CommandFragment.Stringize(fragments) diff --git a/src/Rezoom.SQL.Test/TestTypeInference.fs b/src/Rezoom.SQL.Test/TestTypeInference.fs index e04a9f5..41254a7 100644 --- a/src/Rezoom.SQL.Test/TestTypeInference.fs +++ b/src/Rezoom.SQL.Test/TestTypeInference.fs @@ -2,6 +2,7 @@ open NUnit.Framework open FsUnit open Rezoom.SQL.Compiler +open Rezoom.SQL.Mapping let zeroModel = { Schemas = @@ -21,7 +22,7 @@ let ``simple select`` () = let cmd = CommandEffect.OfSQL(zeroModel, "anonymous", @" create table Users(id int null primary key, name string(128) null, email string(128) null); select * from Users - ") + ", UserTypeLibrary.Empty) Assert.AreEqual(0, cmd.Parameters.Count) let results = cmd.ResultSets() |> toReadOnlyList Assert.AreEqual(1, results.Count) @@ -42,7 +43,7 @@ let ``simple select with parameter`` () = create table Users(id int null primary key, name string(128) null, email string(128) null); select * from Users u where u.id = @id - ") + ", UserTypeLibrary.Empty) Assert.AreEqual(1, cmd.Parameters.Count) Assert.AreEqual ( (NamedParameter (Name("id")), { Nullable = false; Type = IntegerType Integer32 }) @@ -66,7 +67,7 @@ let ``simple select with parameter nullable id`` () = create table Users(id int null primary key, name string(128) null, email string(128) null); select * from Users u where u.id is @id - ") + ", UserTypeLibrary.Empty) Assert.AreEqual(1, cmd.Parameters.Count) Assert.AreEqual ( (NamedParameter (Name("id")), { Nullable = true; Type = IntegerType Integer32 }) @@ -91,7 +92,7 @@ let ``simple select with parameter not null`` () = create table Users(id int primary key, name string(128) null, email string(128) null); select * from Users u where u.id = @id - ") + ", UserTypeLibrary.Empty) Assert.AreEqual(1, cmd.Parameters.Count) Assert.AreEqual ( (NamedParameter (Name("id")), { Nullable = false; Type = IntegerType Integer32 }) @@ -116,14 +117,14 @@ let ``select where id in param`` () = create table Users(id int primary key, name string(128), email string(128)); select * from Users u where u.id in @id - ") + ", UserTypeLibrary.Empty) Assert.AreEqual(1, cmd.Parameters.Count) [] let ``coalesce not null`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select coalesce(u.Name, u.Email, @default) as c from Users u where u.id in @id @@ -137,7 +138,7 @@ let ``coalesce not null`` () = let ``coalesce null`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select coalesce(u.Name, @default, u.Email) as c from Users u where u.id in @id @@ -151,7 +152,7 @@ let ``coalesce null`` () = let ``union null from bottom`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select 1 as x union all select null @@ -166,7 +167,7 @@ let ``union null from bottom`` () = let ``union null from top`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select null as x union all select 1 @@ -181,7 +182,7 @@ let ``union null from top`` () = let ``union null in values clause`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select 1 as x union all values (null) @@ -196,7 +197,7 @@ let ``union null in values clause`` () = let ``select max`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select max(Name) as MaxName from Users ") printfn "%A" cmd.Parameters @@ -209,7 +210,7 @@ let ``select max`` () = let ``correlated subquery`` () = let model = userModel1() let cmd = - CommandEffect.OfSQL(model.Model, "anonymous", @" + model.CommandEffect("anonymous", @" select * from Users lu where exists(select null as x from Users ru where ru.Name = lu.Name || ' stuff') ") diff --git a/src/Rezoom.SQL.Test/TestUserTypeAnnotations.fs b/src/Rezoom.SQL.Test/TestUserTypeAnnotations.fs index 7c10010..bca8e02 100644 --- a/src/Rezoom.SQL.Test/TestUserTypeAnnotations.fs +++ b/src/Rezoom.SQL.Test/TestUserTypeAnnotations.fs @@ -1,5 +1,6 @@ module Rezoom.SQL.Test.TestUserTypeAnnotations open NUnit.Framework +open Rezoom.SQL.Mapping // --- SQLite: RawBackendSQLType emits the literal type verbatim -------- @@ -250,3 +251,48 @@ let ``tsql DU over byte[] without SQLTypeLength emits VARBINARY(max)`` () = |> Some } |> Good } |> assertSimple + +// --- SQLParameterDbType loader-inspection regression tests ------------ +// These do not exercise SQL emission. Instead they load the user-types +// library and inspect UserPrimitiveType.SQLParameterDbType directly, +// catching attribute-loader breakage at compiler-test speed instead of +// having to wait for a TPU run to surface it as a downstream PG error +// like `column "home" is of type jsonb but expression is of type text`. + +let private userTypesLib = + lazy ((userModelByName "user-model-7-usertypes").UserTypeLibrary) + +let private primitive name = + match userTypesLib.Value.UserPrimitiveByName(name) with + | FoundType prim -> prim + | AmbiguousType _ -> + Assert.Fail(sprintf "User primitive '%s' is ambiguous in the loaded library." name) + Unchecked.defaultof<_> + | NotFoundType _ -> + Assert.Fail(sprintf "User primitive '%s' was not found in the loaded library." name) + Unchecked.defaultof<_> + +[] +let ``SQLParameterDbType single-arg ctor on AnsiLabel loads as Some(DbType, int)`` () = + // [] on AnsiLabel + // is the standard-DbType ctor; the C# attribute delegates to the + // two-arg form with property name "DbType" and value (int)dbType, + // and the loader records the same shape. + let expected = Some ("DbType", int System.Data.DbType.AnsiString) + Assert.That((primitive "AnsiLabel").SQLParameterDbType, Is.EqualTo(expected)) + +[] +let ``SQLParameterDbType two-arg ctor on OpaqueDbTypeProbe loads as Some(prop, int)`` () = + // [] on OpaqueDbTypeProbe is + // the escape-hatch ctor used by provider-specific enums; the + // attribute identity check, ctor-arity branch, and tuple read all + // have to survive for the metadata to round-trip. + let expected = Some ("NpgsqlDbType", 36) + Assert.That((primitive "OpaqueDbTypeProbe").SQLParameterDbType, Is.EqualTo(expected)) + +[] +let ``SQLParameterDbType is None on a primitive without the attribute`` () = + // CompactInt has [] but no [], + // so the loader should leave the field as None. Guards against a + // future change that accidentally always-Somes the field. + Assert.That((primitive "CompactInt").SQLParameterDbType, Is.EqualTo(None)) diff --git a/src/Rezoom.SQL.Test/TestVendorStatements.fs b/src/Rezoom.SQL.Test/TestVendorStatements.fs index 43f6682..5b2bf85 100644 --- a/src/Rezoom.SQL.Test/TestVendorStatements.fs +++ b/src/Rezoom.SQL.Test/TestVendorStatements.fs @@ -14,7 +14,7 @@ let normalizeFragments fragments = let vendor (sql : string) expected = let userModel = userModel1() - let parsed = CommandEffect.OfSQL(userModel.Model, "anonymous", sql) + let parsed = userModel.CommandEffect("anonymous", sql) let indexer = dispenserParameterIndexer() let fragments = userModel.Backend.ToCommandFragments(indexer, parsed.Statements) diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres.UserTypes/Library.fs b/src/TypeProviderUsers/TypeProviderUser.Postgres.UserTypes/Library.fs new file mode 100644 index 0000000..a1ee123 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres.UserTypes/Library.fs @@ -0,0 +1,47 @@ +namespace TypeProviderUser.Postgres.UserTypes + +open System.Text.Json +open Rezoom.SQL.Annotations + +/// Address as a user primitive that stores as PG jsonb. Demonstrates the +/// System.Object underlying-CLR-type escape hatch: ToPrimitive returns +/// `obj` (a JSON-serialized string boxed), and FromPrimitive accepts the +/// same `obj` shape coming back from the driver. The RawBackendSQLType +/// pins the SQL type as "jsonb" and the ParameterDbType attribute tells +/// the runtime to set NpgsqlDbType.Jsonb on the parameter so Npgsql +/// binds it as the right backend type. +// Note: 36 = NpgsqlTypes.NpgsqlDbType.Jsonb (Npgsql 8.x). +// F# does not accept enum-to-int casts in attribute argument +// position so the integer literal is the cleanest available form. +[] +[] +type Address = + { Street : string + City : string + State : string + Zip : string + } + static member ToPrimitive(a : Address) : obj = + box (JsonSerializer.Serialize(a)) + static member FromPrimitive(o : obj) : Address = + JsonSerializer.Deserialize
(o :?> string) + +/// 2D point as a user primitive that stores as PG `point`. Where Address +/// exercises an obj-underlying type whose value carries the column data as +/// a string, Point2D exercises an obj-underlying type whose value is a +/// driver-specific struct (NpgsqlPoint) — Npgsql's native CLR +/// representation for the `point` backend type. This proves the +/// System.Object escape hatch also handles non-string driver values. +// Note: 15 = NpgsqlTypes.NpgsqlDbType.Point (Npgsql 8.x). Hardcoded +// as a literal for the same attribute-argument reason as Jsonb above. +[] +[] +type Point2D = + { X : double + Y : double + } + static member ToPrimitive(p : Point2D) : obj = + box (NpgsqlTypes.NpgsqlPoint(p.X, p.Y)) + static member FromPrimitive(o : obj) : Point2D = + let pt = o :?> NpgsqlTypes.NpgsqlPoint + { X = pt.X; Y = pt.Y } diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres.UserTypes/TypeProviderUser.Postgres.UserTypes.fsproj b/src/TypeProviderUsers/TypeProviderUser.Postgres.UserTypes/TypeProviderUser.Postgres.UserTypes.fsproj new file mode 100644 index 0000000..fea24e8 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres.UserTypes/TypeProviderUser.Postgres.UserTypes.fsproj @@ -0,0 +1,24 @@ + + + + net10.0 + true + + + + + + + + + + + + + + + + diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres/Shared.fs b/src/TypeProviderUsers/TypeProviderUser.Postgres/Shared.fs index 77358fc..dcb5419 100644 --- a/src/TypeProviderUsers/TypeProviderUser.Postgres/Shared.fs +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres/Shared.fs @@ -12,11 +12,13 @@ type TestModel = SQLModel<"."> type CleanTestData = SQL<""" vendor postgres { - drop table __RZSQL_MIGRATIONS; - drop table ArticleComments; - drop table Articles; - drop table Users; - drop table Pictures; + drop table if exists __RZSQL_MIGRATIONS; + drop table if exists UserLocations; + drop table if exists UserAddresses; + drop table if exists ArticleComments; + drop table if exists Articles; + drop table if exists Users; + drop table if exists Pictures; } """> diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres/TestUserPrimitivePoint.fs b/src/TypeProviderUsers/TypeProviderUser.Postgres/TestUserPrimitivePoint.fs new file mode 100644 index 0000000..62be7fc --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres/TestUserPrimitivePoint.fs @@ -0,0 +1,103 @@ +module TypeProviderUser.Postgres.TestUserPrimitivePoint +open NUnit.Framework +open Rezoom.SQL +open Rezoom.SQL.Raw +open TypeProviderUser.Postgres.UserTypes + +// Point2D maps to PG's `point` type via the System.Object escape hatch +// with NpgsqlPoint as the driver value (not a string), exercising a +// different shape from the jsonb/Address case in TestUserPrimitiveSystemObject. + +let private homerPoint = { X = 1.5; Y = 2.5 } +let private margePoint = { X = 1.5; Y = 2.5 } +let private bartPoint = { X = -7.25; Y = 99.0 } + +type InsertAndSelectPoints = SQL<""" +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Marge'), @marge); +select Coord from UserLocations order by Id; +"""> + +[] +let ``select roundtrips a Point2D user primitive over PG point`` () = + let results = InsertAndSelectPoints.Command(homerPoint, margePoint) |> runOnTestData + Assert.AreEqual(2, results.Count) + Assert.AreEqual(homerPoint, results.[0].Coord) + Assert.AreEqual(margePoint, results.[1].Coord) + +// PG's point type has no `=` operator (42883: "operator does not +// exist: point = point"). Equality is `~=` (the same-as operator), +// which Rezoom's parser doesn't know — unsafe_inject_raw is the +// idiomatic escape hatch here. The parameter @needle still binds +// through Rezoom as a Point2D, then PG's ~= compares it against the +// column value at row scan time, exercising the full +// parameter-as-point pipeline. +type FindPointByParameterSameAs = SQL<""" +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Marge'), @bart); +select Coord from UserLocations ul where unsafe_inject_raw(@filter); +"""> + +[] +let ``select with Point2D parameter equality matches via PG ~= operator`` () = + // Identifiers are emitted unquoted by Rezoom's PG backend, so PG + // folds them lowercase — `ul.coord`, not `"Coord"`. + // + // Caveat: Rezoom.SQL.Raw.arg does not apply user-type ToPrimitive + // translation — it routes the value straight to ADO.NET with a + // guessed DbType. So we cannot pass a Point2D here and expect the + // Point2D → NpgsqlPoint conversion to happen automatically. We + // pre-convert to NpgsqlPoint in user space; Npgsql then + // auto-detects the wire format from the value's runtime type. + // The fully-translated user-type → parameter pipeline is already + // exercised by the INSERT in the roundtrip test above; this test + // covers the WHERE-side parameter comparison via ~=. + let needle = NpgsqlTypes.NpgsqlPoint(homerPoint.X, homerPoint.Y) + let results = + FindPointByParameterSameAs.Command + ( bart = bartPoint + , filter = [| sql "ul.coord ~= "; arg needle |] + , homer = homerPoint + ) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(homerPoint, results.[0].Coord) + +// Same functional intent as FindPointByParameterSameAs above, but using +// the vendor/imagine escape hatch instead of unsafe_inject_raw. The +// IMAGINE clause is typechecked against Rezoom's dialect, informing the +// typechecker that @needle is a Point2D and that the result set has a +// Coord column. The vendor body runs PG-native SQL — including ~= and +// the `{@needle}` extra-brace param reference — and the user-type +// translation pipeline still fires for @needle on the parameter side, +// so the caller passes a real Point2D, not a NpgsqlPoint, from F#. +type FindPointByParameterVendor = SQL<""" +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Marge'), @bart); +vendor postgres { + select Coord from UserLocations where coord ~= {@needle} +} imagine { + select Coord from UserLocations where Coord = @needle +}; +"""> + +[] +let ``select with Point2D parameter equality matches via vendor ~= with IMAGINE`` () = + // No manual NpgsqlPoint conversion: @needle stays typed as Point2D + // all the way through Rezoom, so the user-type SQLParameterDbType + // attribute is applied to the actual parameter being compared. + let results = + FindPointByParameterVendor.Command + ( bart = bartPoint + , homer = homerPoint + , needle = homerPoint + ) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(homerPoint, results.[0].Coord) diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres/TestUserPrimitiveSystemObject.fs b/src/TypeProviderUsers/TypeProviderUser.Postgres/TestUserPrimitiveSystemObject.fs new file mode 100644 index 0000000..86d2683 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres/TestUserPrimitiveSystemObject.fs @@ -0,0 +1,104 @@ +module TypeProviderUser.Postgres.TestUserPrimitiveSystemObject +open NUnit.Framework +open Rezoom.SQL +open Rezoom.SQL.Raw +open TypeProviderUser.Postgres.UserTypes + +let private homerAddr = + { Street = "742 Evergreen Terrace" + City = "Springfield" + State = "OR" + Zip = "97477" + } + +let private margeAddr = + { Street = "742 Evergreen Terrace" + City = "Springfield" + State = "OR" + Zip = "97477" + } + +let private bartAddr = + { Street = "1313 Mockingbird Lane" + City = "Shelbyville" + State = "OR" + Zip = "97001" + } + +type InsertAndSelectAddresses = SQL<""" +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Marge'), @marge); +select Home from UserAddresses order by Id; +"""> + +[] +let ``select roundtrips an Address user primitive over System.Object`` () = + let results = InsertAndSelectAddresses.Command(homerAddr, margeAddr) |> runOnTestData + Assert.AreEqual(2, results.Count) + Assert.AreEqual(homerAddr, results.[0].Home) + Assert.AreEqual(margeAddr, results.[1].Home) + +type FindAddressByParameterEquality = SQL<""" +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Marge'), @bart); +select Home from UserAddresses where Home = @needle; +"""> + +[] +let ``select with Address parameter equality matches via PG jsonb = operator`` () = + let results = + FindAddressByParameterEquality.Command(bartAddr, homerAddr, homerAddr) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(homerAddr, results.[0].Home) + +type FindAddressByStateViaJsonOperator = SQL<""" +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Marge'), @marge); +select Home from UserAddresses ua where unsafe_inject_raw(@filter) order by ua.Id; +"""> + +[] +let ``PG jsonb path operator on Address column works via unsafe_inject_raw`` () = + // We alias UserAddresses as ua in the SQL above so the raw filter + // can reference the column with a known qualifier. Rezoom's PG + // backend emits identifiers unquoted, so PG folds them to lowercase + // — the raw filter must use ua.home (lowercase) to resolve. + let results = + FindAddressByStateViaJsonOperator.Command + ( filter = [| sql "ua.home ->> 'City' = 'Shelbyville'" |] + , homer = homerAddr + , marge = bartAddr + ) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(bartAddr, results.[0].Home) + +type FindAddressByInList = SQL<""" +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Marge'), @marge); +select Home from UserAddresses where Home in @needles; +"""> + +[] +let ``select Address where in non-empty list matches the expected row`` () = + let results = + FindAddressByInList.Command(homerAddr, bartAddr, [| homerAddr |]) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(homerAddr, results.[0].Home) + +[] +let ``select Address where in empty list returns zero rows via jsonb empty-IN substitution`` () = + let results = + FindAddressByInList.Command(homerAddr, bartAddr, [||]) + |> runOnTestData + Assert.AreEqual(0, results.Count) diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres/TypeProviderUser.Postgres.fsproj b/src/TypeProviderUsers/TypeProviderUser.Postgres/TypeProviderUser.Postgres.fsproj index bced8a4..26713d4 100644 --- a/src/TypeProviderUsers/TypeProviderUser.Postgres/TypeProviderUser.Postgres.fsproj +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres/TypeProviderUser.Postgres.fsproj @@ -16,6 +16,8 @@ + + @@ -24,7 +26,7 @@ - + @@ -39,4 +41,8 @@ + + + + diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres/V1.model.sql b/src/TypeProviderUsers/TypeProviderUser.Postgres/V1.model.sql index 110dfa2..04302db 100644 --- a/src/TypeProviderUsers/TypeProviderUser.Postgres/V1.model.sql +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres/V1.model.sql @@ -30,3 +30,15 @@ create table ArticleComments create index IX_ArticleComments_AuthorId on ArticleComments(AuthorId); +create table UserAddresses +( Id int64 primary key autoincrement +, UserId int64 references Users(Id) +, Home Address +); + +create table UserLocations +( Id int64 primary key autoincrement +, UserId int64 references Users(Id) +, Coord Point2D +); + diff --git a/src/TypeProviderUsers/TypeProviderUser.Postgres/rzsql.json b/src/TypeProviderUsers/TypeProviderUser.Postgres/rzsql.json index 836982c..0269523 100644 --- a/src/TypeProviderUsers/TypeProviderUser.Postgres/rzsql.json +++ b/src/TypeProviderUsers/TypeProviderUser.Postgres/rzsql.json @@ -1,3 +1,4 @@ -{ - "backend": "postgres" -} \ No newline at end of file +{ + "backend": "postgres", + "usertypes": [ "TypeProviderUser.Postgres.UserTypes" ] +} diff --git a/src/TypeProviderUsers/TypeProviderUser.SQLite/TestUserPrimitiveByteArray.fs b/src/TypeProviderUsers/TypeProviderUser.SQLite/TestUserPrimitiveByteArray.fs new file mode 100644 index 0000000..39b4194 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.SQLite/TestUserPrimitiveByteArray.fs @@ -0,0 +1,54 @@ +module TypeProviderUser.SQLite.TestUserPrimitiveByteArray +open NUnit.Framework +open Rezoom.SQL +open TypeProviderUser.UserTypes + +type InsertAndSelectFileHashes = SQL<""" +insert into HashedBlobs(Hash) values(@h1); +insert into HashedBlobs(Hash) values(@h2); +select Hash from HashedBlobs order by Id; +"""> + +[] +let ``select roundtrips a FileHash user primitive over byte[]`` () = + let h1 = FileHash [| 0x01uy; 0x02uy; 0x03uy; 0x04uy |] + let h2 = FileHash [| 0xFFuy; 0xEEuy; 0xDDuy; 0xCCuy |] + let results = InsertAndSelectFileHashes.Command(h1, h2) |> runOnTestData + Assert.AreEqual(2, results.Count) + Assert.AreEqual(h1, results.[0].Hash) + Assert.AreEqual(h2, results.[1].Hash) + +type FindFileHashByParameter = SQL<""" +insert into HashedBlobs(Hash) values(@seed1); +insert into HashedBlobs(Hash) values(@seed2); +select Hash from HashedBlobs where Hash = @needle; +"""> + +[] +let ``select with FileHash parameter equality returns the matching row only`` () = + let target = FileHash [| 0xCAuy; 0xFEuy; 0xBAuy; 0xBEuy |] + let other = FileHash [| 0xDEuy; 0xADuy; 0xBEuy; 0xEFuy |] + let results = FindFileHashByParameter.Command(target, other, target) |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(target, results.[0].Hash) + +type FindFileHashByOptionalParameter = SQL<""" +insert into HashedBlobs(Hash) values(@seed1); +insert into HashedBlobs(Hash) values(@seed2); +select Hash from HashedBlobs where Hash = @needle or @needle is null; +"""> + +[] +let ``select with optional FileHash parameter filters when Some and returns all when None`` () = + let target = FileHash [| 0x12uy; 0x34uy; 0x56uy; 0x78uy |] + let other = FileHash [| 0x9Auy; 0xBCuy; 0xDEuy; 0xF0uy |] + // Rezoom orders Command args alphabetically by name: needle, seed1, seed2. + let withSome = + FindFileHashByOptionalParameter.Command(Some target, target, other) + |> runOnTestData + Assert.AreEqual(1, withSome.Count) + Assert.AreEqual(target, withSome.[0].Hash) + let withNone = + FindFileHashByOptionalParameter.Command(None, target, other) + |> runOnTestData + Assert.AreEqual(2, withNone.Count) diff --git a/src/TypeProviderUsers/TypeProviderUser.SQLite/TestUserPrimitiveEnum.fs b/src/TypeProviderUsers/TypeProviderUser.SQLite/TestUserPrimitiveEnum.fs new file mode 100644 index 0000000..8383e5f --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.SQLite/TestUserPrimitiveEnum.fs @@ -0,0 +1,126 @@ +module TypeProviderUser.SQLite.TestUserPrimitiveEnum +open NUnit.Framework +open System +open Rezoom.SQL +open TypeProviderUser.UserTypes + +// End-to-end coverage for mapping CLR enums via the user-type pipeline. +// Two flavors: +// * FavoriteColor — F# enum (System.Enum subtype) mapped to string via +// ToString / Enum.Parse. Underlying storage is human-readable. +// * DateTimeKind — BCL enum mapped to int via the cast operator. +// Underlying storage is the raw enum integer value. +// +// Each flavor gets the same three-test shape used for FileHash in +// TestUserPrimitiveByteArray: roundtrip, parameter equality, and optional +// parameter equality. Together that exercises the parameter-binding path, +// the result-set materialization path, and the option-wrapping path. + +// --- FavoriteColor (ToString / Enum.Parse, string-underlying) ------------ + +type InsertAndSelectColors = SQL<""" +insert into ColorRows(Color) values(@c1); +insert into ColorRows(Color) values(@c2); +select Color from ColorRows order by Id; +"""> + +[] +let ``select roundtrips FavoriteColor values via ToString/Enum.Parse mapping`` () = + let results = + InsertAndSelectColors.Command(FavoriteColor.Red, FavoriteColor.Blue) + |> runOnTestData + Assert.AreEqual(2, results.Count) + Assert.AreEqual(FavoriteColor.Red, results.[0].Color) + Assert.AreEqual(FavoriteColor.Blue, results.[1].Color) + +type FindColorByParameter = SQL<""" +insert into ColorRows(Color) values(@seed1); +insert into ColorRows(Color) values(@seed2); +select Color from ColorRows where Color = @needle; +"""> + +[] +let ``select with FavoriteColor parameter equality returns the matching row only`` () = + let target = FavoriteColor.Red + let other = FavoriteColor.Blue + // Rezoom orders Command args alphabetically by name: needle, seed1, seed2. + let results = FindColorByParameter.Command(target, other, target) |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(target, results.[0].Color) + +type FindColorByOptionalParameter = SQL<""" +insert into ColorRows(Color) values(@seed1); +insert into ColorRows(Color) values(@seed2); +select Color from ColorRows where Color = @needle or @needle is null; +"""> + +[] +let ``select with optional FavoriteColor parameter filters when Some and returns all when None`` () = + let target = FavoriteColor.Red + let other = FavoriteColor.Blue + // Command args alphabetical: needle, seed1, seed2. + let withSome = + FindColorByOptionalParameter.Command(Some target, target, other) + |> runOnTestData + Assert.AreEqual(1, withSome.Count) + Assert.AreEqual(target, withSome.[0].Color) + let withNone = + FindColorByOptionalParameter.Command(None, target, other) + |> runOnTestData + Assert.AreEqual(2, withNone.Count) + +// --- DateTimeKind (int unwrap, int-underlying) --------------------------- + +type InsertAndSelectKinds = SQL<""" +insert into KindRows(Kind) values(@k1); +insert into KindRows(Kind) values(@k2); +insert into KindRows(Kind) values(@k3); +select Kind from KindRows order by Id; +"""> + +[] +let ``select roundtrips DateTimeKind values via raw int mapping`` () = + let results = + InsertAndSelectKinds.Command + (DateTimeKind.Utc, DateTimeKind.Local, DateTimeKind.Unspecified) + |> runOnTestData + Assert.AreEqual(3, results.Count) + Assert.AreEqual(DateTimeKind.Utc, results.[0].Kind) + Assert.AreEqual(DateTimeKind.Local, results.[1].Kind) + Assert.AreEqual(DateTimeKind.Unspecified, results.[2].Kind) + +type FindKindByParameter = SQL<""" +insert into KindRows(Kind) values(@seed1); +insert into KindRows(Kind) values(@seed2); +select Kind from KindRows where Kind = @needle; +"""> + +[] +let ``select with DateTimeKind parameter equality returns the matching row only`` () = + let target = DateTimeKind.Utc + let other = DateTimeKind.Local + // Command args alphabetical: needle, seed1, seed2. + let results = FindKindByParameter.Command(target, other, target) |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(target, results.[0].Kind) + +type FindKindByOptionalParameter = SQL<""" +insert into KindRows(Kind) values(@seed1); +insert into KindRows(Kind) values(@seed2); +select Kind from KindRows where Kind = @needle or @needle is null; +"""> + +[] +let ``select with optional DateTimeKind parameter filters when Some and returns all when None`` () = + let target = DateTimeKind.Utc + let other = DateTimeKind.Local + // Command args alphabetical: needle, seed1, seed2. + let withSome = + FindKindByOptionalParameter.Command(Some target, target, other) + |> runOnTestData + Assert.AreEqual(1, withSome.Count) + Assert.AreEqual(target, withSome.[0].Kind) + let withNone = + FindKindByOptionalParameter.Command(None, target, other) + |> runOnTestData + Assert.AreEqual(2, withNone.Count) diff --git a/src/TypeProviderUsers/TypeProviderUser.SQLite/TypeProviderUser.SQLite.fsproj b/src/TypeProviderUsers/TypeProviderUser.SQLite/TypeProviderUser.SQLite.fsproj index 0cd84cd..f214f45 100644 --- a/src/TypeProviderUsers/TypeProviderUser.SQLite/TypeProviderUser.SQLite.fsproj +++ b/src/TypeProviderUsers/TypeProviderUser.SQLite/TypeProviderUser.SQLite.fsproj @@ -16,6 +16,8 @@ + + @@ -25,7 +27,7 @@ - + diff --git a/src/TypeProviderUsers/TypeProviderUser.SQLite/V1.model.sql b/src/TypeProviderUsers/TypeProviderUser.SQLite/V1.model.sql index 414d9fb..14998a7 100644 --- a/src/TypeProviderUsers/TypeProviderUser.SQLite/V1.model.sql +++ b/src/TypeProviderUsers/TypeProviderUser.SQLite/V1.model.sql @@ -33,3 +33,18 @@ create table ArticleComments create index IX_ArticleComments_AuthorId on ArticleComments(AuthorId); +create table HashedBlobs +( Id int primary key autoincrement +, Hash FileHash +); + +create table ColorRows +( Id int primary key autoincrement +, Color FavoriteColor +); + +create table KindRows +( Id int primary key autoincrement +, Kind DateTimeKind +); + diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL.UserTypes/Library.fs b/src/TypeProviderUsers/TypeProviderUser.TSQL.UserTypes/Library.fs new file mode 100644 index 0000000..6170a70 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL.UserTypes/Library.fs @@ -0,0 +1,58 @@ +namespace TypeProviderUser.TSQL.UserTypes + +open System.Text.Json +open Rezoom.SQL.Annotations + +/// Address as a user primitive that stores as TSQL `json` (SQL Server +/// 2025+ native type). Same shape as the Postgres jsonb Address fixture: +/// ToPrimitive serializes to a JSON string, FromPrimitive deserializes +/// from one. The RawBackendSQLType pins the SQL column type as "json". +/// +/// Note on SQLParameterDbType: SqlDbType.Json (= 35) exists in +/// System.Data but Microsoft.Data.SqlClient 5.2.2 rejects it as +/// "invalid" when assigned to a SqlParameter. Until SqlClient catches +/// up we bind as NVarChar (= 12); SQL Server implicitly converts an +/// nvarchar parameter value to json when assigning to a json column. +// 12 = System.Data.SqlDbType.NVarChar +[] +[] +type Address = + { Street : string + City : string + State : string + Zip : string + } + static member ToPrimitive(a : Address) : obj = + box (JsonSerializer.Serialize(a)) + static member FromPrimitive(o : obj) : Address = + JsonSerializer.Deserialize
(o :?> string) + +/// 2D geographic location as a user primitive that stores as TSQL +/// `geography`. Same intent as the Postgres Point2D fixture, but the +/// in-flight CLR shape is asymmetric: parameter binding goes through +/// nvarchar carrying WKT (SQL Server auto-converts to geography on +/// INSERT), while reads come back as a SqlGeography UDT instance +/// (which Microsoft.Data.SqlClient deserializes for any geography +/// column). FromPrimitive consequently has to know how to unpack a +/// SqlGeography. +/// +/// Why not UDT-bind directly? Setting up a SqlParameter for a UDT +/// requires both SqlDbType.Udt (= 29) AND the UdtTypeName property +/// ("geography"). SQLParameterDbType is a one-property attribute; the +/// nvarchar+server-conversion path sidesteps that. +// 12 = System.Data.SqlDbType.NVarChar +[] +[] +type GeoLocation = + { Latitude : double + Longitude : double + } + static member ToPrimitive(g : GeoLocation) : obj = + // SRID 4326 (WGS84) — same coordinate system the read side + // assumes. The WKT lon-lat order is intentional: SQL Server + // STGeomFromText interprets POINT(x y) as POINT(lon lat). + box (System.String.Format(System.Globalization.CultureInfo.InvariantCulture, + "POINT({0} {1})", g.Longitude, g.Latitude)) + static member FromPrimitive(o : obj) : GeoLocation = + let sg = o :?> Microsoft.SqlServer.Types.SqlGeography + { Latitude = sg.Lat.Value; Longitude = sg.Long.Value } diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL.UserTypes/TypeProviderUser.TSQL.UserTypes.fsproj b/src/TypeProviderUsers/TypeProviderUser.TSQL.UserTypes/TypeProviderUser.TSQL.UserTypes.fsproj new file mode 100644 index 0000000..ce1f8d3 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL.UserTypes/TypeProviderUser.TSQL.UserTypes.fsproj @@ -0,0 +1,20 @@ + + + net10.0 + true + + + + + + + + + + + + diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL/Shared.fs b/src/TypeProviderUsers/TypeProviderUser.TSQL/Shared.fs index 5efd018..5cc2394 100644 --- a/src/TypeProviderUsers/TypeProviderUser.TSQL/Shared.fs +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL/Shared.fs @@ -13,15 +13,19 @@ type TestModel = SQLModel<"."> type CleanTestData = SQL<""" vendor tsql { - drop table __RZSQL_MIGRATIONS; - drop table ArticleComments; - drop table Articles; - drop table Users; - drop table Pictures; + drop table if exists __RZSQL_MIGRATIONS; + drop table if exists UserLocations; + drop table if exists UserAddresses; + drop table if exists ArticleComments; + drop table if exists Articles; + drop table if exists Users; + drop table if exists Pictures; } """> type TestData = SQL<""" +delete from UserLocations; +delete from UserAddresses; delete from ArticleComments; delete from Articles; delete from Users; diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL/TestUserPrimitiveGeography.fs b/src/TypeProviderUsers/TypeProviderUser.TSQL/TestUserPrimitiveGeography.fs new file mode 100644 index 0000000..237d78b --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL/TestUserPrimitiveGeography.fs @@ -0,0 +1,114 @@ +module TypeProviderUser.TSQL.TestUserPrimitiveGeography +open NUnit.Framework +open Rezoom.SQL +open TypeProviderUser.TSQL.UserTypes + +// Same pattern as TypeProviderUser.Postgres.TestUserPrimitivePoint: +// an obj-underlying user primitive that maps to a SQL Server backend +// type with no `=` operator (geography). Parameter equality goes +// through vendor + IMAGINE using TSQL's `.STEquals(other) = 1` +// method-call style. + +let private homerLoc = { Latitude = 44.0521; Longitude = -123.0868 } +let private margeLoc = { Latitude = 44.0521; Longitude = -123.0868 } +let private bartLoc = { Latitude = 47.6062; Longitude = -122.3321 } + +type InsertAndSelectLocations = SQL<""" +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Marge'), @marge); +select Coord from UserLocations order by Id; +"""> + +[] +let ``select roundtrips a GeoLocation user primitive over TSQL geography`` () = + let results = InsertAndSelectLocations.Command(homerLoc, margeLoc) |> runOnTestData + Assert.AreEqual(2, results.Count) + Assert.AreEqual(homerLoc, results.[0].Coord) + Assert.AreEqual(margeLoc, results.[1].Coord) + +// `geography = geography` raises "Invalid operator for data type" in +// SQL Server. The canonical equality check is `.STEquals(other) = 1`. +// Rezoom's parser doesn't know method-call syntax on UDT columns, so +// we use vendor/imagine the same way the PG Point2D parameter test +// does: vendor body runs the TSQL method call, IMAGINE typechecks +// the parameter and result shape. +// +// On the parameter binding: the runtime applies the GeoLocation user +// type's SQLParameterDbType (NVarChar), so @needle is sent as the WKT +// nvarchar that ToPrimitive produces. SQL Server's STEquals takes a +// geography on both sides; the parameter's nvarchar value is +// implicitly converted to geography in the comparison context +// (geography has higher data-type precedence and STEquals' parameter +// is typed geography). +type FindLocationByStEqualsVendor = SQL<""" +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Marge'), @bart); +vendor tsql { + select Coord from UserLocations + where Coord.STEquals(geography::STGeomFromText({@needle}, 4326)) = 1 +} imagine { + select Coord from UserLocations where @needle = '' +}; +"""> + +[] +let ``select GeoLocation parameter equality matches via vendor STEquals`` () = + // @needle is typed as string in IMAGINE because we're explicitly + // building the geography from WKT inside the vendor body — this + // exercises that the typechecker can still propagate the result- + // set column type (Coord : GeoLocation) from the IMAGINE clause + // even when the parameter type is something simpler. The @homer + // and @bart INSERTs already cover the GeoLocation parameter + // pipeline end-to-end. + let results = + FindLocationByStEqualsVendor.Command + ( bart = bartLoc + , homer = homerLoc + , needle = + System.String.Format + ( System.Globalization.CultureInfo.InvariantCulture + , "POINT({0} {1})", homerLoc.Longitude, homerLoc.Latitude ) + ) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(homerLoc, results.[0].Coord) + +// Bonus: pass @needle as a real GeoLocation user-type parameter, fully +// preserving type-safety from F# all the way through to TSQL's +// STEquals. Mirrors the second Postgres Point2D vendor test (the one +// where the user-type parameter pipeline is fully engaged on both +// the INSERT and the WHERE side). +type FindLocationByGeoLocationVendor = SQL<""" +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserLocations(UserId, Coord) +values((select Id from Users where Name = 'Marge'), @bart); +vendor tsql { + select Coord from UserLocations + where Coord.STEquals(geography::STGeomFromText(cast({@needle} as nvarchar(max)), 4326)) = 1 +} imagine { + select Coord from UserLocations where Coord = @needle +}; +"""> + +[] +let ``select GeoLocation parameter equality matches via vendor STEquals with typed needle`` () = + // The IMAGINE clause types @needle as GeoLocation (column = param), + // so the F# caller passes a real GeoLocation. The vendor body + // casts the bound nvarchar back to nvarchar(max) for safety, then + // STGeomFromText. Demonstrates that vendor/imagine keeps the + // user-type parameter pipeline intact even for backend operators + // Rezoom can't parse. + let results = + FindLocationByGeoLocationVendor.Command + ( bart = bartLoc + , homer = homerLoc + , needle = homerLoc + ) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(homerLoc, results.[0].Coord) diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL/TestUserPrimitiveJson.fs b/src/TypeProviderUsers/TypeProviderUser.TSQL/TestUserPrimitiveJson.fs new file mode 100644 index 0000000..6ad11c5 --- /dev/null +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL/TestUserPrimitiveJson.fs @@ -0,0 +1,80 @@ +module TypeProviderUser.TSQL.TestUserPrimitiveJson +open NUnit.Framework +open Rezoom.SQL +open TypeProviderUser.TSQL.UserTypes + +// Same pattern as TypeProviderUser.Postgres.TestUserPrimitiveSystemObject: +// an obj-underlying user primitive whose value travels as a JSON string. +// On TSQL the backing column is the SQL Server 2025 `json` type, which +// is the natural counterpart to PG's `jsonb`. + +let private homerAddr = + { Street = "742 Evergreen Terrace" + City = "Springfield" + State = "OR" + Zip = "97477" + } + +let private margeAddr = + { Street = "742 Evergreen Terrace" + City = "Springfield" + State = "OR" + Zip = "97477" + } + +let private bartAddr = + { Street = "1313 Mockingbird Lane" + City = "Shelbyville" + State = "OR" + Zip = "97001" + } + +type InsertAndSelectAddresses = SQL<""" +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Marge'), @marge); +select Home from UserAddresses order by Id; +"""> + +[] +let ``select roundtrips an Address user primitive over TSQL json`` () = + let results = InsertAndSelectAddresses.Command(homerAddr, margeAddr) |> runOnTestData + Assert.AreEqual(2, results.Count) + Assert.AreEqual(homerAddr, results.[0].Home) + Assert.AreEqual(margeAddr, results.[1].Home) + +// SQL Server's `json` type has no `=` operator (SQL Server raises "The +// JSON data type cannot be compared or sorted, except when using the +// IS NULL operator"), mirroring PG's lack of `=` for `point`. We test +// parameter equality the recommended way: vendor body runs TSQL-native +// SQL using JSON_VALUE on a known field, IMAGINE clause informs the +// typechecker of the parameter types and result shape. +type FindAddressByJsonValueVendor = SQL<""" +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Homer'), @homer); +insert into UserAddresses(UserId, Home) +values((select Id from Users where Name = 'Marge'), @bart); +vendor tsql { + select Home from UserAddresses where JSON_VALUE(Home, '$.City') = {@city} +} imagine { + select Home from UserAddresses where @city = '' +}; +"""> + +[] +let ``select Address by JSON_VALUE matches via vendor/imagine`` () = + // @city stays typed as string in Rezoom's view; the vendor body + // uses TSQL's JSON_VALUE function on the json column to compare a + // specific field. The Home parameters (@homer, @bart) are typed + // Address and exercise the user-type → nvarchar pipeline on the + // INSERT side. + let results = + FindAddressByJsonValueVendor.Command + ( bart = bartAddr + , city = "Shelbyville" + , homer = homerAddr + ) + |> runOnTestData + Assert.AreEqual(1, results.Count) + Assert.AreEqual(bartAddr, results.[0].Home) diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL/TypeProviderUser.TSQL.fsproj b/src/TypeProviderUsers/TypeProviderUser.TSQL/TypeProviderUser.TSQL.fsproj index 79c1cf9..7dd3b7f 100644 --- a/src/TypeProviderUsers/TypeProviderUser.TSQL/TypeProviderUser.TSQL.fsproj +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL/TypeProviderUser.TSQL.fsproj @@ -12,6 +12,8 @@ + + @@ -20,7 +22,7 @@ - + @@ -34,4 +36,8 @@ + + + + diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL/V1.model.sql b/src/TypeProviderUsers/TypeProviderUser.TSQL/V1.model.sql index 119f071..b0783f7 100644 --- a/src/TypeProviderUsers/TypeProviderUser.TSQL/V1.model.sql +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL/V1.model.sql @@ -30,3 +30,15 @@ create table ArticleComments create index IX_ArticleComments_AuthorId on ArticleComments(AuthorId); +create table UserAddresses +( Id int64 primary key autoincrement +, UserId int64 references Users(Id) +, Home Address +); + +create table UserLocations +( Id int64 primary key autoincrement +, UserId int64 references Users(Id) +, Coord GeoLocation +); + diff --git a/src/TypeProviderUsers/TypeProviderUser.TSQL/rzsql.json b/src/TypeProviderUsers/TypeProviderUser.TSQL/rzsql.json index 9b3dc65..42876ca 100644 --- a/src/TypeProviderUsers/TypeProviderUser.TSQL/rzsql.json +++ b/src/TypeProviderUsers/TypeProviderUser.TSQL/rzsql.json @@ -1,3 +1,4 @@ -{ - "backend": "tsql" -} \ No newline at end of file +{ + "backend": "tsql", + "usertypes": [ "TypeProviderUser.TSQL.UserTypes" ] +} diff --git a/src/TypeProviderUsers/TypeProviderUser.UserTypes/Library.fs b/src/TypeProviderUsers/TypeProviderUser.UserTypes/Library.fs index 15ecb2b..42605b9 100644 --- a/src/TypeProviderUsers/TypeProviderUser.UserTypes/Library.fs +++ b/src/TypeProviderUsers/TypeProviderUser.UserTypes/Library.fs @@ -11,6 +11,36 @@ module Extensions = static member FromPrimitive(s : string) = System.TimeOnly.ParseExact(s, "o") +// --- Enum fixtures ------------------------------------------------------- +// +// Two different ways of routing a CLR enum through the user-type pipeline: +// FavoriteColor — user-defined F# enum, mapped to string via ToString / +// Enum.Parse so storage is human-readable ("Red", "Blue"). +// DateTimeKind — BCL enum we cannot edit, mapped to its underlying int +// value via the cast operator. Demonstrates that the same +// external-static-class pattern that handles BCL classes +// also handles BCL enums. +// +// F# enums compile to CLR enums (System.Enum subtypes), distinct from F# +// single-case DUs which compile to class hierarchies. So findSingleCaseDU +// will return None for these and the loader falls through to the explicit +// ToPrimitive/FromPrimitive static-class path. + +type FavoriteColor = + | Red = 0 + | Green = 1 + | Blue = 2 + +type FavoriteColorMapping() = + static member ToPrimitive(c : FavoriteColor) : string = c.ToString() + static member FromPrimitive(s : string) : FavoriteColor = + System.Enum.Parse(s) + +type DateTimeKindMapping() = + static member ToPrimitive(k : System.DateTimeKind) : int = int k + static member FromPrimitive(i : int) : System.DateTimeKind = + enum i + // --- Fixtures for the Rezoom.SQL.Annotations attribute pipeline ---------- /// Single-case DU with a type-level RawBackendSQLType attribute. The @@ -26,6 +56,10 @@ type CompactInt = CompactInt of int [] type ShortName = ShortName of string +/// Single-case DU over byte[] — exercises the byte[] underlying-CLR-type +/// path end-to-end through the SQLite TPU. +type FileHash = FileHash of byte[] + /// Extension-method conversion on a BCL type the user does not own. /// The attribute is method-level (on ToPrimitive) because we can't /// place an attribute on System.DateTimeOffset itself. Mirrors the diff --git a/src/TypeProviderUsers/TypeProviderUsers.sln b/src/TypeProviderUsers/TypeProviderUsers.sln index 0ce7c37..5e2751d 100644 --- a/src/TypeProviderUsers/TypeProviderUsers.sln +++ b/src/TypeProviderUsers/TypeProviderUsers.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.6.11819.183 stable +VisualStudioVersion = 18.6.11819.183 MinimumVisualStudioVersion = 10.0.40219.1 Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TypeProviderUser.SQLite", "TypeProviderUser.SQLite\TypeProviderUser.SQLite.fsproj", "{05CF934C-7E0C-4C3E-9696-5D78DF6266ED}" EndProject @@ -13,6 +13,12 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TypeProviderUser.UserTypes" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TypeProviderUser.BclOnlyTypes", "TypeProviderUser.BclOnlyTypes\TypeProviderUser.BclOnlyTypes.fsproj", "{880892F4-19E0-42D1-A6CC-55256B7881F2}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TypeProviderUser.Postgres.UserTypes", "TypeProviderUser.Postgres.UserTypes\TypeProviderUser.Postgres.UserTypes.fsproj", "{65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rezoom.SQL.Annotations", "..\Rezoom.SQL.Annotations\Rezoom.SQL.Annotations.csproj", "{7E963635-41D8-411E-B521-9A41AE5CA3AC}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TypeProviderUser.TSQL.UserTypes", "TypeProviderUser.TSQL.UserTypes\TypeProviderUser.TSQL.UserTypes.fsproj", "{2ABDE072-FA83-4E30-8C6F-99EF334F4186}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +89,42 @@ Global {880892F4-19E0-42D1-A6CC-55256B7881F2}.Release|x64.Build.0 = Release|Any CPU {880892F4-19E0-42D1-A6CC-55256B7881F2}.Release|x86.ActiveCfg = Release|Any CPU {880892F4-19E0-42D1-A6CC-55256B7881F2}.Release|x86.Build.0 = Release|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Debug|x64.Build.0 = Debug|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Debug|x86.Build.0 = Debug|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Release|Any CPU.Build.0 = Release|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Release|x64.ActiveCfg = Release|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Release|x64.Build.0 = Release|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Release|x86.ActiveCfg = Release|Any CPU + {65A76A56-4F8A-4F1E-BAEE-C590E6A3C1A8}.Release|x86.Build.0 = Release|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Debug|x64.Build.0 = Debug|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Debug|x86.Build.0 = Debug|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Release|Any CPU.Build.0 = Release|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Release|x64.ActiveCfg = Release|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Release|x64.Build.0 = Release|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Release|x86.ActiveCfg = Release|Any CPU + {7E963635-41D8-411E-B521-9A41AE5CA3AC}.Release|x86.Build.0 = Release|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Debug|x64.ActiveCfg = Debug|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Debug|x64.Build.0 = Debug|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Debug|x86.ActiveCfg = Debug|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Debug|x86.Build.0 = Debug|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Release|Any CPU.Build.0 = Release|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Release|x64.ActiveCfg = Release|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Release|x64.Build.0 = Release|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Release|x86.ActiveCfg = Release|Any CPU + {2ABDE072-FA83-4E30-8C6F-99EF334F4186}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/version.props b/version.props index 25b3354..572fdf3 100644 --- a/version.props +++ b/version.props @@ -9,6 +9,6 @@ into version.local.props (gitignored). The combined version then looks like 0.13.0-dev.5 until the next release. --> - 1.0.0 + 1.1.0