Files
lmxopcua/docs/plans/2026-08-05-sql-postgres-oracle-design.md
T

8.9 KiB
Raw Blame History

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:

  1. Parameter markers are hardcoded @ in two places: SqlGroupPlanner.cs:21-25 (@k/@w) and the catalog path (SqlServerDialect.cs:56/:61 SQL text + SqlCatalogLoader.cs:97-113 bind names). Npgsql happens to accept @; Oracle requires :.
  2. Two provider gates reject everything but SqlServer: CreateDialect (SqlDriverFactoryExtensions.cs:208-215) and the browser's DefaultDialectSelector (SqlDriverBrowser.cs:126-127).
  3. Oracle has no INFORMATION_SCHEMA — catalog SQL must come from ALL_USERS / ALL_TABLES + ALL_VIEWS / ALL_TAB_COLUMNS; liveness is SELECT 1 FROM DUAL; row-limit is FETCH FIRST 1 ROWS ONLY.
  4. AdminUI provider dropdown is single-option (SqlDriverForm.razor:18-25).
  5. Packages — Npgsql and Oracle.ManagedDataAccess.Core are absent from Directory.Packages.props.

2. Decisions

  • Both dialects live in the existing ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect project, which exists precisely because provider packages are unavoidable there (.Dialect.csproj:20-26). Its csproj gains the two PackageReferences; consumers get them transitively exactly as Microsoft.Data.SqlClient flows today. No new projects, no .slnx churn.
  • Dialect-owned parameter naming. ISqlDialect gains 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) — the DbParameter.ParameterName value ("@k0" for SqlClient/Npgsql; bare "k0" for ODP.NET, its documented convention).
    • void ConfigureCommand(DbCommand command) — default no-op; OracleDialect sets OracleCommand.BindByName = true (ODP.NET defaults to bind-by-position, which would silently mis-bind the planner's multi-parameter IN lists — this is the sharpest correctness edge in the branch). SqlGroupPlanner drops its @k/@w consts and takes markers from the dialect (bind names ride SqlQueryPlan alongside, keeping SqlPollReader.cs:398-405's copy-don't-derive discipline). SqlCatalogLoader binds schema/table through the dialect instead of the literal "@schema". Every command creation site (SqlPollReader, SqlCatalogLoader, SqlDriverProbe, SqlBrowseSession) calls ConfigureCommand after CreateCommand.
  • PostgresDialect (NpgsqlFactory.Instance): @ markers; "-quoting with "" escape, max identifier 63; LivenessSql = "SELECT 1"; limit prefix "" / suffix " LIMIT 1"; INFORMATION_SCHEMA catalog SQL with pg_catalog/information_schema schemas excluded and DefaultSchemaSql = "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 FIRST needs 12c anyway); LivenessSql = "SELECT 1 FROM DUAL"; limit suffix " FETCH FIRST 1 ROWS ONLY"; catalog SQL from ALL_USERS (schemas), ALL_TABLES ∪ ALL_VIEWS (tables — matching INFORMATION_SCHEMA.TABLES' view inclusion), ALL_TAB_COLUMNS (columns), DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"; type map over Oracle names — NUMBER maps to Float64 (precision/scale are not visible to MapColumnType(string); the per-tag DeclaredType override is the documented escape hatch), plus BINARY_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; SqlCatalogGate already resolves every authored identifier to the catalog's own spelling, so authored widgets binds to Oracle WIDGETS. The implementer verifies the gate's comparison is case-insensitive and pins it per-dialect with tests.
  • Gates widen to exactly these two. CreateDialect and DefaultDialectSelector add Postgres/Oracle; MySql/Odbc keep the existing loud rejection (tests updated: the "Postgres is rejected" assertions at SqlDriverFactoryTests.cs:133-135 and SqlDriverBrowserTests.cs:188-196 flip to MySql as the still-reserved control).
  • AdminUI: SqlDriverForm.razor dropdown gains Postgres and Oracle options; 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 the SqlConnectionStringPersisted deploy gate needs no change (it keys on the literal connectionString key, not the provider).
  • Packages: Directory.Packages.props gains Npgsql (between MQTTnet and Novell.*) and Oracle.ManagedDataAccess.Core (between the OPCFoundation.* block and OpenTelemetry), latest stable versions on the feed. CentralPackageTransitivePinningEnabled is 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: SqlDriver already renders endpoints via the provider-neutral DbConnectionStringBuilder reading host/data source keys (SqlDriver.cs:47-51, :952-968) and logs exception types only.

3. Out of scope

  • MySql/Odbc dialects, write support (IWritable stays structurally absent, allowWrites stays inert), the deferred Query tag model, connection pooling knobs, and Kerberos/wallet auth schemes (connection-string concerns, owned by the operator's Sql__ConnectionStrings__* env value).

4. Testing

  1. Unit.
    • PostgresDialectTests / OracleDialectTests: quoting (escape doubling, length caps, control-char rejection — mirror SqlServerDialectTests), liveness/limit/catalog SQL text, type maps, markers/bind names, and ConfigureCommand (Oracle: a real OracleCommand has BindByName == true after the call; others: no-op).
    • Planner/loader marker plumbing: a colon-marker fake dialect (or SqliteDialect extended) 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.
  2. 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 (project otopcua-sql-pg, host port 15432) — env SQL_PG_ENDPOINT/SQL_PG_USER/SQL_PG_PASSWORD.
    • gvenzl/oracle-free:23-slim (project otopcua-sql-oracle, host port 11521) — env SQL_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.
  3. Live /run chrome gate — DEFERRED, run separately by the operator. AdminUI legs (provider dropdown authoring, Test-connect, /raw browse 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).