71881163df
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
290 lines
18 KiB
C#
290 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// PostgreSQL dialect (design §2), over the fully managed <c>Npgsql</c> ADO.NET provider — no native
|
|
/// client install anywhere. Double-quote identifier quoting, <c>information_schema</c> catalog SQL, an
|
|
/// end-of-statement <c>LIMIT 1</c>, and a type map over the type names
|
|
/// <c>information_schema.columns.data_type</c> actually reports.
|
|
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
|
|
/// the AdminUI browse session — the same contract <c>SqlServerDialect</c> holds to.</para>
|
|
/// </summary>
|
|
public sealed class PostgresDialect : ISqlDialect
|
|
{
|
|
/// <summary>
|
|
/// PostgreSQL's identifier ceiling — <c>NAMEDATALEN - 1</c>, 63 by default. Enforced because the
|
|
/// server <b>silently truncates</b> anything longer, so a 70-character authored name would resolve to
|
|
/// a different object than it spells; rejecting it surfaces the config mistake instead.
|
|
/// <para><b>The unit is BYTES in the database encoding, not characters</b> — see
|
|
/// <see cref="QuoteIdentifier"/>. Practically that means UTF-8 on any modern estate, so a 32-character
|
|
/// Cyrillic name is already over the limit.</para>
|
|
/// </summary>
|
|
internal const int MaxIdentifierLength = 63;
|
|
|
|
/// <inheritdoc/>
|
|
public SqlProvider Provider => SqlProvider.Postgres;
|
|
|
|
/// <inheritdoc/>
|
|
public DbProviderFactory Factory => NpgsqlFactory.Instance;
|
|
|
|
/// <summary>
|
|
/// Npgsql markers are <c>@</c>-prefixed, exactly as SQL Server's are — <c>ParameterMarker("k0")</c>
|
|
/// is <c>"@k0"</c>. (Npgsql also accepts <c>:</c>, but this dialect commits to one spelling so the
|
|
/// catalog SQL below and the planner's markers can never drift apart.)
|
|
/// </summary>
|
|
/// <param name="baseName">The bare base name.</param>
|
|
/// <returns>The <c>@</c>-prefixed marker.</returns>
|
|
public string ParameterMarker(string baseName) => "@" + baseName;
|
|
|
|
/// <summary>
|
|
/// Identical to <see cref="ParameterMarker"/>: <c>NpgsqlParameter.ParameterName</c> 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.
|
|
/// </summary>
|
|
/// <param name="baseName">The bare base name.</param>
|
|
/// <returns>The <c>@</c>-prefixed bind name.</returns>
|
|
public string ParameterBindName(string baseName) => "@" + baseName;
|
|
|
|
/// <summary>
|
|
/// No-op. <c>NpgsqlCommand</c> binds by name out of the box, so there is nothing Postgres needs set on
|
|
/// a fresh command — the member exists for Oracle's <c>BindByName</c> (see
|
|
/// <see cref="ISqlDialect.ConfigureCommand"/>). It deliberately does not cast
|
|
/// <paramref name="command"/>, so a substituted or instrumented factory's command passes through
|
|
/// untouched rather than throwing.
|
|
/// </summary>
|
|
/// <param name="command">The command to configure; untouched here.</param>
|
|
public void ConfigureCommand(DbCommand command)
|
|
{
|
|
// Intentionally empty: see the summary.
|
|
}
|
|
|
|
/// <summary>
|
|
/// STAB-24: a string-valued bind is sent with <see cref="NpgsqlTypes.NpgsqlDbType.Unknown"/> so the
|
|
/// server infers its type from the compared column. Bound as the default <c>text</c>, a tag whose
|
|
/// <c>keyValue</c> is <c>"3"</c> against an <c>integer</c> key column fails with
|
|
/// <c>operator does not exist: integer = text</c>. Unknown still carries the value as data — it is
|
|
/// never interpolated — so the injection boundary is unchanged.
|
|
/// </summary>
|
|
/// <param name="parameter">The parameter to adjust.</param>
|
|
/// <param name="value">The authored value.</param>
|
|
public void ConfigureParameter(DbParameter parameter, object? value)
|
|
{
|
|
if (parameter is NpgsqlParameter npgsql && value is string)
|
|
npgsql.NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Unknown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 65535 — the extended-query protocol carries the parameter count as a signed/unsigned 16-bit field
|
|
/// in <c>Bind</c>, 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".
|
|
/// </summary>
|
|
public int MaxBindParameters => 65535;
|
|
|
|
/// <inheritdoc/>
|
|
public string LivenessSql => "SELECT 1";
|
|
|
|
/// <summary>
|
|
/// Empty — Postgres spells its row limit at the end of the statement, in
|
|
/// <see cref="SingleRowLimitSuffix"/>. The mirror image of T-SQL's <c>TOP 1</c>, which is exactly why
|
|
/// the seam carries both ends.
|
|
/// </summary>
|
|
public string SingleRowLimitPrefix => string.Empty;
|
|
|
|
/// <summary>
|
|
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
|
|
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
|
|
/// </summary>
|
|
public string SingleRowLimitSuffix => " LIMIT 1";
|
|
|
|
/// <summary>
|
|
/// The schemas holding relations this connection can actually see, system schemas excluded.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Sourced from <c>information_schema.tables</c>, not <c>information_schema.schemata</c></b>
|
|
/// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
|
|
/// Postgres' <c>schemata</c> view lists only schemas the current role <em>owns</em>, so a service
|
|
/// account granted <c>USAGE</c> + <c>SELECT</c> 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?"</para>
|
|
/// <para><b>What counts as a system schema.</b> <c>pg_catalog</c> and <c>information_schema</c> are
|
|
/// named explicitly (design §2). <c>pg_toast</c> and the per-backend <c>pg_temp_N</c> /
|
|
/// <c>pg_toast_temp_N</c> 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 <c>pg_temp_N</c> in
|
|
/// particular names a different thing per session — offering it in the browse tree would present a
|
|
/// schema that vanishes. The <c>ESCAPE</c> clause makes the <c>_</c> literal rather than the
|
|
/// single-character wildcard <c>LIKE</c> would otherwise treat it as.</para>
|
|
/// <para>Aliased <c>"TABLE_SCHEMA"</c> in double quotes — see <see cref="ListTablesSql"/>.</para>
|
|
/// </remarks>
|
|
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\"";
|
|
|
|
/// <summary>
|
|
/// <c>current_schema()</c> is the first schema on the connection's <c>search_path</c> — the one an
|
|
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
|
|
/// </summary>
|
|
public string DefaultSchemaSql => "SELECT current_schema()";
|
|
|
|
/// <summary>
|
|
/// Tables + views in one schema, matching <c>SqlServerDialect.ListTablesSql</c> column for column
|
|
/// (<c>TABLE_NAME</c>, <c>TABLE_TYPE</c>, ordered by name) so both consumers read the same shape.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>The aliases are quoted, and that is load-bearing.</b> <c>SqlCatalogLoader</c> and
|
|
/// <c>SqlBrowseSession</c> both read every catalog column by name
|
|
/// (<c>DbDataReader.GetOrdinal("TABLE_NAME")</c>), while Postgres folds an <em>unquoted</em> 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 <c>GetOrdinal</c>
|
|
/// fallback.</para>
|
|
/// <para>The <c>@schema</c> marker is written literally here because
|
|
/// <see cref="ParameterMarker"/> spells it identically; the two agree by construction on this dialect
|
|
/// (they diverge only on ODP.NET), and a test pins that they do.</para>
|
|
/// </remarks>
|
|
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\"";
|
|
|
|
/// <summary>
|
|
/// Columns of one table in ordinal order — the same three columns
|
|
/// <c>SqlServerDialect.ListColumnsSql</c> projects (<c>COLUMN_NAME</c>, <c>DATA_TYPE</c>,
|
|
/// <c>IS_NULLABLE</c>), quoted for the reason <see cref="ListTablesSql"/> gives.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>, mirroring
|
|
/// <c>SqlServerDialect.QuoteIdentifier</c> so a test written against one dialect fails the same way
|
|
/// against the other): null / empty / all-whitespace; longer than
|
|
/// <see cref="MaxIdentifierLength"/> <b>bytes</b>; any control character (which covers embedded NUL and the C0/C1
|
|
/// ranges that can truncate or corrupt a logged/audited statement); and any Unicode
|
|
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
|
|
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> 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).</para>
|
|
/// <para><b>The length ceiling is measured in UTF-8 bytes, not characters</b> — <c>NAMEDATALEN</c>
|
|
/// bounds the stored name in the database encoding, and every modern estate is UTF-8. Counting UTF-16
|
|
/// code units (<c>ident.Length</c>) would accept a 32-character Cyrillic name, which is 64 bytes and
|
|
/// is therefore truncated server-side to a name that denotes a <em>different</em> object — precisely
|
|
/// the silent-truncation failure this rule exists to prevent. <c>SqlServerDialect</c> deliberately does
|
|
/// <b>not</b> do this: T-SQL's <c>sysname</c> is <c>nvarchar(128)</c>, a genuine 128-character limit,
|
|
/// so counting characters is correct there and counting bytes would reject legal names.</para>
|
|
/// <para>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.</para>
|
|
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
|
|
/// input and this exception's text reaches the driver log.</para>
|
|
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
|
|
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at Initialize
|
|
/// and substitutes the catalog's own spelling — which on Postgres is the <em>lower-cased</em> one an
|
|
/// unquoted <c>CREATE TABLE</c> produced — so on the poll path the value arriving here is a string
|
|
/// read back out of the catalog.</para>
|
|
/// </remarks>
|
|
/// <param name="ident">The bare, unquoted identifier part.</param>
|
|
/// <returns>The double-quoted identifier.</returns>
|
|
/// <exception cref="ArgumentException">The value cannot be a valid PostgreSQL identifier.</exception>
|
|
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), "\"");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Folds an <c>information_schema.columns.data_type</c> family name onto a
|
|
/// <see cref="DriverDataType"/> per design §3.7.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>The input is the canonical, spelled-out name Postgres reports</b> —
|
|
/// <c>double precision</c>, <c>character varying</c>, <c>timestamp without time zone</c> — never the
|
|
/// <c>float8</c>/<c>varchar</c>/<c>timestamptz</c> aliases and never a length
|
|
/// (<c>character varying(50)</c> is reported as <c>character varying</c>). The internal aliases are
|
|
/// accepted anyway, so a caller reading types from <c>pg_catalog</c> instead maps identically.</para>
|
|
/// <para><b>Precision caveat:</b> <c>numeric</c> / <c>decimal</c> / <c>money</c> collapse to
|
|
/// <see cref="DriverDataType.Float64"/>, as they do on SQL Server. A Postgres <c>numeric</c> is
|
|
/// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit
|
|
/// <c>"type": "String"</c> on the tag preserves the exact textual value when that matters.</para>
|
|
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
|
|
/// throws — a schema browse must render a table containing an exotic column (<c>json</c>,
|
|
/// <c>jsonb</c>, <c>inet</c>, <c>tsvector</c>, an enum or composite reported as
|
|
/// <c>USER-DEFINED</c>, an array reported as <c>ARRAY</c>) rather than crash on it. Null/blank input
|
|
/// takes the same fallback, and so does <c>bytea</c> — matching how SQL Server's <c>varbinary</c>
|
|
/// falls back rather than pretending binary is a scalar.</para>
|
|
/// <para><b>Deliberate non-mappings.</b> Postgres <c>bit</c> / <c>bit varying</c> are <em>bit
|
|
/// strings</em>, not booleans — mapping <c>bit</c> to <see cref="DriverDataType.Boolean"/> the way
|
|
/// T-SQL's <c>bit</c> demands would silently coerce a multi-bit value, so they fall back.
|
|
/// <c>time</c> / <c>time with time zone</c> are times of day rather than points in time and take the
|
|
/// same fallback as T-SQL's <c>time</c>; <c>interval</c> is a duration, likewise.</para>
|
|
/// <para><c>uuid</c> maps to <see cref="DriverDataType.String"/>, matching how SQL Server's
|
|
/// <c>uniqueidentifier</c> is handled.</para>
|
|
/// </remarks>
|
|
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
|
|
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
|
|
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,
|
|
};
|
|
}
|
|
}
|