feat(sql): PostgresDialect (Npgsql)
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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.
|
||||
/// </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.
|
||||
}
|
||||
|
||||
/// <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"/>; 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>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));
|
||||
|
||||
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), "\"");
|
||||
}
|
||||
|
||||
/// <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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Golden tests for <see cref="PostgresDialect"/>, mirroring <see cref="SqlServerDialectTests"/> 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 <c>LIMIT 1</c>, and <c>bit</c> being a bit string
|
||||
/// rather than a boolean — the divergence is pinned here rather than left to reading.
|
||||
/// </summary>
|
||||
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<ArgumentException>(() => 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<ArgumentException>(
|
||||
() => 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<ArgumentException>(
|
||||
() => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
|
||||
=> Should.Throw<ArgumentException>(() => 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<ArgumentException>(() => 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user