diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs index 076bd3b4..f6b37df0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs @@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// /// The provider seam (design §2.2). ADO.NET's base types abstract -/// connections, commands, parameters and readers; they do not abstract the two things that differ -/// per backend — identifier quoting and the metadata-catalog SQL. 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 not abstract the three things that differ +/// per backend — identifier quoting, the metadata-catalog SQL, and row-limit syntax. +/// Those live here, so no dialect assumption leaks into the reader, the poll planner, or the schema browser. /// This interface is the driver's SQL-injection boundary. Every runtime value is /// bound as a 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 /// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL). string LivenessSql { get; } + /// + /// The fragment that goes immediately after SELECT to limit a statement to one row — + /// T-SQL's "TOP 1 ". Empty for a dialect that spells the limit at the end of the statement (see + /// ). + /// Include the trailing space when non-empty: the planner concatenates + /// "SELECT " + SingleRowLimitPrefix + columns with no separator of its own, so an empty fragment + /// must leave the statement byte-identical to an unlimited one. + /// + /// + /// Why a prefix/suffix pair rather than one ApplyRowLimit(sql, n) method. The two + /// spellings sit at opposite ends of the statement — SQL Server writes + /// SELECT TOP 1 … ORDER BY … DESC, while SQLite/Postgres/MySQL write + /// SELECT … ORDER BY … DESC LIMIT 1. 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. + /// Why "single row" rather than a row count. The only limit v1 emits is the wide-row + /// topByTimestamp selector's newest-row pick. Modelling an arbitrary n would be untested + /// surface, and FETCH FIRST/ROWNUM dialects need more than a substituted number anyway. + /// Widen this to a method when a feature actually needs n > 1. + /// + string SingleRowLimitPrefix { get; } + + /// + /// The fragment appended to the very end of a statement to limit it to one row — + /// " LIMIT 1" for SQLite/Postgres/MySQL. Empty for a dialect that limits after SELECT + /// (see ). + /// Include the leading space when non-empty, for the same reason the prefix carries a + /// trailing one: the planner appends it directly to the finished statement. + /// Exactly one of the two fragments is non-empty in every dialect modelled so far, but the planner + /// emits both unconditionally — a dialect needing both ends (or neither) is expressible without + /// touching the call site. + /// + string SingleRowLimitSuffix { get; } + /// Catalog query listing schemas — the browser's RootAsync level. Takes no parameters. string ListSchemasSql { get; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs index 54f92d1d..73510d1d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs @@ -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. /// /// - /// A tag carries (design §5.4, deferred to P3), or a - /// topByTimestamp selector is planned for a provider whose row-limit syntax is not yet modelled. + /// A tag carries (design §5.4, deferred to P3). /// Deliberately loud rather than skipped: 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. /// @@ -157,7 +156,10 @@ public static class SqlGroupPlanner /// /// Emits SELECT <cols> FROM <table> WHERE <whereColumn> = @w, or — for a - /// topByTimestamp selector — SELECT TOP 1 <cols> FROM <table> ORDER BY <ts> DESC. + /// topByTimestamp selector — the single-row form + /// SELECT <limit> <cols> FROM <table> ORDER BY <ts> DESC <limit>, whose + /// row limit comes from / + /// (T-SQL fills the prefix: SELECT TOP 1 …). /// The SELECT list is each member's columnName (distinct, in first-appearance order), /// followed by each distinct member timestampColumn — so members of one row may carry different /// timestamp columns without splitting the group. The where-column itself is not 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); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs index bd7eebbd..2e76821b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs @@ -29,6 +29,16 @@ public sealed class SqlServerDialect : ISqlDialect /// public string LivenessSql => "SELECT 1"; + /// + /// T-SQL limits after the SELECT keyword, so the whole limit lives in the prefix — + /// "TOP 1 ", trailing space included so SELECT TOP 1 [col] composes with no extra + /// separator at the call site. + /// + public string SingleRowLimitPrefix => "TOP 1 "; + + /// Empty — T-SQL has no trailing row-limit clause (OFFSET/FETCH is a paging construct, not this). + public string SingleRowLimitSuffix => string.Empty; + /// public string ListSchemasSql => "SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA"; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs index 9fecd3ba..af0e4c0f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs @@ -48,6 +48,19 @@ public class SqlServerDialectTests d.ListColumnsSql.ShouldContain("@table"); } + [Fact] + public void SingleRowLimit_isTSqlsTop1_inThePrefixPosition_withItsOwnTrailingSpace() + { + var d = new SqlServerDialect(); + + // The planner concatenates "SELECT " + prefix + columns with no separator of its own, so the + // trailing space belongs to the fragment. T-SQL has no end-of-statement limit clause. + d.SingleRowLimitPrefix.ShouldBe("TOP 1 "); + d.SingleRowLimitSuffix.ShouldBe(""); + string.Concat("SELECT ", d.SingleRowLimitPrefix, "[a]", d.SingleRowLimitSuffix) + .ShouldBe("SELECT TOP 1 [a]"); + } + // ---- extra guards on the injection boundary (beyond the plan's golden set) ---- [Fact]