refactor(sql): promote the single-row limit onto ISqlDialect
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// The provider seam (design §2.2). ADO.NET's <see cref="System.Data.Common"/> base types abstract
|
||||
/// connections, commands, parameters and readers; they do <b>not</b> abstract the two things that differ
|
||||
/// per backend — <b>identifier quoting</b> and the <b>metadata-catalog SQL</b>. Those live here, so no
|
||||
/// dialect assumption leaks into the reader, the poll planner, or the schema browser.
|
||||
/// connections, commands, parameters and readers; they do <b>not</b> abstract the three things that differ
|
||||
/// per backend — <b>identifier quoting</b>, the <b>metadata-catalog SQL</b>, and <b>row-limit syntax</b>.
|
||||
/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser.
|
||||
/// <para><b>This interface is the driver's SQL-injection boundary.</b> Every runtime <em>value</em> is
|
||||
/// bound as a <see cref="DbParameter"/> and never reaches a command text. Identifiers cannot be
|
||||
/// parameterized in SQL, so the few that must appear as text are sourced only from catalog-validated
|
||||
@@ -47,6 +47,41 @@ public interface ISqlDialect
|
||||
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
|
||||
string LivenessSql { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fragment that goes <b>immediately after <c>SELECT</c></b> to limit a statement to one row —
|
||||
/// T-SQL's <c>"TOP 1 "</c>. Empty for a dialect that spells the limit at the end of the statement (see
|
||||
/// <see cref="SingleRowLimitSuffix"/>).
|
||||
/// <para><b>Include the trailing space</b> when non-empty: the planner concatenates
|
||||
/// <c>"SELECT " + SingleRowLimitPrefix + columns</c> with no separator of its own, so an empty fragment
|
||||
/// must leave the statement byte-identical to an unlimited one.</para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why a prefix/suffix pair rather than one <c>ApplyRowLimit(sql, n)</c> method.</b> The two
|
||||
/// spellings sit at opposite ends of the statement — SQL Server writes
|
||||
/// <c>SELECT TOP 1 … ORDER BY … DESC</c>, while SQLite/Postgres/MySQL write
|
||||
/// <c>SELECT … ORDER BY … DESC LIMIT 1</c>. A single method taking the SELECT list could only serve the
|
||||
/// prefix position; a single method taking the whole statement would move statement assembly out of the
|
||||
/// planner and into every dialect. Two fragments keep the planner the sole author of statement shape
|
||||
/// and let each dialect fill in the end it uses.</para>
|
||||
/// <para><b>Why "single row" rather than a row count.</b> The only limit v1 emits is the wide-row
|
||||
/// <c>topByTimestamp</c> selector's newest-row pick. Modelling an arbitrary <c>n</c> would be untested
|
||||
/// surface, and <c>FETCH FIRST</c>/<c>ROWNUM</c> dialects need more than a substituted number anyway.
|
||||
/// Widen this to a method when a feature actually needs <c>n > 1</c>.</para>
|
||||
/// </remarks>
|
||||
string SingleRowLimitPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fragment appended to the <b>very end</b> of a statement to limit it to one row —
|
||||
/// <c>" LIMIT 1"</c> for SQLite/Postgres/MySQL. Empty for a dialect that limits after <c>SELECT</c>
|
||||
/// (see <see cref="SingleRowLimitPrefix"/>).
|
||||
/// <para><b>Include the leading space</b> when non-empty, for the same reason the prefix carries a
|
||||
/// trailing one: the planner appends it directly to the finished statement.</para>
|
||||
/// <para>Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner
|
||||
/// emits <b>both</b> unconditionally — a dialect needing both ends (or neither) is expressible without
|
||||
/// touching the call site.</para>
|
||||
/// </summary>
|
||||
string SingleRowLimitSuffix { get; }
|
||||
|
||||
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||
string ListSchemasSql { get; }
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@ public static class SqlGroupPlanner
|
||||
/// up front, so reaching one here means a caller built a definition by hand with a broken invariant.
|
||||
/// </exception>
|
||||
/// <exception cref="NotSupportedException">
|
||||
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3), or a
|
||||
/// <c>topByTimestamp</c> selector is planned for a provider whose row-limit syntax is not yet modelled.
|
||||
/// A tag carries <see cref="SqlTagModel.Query"/> (design §5.4, deferred to P3).
|
||||
/// <b>Deliberately loud rather than skipped:</b> silently dropping a tag would leave an authored node
|
||||
/// permanently unfed with nothing in the log, and the parser makes this branch unreachable in practice.
|
||||
/// </exception>
|
||||
@@ -157,7 +156,10 @@ public static class SqlGroupPlanner
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>SELECT <cols> FROM <table> WHERE <whereColumn> = @w</c>, or — for a
|
||||
/// <c>topByTimestamp</c> selector — <c>SELECT TOP 1 <cols> FROM <table> ORDER BY <ts> DESC</c>.
|
||||
/// <c>topByTimestamp</c> selector — the single-row form
|
||||
/// <c>SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit></c>, whose
|
||||
/// row limit comes from <see cref="ISqlDialect.SingleRowLimitPrefix"/> /
|
||||
/// <see cref="ISqlDialect.SingleRowLimitSuffix"/> (T-SQL fills the prefix: <c>SELECT TOP 1 …</c>).
|
||||
/// <para>The SELECT list is each member's <c>columnName</c> (distinct, in first-appearance order),
|
||||
/// followed by each distinct member <c>timestampColumn</c> — so members of one row may carry different
|
||||
/// timestamp columns without splitting the group. The where-column itself is <b>not</b> selected: every
|
||||
@@ -191,18 +193,13 @@ public static class SqlGroupPlanner
|
||||
|
||||
if (!Blank(first.RowSelectorTopByTimestamp))
|
||||
{
|
||||
// "TOP 1" is T-SQL. Other providers spell the row limit differently (LIMIT 1, FETCH FIRST,
|
||||
// ROWNUM), and ISqlDialect does not model it yet because v1 constructs SqlServer only — so refuse
|
||||
// loudly rather than emit a statement that would not parse. Promote a row-limit member onto
|
||||
// ISqlDialect when P2 adds Postgres/ODBC.
|
||||
if (dialect.Provider != SqlProvider.SqlServer)
|
||||
throw new NotSupportedException(
|
||||
$"The wide-row 'topByTimestamp' row selector needs a provider-specific row limit; " +
|
||||
$"{dialect.Provider} is not modelled yet (v1 constructs SqlServer only).");
|
||||
|
||||
// The row limit is dialect syntax and sits at opposite ends of the statement per provider
|
||||
// (T-SQL "SELECT TOP 1 …", SQLite/Postgres/MySQL "… LIMIT 1"), so both ends are emitted
|
||||
// unconditionally and the dialect decides which one it fills. See ISqlDialect.SingleRowLimitPrefix.
|
||||
var sql = string.Concat(
|
||||
"SELECT TOP 1 ", columns, " FROM ", table,
|
||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC");
|
||||
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
|
||||
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
|
||||
dialect.SingleRowLimitSuffix);
|
||||
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,16 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => "SELECT 1";
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL limits after the <c>SELECT</c> keyword, so the whole limit lives in the prefix —
|
||||
/// <c>"TOP 1 "</c>, trailing space included so <c>SELECT TOP 1 [col]</c> composes with no extra
|
||||
/// separator at the call site.
|
||||
/// </summary>
|
||||
public string SingleRowLimitPrefix => "TOP 1 ";
|
||||
|
||||
/// <summary>Empty — T-SQL has no trailing row-limit clause (<c>OFFSET/FETCH</c> is a paging construct, not this).</summary>
|
||||
public string SingleRowLimitSuffix => string.Empty;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||
|
||||
Reference in New Issue
Block a user