886ce1b87c
ISqlDialect gains MaxBindParameters — the per-command ceiling the seam was missing while modelling everything else that differs per backend: 2098 usable of SQL Server's 2100 (sp_executesql spends two), 1000 for Oracle's IN-list limit (ORA-01795, tighter than its 64k bind count), 65535 for the Postgres Bind message. SqlGroupPlanner.AddKeyValuePlans now emits several plans for one group when its distinct keys exceed that ceiling, instead of one statement the provider refuses after the connection opened — which left every tag in an EAV group publishing BadCommunicationError on every poll, permanently, and reported the database endpoint as down. Chunks share the group key; members follow their own key so a shared keyValue never splits. SqlPollReader is unchanged: it runs one command per plan and slices each plan's own Members back, proven by a fixture read at a ceiling of 1.
269 lines
12 KiB
C#
269 lines
12 KiB
C#
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_acceptsExactly63Bytes_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)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>NAMEDATALEN - 1</c> is 63 <b>bytes in the database encoding</b>, not 63 characters — so a
|
||
/// 32-character Cyrillic name is 64 UTF-8 bytes and the server truncates it to something that names a
|
||
/// different object. A ceiling measured in UTF-16 code units lets that name straight through, which
|
||
/// is precisely the silent-truncation failure this rule documents itself as preventing.
|
||
/// </summary>
|
||
[Fact]
|
||
public void QuoteIdentifier_measuresTheCeilingInUtf8Bytes_notCharacters()
|
||
{
|
||
var d = new PostgresDialect();
|
||
|
||
// 31 Cyrillic characters = 62 UTF-8 bytes — inside the limit, and well inside a character count.
|
||
d.QuoteIdentifier(new string('б', 31)).ShouldBe("\"" + new string('б', 31) + "\"");
|
||
|
||
// 32 characters = 64 bytes. Under a character-counting ceiling this passes; the server truncates it.
|
||
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('б', 32)));
|
||
}
|
||
|
||
/// <summary>A non-BMP character is 4 UTF-8 bytes (and 2 UTF-16 code units) — 16 of them fill the limit.</summary>
|
||
[Fact]
|
||
public void QuoteIdentifier_countsASupplementaryCharacterAsItsFourUtf8Bytes()
|
||
{
|
||
var d = new PostgresDialect();
|
||
// U+1D400 MATHEMATICAL BOLD CAPITAL A, as a surrogate pair.
|
||
const string a = "\U0001D400";
|
||
d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 15))).ShouldNotBeNullOrEmpty(); // 60 bytes
|
||
Should.Throw<ArgumentException>(
|
||
() => d.QuoteIdentifier(string.Concat(Enumerable.Repeat(a, 16)))); // 64 bytes
|
||
}
|
||
|
||
[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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 65535 — the <c>Bind</c> message's 16-bit parameter count, a protocol ceiling rather than a server
|
||
/// setting. Pinned so the planner's chunking has a real number here instead of a special case for
|
||
/// "this dialect has no meaningful limit".
|
||
/// </summary>
|
||
[Fact]
|
||
public void MaxBindParameters_isTheWireProtocolsSixteenBitCount()
|
||
=> new PostgresDialect().MaxBindParameters.ShouldBe(65535);
|
||
|
||
[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);
|
||
}
|
||
}
|