From b97b3a360406b29b0a502e453380706b6f06e834 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 5 Aug 2026 06:40:24 -0400 Subject: [PATCH] feat(sql): PostgresDialect (Npgsql) --- .../PostgresDialect.cs | 251 ++++++++++++++++++ .../PostgresDialectTests.cs | 229 ++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs new file mode 100644 index 00000000..eaa5a9b6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs @@ -0,0 +1,251 @@ +using System.Data.Common; +using System.Globalization; +using Npgsql; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; + +/// +/// PostgreSQL dialect (design §2), over the fully managed Npgsql ADO.NET provider — no native +/// client install anywhere. Double-quote identifier quoting, information_schema catalog SQL, an +/// end-of-statement LIMIT 1, and a type map over the type names +/// information_schema.columns.data_type actually reports. +/// Stateless and immutable, so a single instance is safely shared by the driver's poll reader and +/// the AdminUI browse session — the same contract SqlServerDialect holds to. +/// +public sealed class PostgresDialect : ISqlDialect +{ + /// + /// PostgreSQL's identifier ceiling — NAMEDATALEN - 1, 63 by default. Enforced because the + /// server silently truncates anything longer, so a 70-character authored name would resolve to + /// a different object than it spells; rejecting it surfaces the config mistake instead. + /// + internal const int MaxIdentifierLength = 63; + + /// + public SqlProvider Provider => SqlProvider.Postgres; + + /// + public DbProviderFactory Factory => NpgsqlFactory.Instance; + + /// + /// Npgsql markers are @-prefixed, exactly as SQL Server's are — ParameterMarker("k0") + /// is "@k0". (Npgsql also accepts :, but this dialect commits to one spelling so the + /// catalog SQL below and the planner's markers can never drift apart.) + /// + /// The bare base name. + /// The @-prefixed marker. + public string ParameterMarker(string baseName) => "@" + baseName; + + /// + /// Identical to : NpgsqlParameter.ParameterName is matched against + /// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps + /// the parameter collection readable in a log and matches what the SQL text says. + /// + /// The bare base name. + /// The @-prefixed bind name. + public string ParameterBindName(string baseName) => "@" + baseName; + + /// + /// No-op. NpgsqlCommand binds by name out of the box, so there is nothing Postgres needs set on + /// a fresh command — the member exists for Oracle's BindByName (see + /// ). It deliberately does not cast + /// , so a substituted or instrumented factory's command passes through + /// untouched rather than throwing. + /// + /// The command to configure; untouched here. + public void ConfigureCommand(DbCommand command) + { + // Intentionally empty: see the summary. + } + + /// + public string LivenessSql => "SELECT 1"; + + /// + /// Empty — Postgres spells its row limit at the end of the statement, in + /// . The mirror image of T-SQL's TOP 1, which is exactly why + /// the seam carries both ends. + /// + public string SingleRowLimitPrefix => string.Empty; + + /// + /// " LIMIT 1", leading space included so it appends straight onto the finished statement + /// (… ORDER BY "sample_ts" DESC LIMIT 1). + /// + public string SingleRowLimitSuffix => " LIMIT 1"; + + /// + /// The schemas holding relations this connection can actually see, system schemas excluded. + /// + /// + /// Sourced from information_schema.tables, not information_schema.schemata + /// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape. + /// Postgres' schemata view lists only schemas the current role owns, so a service + /// account granted USAGE + SELECT on someone else's schema — the normal way a read-only + /// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving + /// the list from the relations the principal can see answers the question the catalog gate is really + /// asking: "which schemas hold something I may read?" + /// What counts as a system schema. pg_catalog and information_schema are + /// named explicitly (design §2). pg_toast and the per-backend pg_temp_N / + /// pg_toast_temp_N namespaces are excluded by prefix as well: they hold storage internals and + /// session-local temp objects, can never contain an authored poll target, and a pg_temp_N in + /// particular names a different thing per session — offering it in the browse tree would present a + /// schema that vanishes. The ESCAPE clause makes the _ literal rather than the + /// single-character wildcard LIKE would otherwise treat it as. + /// Aliased "TABLE_SCHEMA" in double quotes — see . + /// + public string ListSchemasSql => + "SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " + + "WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " + + "AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " + + "AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " + + "ORDER BY \"TABLE_SCHEMA\""; + + /// + /// current_schema() is the first schema on the connection's search_path — the one an + /// unqualified object name resolves in, which is precisely the question the catalog gate asks. + /// + public string DefaultSchemaSql => "SELECT current_schema()"; + + /// + /// Tables + views in one schema, matching SqlServerDialect.ListTablesSql column for column + /// (TABLE_NAME, TABLE_TYPE, ordered by name) so both consumers read the same shape. + /// + /// + /// The aliases are quoted, and that is load-bearing. SqlCatalogLoader and + /// SqlBrowseSession both read every catalog column by name + /// (DbDataReader.GetOrdinal("TABLE_NAME")), while Postgres folds an unquoted output + /// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those + /// consumers ask for rather than relying on a provider's case-insensitive GetOrdinal + /// fallback. + /// The @schema marker is written literally here because + /// spells it identically; the two agree by construction on this dialect + /// (they diverge only on ODP.NET), and a test pins that they do. + /// + public string ListTablesSql => + "SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " + + "FROM information_schema.tables " + + "WHERE table_schema = @schema ORDER BY \"TABLE_NAME\""; + + /// + /// Columns of one table in ordinal order — the same three columns + /// SqlServerDialect.ListColumnsSql projects (COLUMN_NAME, DATA_TYPE, + /// IS_NULLABLE), quoted for the reason gives. + /// + public string ListColumnsSql => + "SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " + + "is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " + + "WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position"; + + /// + /// Double-quotes one identifier part, doubling any embedded " — the SQL-standard escape rule + /// Postgres follows, and the only metacharacter that could terminate a quoted identifier early. + /// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through + /// unchanged. + /// + /// + /// Rejects (all as , mirroring + /// SqlServerDialect.QuoteIdentifier so a test written against one dialect fails the same way + /// against the other): null / empty / all-whitespace; longer than + /// ; any control character (which covers embedded NUL and the C0/C1 + /// ranges that can truncate or corrupt a logged/audited statement); and any Unicode + /// character — bidi overrides, zero-width spaces, soft hyphen, + /// BOM. Those last cannot escape the quotes, but they spoof the rendered text of a log line + /// or an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source + /// class, CVE-2021-42574). + /// NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string + /// outright, so passing one through would fail the whole statement rather than just the one + /// identifier. + /// The rejection messages deliberately do not echo the offending value — it is untrusted + /// input and this exception's text reaches the driver log. + /// This is a backstop, not the only defence. Design §8.1's catalog gate + /// (SqlCatalogGate) resolves an authored table/column against the live catalog at Initialize + /// and substitutes the catalog's own spelling — which on Postgres is the lower-cased one an + /// unquoted CREATE TABLE produced — so on the poll path the value arriving here is a string + /// read back out of the catalog. + /// + /// The bare, unquoted identifier part. + /// The double-quoted identifier. + /// The value cannot be a valid PostgreSQL identifier. + public string QuoteIdentifier(string ident) + { + if (string.IsNullOrWhiteSpace(ident)) + throw new ArgumentException( + "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident)); + + if (ident.Length > MaxIdentifierLength) + throw new ArgumentException( + $"A PostgreSQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).", + nameof(ident)); + + foreach (var ch in ident) + { + if (char.IsControl(ch)) + throw new ArgumentException( + "A SQL identifier may not contain control characters (including NUL).", nameof(ident)); + + // Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and + // zero-width character. They are inert inside the quotes but spoof rendered log/UI text. + if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format) + throw new ArgumentException( + "A SQL identifier may not contain Unicode format characters " + + "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident)); + } + + return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\""); + } + + /// + /// Folds an information_schema.columns.data_type family name onto a + /// per design §3.7. + /// + /// + /// The input is the canonical, spelled-out name Postgres reports — + /// double precision, character varying, timestamp without time zone — never the + /// float8/varchar/timestamptz aliases and never a length + /// (character varying(50) is reported as character varying). The internal aliases are + /// accepted anyway, so a caller reading types from pg_catalog instead maps identically. + /// Precision caveat: numeric / decimal / money collapse to + /// , as they do on SQL Server. A Postgres numeric is + /// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit + /// "type": "String" on the tag preserves the exact textual value when that matters. + /// Fallback: an unrecognised family maps to and never + /// throws — a schema browse must render a table containing an exotic column (json, + /// jsonb, inet, tsvector, an enum or composite reported as + /// USER-DEFINED, an array reported as ARRAY) rather than crash on it. Null/blank input + /// takes the same fallback, and so does bytea — matching how SQL Server's varbinary + /// falls back rather than pretending binary is a scalar. + /// Deliberate non-mappings. Postgres bit / bit varying are bit + /// strings, not booleans — mapping bit to the way + /// T-SQL's bit demands would silently coerce a multi-bit value, so they fall back. + /// time / time with time zone are times of day rather than points in time and take the + /// same fallback as T-SQL's time; interval is a duration, likewise. + /// uuid maps to , matching how SQL Server's + /// uniqueidentifier is handled. + /// + /// The bare SQL type family name; matched case-insensitively. + /// The mapped driver data type, or when unrecognised. + public DriverDataType MapColumnType(string sqlDataType) + { + if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String; + + return sqlDataType.Trim().ToLowerInvariant() switch + { + "boolean" or "bool" => DriverDataType.Boolean, + "smallint" or "int2" => DriverDataType.Int16, + "integer" or "int" or "int4" => DriverDataType.Int32, + "bigint" or "int8" => DriverDataType.Int64, + "real" or "float4" => DriverDataType.Float32, + "double precision" or "float8" + or "numeric" or "decimal" or "money" => DriverDataType.Float64, + "text" or "character varying" or "varchar" or "character" or "char" or "bpchar" + or "name" or "uuid" => DriverDataType.String, + "date" + or "timestamp without time zone" or "timestamp with time zone" + or "timestamp" or "timestamptz" => DriverDataType.DateTime, + _ => DriverDataType.String, + }; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs new file mode 100644 index 00000000..0ca35ec1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs @@ -0,0 +1,229 @@ +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; + +/// +/// Golden tests for , mirroring rule for +/// rule: the same injection boundary (values bound, identifiers quoted), the same rejection discipline, +/// and the exact catalog SQL. Where Postgres deliberately differs from T-SQL — double-quote escaping, a +/// 63-character identifier ceiling, the end-of-statement LIMIT 1, and bit being a bit string +/// rather than a boolean — the divergence is pinned here rather than left to reading. +/// +public class PostgresDialectTests +{ + [Fact] + public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes() + { + var d = new PostgresDialect(); + d.QuoteIdentifier("Speed").ShouldBe("\"Speed\""); + // " doubled, per the SQL-standard rule Postgres follows. + d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); + } + + [Fact] + public void QuoteIdentifier_rejectsControlCharsAndNul() + => Should.Throw(() => new PostgresDialect().QuoteIdentifier("x\0y")); + + // NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. The + // code points are spelled numerically so the offending characters never appear raw in this source file. + [Theory] + [InlineData(0x0001)] // C0 SOH + [InlineData(0x001B)] // C0 ESC + [InlineData(0x007F)] // DEL + [InlineData(0x0085)] // C1 NEL + [InlineData(0x0009)] // TAB + [InlineData(0x000A)] // LF + public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(int codePoint) + => Should.Throw( + () => new PostgresDialect().QuoteIdentifier("x" + (char)codePoint + "y")); + + // Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of + // these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing + // byte-different from the real catalog name (Trojan Source, CVE-2021-42574). + [Theory] + [InlineData(0x200B)] // zero-width space + [InlineData(0x202E)] // right-to-left override + [InlineData(0x2066)] // left-to-right isolate + [InlineData(0xFEFF)] // BOM / zero-width no-break space + [InlineData(0x00AD)] // soft hyphen + public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint) + => Should.Throw( + () => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed")); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident) + => Should.Throw(() => new PostgresDialect().QuoteIdentifier(ident!)); + + [Fact] + public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote() + => new PostgresDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\""); // the 1-char name " + + // NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it + // is a config mistake rather than something to quote and send. + [Fact] + public void QuoteIdentifier_acceptsExactly63Chars_andRejects64() + { + var d = new PostgresDialect(); + d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\""); + Should.Throw(() => d.QuoteIdentifier(new string('a', 64))); + } + + [Fact] + public void QuoteIdentifier_neutralisesAClassicInjectionPayload() + { + // A hostile "column name" cannot escape the quotes: the only metacharacter that could is ", and it + // is doubled. The result is a single (nonexistent) identifier, never executable SQL. + new PostgresDialect().QuoteIdentifier("x\" ; DROP TABLE users --") + .ShouldBe("\"x\"\" ; DROP TABLE users --\""); + } + + [Theory] + [InlineData("boolean", DriverDataType.Boolean)] + [InlineData("smallint", DriverDataType.Int16)] + [InlineData("integer", DriverDataType.Int32)] + [InlineData("bigint", DriverDataType.Int64)] + [InlineData("real", DriverDataType.Float32)] + [InlineData("double precision", DriverDataType.Float64)] + [InlineData("numeric", DriverDataType.Float64)] + [InlineData("text", DriverDataType.String)] + [InlineData("character varying", DriverDataType.String)] + [InlineData("character", DriverDataType.String)] + [InlineData("uuid", DriverDataType.String)] + [InlineData("timestamp without time zone", DriverDataType.DateTime)] + [InlineData("timestamp with time zone", DriverDataType.DateTime)] + [InlineData("date", DriverDataType.DateTime)] + public void MapColumnType_mapsFamilies(string sql, DriverDataType expected) + => new PostgresDialect().MapColumnType(sql).ShouldBe(expected); + + [Theory] + [InlineData("BOOLEAN", DriverDataType.Boolean)] + [InlineData("Double Precision", DriverDataType.Float64)] + public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected) + => new PostgresDialect().MapColumnType(sql).ShouldBe(expected); + + [Theory] + [InlineData("bytea")] // binary, exactly as SQL Server's varbinary falls back + [InlineData("bit")] // a Postgres BIT STRING, deliberately NOT a boolean + [InlineData("bit varying")] + [InlineData("time without time zone")] // a time of day, not a point in time (mirrors T-SQL `time`) + [InlineData("interval")] + [InlineData("json")] + [InlineData("ARRAY")] + [InlineData("USER-DEFINED")] + [InlineData("")] + [InlineData(null)] + public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql) + => new PostgresDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String); + + [Fact] + public void CatalogSql_isInformationSchemaAndParameterized() + { + var d = new PostgresDialect(); + d.LivenessSql.ShouldBe("SELECT 1"); + d.DefaultSchemaSql.ShouldBe("SELECT current_schema()"); + d.ListTablesSql.ShouldContain("information_schema.tables"); + d.ListColumnsSql.ShouldContain("information_schema.columns"); + d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated + d.ListColumnsSql.ShouldContain("@schema"); + d.ListColumnsSql.ShouldContain("@table"); + } + + // The markers baked into the catalog SQL text and the ones ParameterMarker hands the loader must be the + // same string, or the loader binds a name the statement never mentions. + [Fact] + public void CatalogSql_markersAgreeWithParameterMarker() + { + var d = new PostgresDialect(); + d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); + d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema")); + d.ListColumnsSql.ShouldContain(d.ParameterMarker("table")); + } + + [Fact] + public void ListSchemasSql_excludesTheSystemSchemas() + { + var sql = new PostgresDialect().ListSchemasSql; + sql.ShouldContain("'pg_catalog'"); + sql.ShouldContain("'information_schema'"); + + // The prefix families are matched with LIKE, where a bare _ is the single-character wildcard — so + // they carry an explicit escape. Asserting the escaped spelling is what makes this test able to fail + // if someone "tidies" the backslash away and silently widens the exclusion. + sql.ShouldContain("'pg\\_toast%' ESCAPE '\\'"); + sql.ShouldContain("'pg\\_temp%' ESCAPE '\\'"); + } + + // The loader and the browse session read every catalog column BY ALIAS (DbDataReader.GetOrdinal), and + // Postgres folds an unquoted output-column name to lower case. The aliases are therefore quoted, and + // this pins the exact ones the consumers ask for. + [Fact] + public void CatalogSql_projectsTheQuotedUpperCaseAliasesTheConsumersReadByName() + { + var d = new PostgresDialect(); + d.ListSchemasSql.ShouldContain("\"TABLE_SCHEMA\""); + d.ListTablesSql.ShouldContain("\"TABLE_NAME\""); + d.ListTablesSql.ShouldContain("\"TABLE_TYPE\""); + d.ListColumnsSql.ShouldContain("\"COLUMN_NAME\""); + d.ListColumnsSql.ShouldContain("\"DATA_TYPE\""); + d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\""); + } + + [Fact] + public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace() + { + var d = new PostgresDialect(); + + // Postgres spells the limit at the END of the statement, the mirror image of T-SQL's TOP 1. The + // planner appends the suffix straight onto the finished statement, so the space belongs to it. + d.SingleRowLimitPrefix.ShouldBe(""); + d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1"); + string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"a\"", d.SingleRowLimitSuffix) + .ShouldBe("SELECT \"a\" LIMIT 1"); + } + + [Fact] + public void Dialect_identifiesItselfAsPostgres_andExposesTheAbstractFactory() + { + var d = new PostgresDialect(); + d.Provider.ShouldBe(SqlProvider.Postgres); + d.Factory.ShouldBeSameAs(Npgsql.NpgsqlFactory.Instance); + // The seam must not leak Npgsql into its signature. + typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))! + .PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory)); + } + + [Theory] + [InlineData("k0")] + [InlineData("w")] + [InlineData("schema")] + public void ParameterMarkerAndBindName_areBothAtPrefixed(string baseName) + { + var d = new PostgresDialect(); + d.ParameterMarker(baseName).ShouldBe("@" + baseName); + d.ParameterBindName(baseName).ShouldBe("@" + baseName); + } + + // Npgsql binds by name out of the box, so ConfigureCommand has nothing to do — and it must tolerate a + // command that is not an NpgsqlCommand at all (the driver can be pointed at a substituted factory). + [Fact] + public void ConfigureCommand_leavesAForeignCommandUntouched() + { + using var command = new SqliteCommand("SELECT 1"); + command.Parameters.AddWithValue("@k0", 7); + + Should.NotThrow(() => new PostgresDialect().ConfigureCommand(command)); + + command.CommandText.ShouldBe("SELECT 1"); + command.Parameters.Count.ShouldBe(1); + command.Parameters[0].ParameterName.ShouldBe("@k0"); + command.Parameters[0].Value.ShouldBe(7); + } +}