TigerQuery engine
The ItTiger.TigerQuery
package parses SQL scripts and coordinates asynchronous execution against SQL
Server. It recognizes GO separators and repeat counts, sqlcmd variables and
:setvar, :on error, and plain, SqlCmd, and extended SqlCmdEx modes.
The main composition types are TigerQueryEngine and TigerQueryEngineOptions. Lower-level parsing is available through SqlCmdParser.
SqlCmd and SqlCmdEx
SqlCmdEx is TigerQuery's extended scripting mode for applications and
automation. It keeps familiar sqlcmd script syntax while allowing the host
application to provide protected variables that scripts cannot override with
:setvar.
Select the mode with TigerQueryEngineOptions.Mode:
| Mode | Application-provided variables | Script-local variables | Typical use |
|---|---|---|---|
SqlCmd |
Seed the variable table; a matching :setvar replaces the host value |
Created and updated by :setvar |
Conventional sqlcmd-style scripts |
SqlCmdEx |
Take precedence; a matching :setvar is ignored |
Created and updated when the name does not conflict with a protected host value | Embedded applications, automation, and controlled workflows |
Variables supplied through
TigerQueryEngineOptions.Variables
are loaded before parsing and matched case-insensitively. In SqlCmd, they are
ordinary initial values. In SqlCmdEx, each host-provided value is protected
for the run. A script can still create its own variables with :setvar, and
later :setvar commands can update those script-local values.
For example, the script attempts to choose its own target:
:setvar TargetDatabase ScriptDatabase
PRINT 'Deploying $(TargetDatabase)';
GO
The application retains control by supplying TargetDatabase in SqlCmdEx:
using System;
using System.Collections.Generic;
using ItTiger.TigerQuery;
using ItTiger.TigerQuery.Engine;
const string script = """
:setvar TargetDatabase ScriptDatabase
PRINT 'Deploying $(TargetDatabase)';
GO
""";
var options = new TigerQueryEngineOptions
{
ConnectionString =
"Server=localhost;Database=master;Integrated Security=true;TrustServerCertificate=true",
Mode = SqlCmdMode.SqlCmdEx,
Variables = new Dictionary<string, string>
{
["TargetDatabase"] = "HostDatabase"
},
OnMessage = (message, _) => Console.WriteLine(message.Text)
};
await new TigerQueryEngine(options).RunFromStringAsync(script);
// PRINT reports: Deploying HostDatabase
Practical uses include deployment automation, test orchestration, code generation, database provisioning, and applications that inject environment or project values into reusable scripts. Protection prevents a script from accidentally changing an application-owned target or workflow value.
SqlCmdEx and prepared execution solve separate problems. SqlCmdEx controls
variable precedence; prepared versus streaming execution
controls when parsing occurs relative to connection opening and batch
execution. Either execution mode can be combined with SqlCmd or SqlCmdEx.
Prepared versus streaming execution
Important
Choose the execution mode according to when the complete script structure must be known—not according to SQL validation. Neither mode parses or validates T-SQL.
| Streaming | Prepared | |
|---|---|---|
| Configuration | Default | ExecutionMode = TigerQueryExecutionMode.Prepared |
| Parsing | One logical batch at a time | Entire TigerQuery/sqlcmd structure first |
| SQL connection | Opened as incremental execution begins | Opened only after successful preparation |
| Late directive errors | May occur after earlier batches executed | Prevent any SQL execution |
| Memory | Retains little script text | Retains every expanded logical batch |
| Plan totals | Not available up front | Reported once through OnExecutionPlanReady |
| Best fit | Very large scripts and sqlcmd-like incremental flow | Full-script structural validation and known scheduling totals |
Streaming execution minimizes retained text, but a malformed TigerQuery/sqlcmd directive late in the script can be discovered after earlier batches execute.
Prepared execution parses the complete structure first:
using ItTiger.TigerQuery.Engine;
var options = new TigerQueryEngineOptions
{
ConnectionString = connectionString,
ExecutionMode = TigerQueryExecutionMode.Prepared,
OnExecutionPlanReady = plan => Console.WriteLine(
$"{plan.LogicalBatchCount} logical batch(es), "
+ $"{plan.TotalExecutionCount} scheduled execution(s)")
};
After preparation succeeds, OnExecutionPlanReady fires once, before the
connection opens and before batch callbacks. Its execution total includes
positive GO n repeat counts. GO 0 and negative repeat counts still count as
logical batches but schedule no executions.
Bounded batch progress
In prepared mode, BatchStart and BatchEnd include the same
TotalLogicalBatchCount and TotalExecutionCount values reported by
OnExecutionPlanReady. OverallExecutionNumber advances from one as each
execution attempt begins, so an end callback can report bounded progress:
var options = new TigerQueryEngineOptions
{
ExecutionMode = TigerQueryExecutionMode.Prepared,
OnBatchEnd = batch =>
{
if (batch.TotalExecutionCount is long total)
{
var percent = 100d * batch.OverallExecutionNumber / total;
Console.WriteLine(
$"{batch.OverallExecutionNumber}/{total} attempts ended "
+ $"({percent:F0}%)");
}
}
};
These totals continue to describe the complete prepared plan if execution
stops early. In streaming mode they are null because the remaining script is
not known; OverallExecutionNumber is still populated. The values measure
scheduled batch execution attempts, not progress within a SQL batch.
Prepared mode does not prevalidate the connection, permissions, T-SQL syntax,
compilation, or runtime behavior. Those failures still occur during execution.
It retains each expanded logical batch until execution finishes, although
GO n does not duplicate SQL text in memory.
See TigerQueryExecutionMode and ExecutionPlanReady for the precise API contracts.
Output and results
The engine reports work through callbacks:
OnMessagereceivesPRINT,RAISERROR, informational messages, and errors.OnBatchStartandOnBatchEndreport batch progress and duration.OnResultSetreceives column metadata and rows.OnExecutionPlanReadyreports prepared-mode scheduling totals.
The message and result-set callbacks are the default destinations. They can be
redirected to files by OutputRouting, :Out, and :Error, as described in
Output routing and CSV files.
A run that reaches the execution coordinator's result path returns an
ExecutionResult. Parser and
connection-opening failures can escape the run method instead of being
normalized into an execution result. An optional
Microsoft.Extensions.Logging.ILogger receives structured logs.
:on error semantics
A batch attempt fails when SQL Server reports an error of severity 11 or
higher for it. That includes errors the provider delivers as informational
messages rather than by throwing, which is how severity 11-16 arrives — the
range that RAISERROR(..., 16, ...), THROW, and ordinary compilation errors
such as an invalid object name fall into. A batch that returns normally after
such an error is not a successful batch.
The effective policy comes from
ContinueOnError
and is updated by :ON ERROR EXIT and :ON ERROR IGNORE as the script is
parsed.
| Effective exit-on-error | Effective continue-on-error | |
|---|---|---|
| Triggering batch | BatchEnd.Success = false |
BatchEnd.Success = false |
FailedBatches |
Incremented | Incremented |
ExecutedBatches |
Not incremented for the failing attempt | Not incremented for the failing attempt |
| Later batches | Not started — no BatchStart or BatchEnd is raised for them |
The next scheduled batch runs |
ResultCode |
BatchFailed, or Fatal for a fatal server error |
BatchFailed, or Fatal for a fatal server error |
GO n repeat counts follow the same rule per iteration: under exit-on-error a
failing iteration stops the remaining iterations, under continue-on-error they
proceed. A fatal server error stops the run under either policy.
The policy decides how much of the script runs; it does not decide what the run
reports. ResultCode is Success only when FailedBatches is zero. Continuing
past a failed batch still ends the run as BatchFailed, and a later successful
batch never clears an earlier failure — reaching the end of a script is not
evidence that it worked.
Diagnostics reach OnMessage in server order with their original number,
severity, state, procedure, and line, and a diagnostic delivered both as a
message and on a thrown exception is raised once. When a batch fails without a
thrown exception, BatchEnd.Exception and ExecutionResult.Exception carry a
SqlBatchErrorException whose
Errors collection holds those same diagnostics.
Prepared and streaming execution share one coordinator, so this behavior, the batch lifecycle, and the counts are identical in both modes.
Batch command timeout
CommandTimeoutSeconds bounds how long each executed batch may run:
var options = new TigerQueryEngineOptions
{
ConnectionString = connectionString,
CommandTimeoutSeconds = 600
};
| Value | Meaning |
|---|---|
null (default) |
The engine sets nothing, so SqlCommand.CommandTimeout keeps the provider default of 30 seconds. |
| n > 0 | Each batch may run for n seconds. |
0 |
No limit, the SqlClient meaning of CommandTimeout = 0. |
| negative | Rejected: the run methods throw ArgumentOutOfRangeException before parsing, opening the connection, or creating an output file. |
The value is applied to each batch independently — including each iteration of a
GO n repeat — so it bounds a batch rather than a run. It is unrelated to the
connection-open timeout, which belongs to the connection string's
Connect Timeout.
A batch that exceeds the timeout fails exactly like any other batch failure: it
increments FailedBatches, the effective :on error policy decides whether
later batches run, and ResultCode is not Success. Parsing mode and execution
mode do not affect it; every mode executes batches through the same path.
:Out and :Error directives
In SqlCmd and SqlCmdEx modes the parser recognizes and validates the
:Out and :Error output directives, which previously failed as unknown colon
commands. Each takes exactly one non-empty filename, either bare or in the
sqlcmd double-quoted form (a doubled quote represents a quote), optionally
followed by a single-line comment. $(name) references in the filename are
expanded at the directive's source position, and undefined references stay
literal. In SqlCmdMode.Normal the text is still sent to SQL Server unchanged.
Internally the engine consumes an ordered step stream in which each directive
keeps its source position relative to the batches around it, so repeated routes
to the same path and a directive placed between buffered SQL and its terminating
GO are preserved rather than collapsed into final parser state. That stream is
an implementation detail; there is no public script-step API.
SqlCmdParser.ReadBatchesAsync
remains batch-only and unchanged for direct consumers: it validates the
directives and then projects them away. Applications that need routing must
execute through
TigerQueryEngine.
A host can refuse script-directed routing entirely with
OutputRouting.AllowScriptOutputDirectives = false, which turns an encountered
directive into a parser error rather than silently ignoring a script command.
Initial paths supplied by the host still apply.
Output routing and CSV files
OutputRoutingOptions
configures where output goes. Routing is entirely opt-in: with no initial path
and no directive, result sets and messages reach OnResultSet and OnMessage
exactly as before and no file is created.
TigerQuery models three routable channels:
| Channel | Payload | Application destination | File destination |
|---|---|---|---|
| Result sets | ResultSetInfo |
OnResultSet |
Built-in CSV |
| Normal messages | Severity 0-10, including PRINT |
OnMessage |
Plain UTF-8 text |
| Error messages | SQL Server diagnostics with IsError |
OnMessage |
Plain UTF-8 text |
Redirecting a channel replaces its callback; it does not mirror to both.
Batch lifecycle, plan readiness, and progress are never redirected, and the
configured ILogger keeps receiving every message whatever the routes are.
Only SQL Server diagnostics can enter a routed message file. Parser failures,
connection failures, configuration and encoding failures, output failures, and
unrelated application exceptions are surfaced through OnMessage, the logger,
and ExecutionResult — never written to an :Error file — so the file cannot
accumulate connection strings or unstable framework text.
Precedence is: callbacks by default, then InitialOutPath/InitialErrorPath at
run start, then each script directive from its position onward, latest wins.
:Out never changes the error route, and :Error never changes the result-set
or normal-message routes.
OutDirectiveBehavior
selects what :Out moves. Under ResultSetsAndNormalMessages, result sets use
the requested path and normal messages use a companion file named by appending
.messages.log to the complete resolved result path, because CSV cannot safely
hold both rows and arbitrary prose.
Files and naming
Relative paths resolve against OutputRouting.BaseDirectory, captured once at
run start; without it the process's current directory is captured. The same rule
applies to RunFromFileAsync, so a host that wants script-relative paths must
pass the script directory explicitly.
The parent directory must already exist — TigerQuery never creates directories. Files are created lazily on the first payload, truncated on first use in a run, never appended across runs, and kept open until the run ends so a script can leave a path and return to it without a second byte-order mark or header. Every destination is flushed and closed on success, failure, and cancellation alike.
A resolved path belongs to exactly one channel for the whole run. Pointing two channels at the same file is a configuration error, not permission to mix payloads.
ResultSetFileMode chooses the
file layout. SingleFile uses the requested path exactly as supplied — no
extension is inferred, so :Out report writes a CSV file named report.
FilePerResultSet treats the path as a base name and generates
<stem>_b<batch>_e<execution>_r<result><extension> from the engine's stable
coordinates, each component one-based, invariant, and padded to at least four
digits and never truncated. :Out report.csv therefore gives
report_b0001_e0001_r0001.csv. Coordinates are the original engine coordinates,
so route changes and skipped zero-column results never renumber later files, and
names are identical in streaming and prepared mode. An :Error file never
receives a result-set suffix.
CSV behavior
Version one is fixed: UTF-8 with a byte-order mark, comma delimiter, CRLF records, a header, minimal RFC 4180 quoting, and invariant formatting. A field is quoted when it contains a comma, a double quote, CR, or LF, and inner quotes are doubled; header names use the same rules as data.
DateTime and DateTimeOffset use ISO 8601 round-trip form, TimeSpan the
constant c form, Guid the D form, byte[] uppercase hex with a 0x
prefix, floating-point values a round-trip form, and other IFormattable values
invariant culture.
Important
SQL NULL and the empty string both produce an empty field and are therefore
indistinguishable in version one.
A zero-column result is not a CSV result set: it writes nothing and creates no file, though its coordinate is still consumed. A result with columns and no rows writes its header.
In SingleFile mode the first result set establishes the header and the required
schema. A later result set is compatible when it has the same column count and
the same column names in the same ordinal positions, compared ordinally; it then
appends rows only and its header is never rewritten. Differing SQL or CLR types
are fine, because CSV has no type schema. An incompatible result set is validated
before any of its bytes are written, so the file keeps only complete earlier
content.
The encoding is always configured with exception fallbacks, so an unencodable
character fails the run instead of producing a replacement character. Supplying
FileEncoding keeps that encoding's byte-order-mark preference but strengthens
its fallbacks; interoperability then depends on that encoding.
Output failures
Path, permission, sharing, directory, encoding, schema, flush, and close failures
become OutputRoutingException,
which carries the target Path and derives from TigerQueryException.
An output failure is fatal regardless of :ON ERROR IGNORE and
ContinueOnErrorForUnhandledExceptions, because continuing would execute later
SQL while losing its requested output. During a batch it becomes the primary
exception on a failed BatchEnd, counts as a failed batch, stops the run
immediately, and produces
ExecutionResultCode.OutputFailed
with the same exception on the result. A route change that fails between batches
uses the same classification and stops before the next batch. SQL Server may
already have completed the command, and may have committed side effects, before
the failure was discovered; the batch is still counted as failed, and the log
says so when it is known.
Because version one writes directly to destination files, a failed or cancelled run can leave valid partial CSV containing every result set completed before the failure.
Prepared mode resolves routes and detects statically known collisions before plan readiness and before the connection opens, so a prepared script that cannot route creates no file at all. Streaming mode reaches the same failure when it gets there, after any earlier batches and files — the established difference between the two modes.
Parsing modes
SqlCmdMode selects normal SQL parsing,
ordinary TigerQuery sqlcmd-style behavior, or TigerQuery's extended SqlCmdEx
behavior.
Parsed batches include one-based source positions, batch text, and repeat
metadata for tooling that needs lower-level control.