using System.Data.Common; using System.Globalization; using System.Text; 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. /// The unit is BYTES in the database encoding, not characters — see /// . Practically that means UTF-8 on any modern estate, so a 32-character /// Cyrillic name is already over the limit. /// 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. } /// /// STAB-24: a string-valued bind is sent with so the /// server infers its type from the compared column. Bound as the default text, a tag whose /// keyValue is "3" against an integer key column fails with /// operator does not exist: integer = text. Unknown still carries the value as data — it is /// never interpolated — so the injection boundary is unchanged. /// /// The parameter to adjust. /// The authored value. public void ConfigureParameter(DbParameter parameter, object? value) { if (parameter is NpgsqlParameter npgsql && value is string) npgsql.NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown; } /// /// 65535 — the extended-query protocol carries the parameter count as a signed/unsigned 16-bit field /// in Bind, so that is a protocol ceiling rather than a server setting, and Npgsql raises it /// client-side before a round-trip. Far above anything a poll group reaches in practice; it is here so /// the planner never has to special-case "this dialect has no meaningful limit". /// public int MaxBindParameters => 65535; /// 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 /// bytes; 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). /// The length ceiling is measured in UTF-8 bytes, not characters — NAMEDATALEN /// bounds the stored name in the database encoding, and every modern estate is UTF-8. Counting UTF-16 /// code units (ident.Length) would accept a 32-character Cyrillic name, which is 64 bytes and /// is therefore truncated server-side to a name that denotes a different object — precisely /// the silent-truncation failure this rule exists to prevent. SqlServerDialect deliberately does /// not do this: T-SQL's sysname is nvarchar(128), a genuine 128-character limit, /// so counting characters is correct there and counting bytes would reject legal names. /// 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)); // Byte count, not ident.Length: NAMEDATALEN bounds the stored name in the database encoding, so a // 32-character Cyrillic name is 64 UTF-8 bytes and the server truncates it — the exact silent // wrong-object outcome this check exists to prevent, and one a UTF-16 code-unit count waves through. var byteLength = Encoding.UTF8.GetByteCount(ident); if (byteLength > MaxIdentifierLength) throw new ArgumentException( $"A PostgreSQL identifier may not exceed {MaxIdentifierLength} bytes (got {byteLength}).", 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, }; } }