8.9 KiB
Sql driver: Postgres + Oracle providers (managed drivers only) — design
Status: DRAFT 2026-08-05.
1. Problem
docs/drivers/Sql.md:15-18: v1 constructs SQL Server only; SqlProvider declares Postgres,
MySql, Odbc, Oracle as reserved names that fail the driver. This branch makes Postgres and
Oracle working backends via fully managed ADO.NET providers — Npgsql and
Oracle.ManagedDataAccess.Core — no native/thin-client installs. MySql/Odbc stay reserved.
The seam already exists and is proven: ISqlDialect exposes an abstract DbProviderFactory
(ISqlDialect.cs:38), all runtime I/O goes through DbConnection/DbCommand/DbParameter
(SqlPollReader.cs:379-389), and the test-only SqliteDialect is the standing falsifiability
control that the planner emits dialect SQL rather than T-SQL. The concrete gaps:
- Parameter markers are hardcoded
@in two places:SqlGroupPlanner.cs:21-25(@k/@w) and the catalog path (SqlServerDialect.cs:56/:61SQL text +SqlCatalogLoader.cs:97-113bind names). Npgsql happens to accept@; Oracle requires:. - Two provider gates reject everything but SqlServer:
CreateDialect(SqlDriverFactoryExtensions.cs:208-215) and the browser'sDefaultDialectSelector(SqlDriverBrowser.cs:126-127). - Oracle has no
INFORMATION_SCHEMA— catalog SQL must come fromALL_USERS/ALL_TABLES+ALL_VIEWS/ALL_TAB_COLUMNS; liveness isSELECT 1 FROM DUAL; row-limit isFETCH FIRST 1 ROWS ONLY. - AdminUI provider dropdown is single-option (
SqlDriverForm.razor:18-25). - Packages —
NpgsqlandOracle.ManagedDataAccess.Coreare absent fromDirectory.Packages.props.
2. Decisions
- Both dialects live in the existing
ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialectproject, which exists precisely because provider packages are unavoidable there (.Dialect.csproj:20-26). Its csproj gains the twoPackageReferences; consumers get them transitively exactly asMicrosoft.Data.SqlClientflows today. No new projects, no.slnxchurn. - Dialect-owned parameter naming.
ISqlDialectgains three members with SqlServer-compatible semantics implemented on every dialect:string ParameterMarker(string baseName)— the token in SQL text ("@k0"/":k0").string ParameterBindName(string baseName)— theDbParameter.ParameterNamevalue ("@k0"for SqlClient/Npgsql; bare"k0"for ODP.NET, its documented convention).void ConfigureCommand(DbCommand command)— default no-op; OracleDialect setsOracleCommand.BindByName = true(ODP.NET defaults to bind-by-position, which would silently mis-bind the planner's multi-parameterINlists — this is the sharpest correctness edge in the branch).SqlGroupPlannerdrops its@k/@wconsts and takes markers from the dialect (bind names rideSqlQueryPlanalongside, keepingSqlPollReader.cs:398-405's copy-don't-derive discipline).SqlCatalogLoaderbindsschema/tablethrough the dialect instead of the literal"@schema". Every command creation site (SqlPollReader,SqlCatalogLoader,SqlDriverProbe,SqlBrowseSession) callsConfigureCommandafterCreateCommand.
PostgresDialect(NpgsqlFactory.Instance):@markers;"-quoting with""escape, max identifier 63;LivenessSql = "SELECT 1"; limit prefix""/ suffix" LIMIT 1";INFORMATION_SCHEMAcatalog SQL withpg_catalog/information_schemaschemas excluded andDefaultSchemaSql = "SELECT current_schema()"; type map over Postgres names (integer,bigint,smallint,real,double precision,numeric,boolean,text,character varying,timestamp*,uuid,bytea, …).OracleDialect(OracleClientFactory.Instance)::markers, bare bind names, BindByName;"-quoting with""escape, max identifier 128 (declares Oracle 12.2+ as the supported floor —FETCH FIRSTneeds 12c anyway);LivenessSql = "SELECT 1 FROM DUAL"; limit suffix" FETCH FIRST 1 ROWS ONLY"; catalog SQL fromALL_USERS(schemas),ALL_TABLES∪ALL_VIEWS(tables — matchingINFORMATION_SCHEMA.TABLES' view inclusion),ALL_TAB_COLUMNS(columns),DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"; type map over Oracle names —NUMBERmaps toFloat64(precision/scale are not visible toMapColumnType(string); the per-tagDeclaredTypeoverride is the documented escape hatch), plusBINARY_DOUBLE/BINARY_FLOAT,VARCHAR2/NVARCHAR2/CHAR/NCHAR,DATE/TIMESTAMP*,CLOB,RAW.- Case-folding rides the existing catalog gate. Postgres lower-cases and Oracle upper-cases
unquoted identifiers;
SqlCatalogGatealready resolves every authored identifier to the catalog's own spelling, so authoredwidgetsbinds to OracleWIDGETS. The implementer verifies the gate's comparison is case-insensitive and pins it per-dialect with tests. - Gates widen to exactly these two.
CreateDialectandDefaultDialectSelectoraddPostgres/Oracle;MySql/Odbckeep the existing loud rejection (tests updated: the "Postgres is rejected" assertions atSqlDriverFactoryTests.cs:133-135andSqlDriverBrowserTests.cs:188-196flip toMySqlas the still-reserved control). - AdminUI:
SqlDriverForm.razordropdown gainsPostgresandOracleoptions; helper text updated. No other UI change — Sql stays a single-connection driver with no device form (DriverFormMaps.cs:81-89),connectionStringRef+Sql__ConnectionStrings__<ref>env resolution is provider-agnostic already, and theSqlConnectionStringPersisteddeploy gate needs no change (it keys on the literalconnectionStringkey, not the provider). - Packages:
Directory.Packages.propsgainsNpgsql(betweenMQTTnetandNovell.*) andOracle.ManagedDataAccess.Core(between theOPCFoundation.*block andOpenTelemetry), latest stable versions on the feed.CentralPackageTransitivePinningEnabledis OFF by design, so if restore surfaces an NU1903 in either package's transitive tree, the fix is the established surgical direct-pin pattern (Directory.Packages.props:118-145), never a suppress. - Credential discipline holds automatically:
SqlDriveralready renders endpoints via the provider-neutralDbConnectionStringBuilderreadinghost/data sourcekeys (SqlDriver.cs:47-51, :952-968) and logs exception types only.
3. Out of scope
MySql/Odbcdialects, write support (IWritablestays structurally absent,allowWritesstays inert), the deferredQuerytag model, connection pooling knobs, and Kerberos/wallet auth schemes (connection-string concerns, owned by the operator'sSql__ConnectionStrings__*env value).
4. Testing
- Unit.
PostgresDialectTests/OracleDialectTests: quoting (escape doubling, length caps, control-char rejection — mirrorSqlServerDialectTests), liveness/limit/catalog SQL text, type maps, markers/bind names, andConfigureCommand(Oracle: a realOracleCommandhasBindByName == trueafter the call; others: no-op).- Planner/loader marker plumbing: a colon-marker fake dialect (or
SqliteDialectextended) proves the planner emits dialect markers and the reader binds the dialect's bind names — the falsifiability control for gap 1. - Gate tests updated as in §2 (MySql becomes the rejected control).
- The SQLite-backed read/poll suites keep running unchanged — they prove provider-agnosticism.
- Integration (env-gated, skip-clean, docker). Two new fixtures under
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/, deployed to the shared docker host per the established pattern:postgres:17-alpine(projectotopcua-sql-pg, host port 15432) — envSQL_PG_ENDPOINT/SQL_PG_USER/SQL_PG_PASSWORD.gvenzl/oracle-free:23-slim(projectotopcua-sql-oracle, host port 11521) — envSQL_ORACLE_ENDPOINT/SQL_ORACLE_USER/SQL_ORACLE_PASSWORD. Each suite: initialize (liveness + catalog load), KeyValue + WideRow reads against a seeded schema, catalog-gate case-folding leg (authored lower-case vs Oracle upper-case catalog), browse-session schema/table/column walk. The integration csproj keeps its no-direct-provider- reference discipline (.IntegrationTests.csproj:29-33) — packages arrive transitively.
- Live
/runchrome gate — DEFERRED, run separately by the operator. AdminUI legs (provider dropdown authoring, Test-connect,/rawbrowse against the Postgres and Oracle fixtures, browse-commit of a column). The executing session must NOT run this; it hands the operator the command at the end.
5. Bookkeeping
docs/drivers/Sql.md (§providers table, config table, browse section),
SqlProvider.cs doc comments ("reserved for P2" → supported), the tracking doc's Sql row, and
docs/research/drivers/sql-poll.md stays as-is (research is a record, not living doc).