diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 06d1495..216e284 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -18,16 +18,36 @@ jobs:
with:
dotnet-version: 8.0.x
- - name: Restore solution
+ - name: Restore projects
run: dotnet restore projects/csharp/SqlRace.Examples.sln
env:
NUGET_AUTH_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
NUGET_AUTH_TOKEN: ${{ secrets.PACKAGES_PATTOKEN }}
- - name: Build solution
+ - name: Build projects
run: dotnet build projects/csharp/SqlRace.Examples.sln --no-restore --configuration Release
- # NOTE: Tests are NOT executed in CI. Several tests initialise the SQL Race
- # runtime via SqlRaceBootstrapper, which requires a SQL Race licence that the
- # CI runner does not have (LicenceException). CI verifies compilation only;
- # run `dotnet test` locally on a licenced machine to execute the test suite.
+ - name: Restore tests
+ run: dotnet restore tests/SqlRace.Examples.Tests/SqlRace.Examples.Tests.csproj
+ env:
+ NUGET_AUTH_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
+ NUGET_AUTH_TOKEN: ${{ secrets.PACKAGES_PATTOKEN }}
+
+ - name: Build tests
+ run: dotnet build tests/SqlRace.Examples.Tests/SqlRace.Examples.Tests.csproj --no-restore --configuration Release
+
+ # NOTE: MOST tests are not executed in CI. Several of them initialise the SQL
+ # Race runtime via SqlRaceBootstrapper, which requires a SQL Race licence that
+ # the CI runner does not have (LicenceException). Run `dotnet test` locally on
+ # a licenced machine to execute the full suite, or scripts/run-examples-e2e.ps1
+ # for the runtime checks across C#, Python and MATLAB.
+ #
+ # The snippet compilation tests are the exception: they compile every snippet
+ # against the real SQL Race assemblies using Roslyn, which is a metadata-only
+ # operation needing no licence. They are the early warning that a new
+ # MESL.SQLRace.API release has broken the examples.
+ - name: Verify snippets compile against SQL Race
+ run: >
+ dotnet test tests/SqlRace.Examples.Tests/SqlRace.Examples.Tests.csproj
+ --no-build --configuration Release
+ --filter "FullyQualifiedName~SnippetCompilation"
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..0202b29
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,26 @@
+
+
+
+
+ *
+
+
+
diff --git a/projects/csharp/Directory.Build.props b/projects/csharp/Directory.Build.props
index ab7c948..76015a1 100644
--- a/projects/csharp/Directory.Build.props
+++ b/projects/csharp/Directory.Build.props
@@ -1,5 +1,18 @@
+
+
+
net8.0enable
diff --git a/projects/csharp/FunctionLibrary/FunctionLibrary.csproj b/projects/csharp/FunctionLibrary/FunctionLibrary.csproj
index 1cfe0b7..e292fb8 100644
--- a/projects/csharp/FunctionLibrary/FunctionLibrary.csproj
+++ b/projects/csharp/FunctionLibrary/FunctionLibrary.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/projects/csharp/SqlRace.Examples.Core/ParameterBuilder.cs b/projects/csharp/SqlRace.Examples.Core/ParameterBuilder.cs
index d6855b3..87b3723 100644
--- a/projects/csharp/SqlRace.Examples.Core/ParameterBuilder.cs
+++ b/projects/csharp/SqlRace.Examples.Core/ParameterBuilder.cs
@@ -194,7 +194,15 @@ public BuiltParameter Build()
/// The session to add parameters to.
/// The configured builders to build.
/// A list of created parameters.
- public static IReadOnlyList BuildBatch(Session session, IEnumerable builders)
+ ///
+ /// Optional hook to add anything else - conversions, event definitions - to the same
+ /// configuration before it is committed. Only the first configuration set committed to a
+ /// session survives a reload, so anything that must persist has to go in this one.
+ ///
+ public static IReadOnlyList BuildBatch(
+ Session session,
+ IEnumerable builders,
+ Action? configure = null)
{
var config = session.CreateConfiguration();
var parameters = new List();
@@ -264,6 +272,8 @@ public static IReadOnlyList BuildBatch(Session session, IEnumera
parameters.Add(new BuiltParameter(parameter, (uint)channelId));
}
+ configure?.Invoke(config);
+
config.Commit();
return parameters;
}
diff --git a/projects/csharp/SqlRace.Examples.Core/SqlRace.Examples.Core.csproj b/projects/csharp/SqlRace.Examples.Core/SqlRace.Examples.Core.csproj
index 4a0d91b..a806d49 100644
--- a/projects/csharp/SqlRace.Examples.Core/SqlRace.Examples.Core.csproj
+++ b/projects/csharp/SqlRace.Examples.Core/SqlRace.Examples.Core.csproj
@@ -8,7 +8,7 @@
-
+
diff --git a/scripts/run-examples-e2e.ps1 b/scripts/run-examples-e2e.ps1
index 1115374..6b3a762 100644
--- a/scripts/run-examples-e2e.ps1
+++ b/scripts/run-examples-e2e.ps1
@@ -1,73 +1,612 @@
# ─────────────────────────────────────────────────────────────
-# End-to-end snippet smoke-runner.
+# End-to-end snippet runner: C#, Python and MATLAB.
#
-# Generates the deterministic verified fixture (tools/FixtureGenerator) at the default
-# SQLite path the snippets read from, then runs each runnable snippet against it and
-# checks it exits cleanly and prints the expected anchor text.
+# Generates the deterministic fixture (tools/FixtureGenerator), runs every
+# runnable snippet from scripts/snippet-manifest.psd1 against it, and compares
+# each snippet's output against a checked-in golden file under tests/golden/.
#
-# Requires the SQL Race runtime + licence — local / licenced-runner only, NOT GitHub CI.
-# Usage: pwsh scripts/run-examples-e2e.ps1
+# Why golden files rather than "did it exit 0": an API change can leave a
+# snippet compiling and exiting cleanly while quietly returning different data.
+# Comparing normalised output catches that. It also means adding a snippet costs
+# a manifest line rather than a hand-authored assertion.
+#
+# Python and MATLAB matter disproportionately here. They bind to SQL Race by
+# name at runtime, so a rename that a C# compiler would catch fails silently for
+# them - running them is the only way to find out.
+#
+# Requires the SQL Race runtime + licence - local / licenced-runner only, NOT
+# GitHub CI. The licence-free compile check is in tests/SqlRace.Examples.Tests
+# (SnippetCompilationTests).
+#
+# Usage:
+# ./scripts/run-examples-e2e.ps1
+# ./scripts/run-examples-e2e.ps1 -Language CSharp
+# ./scripts/run-examples-e2e.ps1 -Filter '*read*'
+# ./scripts/run-examples-e2e.ps1 -SqlRaceApiVersion
+# ./scripts/run-examples-e2e.ps1 -UpdateGolden # after an intended change
# ─────────────────────────────────────────────────────────────
-$ErrorActionPreference = "Stop"
+[CmdletBinding()]
+param (
+ [ValidateSet('All', 'CSharp', 'Python', 'Matlab')]
+ [string] $Language = 'All',
+
+ # Wildcard over snippet names, for iterating on one snippet.
+ [string] $Filter = '*',
+
+ # Rewrite golden files from this run's output instead of comparing. Review
+ # the resulting diff - it will happily bake in a regression.
+ [switch] $UpdateGolden,
+
+ # Pin the MESL.SQLRace.API version to validate a candidate build before it
+ # ships. Empty means whatever the projects resolve normally.
+ [string] $SqlRaceApiVersion,
+
+ # Extra NuGet source, normally a folder holding a .nupkg that has not been
+ # published anywhere - the package a CI build just produced, for instance.
+ # Passed as RestoreAdditionalProjectSources rather than added with
+ # "dotnet nuget add source", because the repo nuget.config starts with
+ # and would discard a source added at user level.
+ [string] $AdditionalPackageSource,
+
+ # Hard limit per snippet. Nothing here is supposed to wait on anything, so
+ # a snippet still running after this has hung and is killed. Generous
+ # because a cold MATLAB start alone is most of a minute.
+ [int] $SnippetTimeoutSeconds = 180,
+
+ [switch] $SkipBuild
+)
+
+$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
Push-Location $repo
+
try {
- $fixtureKey = "f1c0ffee-0000-4000-8000-000000000001" # VerifiedFixture.SessionKeyString
- $fixturePath = Join-Path $env:TEMP "sqlrace-examples.ssn2" # default path snippets read
-
- Write-Host "Building generator + snippet runner..." -ForegroundColor Cyan
- dotnet build tools/FixtureGenerator/FixtureGenerator.csproj -c Release -v quiet | Out-Null
- if (-not $?) { throw "FixtureGenerator build failed" }
-
- Write-Host "Generating verified fixture at $fixturePath" -ForegroundColor Cyan
- & "tools/FixtureGenerator/bin/Release/net8.0/FixtureGenerator.exe" $fixturePath | Out-Null
- if (-not $?) { throw "Fixture generation failed" }
-
- # Each snippet: args to pass, and an expected substring proving it ran against the fixture.
- $snippets = @(
- @{ Name = "01-load-session-from-file"; Path = "snippets\csharp\getting-started\01-load-session-from-file.cs"; Args = @($fixturePath); Expect = "Verified Test Session" },
- @{ Name = "03-read-parameter-samples"; Path = "snippets\csharp\getting-started\03-read-parameter-samples.cs"; Args = @($fixtureKey); Expect = "1000" },
- @{ Name = "read-samples-between"; Path = "snippets\csharp\data-access\read-samples-between.cs"; Args = @($fixtureKey); Expect = "Temperature:Sensors" },
- @{ Name = "bulk-parameter-read"; Path = "snippets\csharp\data-access\bulk-parameter-read.cs"; Args = @($fixtureKey); Expect = $null },
- @{ Name = "multi-rate-alignment"; Path = "snippets\csharp\data-access\multi-rate-alignment.cs"; Args = @($fixtureKey); Expect = $null },
- @{ Name = "reverse-iteration"; Path = "snippets\csharp\data-access\reverse-iteration.cs"; Args = @($fixtureKey); Expect = $null },
- @{ Name = "parameter-unit-resolution"; Path = "snippets\csharp\session-management\parameter-unit-resolution.cs"; Args = @($fixtureKey); Expect = "Temperature:Sensors" }
- )
-
- # Preserve the SnippetRunner program (the runner mechanism overwrites it).
- $runnerProgram = "tools\SnippetRunner\Program.cs"
- $backup = Get-Content $runnerProgram -Raw
+ $fixtureKey = 'f1c0ffee-0000-4000-8000-000000000001' # VerifiedFixture.SessionKeyString
+ $fixturePath = Join-Path $env:TEMP 'sqlrace-examples.ssn2'
+ $goldenRoot = Join-Path $repo 'tests\golden'
+ $manifest = Import-PowerShellDataFile (Join-Path $PSScriptRoot 'snippet-manifest.psd1')
+
+ $buildArgs = @('-c', 'Release', '-v', 'quiet')
+ if ($SqlRaceApiVersion) { $buildArgs += "-p:SqlRaceApiVersion=$SqlRaceApiVersion" }
+ if ($AdditionalPackageSource) {
+ $buildArgs += "-p:RestoreAdditionalProjectSources=$AdditionalPackageSource"
+ }
+
+ # ── Normalisation ────────────────────────────────────────
+ #
+ # Snippets print values that legitimately differ between runs. Without this
+ # every golden comparison would fail on the first newly generated GUID.
+ function ConvertTo-ComparableOutput {
+ param ([string] $Text)
+
+ if (-not $Text) { return '' }
+
+ $normalised = $Text -replace "`r`n", "`n"
+
+ # Volatile values, most specific pattern first.
+ $normalised = $normalised -replace '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', ''
+ $normalised = $normalised -replace '\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?', ''
+ # Single-digit month and day, and an AM/PM suffix: pythonnet renders
+ # DateTime with the current culture, so 9/3/2026 10:00:00 AM has to
+ # normalise too or the golden only holds for the day it was written.
+ $normalised = $normalised -replace '\d{1,2}/\d{1,2}/\d{4} \d{1,2}:\d{2}:\d{2}( [AP]M)?', ''
+ # Windows paths, including the per-user temp directory the fixture lives in.
+ $normalised = $normalised -replace '[A-Za-z]:\\[^\s"'']*', ''
+ # Elapsed times and sizes: "12.345 s", "1234 ms", "4.2 MB".
+ $normalised = $normalised -replace '\d+(\.\d+)?\s*(ms|s\b|KB|MB|GB)', ''
+
+ # Trailing whitespace and blank lines at either end are noise.
+ $lines = $normalised -split "`n" | ForEach-Object { $_.TrimEnd() }
+ return (($lines -join "`n").Trim())
+ }
+
+ # Build noise that says nothing about the snippet's behaviour.
+ $noisePattern = 'warning|NU1701|Restored|Determining|reachable on all platforms|' +
+ 'Build succeeded|Time Elapsed|^\s*\d+ Warning|^\s*\d+ Error'
+
+ function Remove-BuildNoise {
+ param ([string[]] $Lines)
+ return $Lines | Where-Object { $_ -notmatch $noisePattern -and $_.Trim() -ne '' }
+ }
+
+ # ── Fixture ──────────────────────────────────────────────
+ if (-not $SkipBuild) {
+ Write-Host 'Building fixture generator...' -ForegroundColor Cyan
+ & dotnet build tools/FixtureGenerator/FixtureGenerator.csproj @buildArgs | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw 'FixtureGenerator build failed' }
+ }
+
+ # Regenerated before every snippet, not once per run. Several snippets create
+ # their own sessions in the same SQLite file the read-only snippets query, so
+ # a single fixture would accumulate state: 01-load-session-from-file reports
+ # "Sessions found: 1" on its own and "Sessions found: 10" after the session
+ # creating snippets have run. Golden output has to be independent of the
+ # order snippets execute in, and of whether a previous run left anything
+ # behind.
+ function Reset-Fixture {
+ # SQLite handles are not always released the instant a snippet process
+ # exits, so deleting and regenerating can lose a race with the previous
+ # snippet. Retry rather than failing the whole run on a transient lock.
+ for ($attempt = 1; $attempt -le 5; $attempt++) {
+ try {
+ if (Test-Path $fixturePath) { Remove-Item $fixturePath -Force -ErrorAction Stop }
+ & 'tools/FixtureGenerator/bin/Release/net8.0/FixtureGenerator.exe' $fixturePath | Out-Null
+ if ($LASTEXITCODE -eq 0) { return }
+ }
+ catch {
+ if ($attempt -eq 5) { throw "Could not reset the fixture: $($_.Exception.Message)" }
+ }
+ Start-Sleep -Milliseconds (200 * $attempt)
+ }
+ throw "Fixture generation failed after 5 attempts (file locked by a previous snippet?)"
+ }
+
+ Write-Host "Verified fixture at $fixturePath" -ForegroundColor Cyan
+ Reset-Fixture
+
+ # Runs an external command with a hard timeout, capturing both streams.
+ #
+ # Needed because a snippet can hang rather than fail - one waiting on live
+ # data that never arrives, or a MATLAB session that stops for input. Without
+ # a timeout that wedges the entire run, and because the hung process keeps a
+ # handle on the fixture, every subsequent snippet fails too. Nothing is
+ # supposed to sit and wait here, so a snippet still running after the
+ # timeout is a failure by definition.
+ function Invoke-WithTimeout {
+ param (
+ [Parameter(Mandatory)][string] $FilePath,
+ [string[]] $ArgumentList = @(),
+ [string] $WorkingDirectory = $repo,
+ [int] $TimeoutSeconds = $SnippetTimeoutSeconds
+ )
+
+ $stdout = [System.IO.Path]::GetTempFileName()
+ $stderr = [System.IO.Path]::GetTempFileName()
+
+ try {
+ $startArgs = @{
+ FilePath = $FilePath
+ WorkingDirectory = $WorkingDirectory
+ RedirectStandardOutput = $stdout
+ RedirectStandardError = $stderr
+ NoNewWindow = $true
+ PassThru = $true
+ }
+ if ($ArgumentList.Count -gt 0) { $startArgs.ArgumentList = $ArgumentList }
+
+ $process = Start-Process @startArgs
+
+ # Touching Handle caches it. Without this, Start-Process -PassThru
+ # hands back an object that releases the native handle on exit and
+ # ExitCode reads back empty - every snippet then looks like a
+ # failure with no exit code to explain it.
+ $null = $process.Handle
+
+ if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
+ # Kill the tree: dotnet run spawns the snippet as a child, and
+ # killing only the parent would leave the child holding the
+ # fixture open.
+ try { $process.Kill($true) } catch { }
+ $process.WaitForExit(5000) | Out-Null
+ return [pscustomobject]@{
+ ExitCode = -1
+ TimedOut = $true
+ Lines = @("Timed out after $TimeoutSeconds seconds.")
+ }
+ }
+
+ # The timed WaitForExit overload can return before the exit code is
+ # cached on the object, leaving ExitCode empty. The parameterless
+ # overload also waits for the redirected streams to be flushed, so
+ # this both populates ExitCode and guarantees the files are complete.
+ $process.WaitForExit()
+
+ $lines = @()
+ foreach ($file in $stdout, $stderr) {
+ if ((Get-Item $file).Length -gt 0) {
+ # The snippets write UTF-8 (box-drawing characters in table
+ # rules, en dashes in ranges). Get-Content defaults to the
+ # ANSI codepage on Windows PowerShell, which would mangle
+ # them into mojibake and fail every golden comparison.
+ $lines += (Get-Content $file -Encoding UTF8 -ErrorAction SilentlyContinue)
+ }
+ }
+
+ return [pscustomobject]@{
+ ExitCode = $process.ExitCode
+ TimedOut = $false
+ Lines = $lines
+ }
+ }
+ finally {
+ Remove-Item $stdout, $stderr -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ # ── C# runner project ────────────────────────────────────
+ #
+ # tools/SnippetRunner compiles whichever file its SnippetFile property points
+ # at, so a snippet runs without anything in the working tree changing. The
+ # previous version of this script copied each snippet over Program.cs and
+ # restored it in a finally block, which left the repo dirty for the duration
+ # and lost the original if the run was interrupted.
+ $runnerProject = 'tools\SnippetRunner\SnippetRunner.csproj'
+
+ function Initialize-SnippetRunner {
+ # Build once with the default Program.cs: this resolves the package and
+ # populates the output folder that SQLRACE_DLL_PATH points at, so all
+ # three languages exercise the same assemblies.
+ Write-Host 'Preparing snippet runner...' -ForegroundColor Cyan
+ & dotnet build $runnerProject @buildArgs | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw ("SnippetRunner build failed. If this is a NuGet 401, MESL.SQLRace.API is " +
+ "referenced as Version=`"*`" and NuGet cannot resolve a floating version " +
+ "without reaching the feed: set NUGET_AUTH_USERNAME and NUGET_AUTH_TOKEN " +
+ "(see .env.example), or pass -SqlRaceApiVersion to pin a version already " +
+ "in the local NuGet cache.")
+ }
+
+ $domain = Join-Path $repo 'tools\SnippetRunner\bin\Release\net8.0\MESL.SqlRace.Domain.dll'
+ if (-not (Test-Path $domain)) { throw "MESL.SqlRace.Domain.dll not found at $domain" }
+ return $domain
+ }
+
+ function Invoke-CSharpSnippet {
+ param ([hashtable] $Snippet, [string[]] $Arguments)
+
+ # SnippetFile must be absolute - MSBuild resolves relative Compile items
+ # against the project directory, not the working directory.
+ $snippetFile = (Resolve-Path $Snippet.Path).Path
+
+ $dotnetArgs = @('run', '--project', $runnerProject) + $buildArgs +
+ @("-p:SnippetFile=$snippetFile", '--') + $Arguments
+
+ return Invoke-WithTimeout -FilePath 'dotnet' -ArgumentList $dotnetArgs
+ }
+
+ # A NuGet-restored build keeps platform-specific assets under runtimes//
+ # and relies on the app's deps.json to pick the right ones. Python and MATLAB
+ # host the CLR themselves, so there is no deps.json for SQL Race: the loader
+ # falls back to the flat directory, finds the placeholder System.Data.SqlClient
+ # ("not supported on this platform") and cannot find SQLite.Interop.dll at all.
+ #
+ # Flattening the Windows assets over a copy of the output gives those hosts the
+ # same layout an ATLAS installation has. This is not only a harness concern - a
+ # customer pointing pythonnet or MATLAB at a NuGet-restored SQL Race hits
+ # exactly this, while one pointing at an ATLAS install does not.
+ function New-FlatRuntimeDirectory {
+ param ([Parameter(Mandatory)][string] $BuildOutputDir)
+
+ $flatDir = Join-Path $env:TEMP 'sqlrace-flat-runtime'
+ if (Test-Path $flatDir) { Remove-Item $flatDir -Recurse -Force }
+ New-Item -ItemType Directory -Path $flatDir -Force | Out-Null
+
+ Copy-Item (Join-Path $BuildOutputDir '*') $flatDir -Recurse -Force
+
+ # Overlay Windows assets last so they win over the cross-platform
+ # placeholders sitting in the output root.
+ $overlays = @(
+ 'runtimes\win\lib\netstandard2.0'
+ 'runtimes\win\lib\netcoreapp2.0'
+ 'runtimes\win\lib\netcoreapp2.1'
+ 'runtimes\win\lib\netcoreapp3.0'
+ 'runtimes\win-x64\native'
+ )
+ foreach ($overlay in $overlays) {
+ $source = Join-Path $BuildOutputDir $overlay
+ if (Test-Path $source) {
+ Copy-Item (Join-Path $source '*') $flatDir -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ return $flatDir
+ }
+
+ function Invoke-PythonSnippet {
+ param ([hashtable] $Snippet, [string[]] $Arguments)
+
+ # Python defaults stdout to the console codepage on Windows (cp1252),
+ # and the snippets print box-drawing characters in their table rules -
+ # without this they die with UnicodeEncodeError before reaching any SQL
+ # Race call, which would look like an API failure but is not one.
+ $previousEncoding = $env:PYTHONIOENCODING
+ $env:PYTHONIOENCODING = 'utf-8'
+ try {
+ return Invoke-WithTimeout -FilePath 'python' `
+ -ArgumentList (@((Resolve-Path $Snippet.Path).Path) + $Arguments)
+ }
+ finally {
+ $env:PYTHONIOENCODING = $previousEncoding
+ }
+ }
+
+# Preflight before the MATLAB leg. A MATLAB startup costs the better part of a
+ # minute, so without this a missing install or an unavailable licence seat
+ # produces twenty-odd identical failures over quarter of an hour instead of
+ # one clear message in forty seconds.
+ #
+ # Licensing is a network checkout: /licenses/network.lic points at
+ # the FlexLM server, or MLM_LICENSE_FILE overrides it as port@host. Note that
+ # FlexLM needs two ports open - lmgrd (27000 by default) and the MLM vendor
+ # daemon, which is randomly assigned unless the server pins it with
+ # "port=" on its DAEMON line.
+ function Test-MatlabAvailable {
+ $matlab = Get-Command matlab -ErrorAction SilentlyContinue
+ if (-not $matlab) {
+ Write-Host 'MATLAB not found on PATH - skipping the MATLAB leg.' -ForegroundColor Yellow
+ Write-Host ' Install MATLAB, or run with -Language CSharp or -Language Python.' -ForegroundColor Yellow
+ return $false
+ }
+
+ Write-Host "Checking out a MATLAB licence ($($matlab.Source))..." -ForegroundColor Cyan
+ $previousPreference = $ErrorActionPreference
+ $ErrorActionPreference = 'Continue'
+ try {
+ $probe = & matlab -batch "fprintf('MATLAB %s licensed\n', version)" 2>&1 |
+ ForEach-Object { $_.ToString() }
+ $exitCode = $LASTEXITCODE
+ }
+ finally {
+ $ErrorActionPreference = $previousPreference
+ }
+
+ if ($exitCode -ne 0) {
+ Write-Host 'MATLAB is installed but would not start - skipping the MATLAB leg.' -ForegroundColor Yellow
+ $probe | Select-Object -First 6 | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
+ Write-Host " MLM_LICENSE_FILE = $($env:MLM_LICENSE_FILE)" -ForegroundColor Yellow
+ Write-Host ' A licence manager error here usually means the agent cannot reach' -ForegroundColor Yellow
+ Write-Host ' the FlexLM server, or no concurrent seat is free.' -ForegroundColor Yellow
+ return $false
+ }
+
+ Write-Host " $($probe | Where-Object { $_ -match 'licensed' } | Select-Object -First 1)" -ForegroundColor DarkGray
+ return $true
+ }
+
+# Preflight for the Python leg. Without it, a machine with no Python produces
+ # one identical "The system cannot find the file specified" per snippet and
+ # nothing that says Python is simply not installed - which is exactly how it
+ # first showed up on a build agent.
+ #
+ # Returns $null when Python is usable, otherwise the reason it is not.
+ function Test-PythonAvailable {
+ $python = Get-Command python -ErrorAction SilentlyContinue
+ if (-not $python) {
+ Write-Host 'Python not found on PATH - skipping the Python leg.' -ForegroundColor Yellow
+ Write-Host ' Install Python 3, then: pip install pythonnet cffi pandas' -ForegroundColor Yellow
+ return 'Python is not installed on this machine'
+ }
+
+ # pythonnet needs clr_loader, which needs cffi. A missing cffi fails
+ # inside pythonnet.load() with a runtime error that reads like a SQL Race
+ # fault rather than a missing dependency.
+ $previousPreference = $ErrorActionPreference
+ $ErrorActionPreference = 'Continue'
+ try {
+ $probe = & python -c "import clr_loader, cffi" 2>&1 | ForEach-Object { $_.ToString() }
+ $exitCode = $LASTEXITCODE
+ }
+ finally {
+ $ErrorActionPreference = $previousPreference
+ }
+
+ if ($exitCode -ne 0) {
+ Write-Host "Python is installed ($($python.Source)) but pythonnet is not usable - skipping the Python leg." -ForegroundColor Yellow
+ $probe | Select-Object -First 3 | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
+ Write-Host ' Fix with: pip install pythonnet cffi pandas' -ForegroundColor Yellow
+ return 'pythonnet or its dependencies are missing'
+ }
+
+ Write-Host "Python ready ($($python.Source))" -ForegroundColor DarkGray
+ return $null
+ }
+
+ function Invoke-MatlabSnippet {
+ param ([hashtable] $Snippet, [string[]] $Arguments)
+
+ # -batch runs headless, returns a non-zero exit code on an uncaught
+ # error, and needs an absolute path since it does not inherit our cwd
+ # semantics for run().
+ $full = (Resolve-Path $Snippet.Path).Path
+ return Invoke-WithTimeout -FilePath 'matlab' -ArgumentList @('-batch', "run('$full')")
+ }
+
+ $runners = @{
+ CSharp = ${function:Invoke-CSharpSnippet}
+ Python = ${function:Invoke-PythonSnippet}
+ Matlab = ${function:Invoke-MatlabSnippet}
+ }
+
+ $languages = if ($Language -eq 'All') { @('CSharp', 'Python', 'Matlab') } else { @($Language) }
+
+ # Only prepare the C# runner if we need it - it is also what supplies
+ # SQLRACE_DLL_PATH for the other two languages.
+ $sqlRaceDllPath = Initialize-SnippetRunner
+
+ # The C# leg runs through dotnet, which resolves assets from deps.json. Python
+ # and MATLAB need the flattened layout instead - but the same build either way,
+ # so all three languages exercise identical assemblies.
+ $flatRuntimeDir = New-FlatRuntimeDirectory -BuildOutputDir (Split-Path $sqlRaceDllPath -Parent)
+ $env:SQLRACE_DLL_PATH = Join-Path $flatRuntimeDir 'MESL.SqlRace.Domain.dll'
+ $env:PATH = "$flatRuntimeDir;$env:PATH"
+ Write-Host "SQLRACE_DLL_PATH = $($env:SQLRACE_DLL_PATH)" -ForegroundColor DarkGray
$results = @()
- try {
- foreach ($s in $snippets) {
- Write-Host "`n===== $($s.Name) =====" -ForegroundColor Cyan
- Copy-Item $s.Path $runnerProgram -Force
- (Get-Item $runnerProgram).LastWriteTime = Get-Date
- $out = dotnet run --project tools\SnippetRunner\SnippetRunner.csproj -c Release -- @($s.Args) 2>&1 |
- Where-Object { $_ -notmatch "warning|NU1701|Restored|Determining|reachable on all platforms" -and $_ -ne "" }
- $exit = $LASTEXITCODE
- $text = ($out | Out-String)
+ foreach ($lang in $languages) {
+ $snippets = @($manifest[$lang] | Where-Object { $_.Name -like $Filter })
+ if ($snippets.Count -eq 0) { continue }
+
+ if ($lang -eq 'Matlab' -and -not (Test-MatlabAvailable)) {
+ foreach ($snippet in $snippets) {
+ $results += [pscustomobject]@{
+ Language = 'Matlab'; Snippet = $snippet.Name; Status = 'SKIP'
+ Detail = 'MATLAB unavailable or unlicensed on this machine'
+ }
+ }
+ continue
+ }
+
+ if ($lang -eq 'Python') {
+ $reason = Test-PythonAvailable
+ if ($reason) {
+ foreach ($snippet in $snippets) {
+ $results += [pscustomobject]@{
+ Language = 'Python'; Snippet = $snippet.Name; Status = 'SKIP'; Detail = $reason
+ }
+ }
+ continue
+ }
+ }
+
+ Write-Host ""
+ Write-Host "########## $lang ##########" -ForegroundColor Yellow
+
+ $goldenDir = Join-Path $goldenRoot $lang
+ if ($UpdateGolden -and -not (Test-Path $goldenDir)) {
+ New-Item -ItemType Directory -Path $goldenDir -Force | Out-Null
+ }
+
+ foreach ($snippet in $snippets) {
+ if ($snippet.Skip) {
+ Write-Host ("[SKIP] {0} - {1}" -f $snippet.Name, $snippet.Skip) -ForegroundColor DarkGray
+ $results += [pscustomobject]@{
+ Language = $lang; Snippet = $snippet.Name; Status = 'SKIP'; Detail = $snippet.Skip
+ }
+ continue
+ }
+
+ $arguments = @()
+ foreach ($argument in @($snippet.Args)) {
+ if ($null -eq $argument) { continue }
+ $arguments += $argument.Replace('{fixtureKey}', $fixtureKey).Replace('{fixturePath}', $fixturePath)
+ }
+
+ Write-Host ("`n===== {0}/{1} =====" -f $lang, $snippet.Name) -ForegroundColor Cyan
- $ok = ($exit -eq 0) -and ($text -notmatch "ERROR:|Usage:|not found|No sessions")
- if ($ok -and $s.Expect) { $ok = $text -match [regex]::Escape($s.Expect) }
+ Reset-Fixture
- $status = if ($ok) { "PASS" } else { "FAIL" }
- Write-Host "[$status] exit=$exit" -ForegroundColor $(if ($ok) { "Green" } else { "Red" })
- $out | Select-Object -First 4 | ForEach-Object { Write-Host " $_" }
- $results += [pscustomobject]@{ Snippet = $s.Name; Status = $status }
+ # Under Windows PowerShell 5.1, redirecting a native command's stderr
+ # turns each line into an ErrorRecord, which $ErrorActionPreference
+ # = 'Stop' promotes to a terminating error. A Python traceback or a
+ # MATLAB warning would then abort the run instead of being captured
+ # as the snippet's output. $ErrorActionPreference is dynamically
+ # scoped, so relaxing it here covers the runner functions too.
+ $previousPreference = $ErrorActionPreference
+ $ErrorActionPreference = 'Continue'
+ try {
+ $run = & $runners[$lang] $snippet $arguments
+ }
+ catch {
+ Write-Host "[FAIL] runner threw: $($_.Exception.Message)" -ForegroundColor Red
+ $results += [pscustomobject]@{
+ Language = $lang; Snippet = $snippet.Name; Status = 'FAIL'; Detail = $_.Exception.Message
+ }
+ continue
+ }
+ finally {
+ $ErrorActionPreference = $previousPreference
+ }
+
+ $meaningful = Remove-BuildNoise -Lines $run.Lines
+ $actual = ConvertTo-ComparableOutput (($meaningful | Out-String))
+
+ $status = 'PASS'
+ $detail = ''
+
+ if ($run.TimedOut) {
+ # Distinct from a plain failure: the snippet is hung, not wrong.
+ # Named separately so nobody goes looking for a data mismatch.
+ $status = 'TIMEOUT'
+ $detail = "hung and was killed after $SnippetTimeoutSeconds seconds"
+ }
+ elseif ($run.ExitCode -ne 0) {
+ $status = 'FAIL'
+ $detail = "exit code $($run.ExitCode)"
+ }
+ elseif ($actual -match 'ERROR:|Unhandled exception|Traceback \(most recent call last\)') {
+ $status = 'FAIL'
+ $detail = 'snippet reported an error'
+ }
+ else {
+ $goldenPath = Join-Path $goldenDir "$($snippet.Name).txt"
+
+ if ($UpdateGolden) {
+ $actual | Set-Content $goldenPath -Encoding utf8
+ $status = 'GOLDEN'
+ $detail = 'written'
+ }
+ elseif (-not (Test-Path $goldenPath)) {
+ # Not a pass: an absent golden means nothing was verified.
+ $status = 'NOGOLD'
+ $detail = 'no golden file - run with -UpdateGolden'
+ }
+ else {
+ $expected = ConvertTo-ComparableOutput (Get-Content $goldenPath -Raw)
+ if ($expected -ne $actual) {
+ $status = 'DIFF'
+ $detail = 'output differs from golden'
+ }
+ }
+ }
+
+ $colour = switch ($status) {
+ 'PASS' { 'Green' }
+ 'GOLDEN' { 'Cyan' }
+ 'NOGOLD' { 'Yellow' }
+ default { 'Red' }
+ }
+ Write-Host "[$status] $detail" -ForegroundColor $colour
+ $meaningful | Select-Object -First 4 | ForEach-Object { Write-Host " $_" }
+
+ if ($status -eq 'DIFF') {
+ # Show the first few differing lines - the whole point is to make
+ # the behaviour change legible, not just flag that one happened.
+ $diff = Compare-Object ($expected -split "`n") ($actual -split "`n") |
+ Select-Object -First 6
+ Write-Host ' --- differences (< golden, > this run) ---' -ForegroundColor Red
+ $diff | ForEach-Object { Write-Host " $($_.SideIndicator) $($_.InputObject)" -ForegroundColor Red }
+ }
+
+ $results += [pscustomobject]@{
+ Language = $lang; Snippet = $snippet.Name; Status = $status; Detail = $detail
+ }
}
}
- finally {
- Set-Content $runnerProgram -Value $backup -NoNewline
+
+ # ── Summary ──────────────────────────────────────────────
+ Write-Host ""
+ Write-Host '=== Summary ===' -ForegroundColor Yellow
+ $results | Group-Object Language, Status |
+ Sort-Object Name |
+ ForEach-Object { "{0,-22} {1}" -f $_.Name, $_.Count } |
+ Write-Host
+
+ $bad = @($results | Where-Object { $_.Status -in @('FAIL', 'DIFF', 'TIMEOUT') })
+ $ungolden = @($results | Where-Object { $_.Status -eq 'NOGOLD' })
+ $skipped = @($results | Where-Object { $_.Status -eq 'SKIP' })
+
+ if ($skipped.Count -gt 0) {
+ Write-Host ""
+ Write-Host "$($skipped.Count) snippet(s) not executed:" -ForegroundColor DarkGray
+ $skipped | ForEach-Object { Write-Host " $($_.Language)/$($_.Snippet): $($_.Detail)" -ForegroundColor DarkGray }
+ }
+
+ if ($ungolden.Count -gt 0) {
+ Write-Host ""
+ Write-Host "$($ungolden.Count) snippet(s) ran but were not verified (no golden file)." -ForegroundColor Yellow
+ $ungolden | ForEach-Object { Write-Host " $($_.Language)/$($_.Snippet)" -ForegroundColor Yellow }
+ }
+
+ if ($bad.Count -gt 0) {
+ Write-Host ""
+ Write-Host "$($bad.Count) snippet(s) failed:" -ForegroundColor Red
+ $bad | ForEach-Object { Write-Host " $($_.Language)/$($_.Snippet): $($_.Detail)" -ForegroundColor Red }
+ exit 1
}
- Write-Host "`n=== Summary ===" -ForegroundColor Yellow
- $results | Format-Table -AutoSize | Out-String | Write-Host
- $failed = @($results | Where-Object { $_.Status -ne "PASS" }).Count
- if ($failed -gt 0) { Write-Host "$failed snippet(s) failed." -ForegroundColor Red; exit 1 }
- Write-Host "All snippets passed against the verified fixture." -ForegroundColor Green
+ Write-Host ""
+ Write-Host 'All executed snippets matched their golden output.' -ForegroundColor Green
}
finally {
Pop-Location
diff --git a/scripts/snippet-manifest.psd1 b/scripts/snippet-manifest.psd1
new file mode 100644
index 0000000..e44f30e
--- /dev/null
+++ b/scripts/snippet-manifest.psd1
@@ -0,0 +1,95 @@
+<#
+ Which example snippets the end-to-end runner executes, and how.
+
+ scripts/run-examples-e2e.ps1 reads this file. Keeping the list here rather
+ than inline in the script means adding a snippet is a data change, and it
+ puts the skips in one visible place - a snippet silently missing from the
+ run is indistinguishable from one that passed.
+
+ Name - identifies the snippet and names its golden output file under
+ tests/golden//.txt
+ Path - repo-relative path to the snippet
+ Args - arguments to pass. '{fixtureKey}' and '{fixturePath}' are replaced
+ with the deterministic fixture's session key and file path. Omit for
+ snippets that create their own session or take no input.
+ Skip - why this snippet is not executed. Skipped snippets are listed in the
+ summary so the coverage gap stays visible.
+
+ MATLAB snippets take no arguments - they discover the session themselves
+ from the fixture - so none of them declare Args.
+#>
+@{
+
+ CSharp = @(
+ @{ Name = 'append-multiple-sessions'; Path = 'snippets\csharp\composite-sessions\append-multiple-sessions.cs' }
+ @{ Name = 'composite-with-associates'; Path = 'snippets\csharp\composite-sessions\composite-with-associates.cs' }
+ @{ Name = 'whole-session-compare'; Path = 'snippets\csharp\composite-sessions\whole-session-compare.cs' }
+ @{ Name = 'conversion-types'; Path = 'snippets\csharp\configuration\conversion-types.cs' }
+ @{ Name = 'create-parameter-config'; Path = 'snippets\csharp\configuration\create-parameter-config.cs' }
+ @{ Name = 'create-transient-parameter'; Path = 'snippets\csharp\configuration\create-transient-parameter.cs' }
+ @{ Name = 'introspect-session-config'; Path = 'snippets\csharp\configuration\introspect-session-config.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'bulk-parameter-read'; Path = 'snippets\csharp\data-access\bulk-parameter-read.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'data-status-filtering'; Path = 'snippets\csharp\data-access\data-status-filtering.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'multi-rate-alignment'; Path = 'snippets\csharp\data-access\multi-rate-alignment.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'read-samples-between'; Path = 'snippets\csharp\data-access\read-samples-between.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'read-subsampled-data'; Path = 'snippets\csharp\data-access\read-subsampled-data.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'reverse-iteration'; Path = 'snippets\csharp\data-access\reverse-iteration.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'timestamp-array-construction'; Path = 'snippets\csharp\data-access\timestamp-array-construction.cs' }
+ @{ Name = 'dotnet-function-basic'; Path = 'snippets\csharp\functions\dotnet-function-basic.cs'; Skip = 'declares a function class rather than running one - needs a MEF host and the .NET Functions licence option' }
+ @{ Name = 'dotnet-function-with-pda'; Path = 'snippets\csharp\functions\dotnet-function-with-pda.cs'; Skip = 'declares a function class rather than running one - needs a MEF host and the .NET Functions licence option' }
+ @{ Name = 'fdl-function-basic'; Path = 'snippets\csharp\functions\fdl-function-basic.cs'; Skip = 'needs the ATLAS Functions licence option, which the fixture runner does not have' }
+ @{ Name = '01-load-session-from-file'; Path = 'snippets\csharp\getting-started\01-load-session-from-file.cs'; Args = @('{fixturePath}') }
+ @{ Name = '02-load-session-from-database'; Path = 'snippets\csharp\getting-started\02-load-session-from-database.cs'; Skip = 'needs a SQL Server instance, not the SQLite fixture' }
+ @{ Name = '03-read-parameter-samples'; Path = 'snippets\csharp\getting-started\03-read-parameter-samples.cs'; Args = @('{fixtureKey}') }
+ @{ Name = '04-create-session-write-data'; Path = 'snippets\csharp\getting-started\04-create-session-write-data.cs' }
+ @{ Name = '05-query-sessions-by-metadata'; Path = 'snippets\csharp\getting-started\05-query-sessions-by-metadata.cs' }
+ @{ Name = 'poll-live-samples'; Path = 'snippets\csharp\live-data\poll-live-samples.cs'; Skip = 'needs a live session from a recorder or Stream API' }
+ @{ Name = 'rda-parameter-change-detection'; Path = 'snippets\csharp\live-data\rda-parameter-change-detection.cs'; Skip = 'needs a live session from a recorder or Stream API' }
+ @{ Name = 'subscribe-to-data-events'; Path = 'snippets\csharp\live-data\subscribe-to-data-events.cs'; Skip = 'needs a live session from a recorder or Stream API' }
+ @{ Name = 'subscribe-to-lap-events'; Path = 'snippets\csharp\live-data\subscribe-to-lap-events.cs'; Skip = 'needs a live session from a recorder or Stream API' }
+ @{ Name = 'event-definitions-and-data'; Path = 'snippets\csharp\session-management\event-definitions-and-data.cs' }
+ @{ Name = 'lap-and-segment-management'; Path = 'snippets\csharp\session-management\lap-and-segment-management.cs' }
+ @{ Name = 'marker-management'; Path = 'snippets\csharp\session-management\marker-management.cs' }
+ @{ Name = 'parameter-unit-resolution'; Path = 'snippets\csharp\session-management\parameter-unit-resolution.cs'; Args = @('{fixtureKey}') }
+ @{ Name = 'query-with-composite-filter'; Path = 'snippets\csharp\session-management\query-with-composite-filter.cs' }
+ @{ Name = 'session-association'; Path = 'snippets\csharp\session-management\session-association.cs' }
+ @{ Name = 'session-metadata-crud'; Path = 'snippets\csharp\session-management\session-metadata-crud.cs' }
+ )
+
+ Python = @(
+ @{ Name = 'export_to_pandas'; Path = 'snippets\python\data-access\export_to_pandas.py'; Args = @('{fixtureKey}') }
+ @{ Name = 'multi_parameter_extract'; Path = 'snippets\python\data-access\multi_parameter_extract.py'; Args = @('{fixtureKey}') }
+ @{ Name = 'read_samples_to_list'; Path = 'snippets\python\data-access\read_samples_to_list.py'; Args = @('{fixtureKey}') }
+ @{ Name = '01_load_session'; Path = 'snippets\python\getting-started\01_load_session.py'; Args = @('{fixtureKey}') }
+ @{ Name = '02_read_parameters'; Path = 'snippets\python\getting-started\02_read_parameters.py'; Args = @('{fixtureKey}') }
+ @{ Name = '03_query_sessions'; Path = 'snippets\python\getting-started\03_query_sessions.py' }
+ @{ Name = '04_extract_events'; Path = 'snippets\python\getting-started\04_extract_events.py'; Args = @('{fixtureKey}') }
+ )
+
+ Matlab = @(
+ @{ Name = 'introspect_session_config'; Path = 'snippets\matlab\configuration\introspect_session_config.m' }
+ @{ Name = 'parameter_unit_resolution'; Path = 'snippets\matlab\configuration\parameter_unit_resolution.m' }
+ @{ Name = 'data_status_filtering'; Path = 'snippets\matlab\data-access\data_status_filtering.m' }
+ @{ Name = 'extract_to_timetable'; Path = 'snippets\matlab\data-access\extract_to_timetable.m' }
+ @{ Name = 'multi_channel_read'; Path = 'snippets\matlab\data-access\multi_channel_read.m' }
+ @{ Name = 'plot_parameters'; Path = 'snippets\matlab\data-access\plot_parameters.m' }
+ @{ Name = 'read_samples_between'; Path = 'snippets\matlab\data-access\read_samples_between.m' }
+ @{ Name = 'reverse_iteration'; Path = 'snippets\matlab\data-access\reverse_iteration.m' }
+ @{ Name = 'segment_statistics'; Path = 'snippets\matlab\data-access\segment_statistics.m' }
+ @{ Name = 'subsampled_read'; Path = 'snippets\matlab\data-access\subsampled_read.m' }
+ @{ Name = 'read_function_output'; Path = 'snippets\matlab\functions\read_function_output.m' }
+ @{ Name = 'create_session_write_data'; Path = 'snippets\matlab\getting-started\create_session_write_data.m' }
+ @{ Name = 'diagnose_setup'; Path = 'snippets\matlab\getting-started\diagnose_setup.m' }
+ @{ Name = 'list_parameters'; Path = 'snippets\matlab\getting-started\list_parameters.m' }
+ @{ Name = 'load_session'; Path = 'snippets\matlab\getting-started\load_session.m' }
+ @{ Name = 'load_session_from_database'; Path = 'snippets\matlab\getting-started\load_session_from_database.m'; Skip = 'needs a SQL Server instance, not the SQLite fixture' }
+ @{ Name = 'query_sessions'; Path = 'snippets\matlab\getting-started\query_sessions.m' }
+ @{ Name = 'read_parameters'; Path = 'snippets\matlab\getting-started\read_parameters.m' }
+ @{ Name = 'poll_live_samples'; Path = 'snippets\matlab\live-data\poll_live_samples.m'; Skip = 'needs a live session from a recorder or Stream API' }
+ @{ Name = 'server_listener'; Path = 'snippets\matlab\live-data\server_listener.m'; Skip = 'needs a live session from a recorder or Stream API' }
+ @{ Name = 'event_extraction'; Path = 'snippets\matlab\session-management\event_extraction.m' }
+ @{ Name = 'lap_segment_management'; Path = 'snippets\matlab\session-management\lap_segment_management.m' }
+ @{ Name = 'marker_management'; Path = 'snippets\matlab\session-management\marker_management.m' }
+ @{ Name = 'session_metadata'; Path = 'snippets\matlab\session-management\session_metadata.m' }
+ )
+}
diff --git a/snippets/csharp/functions/dotnet-function-basic.cs b/snippets/csharp/functions/dotnet-function-basic.cs
index f334665..abc523b 100644
--- a/snippets/csharp/functions/dotnet-function-basic.cs
+++ b/snippets/csharp/functions/dotnet-function-basic.cs
@@ -6,19 +6,19 @@
// that doubles an input value
// Prerequisites: MESL.SQLRace.API NuGet package, .NET 8
// Input: A session with a Temperature:Sensors parameter
-// Output: Doubled values from the .NET function output
+// Output: Doubled values published as DoubledTemperature:Functions
//
// Related: snippets/csharp/functions/fdl-function-basic.cs
// Docs: https://mat-docs.github.io/Atlas.SQLRaceAPI.Documentation/api/index.html
// ─────────────────────────────────────────────────────────────
-// NOTE: This file shows the complete pattern for a .NET function.
-// In production, the function class would be in a separate assembly
-// loaded by the function manager via MEF [Export] attributes.
+// NOTE: This file shows the complete pattern for a .NET function. In production
+// the class would live in a separate assembly, discovered via MEF [Export].
using System.ComponentModel.Composition;
-using MESL.SqlRace.Domain;
using MESL.SqlRace.Domain.Functions;
+using MESL.SqlRace.Domain.Functions.DotNet;
+using MESL.SqlRace.Enumerators;
Console.WriteLine("DoubleValueFunction defined.");
Console.WriteLine("See fdl-function-basic.cs for a complete runnable example.");
@@ -26,49 +26,75 @@
// --- The function implementation ---
[Export(typeof(IDotNetFunction))]
+[Serializable]
public class DoubleValueFunction : IDotNetFunction
{
- private const string InputParam = "Temperature:Sensors";
- private const string OutputParam = "DoubledTemperature:Calculated";
+ private const string InputParameter = "Temperature:Sensors";
+ private const string OutputName = "DoubledTemperature";
+ private const string OutputGroup = "Functions";
+ private const string OutputIdentifier = OutputName + ":" + OutputGroup;
- public IFunctionDefinition? Definition { get; private set; }
+ // Diagnostics only; keep it the same as the definition's name.
+ public string Name => "DoubleValue";
- public void Initialize(IFunctionManager functionManager, Session session)
+ // Called once by the function manager to declare what this function
+ // consumes, what it publishes, and where its code lives.
+ public void Initialize(IFunctionManager functionManager)
{
- // --- Create the function definition ---
- Definition = functionManager.CreateFunctionDefinition(
- DotNetFunctionConstants.DotNetFunctionTypeId,
- OutputParam,
- "Temperature doubled for testing");
+ var definition = functionManager.CreateFunctionDefinition(DotNetFunctionConstants.UniqueId);
+ definition.Name = this.Name;
+ definition.CalculateOverWholeSession = false;
+ definition.InterpolateBetweenSamples = false;
+ definition.JoinGapsAroundNull = false;
+ definition.StoreInSession = false;
- Definition.AddInputParameter(InputParam);
- Definition.SetImplementation(this);
- Definition.SetOutputParameter(OutputParam, "degC", "%5.1f", 0, 1000);
+ // Link the definition to this implementation; without it the framework
+ // has no Execute() body to invoke.
+ var implementation = (DotNetFunctionImplementationDefinition)definition.ImplementationDefinition;
+ implementation.Function = this;
- var errors = functionManager.Build(Definition, session);
- if (errors?.Any() == true)
- throw new InvalidOperationException($"Function build failed: {string.Join(", ", errors)}");
+ definition.InputParameterIdentifiers.Add(InputParameter);
+
+ definition.OutputParameterDefinitions.Add(new FunctionOutputParameterDefinition
+ {
+ Name = OutputName,
+ ApplicationName = OutputGroup,
+ Description = "Temperature doubled for testing",
+ Units = "degC",
+ FormatOverride = "%5.1f",
+ ByteOrder = ByteOrderType.BigEndian,
+ MinimumValue = "0",
+ MaximumValue = "1000",
+ ShowInBrowser = true,
+ });
+
+ var buildResults = functionManager.Build(definition);
+ if (buildResults.Errors.Count > 0)
+ {
+ throw new InvalidOperationException(
+ $"Function build failed: {buildResults.Errors[0].ErrorText}");
+ }
}
- public void Execute(
- IFunctionContext context,
- long timestamp,
- IDictionary inputValues,
- IDictionary outputValues)
+ // Called once per data request with a batch of samples. Inputs and outputs
+ // are aligned on the request's timebase, so they share indexing.
+ public void Execute(IExecutionContext context)
{
- if (inputValues.TryGetValue(InputParam, out var input))
+ var sampleCount = context.FunctionInput.Timestamps.Length;
+ var inputIndex = context.FunctionInput.InputParameterIndexes[InputParameter];
+ var outputIndex = context.FunctionOutput.OutputParameterIndexes[OutputIdentifier];
+
+ var input = context.FunctionInput.Values[inputIndex];
+ var output = context.FunctionOutput.OutputParametersValues[outputIndex];
+
+ for (var i = 0; i < sampleCount; i++)
{
- outputValues[OutputParam] = input * 2.0;
+ output[i] = input[i] * 2.0;
}
}
}
-// --- Usage (host application) ---
-// NOTE: In practice, function assemblies are discovered via MEF.
-// This snippet shows the manual pattern for clarity.
+// --- Usage (host application), shown manually for clarity ---
// var functionManager = FunctionManagerFactory.Create();
-// var func = new DoubleValueFunction();
-// func.Initialize(functionManager, session);
-// using var pda = session.CreateParameterDataAccess("DoubledTemperature:Calculated");
-
-
+// new DoubleValueFunction().Initialize(functionManager);
+// using var pda = session.CreateParameterDataAccess("DoubledTemperature:Functions");
diff --git a/snippets/csharp/functions/dotnet-function-with-pda.cs b/snippets/csharp/functions/dotnet-function-with-pda.cs
index 128b495..51c3c4d 100644
--- a/snippets/csharp/functions/dotnet-function-with-pda.cs
+++ b/snippets/csharp/functions/dotnet-function-with-pda.cs
@@ -6,76 +6,95 @@
// inside Execute for cross-parameter lookups
// Prerequisites: MESL.SQLRace.API NuGet package, .NET 8
// Input: A session with Temperature:Sensors and Pressure:Inlet parameters
-// Output: Computed values using data from an additional parameter
+// Output: Computed values published as CorrectedDensity:Functions
//
// Related: snippets/csharp/functions/dotnet-function-basic.cs
// Docs: https://mat-docs.github.io/Atlas.SQLRaceAPI.Documentation/api/index.html
// ─────────────────────────────────────────────────────────────
-// NOTE: This is an advanced pattern. The function reads an additional parameter
-// via its own PDA to perform cross-parameter calculations (e.g., density
-// correction using both temperature and pressure).
+// NOTE: An advanced pattern. Prefer declaring every parameter you need as an
+// input (see dotnet-function-basic.cs) - the framework aligns those for you.
+// Reach for a PDA only to sample a parameter off the request's own timebase.
using System.ComponentModel.Composition;
using MESL.SqlRace.Domain;
using MESL.SqlRace.Domain.Functions;
+using MESL.SqlRace.Domain.Functions.DotNet;
+using MESL.SqlRace.Enumerators;
Console.WriteLine("DensityCorrectionFunction defined (Temperature + Pressure → Density).");
-Console.WriteLine("Uses lazy-initialised PDA for cross-parameter lookup in Execute().");
+Console.WriteLine("Uses a lazily-created PDA for cross-parameter lookup in Execute().");
Console.WriteLine("See dotnet-function-basic.cs for the simpler single-input pattern.");
[Export(typeof(IDotNetFunction))]
+[Serializable]
public class DensityCorrectionFunction : IDotNetFunction
{
- private const string InputTemp = "Temperature:Sensors";
+ private const string InputTemperature = "Temperature:Sensors";
private const string LookupPressure = "Pressure:Inlet";
- private const string OutputParam = "CorrectedDensity:Calculated";
+ private const string OutputName = "CorrectedDensity";
+ private const string OutputGroup = "Functions";
+ private const string OutputIdentifier = OutputName + ":" + OutputGroup;
+ // A PDA belongs to one session, so it cannot travel with a serialised function.
[NonSerialized]
- private ParameterDataAccessBase? _pressurePda;
- private Session? _session;
+ private ParameterDataAccessBase? pressurePda;
- public IFunctionDefinition? Definition { get; private set; }
+ public string Name => "DensityCorrection";
- public void Initialize(IFunctionManager functionManager, Session session)
+ public void Initialize(IFunctionManager functionManager)
{
- _session = session;
-
- Definition = functionManager.CreateFunctionDefinition(
- DotNetFunctionConstants.DotNetFunctionTypeId,
- OutputParam,
- "Density corrected for temperature and pressure");
-
- Definition.AddInputParameter(InputTemp);
- Definition.SetImplementation(this);
- Definition.SetOutputParameter(OutputParam, "kg/m3", "%6.2f", 0, 2000);
-
- var errors = functionManager.Build(Definition, session);
- if (errors?.Any() == true)
- throw new InvalidOperationException($"Build failed: {string.Join(", ", errors)}");
+ var definition = functionManager.CreateFunctionDefinition(DotNetFunctionConstants.UniqueId);
+ definition.Name = this.Name;
+ definition.CalculateOverWholeSession = false;
+ definition.InterpolateBetweenSamples = false;
+ definition.JoinGapsAroundNull = false;
+ definition.StoreInSession = false;
+
+ // Only temperature is a declared input; pressure is read via the PDA below.
+ var implementation = (DotNetFunctionImplementationDefinition)definition.ImplementationDefinition;
+ implementation.Function = this;
+ definition.InputParameterIdentifiers.Add(InputTemperature);
+
+ definition.OutputParameterDefinitions.Add(new FunctionOutputParameterDefinition
+ {
+ Name = OutputName,
+ ApplicationName = OutputGroup,
+ Description = "Density corrected for temperature and pressure",
+ Units = "kg/m3",
+ FormatOverride = "%6.2f",
+ ByteOrder = ByteOrderType.BigEndian,
+ MinimumValue = "0",
+ MaximumValue = "2000",
+ ShowInBrowser = true,
+ });
+
+ var buildResults = functionManager.Build(definition);
+ if (buildResults.Errors.Count > 0)
+ throw new InvalidOperationException($"Build failed: {buildResults.Errors[0].ErrorText}");
}
- public void Execute(
- IFunctionContext context,
- long timestamp,
- IDictionary inputValues,
- IDictionary outputValues)
+ public void Execute(IExecutionContext context)
{
- // --- Lazy-initialise the pressure PDA ---
- _pressurePda ??= _session!.CreateParameterDataAccess(LookupPressure);
+ // --- Lazily create the pressure PDA against the session being calculated ---
+ this.pressurePda ??= context.Session.CreateParameterDataAccess(LookupPressure);
- if (!inputValues.TryGetValue(InputTemp, out var tempC))
- return;
+ var timestamps = context.FunctionInput.Timestamps;
+ var temperatureIndex = context.FunctionInput.InputParameterIndexes[InputTemperature];
+ var outputIndex = context.FunctionOutput.OutputParameterIndexes[OutputIdentifier];
- // --- Read pressure at this timestamp ---
- var pressureSamples = _pressurePda.GetData(new[] { timestamp });
- var pressureBar = pressureSamples.SampleCount > 0 ? pressureSamples.Data[0] : 1.013;
+ var temperature = context.FunctionInput.Values[temperatureIndex];
+ var output = context.FunctionOutput.OutputParametersValues[outputIndex];
+
+ // One batched read on the request's timestamps, not one read per sample.
+ var pressure = this.pressurePda.GetData(timestamps);
// Ideal gas approximation: density ∝ P / T (simplified)
- var tempK = tempC + 273.15;
- var density = (pressureBar * 100_000) / (287.05 * tempK);
- outputValues[OutputParam] = density;
+ for (var i = 0; i < timestamps.Length; i++)
+ {
+ var pressureBar = i < pressure.SampleCount ? pressure.Data[i] : 1.013;
+ var tempK = temperature[i] + 273.15;
+ output[i] = pressureBar * 100_000.0 / (287.05 * tempK);
+ }
}
}
-
-
diff --git a/snippets/csharp/functions/fdl-function-basic.cs b/snippets/csharp/functions/fdl-function-basic.cs
index d475df8..886ab59 100644
--- a/snippets/csharp/functions/fdl-function-basic.cs
+++ b/snippets/csharp/functions/fdl-function-basic.cs
@@ -2,11 +2,12 @@
// SQL Race Example: FDL Function (Basic)
// ─────────────────────────────────────────────────────────────
//
-// Demonstrates: Creating an FDL function (Celsius to Fahrenheit), building it,
-// and consuming the output via a PDA
-// Prerequisites: MESL.SQLRace.API NuGet package, .NET 8, a session with data
-// Input: A session with a Temperature:Sensors parameter
-// Output: Converted values read from the FDL function output
+// Demonstrates: Building an FDL function (Celsius to Fahrenheit) and reading
+// its output parameter back through a PDA
+// Prerequisites: MESL.SQLRace.API NuGet package, .NET 8, the "ATLAS Functions"
+// licence option, a session with a Temperature:Sensors parameter
+// Input: A session GUID
+// Output: Converted values read from the FDL function's output parameter
//
// Related: snippets/csharp/functions/dotnet-function-basic.cs
// Docs: https://mat-docs.github.io/Atlas.SQLRaceAPI.Documentation/api/index.html
@@ -15,6 +16,8 @@
using MAT.OCS.Core;
using MESL.SqlRace.Domain;
using MESL.SqlRace.Domain.Functions;
+using MESL.SqlRace.Domain.Functions.Fdl;
+using MESL.SqlRace.Functions.Interfaces.Enums;
Core.LicenceProgramName = "SQLRace";
Core.Initialize();
@@ -29,40 +32,57 @@
}
var sessionKey = SessionKey.Parse(args[0]);
-var sessionManager = SessionManager.CreateSessionManager();
-using var clientSession = sessionManager.Load(sessionKey, connectionString);
-var session = clientSession.Session;
-// --- Create the FDL function: Celsius → Fahrenheit ---
+// --- Define the FDL function: Celsius → Fahrenheit ---
+// An output parameter's identifier is ":", so this
+// function publishes "TemperatureFahrenheit:Functions".
+const string FunctionName = "TemperatureFahrenheit";
+const string OutputGroup = "Functions";
+const string OutputIdentifier = $"{FunctionName}:{OutputGroup}";
+const string FdlCode = "return ($Temperature:Sensors * 1.8 + 32);";
+
var functionManager = FunctionManagerFactory.Create();
-var fdlCode = "return ($Temperature:Sensors * 1.8 + 32)";
-var outputIdentifier = "TemperatureFahrenheit:Calculated";
-var funcDef = functionManager.CreateFunctionDefinition(
- FdlFunctionConstants.FdlFunctionTypeId,
- outputIdentifier,
- "Temperature in Fahrenheit");
+var definition = functionManager.CreateFunctionDefinition(FdlFunctionConstants.UniqueId);
+definition.Name = FunctionName;
+definition.FunctionMode = FunctionMode.Instantaneous;
+
+// The FDL source lives on the implementation definition, which the function
+// manager creates alongside the definition itself.
+var implementation = (IFdlFunctionImplementationDefinition)definition.ImplementationDefinition;
+implementation.FunctionCode = FdlCode;
-funcDef.SetFdlCode(fdlCode);
-funcDef.SetOutputParameter(outputIdentifier, "degF", "%5.1f", -40, 1000);
+// FDL functions require exactly one output parameter - the value they return.
+definition.OutputParameterDefinitions.Add(
+ FunctionOutputParameterDefinition.Create(FunctionName, OutputGroup, "Temperature in Fahrenheit")
+ .Units("degF")
+ .FormatOverride("%5.1f")
+ .MinimumValue("-40.0")
+ .MaximumValue("1000.0"));
-var errors = functionManager.Build(funcDef, session);
+var buildResults = functionManager.Build(definition);
-if (errors is not null && errors.Any())
+if (buildResults.Errors.Count > 0)
{
Console.WriteLine("FDL build errors:");
- foreach (var error in errors)
- Console.WriteLine($" {error}");
+ foreach (var error in buildResults.Errors)
+ Console.WriteLine($" {error.ErrorText}");
return;
}
-Console.WriteLine($"FDL function built: {fdlCode}");
+Console.WriteLine($"FDL function built: {FdlCode}");
// --- Read the function output ---
-using var pda = session.CreateParameterDataAccess(outputIdentifier);
+// Load the session *after* the build: SQL Race adds a built function's output
+// parameters to sessions as they load.
+var sessionManager = SessionManager.CreateSessionManager();
+using var clientSession = sessionManager.Load(sessionKey, connectionString);
+var session = clientSession.Session;
+
+using var pda = session.CreateParameterDataAccess(OutputIdentifier);
var samples = pda.GetSamplesBetween(session.StartTime, session.EndTime);
-Console.WriteLine($"Output: {samples.SampleCount} samples of {outputIdentifier}\n");
+Console.WriteLine($"Output: {samples.SampleCount} samples of {OutputIdentifier}\n");
for (var i = 0; i < Math.Min(samples.SampleCount, 10); i++)
{
diff --git a/snippets/csharp/session-management/event-definitions-and-data.cs b/snippets/csharp/session-management/event-definitions-and-data.cs
index 72b3d43..2dc71e0 100644
--- a/snippets/csharp/session-management/event-definitions-and-data.cs
+++ b/snippets/csharp/session-management/event-definitions-and-data.cs
@@ -6,8 +6,12 @@
// Prerequisites: MESL.SQLRace.API NuGet package, .NET 8
// Input: None (creates its own session)
// Output: Event definitions structure and API usage patterns
-// Note: event definitions are persisted on ConfigurationSet.Commit(); on a brand-new
-// SQLite session you may need at least one committed parameter/channel first.
+// Note: two things are easy to get wrong here. An EventDefinition's conversion
+// function names must be registered with AddConversion before Commit(), and
+// the session needs channel data before it will accept any event. Neither
+// failure raises anything - the events are simply discarded.
+// Events are also readable only within the recording session; they do not
+// survive a reload, whereas parameters do.
//
// Related: snippets/csharp/session-management/marker-management.cs
// Docs: https://mat-docs.github.io/Atlas.SQLRaceAPI.Documentation/api/index.html
@@ -15,6 +19,7 @@
using MAT.OCS.Core;
using MESL.SqlRace.Domain;
+using MESL.SqlRace.Domain.Infrastructure.DataPipeline;
using MESL.SqlRace.Enumerators;
Core.LicenceProgramName = "SQLRace";
@@ -31,11 +36,42 @@
// --- Define event types (via configuration) ---
var config = session.CreateConfiguration();
+
+// The fourth EventDefinition argument lists CONVERSION FUNCTION names, not field
+// labels, and each must be registered on the same configuration. Commit() drops
+// any definition whose conversions are missing - it logs an error but does not
+// throw, so the failure surfaces later as "event definition not found" from
+// AddEventData.
+foreach (var conversionName in new[] { "Value", "Threshold", "SensorId", "FaultCode", "Parameter", "Direction" })
+{
+ config.AddConversion(RationalConversion.CreateSimple1To1Conversion(conversionName, "", "%5.2f"));
+}
+
+// Events are stored in time segments, so the session needs a time base before it
+// can accept any. A session with no channel data has StartTime 0, and AddEventData
+// then discards the event without raising anything.
+uint signalChannelId = 1;
+config.AddConversion(RationalConversion.CreateSimple1To1Conversion("signal_conv", "", "%5.2f"));
+config.AddChannel(new Channel(signalChannelId, "Signal", new Frequency(100, FrequencyUnit.Hz).ToInterval(),
+ DataType.Double64Bit, ChannelDataSourceType.Periodic, "Signal:Diagnostics", false));
+config.AddParameterGroup(new ParameterGroup("Diag", "Diag"));
+config.AddGroup(new ApplicationGroup("Diagnostics", "Diagnostics", new List { "Diag" }) { SupportsRda = false });
+config.AddParameter(new Parameter(
+ "Signal:Diagnostics", "Signal", "Signal being monitored",
+ 100, 0, 100, 0, 0.0, 0u, 0u, "signal_conv",
+ new List { "Diag" }, signalChannelId, "Diagnostics"));
+
config.AddEventDefinition(new EventDefinition(1, "LimitExceedance", EventPriorityType.High, new List { "Value", "Threshold" }, "Diagnostics"));
config.AddEventDefinition(new EventDefinition(2, "SensorFault", EventPriorityType.Medium, new List { "SensorId", "FaultCode" }, "Diagnostics"));
config.AddEventDefinition(new EventDefinition(3, "ThresholdCrossing", EventPriorityType.Low, new List { "Parameter", "Direction" }, "Diagnostics"));
config.Commit();
+// --- Give the session a time base ---
+for (var i = 0; i < 250; i++)
+{
+ session.AddChannelData(signalChannelId, baseTime + i * 100_000_000L, 1, BitConverter.GetBytes(20.0 + (i * 0.1)));
+}
+
// --- Add event instances ---
session.Events.AddEventData(1, "Diagnostics", baseTime + 5_000_000_000L, new List { 127.3, 120.0 });
session.Events.AddEventData(2, "Diagnostics", baseTime + 10_000_000_000L, new List { 3.0, 1.0 });
@@ -49,7 +85,8 @@
Console.WriteLine($"{"Timestamp (ns)",20} {"DefKey",-10} {"Group",-15} {"Data"}");
Console.WriteLine(new string('─', 80));
-foreach (var evt in events)
+// Events come back in segment order, not time order.
+foreach (var evt in events.OrderBy(e => e.TimeStamp))
{
var dataStr = string.Join(", ", evt.RawData);
Console.WriteLine($"{evt.TimeStamp,20} {evt.EventDefinitionKey,-10} {evt.GroupName,-15} [{dataStr}]");
diff --git a/snippets/csharp/session-management/query-with-composite-filter.cs b/snippets/csharp/session-management/query-with-composite-filter.cs
index edcd9ae..c0dd078 100644
--- a/snippets/csharp/session-management/query-with-composite-filter.cs
+++ b/snippets/csharp/session-management/query-with-composite-filter.cs
@@ -13,6 +13,7 @@
using MESL.SqlRace.Domain;
using MESL.SqlRace.Domain.Query;
+using MESL.SqlRace.Domain.Sort;
Core.LicenceProgramName = "SQLRace";
Core.Initialize();
@@ -21,10 +22,18 @@
var queryManager = QueryManager.CreateQueryManager(connectionString);
-// --- Build a composite filter: session type contains "Example" AND recorded after a date ---
+// --- Build a composite filter: identifier contains "Example" AND recorded after a date ---
+// Field names come from SessionFieldIdentifiers, not from the property names on
+// the result. "TimeOfRecording" is the property; the filter field is
+// SessionStartDateTime. An unrecognised name is treated as a session metadata
+// item and quietly matches nothing rather than raising.
+//
+// Pass the date as a DateTime: the string overload has to be in
+// ScalarFilter.DateFormat exactly, and anything else - ISO 8601 "O" included -
+// fails to parse and silently matches nothing.
var composite = new CompositeFilter(CombineType.AND);
-composite.Add(new ScalarFilter("Identifier", MatchingRule.Contains, "Example", ignoreCase: true));
-composite.Add(new ScalarFilter("TimeOfRecording", MatchingRule.GreaterThan, DateTime.UtcNow.AddDays(-30).ToString("O"), ignoreCase: true));
+composite.Add(new ScalarFilter(SessionFieldIdentifiers.SessionIdentifier, MatchingRule.Contains, "Example", ignoreCase: true));
+composite.Add(new ScalarFilter(SessionFieldIdentifiers.SessionStartDateTime, MatchingRule.GreaterThan, DateTime.UtcNow.AddDays(-30)));
queryManager.Filter = composite;
diff --git a/snippets/python/data-access/multi_parameter_extract.py b/snippets/python/data-access/multi_parameter_extract.py
index c1fc48c..3b55cb3 100644
--- a/snippets/python/data-access/multi_parameter_extract.py
+++ b/snippets/python/data-access/multi_parameter_extract.py
@@ -90,10 +90,19 @@
if all_data:
first_key = identifiers[0]
df = pd.DataFrame({"timestamp_ns": all_data[first_key]["timestamps"]})
+ # Columns are matched by position, not by timestamp. Parameters at
+ # different rates have different sample counts, so a shorter one is
+ # padded with NaN rather than forced into the first parameter's row
+ # count - and its values do NOT line up with timestamp_ns. To compare
+ # across rates, sample onto a common timestamp array as
+ # snippets/csharp/data-access/multi-rate-alignment.cs does.
for param_id in identifiers:
- df[param_id] = all_data[param_id]["values"][:len(df)]
+ df[param_id] = pd.Series(all_data[param_id]["values"][:len(df)])
print(f"\nDataFrame: {df.shape[0]} rows x {df.shape[1]} columns")
+ counts = {p: len(all_data[p]["values"]) for p in identifiers}
+ if len(set(counts.values())) > 1:
+ print(f"Note: sample counts differ {counts} - columns are positional, not time-aligned.")
print(df.head(10).to_string(index=False))
finally:
client_session.Dispose()
diff --git a/snippets/python/getting-started/04_extract_events.py b/snippets/python/getting-started/04_extract_events.py
index b61bc9c..8c87909 100644
--- a/snippets/python/getting-started/04_extract_events.py
+++ b/snippets/python/getting-started/04_extract_events.py
@@ -66,19 +66,19 @@
# --- Event definitions → Python dict ---
definitions = {}
- for evt_def in session.Events.EventDefinitions:
- definitions[evt_def.Name] = evt_def.Priority
- print(f"Definition: {evt_def.Name} (priority: {evt_def.Priority})")
+ for evt_def in session.EventDefinitions:
+ definitions[evt_def.Description] = evt_def.Priority
+ print(f"Definition: {evt_def.Description} (priority: {evt_def.Priority})")
# --- Event data ---
events = session.Events.GetEventData(session.StartTime, session.EndTime)
- print(f"\n{'Timestamp (ns)':>20} {'Event':<22} {'Data'}")
+ print(f"\n{'Timestamp (ns)':>20} {'Key':<5} {'Group':<16} {'Data'}")
print("─" * 70)
for evt in events:
- values = [str(evt.Values[i]) for i in range(evt.Values.Count)] if evt.Values else []
+ values = [str(evt.RawData[i]) for i in range(evt.RawData.Count)] if evt.RawData else []
data_str = ", ".join(values)
- print(f"{evt.Timestamp:>20} {evt.EventDefinitionName:<22} [{data_str}]")
+ print(f"{evt.TimeStamp:>20} {evt.EventDefinitionKey:<5} {evt.GroupName:<16} [{data_str}]")
finally:
client_session.Dispose()
diff --git a/tests/README.md b/tests/README.md
index 4412cfe..e0af748 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -13,7 +13,70 @@ correct outputs are known — so the examples are checked for *correctness*, not
| Fixture generator | [`../tools/FixtureGenerator`](../tools/FixtureGenerator) | Writes a deterministic `.ssn2` from known formulas. `VerifiedFixture` exposes the formulas as constants the tests assert against. |
| Capability tests | [`SqlRace.Examples.Tests/EndToEndExampleTests.cs`](SqlRace.Examples.Tests/EndToEndExampleTests.cs) | Loads the fixture and asserts that load/read/time-range/multi-rate/query operations return the values the formulas imply. |
| Function tests | [`SqlRace.Examples.Tests/FunctionComputationTests.cs`](SqlRace.Examples.Tests/FunctionComputationTests.cs) | Asserts the FunctionLibrary maths (C→F, ratio, rolling average) against hand-derived values. No licence needed. |
-| Snippet smoke-runner | [`../scripts/run-examples-e2e.ps1`](../scripts/run-examples-e2e.ps1) | Generates the fixture, runs the actual runnable snippets against it, and checks they exit cleanly and print the expected anchor output. |
+| Snippet compilation | [`SqlRace.Examples.Tests/SnippetCompilationTests.cs`](SqlRace.Examples.Tests/SnippetCompilationTests.cs) | Compiles every `snippets/csharp/**.cs` against the real SQL Race assemblies with Roslyn. Metadata-only, so **no licence needed** — the one suite GitHub CI can run. |
+| Snippet runner | [`../scripts/run-examples-e2e.ps1`](../scripts/run-examples-e2e.ps1) | Runs the runnable C#, Python and MATLAB snippets against the fixture and compares their output against golden files in [`golden/`](golden). |
+| Snippet manifest | [`../scripts/snippet-manifest.psd1`](../scripts/snippet-manifest.psd1) | Which snippets run, what arguments they take, and why the rest are skipped. |
+
+## Breaking-change early warning
+
+These pieces exist to answer one question: *will a new `MESL.SQLRace.API` release
+break code written against the previous one?* Three layers, each catching something
+the previous one cannot.
+
+| Layer | Catches | Licence? |
+|-------|---------|----------|
+| `SnippetCompilationTests` | A snippet that no longer compiles — the same errors you would hit copying it | No |
+| `run-examples-e2e.ps1` golden comparison | A snippet that still compiles and runs but now returns different data | Yes |
+| Its Python and MATLAB legs | Renames and signature changes a C# compile would catch but which fail silently for `pythonnet` and `NET.addAssembly` callers | Yes |
+
+To validate a candidate build before it is published, pin the version:
+
+```powershell
+dotnet test SqlRace.Examples.Tests/SqlRace.Examples.Tests.csproj -p:SqlRaceApiVersion=
+../scripts/run-examples-e2e.ps1 -SqlRaceApiVersion
+```
+
+If the candidate has not been published anywhere — a package a PR build just
+produced, for instance — point at the folder holding the `.nupkg` instead. Nothing
+needs publishing for this to work:
+
+```powershell
+../scripts/run-examples-e2e.ps1 `
+ -SqlRaceApiVersion `
+ -AdditionalPackageSource C:\path\to\packages
+```
+
+`SqlRaceApiVersion` is defined once in `Directory.Build.props` at the repo root, so
+it applies to every project at once. Overriding it on the test project alone would
+leave `SqlRace.Examples.Core` and `FunctionLibrary` resolving whatever is published,
+and the run would silently mix two different SQL Race builds.
+
+Both layers are also run automatically against SQL Race builds before they are
+published, so a break in the examples is caught alongside a break in the API itself.
+
+### Golden output
+
+`run-examples-e2e.ps1` compares each snippet's stdout against `golden//.txt`.
+Values that legitimately vary between runs — GUIDs, timestamps, absolute paths, elapsed times
+— are normalised before comparison, so what is actually asserted is the data: parameter
+names, sample counts, computed values.
+
+After an intended change, regenerate and **review the diff** — `-UpdateGolden` will happily
+bake in a regression:
+
+```powershell
+../scripts/run-examples-e2e.ps1 -UpdateGolden
+```
+
+A snippet that runs but has no golden file is reported as `NOGOLD`, not as a pass: an absent
+golden means nothing was verified. Skipped snippets are listed in the summary for the same
+reason — a snippet silently missing from the run would otherwise be indistinguishable from
+one that passed.
+
+`Matlab/event_extraction` has no golden on purpose. None of the scenario files under
+`../notebooks/scenarios/data/` contain event definitions, so it reports zero events and a
+golden would record that as a pass over nothing. It needs event data adding to
+`motorsport-lap-analysis.ssn2` before it is worth capturing.
## The verified fixture
@@ -36,12 +99,37 @@ fixed key `f1c0ffee-0000-4000-8000-000000000001`, start 10:00:00, 10 s long):
# Capability + function tests
dotnet test SqlRace.Examples.Tests/SqlRace.Examples.Tests.csproj -c Release
-# Snippet smoke-run against the fixture
-pwsh ../scripts/run-examples-e2e.ps1
+# All three languages against the fixture
+../scripts/run-examples-e2e.ps1
+
+# One language, or one snippet
+../scripts/run-examples-e2e.ps1 -Language Python
+../scripts/run-examples-e2e.ps1 -Filter '*read*'
```
-The function-computation tests need no licence, so they can be run anywhere with
-`--filter "FullyQualifiedName~FunctionComputation"`.
+The function-computation and snippet-compilation tests need no licence, so they can be run
+anywhere with `--filter "FullyQualifiedName~FunctionComputation"` or
+`--filter "FullyQualifiedName~SnippetCompilation"`.
+
+### Prerequisites for the Python and MATLAB legs
+
+- **`pip install pythonnet cffi`.** `cffi` is a `clr_loader` dependency that does not always
+ come in; without it every Python snippet dies in `pythonnet.load()` with
+ `Failed to create a .NET runtime (coreclr)`, which looks like a SQL Race problem and is not.
+- **A licensed MATLAB on `PATH`** (`matlab -batch` is used). Tested against R2025b.
+- **Restore needs feed credentials.** `MESL.SQLRace.API` is referenced as `Version="*"`, and
+ NuGet cannot resolve a floating version without reaching the feed. Set `NUGET_AUTH_USERNAME`
+ and `NUGET_AUTH_TOKEN` (see `.env.example`), or pass `-SqlRaceApiVersion` to pin a version
+ already in the local NuGet cache.
+
+The script points `SQLRACE_DLL_PATH` at a *flattened* copy of the build output rather than at
+the output folder itself. A NuGet-restored build keeps platform-specific assemblies under
+`runtimes//` and relies on `deps.json` to select them; Python and MATLAB host the CLR
+themselves, so there is no `deps.json` and the loader picks up the cross-platform placeholder
+`System.Data.SqlClient` (which throws `PlatformNotSupportedException`) and cannot find
+`SQLite.Interop.dll` at all. **Customers pointing pythonnet or MATLAB at a NuGet-restored SQL
+Race hit exactly this**; those pointing at an ATLAS installation do not, because that
+directory is already flat.
## Notes / known limitations (discovered while building this)
diff --git a/tests/SqlRace.Examples.Tests/SnippetCompilationTests.cs b/tests/SqlRace.Examples.Tests/SnippetCompilationTests.cs
index bb4bce4..1177414 100644
--- a/tests/SqlRace.Examples.Tests/SnippetCompilationTests.cs
+++ b/tests/SqlRace.Examples.Tests/SnippetCompilationTests.cs
@@ -3,11 +3,21 @@
// ─────────────────────────────────────────────────────────────
//
// Dynamically discovers all .cs files under snippets/csharp/ and
-// verifies they parse as valid C# syntax using Roslyn.
+// compiles each one against the real SQL Race assemblies using
+// Roslyn.
//
-// This catches syntax errors and missing brackets but does NOT
-// verify against the real SQL Race assemblies (that requires the
-// NuGet package to be resolvable at test time).
+// This is the early-warning system for API breaks. A snippet
+// that stops compiling against a new MESL.SQLRace.API release is
+// a script written against the old one that stops compiling too,
+// so the break is worth catching before the release, not after.
+//
+// Compilation is metadata-only: nothing here initialises the SQL
+// Race runtime, so no licence is needed and this suite runs on a
+// plain GitHub runner. Runtime behaviour of the same snippets is
+// covered by scripts/run-examples-e2e.ps1, which does need one.
+//
+// To test a specific API build:
+// dotnet test -p:SqlRaceApiVersion=
//
// Run: dotnet test --filter "FullyQualifiedName~SnippetCompilation"
// ─────────────────────────────────────────────────────────────
@@ -40,19 +50,131 @@ public static IEnumerable
diff --git a/tools/FindLiveSession/FindLiveSession.csproj b/tools/FindLiveSession/FindLiveSession.csproj
index 7bf73cb..256bd58 100644
--- a/tools/FindLiveSession/FindLiveSession.csproj
+++ b/tools/FindLiveSession/FindLiveSession.csproj
@@ -7,6 +7,6 @@
$(NoWarn);CA1416
-
+
diff --git a/tools/FixtureGenerator/VerifiedFixture.cs b/tools/FixtureGenerator/VerifiedFixture.cs
index c7d7dcc..72d89f3 100644
--- a/tools/FixtureGenerator/VerifiedFixture.cs
+++ b/tools/FixtureGenerator/VerifiedFixture.cs
@@ -19,7 +19,9 @@ public static class VerifiedFixture
// ── Session identity ───────────────────────────────────────
/// Fixed session key so examples that take a GUID can be pointed at the fixture.
public const string SessionKeyString = "f1c0ffee-0000-4000-8000-000000000001";
- public const string SessionName = "Verified Test Session";
+ // Contains "Example" so the query snippets, which filter on
+ // Identifier contains "Example", have something to match.
+ public const string SessionName = "Verified Example Session";
/// Session start in nanoseconds-of-day (10:00:00.000 → 36,000 s).
public const long StartTimeNs = 36_000_000_000_000L;
@@ -48,6 +50,19 @@ public static class VerifiedFixture
public const int ParameterCount = 3;
+ // ── Events ─────────────────────────────────────────────────
+ // One definition, three instances, each raising the temperature ramp's value
+ // at that moment against a fixed threshold - so the expected output is still
+ // derivable by hand.
+ public const int EventDefinitionId = 1;
+ public const string EventDescription = "LimitExceedance";
+ public const string EventGroupName = "Sensors";
+ public const double EventThreshold = 50.0;
+
+ /// Seconds after the session start at which an event is raised.
+ public static readonly int[] EventOffsetsSeconds = { 2, 5, 8 };
+ public const int EventCount = 3;
+
// NOTE: a freshly-created SQLite session always reloads with exactly one (default) lap.
// Laps added via LapCollection.Add do not survive a reload in this API path, so the
// verified fixture does not rely on laps. (Pre-existing .ssn2 files may contain laps
@@ -73,8 +88,11 @@ public static void Generate(string connectionString)
var sessionManager = SessionManager.CreateSessionManager();
- // Date with a 10:00:00 time-of-day so Session.StartTime == StartTimeNs.
- var sessionDate = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc);
+ // Only the 10:00:00 time-of-day matters - it is what makes
+ // Session.StartTime equal StartTimeNs. The date is today so that
+ // TimeOfRecording falls inside the "last 30 days" window the query
+ // snippets filter on; a fixed date made them match nothing.
+ var sessionDate = DateTime.UtcNow.Date.AddHours(10);
using var clientSession = sessionManager.CreateSession(
connectionString, Key, SessionName, sessionDate, "Example");
@@ -112,12 +130,37 @@ public static void Generate(string connectionString)
.WithDataType(DataType.Double64Bit)
.WithConversion("rad/s", "%6.2f")
.WithRange(min: 0, max: 200),
+ },
+ config =>
+ {
+ // An EventDefinition's conversion function names must already be registered
+ // or Commit() drops the definition, logging rather than throwing.
+ config.AddConversion(RationalConversion.CreateSimple1To1Conversion("Value", "degC", "%5.2f"));
+ config.AddConversion(RationalConversion.CreateSimple1To1Conversion("Threshold", "degC", "%5.2f"));
+ config.AddEventDefinition(new EventDefinition(
+ EventDefinitionId,
+ EventDescription,
+ EventPriorityType.High,
+ new List { "Value", "Threshold" },
+ EventGroupName));
});
WriteRamp(session, built[0].ChannelId, TemperatureHz, TemperatureSampleCount, Temperature);
WriteRamp(session, built[1].ChannelId, PressureHz, PressureSampleCount, _ => PressureConstant);
WriteRamp(session, built[2].ChannelId, SpeedHz, SpeedSampleCount, Speed);
+ // Events need a session that already has channel data: they are stored in
+ // time segments, and a session with no time base discards them silently.
+ foreach (var offset in EventOffsetsSeconds)
+ {
+ var timestamp = StartTimeNs + (long)offset * 1_000_000_000L;
+ session.Events.AddEventData(
+ EventDefinitionId,
+ EventGroupName,
+ timestamp,
+ new List { Temperature(offset * TemperatureHz), EventThreshold });
+ }
+
// Finalise the recording so all data and metadata are persisted to disk.
session.Flush();
session.EndData();
diff --git a/tools/SnippetRunner/SnippetRunner.csproj b/tools/SnippetRunner/SnippetRunner.csproj
index 7bf73cb..cb99046 100644
--- a/tools/SnippetRunner/SnippetRunner.csproj
+++ b/tools/SnippetRunner/SnippetRunner.csproj
@@ -5,8 +5,28 @@
enableenable$(NoWarn);CA1416
+
+
-
+
+
+
+
+
+
+