refactor(sql): split ISqlDialect/SqlServerDialect into a .Dialect leaf (#526)

`Driver.Sql.Browser` referenced the runtime `Driver.Sql` project. The AdminUI
references that browser, so the whole SQL driver runtime — and
`ZB.MOM.WW.OtOpcUa.Core` through it — sat in the AdminUI's build/deploy graph.
This is the same layering violation #512 removed from the MQTT browser, in a
second driver, and it is why #512's expected removal of `Core.dll` from the
AdminUI's output did not happen.

MEASURED, both directions, on a clean AdminUI build:

  before: ZB.MOM.WW.OtOpcUa.Core.dll + ZB.MOM.WW.OtOpcUa.Driver.Sql.dll present
  after:  both absent

The old csproj comment's premise was sound and survives — the dialect's catalog
SQL *is* the browse engine, so it must be shared rather than duplicated. What
was wrong was the conclusion that sharing it required referencing the runtime
driver. `ISqlDialect`/`SqlServerDialect` depend on nothing in `Driver.Sql`, so
they move to a `.Dialect` leaf that both consume.

This is the `.Transport` shape, not the cheap one: `ISqlDialect` exposes
`DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`,
so `.Contracts` (deliberately provider-free) cannot hold them. Assembly name is
`.Dialect`; the NAMESPACE stays `ZB.MOM.WW.OtOpcUa.Driver.Sql`, matching the
`Driver.Mqtt.Transport` precedent — the extraction cost zero `using` changes.

Two dependencies the cut edge surfaced as the implicit ones they were:

- The AdminUI itself read `SqlDriver.DriverTypeName` in three places — the tag
  editor map, the tag config validator, and the Sql address picker. All three
  now read `DriverTypeNames.Sql`, which is exactly what that constant resolves
  to and exactly the repoint `SqlDriverBrowser`'s own comment had deferred
  until a factory was registered against the shared constant. One exists now.
- `SqlDriverFormContractTests` asserts the AdminUI form's emitted JSON against
  the runtime factory that reads it, so it genuinely needs the runtime driver.
  `AdminUI.Tests` now references it explicitly. A test project referencing a
  runtime driver is fine — tests are not in the AdminUI's deploy graph.

New guard: `DriverBrowserLayeringTests` (Commons.Tests) scans the csproj graph
of every `*.Browser` project in the tree, transitively. It reads csproj files
rather than loaded assemblies because the cost is a build-graph cost — the
compiler emits no assembly reference for an unused `ProjectReference`, so
`MqttBrowserLayeringTests`' assembly-reference approach cannot see that case.
The two are complementary and each now points at the other.

Falsified three ways, each red then green again: the direct reference re-added
to the browser; the same reference one hop out via `.Dialect` (proving the walk
is really transitive); and both controls — browsers-discovered and
references-parsed — which exist so an empty discovery cannot pass the absence
assertion vacuously.

#526's claim that the reference "carries no comment at all" was wrong; it
carried a detailed one from the scaffold commit. Corrected on the issue and in
the design doc's project table, which had recorded the deviation as deliberate.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 08:38:57 -04:00
parent 857a10b4f7
commit 803f4e6efe
15 changed files with 249 additions and 27 deletions
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -100,12 +101,14 @@ public sealed class SqlDriverBrowser : IDriverBrowser
/// <inheritdoc />
/// <remarks>
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
/// registered against it. Repointing that one constant repoints this too.
/// Sourced from the shared <see cref="DriverTypeNames"/> set, which is what the driver's own
/// <c>SqlDriver.DriverTypeName</c> resolves to. This used to read that interim local constant, and
/// the comment here said so; repointing it was deferred until a factory was registered against
/// <see cref="DriverTypeNames.Sql"/> (else <c>DriverTypeNamesGuardTests</c> goes red). One exists
/// now, and reading the shared constant is also what lets this browser stay off the runtime driver
/// — <c>SqlDriver</c> is the only type it still needed from there (Gitea #526).
/// </remarks>
public string DriverType => SqlDriver.DriverTypeName;
public string DriverType => DriverTypeNames.Sql;
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
@@ -11,15 +11,34 @@
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
Contracts project and owns its own transport packages): the dialect's catalog SQL
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
Contracts-only reference without reading that context first. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<!--
The layering exception this project used to carry is GONE (Gitea #526). It referenced the
runtime Driver.Sql project, and the AdminUI references this browser: the AdminUI's
build/deploy graph therefore pulled in the whole SQL driver runtime and, through it,
ZB.MOM.WW.OtOpcUa.Core.
The premise of the old comment was sound and survives — the dialect's catalog SQL
(ISqlDialect / SqlServerDialect) *is* the browse engine, so it must be SHARED here rather
than duplicated. What was wrong was the conclusion that sharing it required a reference to
the runtime driver. Those two types depend on nothing from Driver.Sql, so they now live in
the .Dialect leaf below, referenced by the runtime driver and by this browser alike.
Note this is the .Transport shape, not the cheap one: ISqlDialect exposes DbProviderFactory
and SqlServerDialect returns SqlClientFactory.Instance, so the dialect cannot move into
.Contracts (deliberately provider-free) — it needs a leaf of its own.
MEASURED, not assumed: after this change ZB.MOM.WW.OtOpcUa.Driver.Sql.dll and
ZB.MOM.WW.OtOpcUa.Core.dll are both absent from the AdminUI's output, and both were present
immediately before it. #512 removed the matching MQTT reference and expected Core to
disappear with it; it did not, because this project was the remaining cause.
The transitive Microsoft.Data.SqlClient on AdminUI is unchanged and remains accepted —
AdminUI already carries SqlClient for ConfigDb. Design doc context, still worth reading
before changing the sharing decision: docs/plans/2026-07-15-sql-poll-driver-design.md §2.
Guarded by DriverBrowserLayeringTests, which scans every *.Browser csproj in the tree.
-->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// implemented: <c>SqlCatalogGate</c> resolves every authored table/column against the live
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
@@ -96,7 +96,7 @@ public interface ISqlDialect
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <para>Needed by <c>SqlCatalogGate</c>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
@@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- Assembly is .Dialect; the NAMESPACE stays ZB.MOM.WW.OtOpcUa.Driver.Sql, matching the
Driver.Mqtt.Transport precedent. The split is a build-graph concern, not an API one, so
extracting these two types cost zero `using` changes anywhere in the tree. -->
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
</ItemGroup>
</Project>
@@ -13,6 +13,9 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<!-- ISqlDialect / SqlServerDialect moved to the .Dialect leaf so Driver.Sql.Browser can share
the catalog SQL without referencing this runtime project (Gitea #526). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
@@ -269,7 +269,7 @@ else
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
@@ -22,10 +22,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
// note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
// the factory" — went stale when the constant landed; the key is correct either way.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
@@ -24,8 +24,7 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
@@ -41,8 +41,10 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
.Contracts + .Dialect + Microsoft.Data.SqlClient transitively — SqlClient is
reviewed/accepted (AdminUI already carries it for ConfigDb). It no longer pulls the
runtime Driver.Sql, nor Core through it: see Gitea #526 and the Browser project's own
csproj comment. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
</ItemGroup>