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,
};
}
}