diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
index ff397fcd..6cc9236b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// Every level is the dialect's catalog SQL ( /
/// / ) — nothing here
/// knows what INFORMATION_SCHEMA is, because Oracle and SQLite do not have it. The catalog SQL is
-/// read verbatim and @schema / @table are bound as parameters; no name from a NodeId
-/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
+/// read verbatim and its schema / table parameters are bound as parameters under
+/// — so the bind names cannot drift from the markers the
+/// dialect's own SQL carries; no name from a NodeId is ever concatenated into a command text, so a
+/// hostile or merely awkward catalog name is inert here.
/// is deliberately not used on this path — the browse
/// never needs an identifier in text.
/// Connection ownership: this session owns the connection it is handed and closes it on
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// The TABLE_TYPE value marking a view rather than a base table.
private const string ViewTableType = "VIEW";
+ /// Base name of the schema parameter every dialect's catalog SQL carries.
+ private const string SchemaParameter = "schema";
+
+ /// Base name of the table parameter carries.
+ private const string TableParameter = "table";
+
///
/// The security class every browsed column reports. The Sql driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
@@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
{
var rows = await QueryAsync(
_dialect.ListTablesSql,
- command => Bind(command, "@schema", schema),
+ command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
static reader => (
Name: ReadString(reader, TableNameColumn),
Type: ReadOptionalString(reader, TableTypeColumn)),
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
_dialect.ListColumnsSql,
command =>
{
- Bind(command, "@schema", schema);
- Bind(command, "@table", table);
+ Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
+ Bind(command, _dialect.ParameterBindName(TableParameter), table);
},
static reader => (
Name: ReadString(reader, ColumnNameColumn),
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
var results = new List();
await using var command = _connection.CreateCommand();
+ // Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter
+ // column query is exactly where positional binding swaps the schema and the table silently.
+ _dialect.ConfigureCommand(command);
command.CommandText = sql;
bind(command);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
index 31dae6ef..35096a4c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs
@@ -50,6 +50,58 @@ public interface ISqlDialect
/// The value cannot be a valid catalog identifier.
string QuoteIdentifier(string ident);
+ ///
+ /// The token that stands for a bound parameter in the command text — "@k0" for
+ /// SqlClient/Npgsql, ":k0" for ODP.NET — given the caller's bare base name ("k0",
+ /// "w", "schema").
+ /// Why the marker and the bind name are two members. They are the same string on every
+ /// provider but Oracle, where the SQL text carries :schema while
+ /// is the bare schema. Modelling one and deriving the
+ /// other means a call site guessing at a convention it does not own — the exact defect this seam
+ /// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under
+ /// bind-by-position, as a silently mis-bound value) rather than a compile error.
+ /// The base name is caller-owned and never operator input: the poll planner mints
+ /// k0…kN/w, the catalog path uses schema/table. Authored values
+ /// ride the parameter; authored identifiers go through . Nothing
+ /// else reaches a command text.
+ ///
+ /// The bare base name, without any provider prefix.
+ /// The marker to embed in the SQL text.
+ string ParameterMarker(string baseName);
+
+ ///
+ /// The value to assign to for the same
+ /// — "@k0" for SqlClient-style providers (which expect the marker
+ /// verbatim), the bare "k0" for ODP.NET-style ones (whose documented convention is a
+ /// prefix-less bind name).
+ /// Always paired with over the same base name: a plan
+ /// carries both lists and every binding site copies them index-for-index rather than
+ /// re-deriving either from the other.
+ ///
+ /// The bare base name, without any provider prefix.
+ /// The parameter name to bind under.
+ string ParameterBindName(string baseName);
+
+ ///
+ /// Applies provider-specific setup to a freshly created , before its
+ /// parameters are added or it is executed. A no-op for providers that need none.
+ /// Oracle uses this to set OracleCommand.BindByName = true, and that call is the
+ /// sharpest correctness edge on this seam: ODP.NET binds by position by default, so a
+ /// multi-parameter statement (the planner's IN (:k0, :k1, …) list, the catalog path's
+ /// schema + table pair) would bind values to the wrong markers whenever the parameter
+ /// order and the marker order differ — publishing one tag's value onto another tag's node with no
+ /// error anywhere. Every command that carries parameters — the poll path, the catalog path, the
+ /// browse path — therefore calls this immediately after CreateCommand() and before the
+ /// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
+ /// ones are indifferent: SqlDriverProbe calls it anyway for uniformity,
+ /// SqlDriver.PingAsync does not.)
+ /// Implementations must tolerate any they are handed: the
+ /// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own
+ /// provider type must do so defensively rather than throwing.
+ ///
+ /// The command to configure.
+ void ConfigureCommand(DbCommand command);
+
/// Cheapest statement that proves the connection is alive (SELECT 1; Oracle needs FROM DUAL).
string LivenessSql { get; }
@@ -105,10 +157,19 @@ public interface ISqlDialect
///
string DefaultSchemaSql { get; }
- /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind @schema.
+ ///
+ /// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
+ /// parameter under the base name schema, spelled with this dialect's own
+ /// (@schema on SqlClient, :schema on ODP.NET); callers bind
+ /// it under of the same base name.
+ ///
string ListTablesSql { get; }
- /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind @schema and @table.
+ ///
+ /// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
+ /// parameters under the base names schema and table, spelled and bound exactly as
+ /// describes.
+ ///
string ListColumnsSql { get; }
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
index 1f9b4c5b..2bbb1a0a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
///
public DbProviderFactory Factory => SqlClientFactory.Instance;
+ ///
+ /// T-SQL markers are @-prefixed — ParameterMarker("k0") is "@k0".
+ ///
+ /// The bare base name.
+ /// The @-prefixed marker.
+ public string ParameterMarker(string baseName) => "@" + baseName;
+
+ ///
+ /// Identical to : Microsoft.Data.SqlClient expects
+ /// to carry the same @ the statement does. (It also
+ /// accepts a bare name and prepends the @ itself, but spelling it out keeps the parameter
+ /// collection readable in a profiler trace and matches what the SQL text says.)
+ ///
+ /// The bare base name.
+ /// The @-prefixed bind name.
+ public string ParameterBindName(string baseName) => "@" + baseName;
+
+ ///
+ /// No-op. SqlCommand binds by name out of the box, so there is nothing SQL Server needs set on a
+ /// fresh command — the member exists for Oracle's BindByName (see
+ /// ).
+ ///
+ /// The command to configure; untouched here.
+ public void ConfigureCommand(DbCommand command)
+ {
+ // Intentionally empty: see the summary.
+ }
+
///
public string LivenessSql => "SELECT 1";
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
index f0802333..289163c2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.
/// Every parameter is bound. Authored names reach the catalog queries as
-/// @schema/@table parameters — the same discipline the schema browser follows — so loading
-/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
+/// schema/table parameters — the same discipline the schema browser follows — so loading
+/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
+/// The bind names come from over those two base names, so
+/// they can never disagree with the markers in the dialect's own catalog SQL (@schema on SQL
+/// Server, :schema on Oracle).
/// Failures throw; they do not degrade to an empty catalog. An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
@@ -33,6 +36,12 @@ internal static class SqlCatalogLoader
/// Column alias projects.
private const string ColumnNameColumn = "COLUMN_NAME";
+ /// Base name of the schema parameter every dialect's catalog SQL carries.
+ private const string SchemaParameter = "schema";
+
+ /// Base name of the table parameter carries.
+ private const string TableParameter = "table";
+
///
/// Reads the catalog slice covering .
///
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
- connection, dialect.ListSchemasSql, SchemaColumn,
+ connection, dialect, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
@@ -94,8 +103,8 @@ internal static class SqlCatalogLoader
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
- connection, dialect.ListTablesSql, TableNameColumn,
- command => Bind(command, "@schema", schema),
+ connection, dialect, dialect.ListTablesSql, TableNameColumn,
+ command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
- connection, dialect.ListColumnsSql, ColumnNameColumn,
+ connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
- Bind(command, "@schema", schema);
- Bind(command, "@table", table);
+ Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
+ Bind(command, dialect.ParameterBindName(TableParameter), table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
+ dialect.ConfigureCommand(command);
command.CommandText = dialect.DefaultSchemaSql;
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
@@ -144,6 +154,7 @@ internal static class SqlCatalogLoader
/// Runs one catalog query and projects a single string column from every row.
private static async Task> QueryColumnAsync(
DbConnection connection,
+ ISqlDialect dialect,
string sql,
string columnName,
Action bind,
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
+ // Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
+ dialect.ConfigureCommand(command);
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
index 79c5b986..95222661 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
+ // Uniformly applied at every command site, even where this statement takes no
+ // parameters — a provider hook that is only *sometimes* honoured is the harder bug.
+ dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
}
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 73510d1d..a6aa0854 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// SqlGroupPlannerTests.
/// Injection boundary (design §8.1). Identifiers reach the command text through
/// and nothing else; every authored value becomes a
-/// @k0/@w marker with the value carried in . There is no
-/// interpolation of tag content into SQL anywhere in this type.
+/// k0/w parameter — spelled with the dialect's own
+/// (@k0 on SQL Server, :k0 on Oracle) — with the value carried in
+/// . There is no interpolation of tag content into SQL anywhere in
+/// this type.
+/// The base names are this planner's, the spelling is the dialect's. Each plan carries the
+/// markers it emitted and the matching s, so the
+/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
+/// ODP.NET (:k0 in the text, bare k0 on the parameter).
///
public static class SqlGroupPlanner
{
- /// The parameter-marker prefix for a key-value model's IN list.
- private const string KeyMarkerPrefix = "@k";
+ /// The base-name prefix for a key-value model's IN-list parameters (k0…kN).
+ private const string KeyBaseNamePrefix = "k";
- /// The single parameter marker for a wide-row model's row selector.
- private const string RowSelectorMarker = "@w";
+ /// The single parameter base name for a wide-row model's row selector.
+ private const string RowSelectorBaseName = "w";
///
/// Groups by source and emits one per group.
@@ -132,7 +138,8 @@ public static class SqlGroupPlanner
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
- var names = new List(members.Count);
+ var markers = new List(members.Count);
+ var bindNames = new List(members.Count);
var values = new List