fix(sql): bind string keys untyped on Postgres so integer key columns compare (#602 STAB-24)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:03:00 -04:00
parent 5a5c80ee57
commit 71881163df
8 changed files with 161 additions and 0 deletions
@@ -107,6 +107,21 @@ public interface ISqlDialect
/// <param name="command">The command to configure.</param>
void ConfigureCommand(DbCommand command);
/// <summary>
/// Gives the dialect a chance to adjust a poll-path parameter after its name and value are set and
/// before it is added to the command (STAB-24). Authored key / row-selector values are CLR strings,
/// because a tag's <c>keyValue</c> is configuration text — which a strictly-typed provider will not
/// compare against a numeric key column (Npgsql sends <c>text</c>, and
/// <c>integer = text</c> has no operator). A dialect that needs the server to infer the type from
/// context overrides this. The default does nothing, which is correct for providers that coerce
/// implicitly (SQL Server, ODP.NET).
/// <para>Implementations must tolerate any <see cref="DbParameter"/> they are handed, for the same
/// substituted-factory reason as <see cref="ConfigureCommand"/>.</para>
/// </summary>
/// <param name="parameter">The parameter, with <see cref="DbParameter.ParameterName"/> and value already set.</param>
/// <param name="value">The authored value being bound (never <see cref="DBNull"/>; may be <c>null</c>).</param>
void ConfigureParameter(DbParameter parameter, object? value) { }
/// <summary>
/// The most bound parameters this backend accepts in <b>one</b> command — 2098 usable of SQL Server's
/// 2100, 1000 entries in an Oracle <c>IN</c> expression list, 65535 on the Postgres wire protocol.
@@ -64,6 +64,21 @@ public sealed class PostgresDialect : ISqlDialect
// 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
@@ -405,6 +405,7 @@ public sealed class SqlPollReader
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
parameter.ParameterName = plan.ParameterBindNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
_dialect.ConfigureParameter(parameter, plan.Parameters[i]);
command.Parameters.Add(parameter);
}