< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.Internal.SqlServerRelationVerifier
Assembly: AsyncResponse.Channels.SqlServer
File(s): /_/src/Shared/SqlServerRelationVerifier.cs
Line coverage
91%
Covered lines: 274
Uncovered lines: 26
Coverable lines: 300
Total lines: 718
Line coverage: 91.3%
Branch coverage
78%
Covered branches: 174
Total branches: 222
Branch coverage: 78.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Name()100%11100%
get_Type()100%11100%
get_Nullable()100%11100%
get_RequiresBinaryCollation()100%11100%
get_DefaultExpression()100%11100%
get_Name()100%11100%
get_Kind()100%11100%
get_Columns()100%11100%
get_PrimaryKey()100%11100%
get_OwningTable()100%11100%
get_KeyColumns()100%11100%
VerifyAsync(...)100%11100%
VerifyCoreAsync()94.44%181890.9%
ThrowDiagnosedCollisionAsync()100%1177.77%
LoadObjectKindsAsync()100%22100%
VerifySequencesAsync()100%44100%
EvaluateSequence(...)62.5%88100%
VerifyTableColumnsAsync()100%44100%
EvaluateTableColumns(...)68.42%1413858.53%
VerifyPrimaryKeysAsync()100%66100%
EvaluatePrimaryKeys(...)100%88100%
VerifyIndexesAsync()87.5%8896.66%
EvaluateIndexes(...)100%2828100%
DescribeIndexType(...)37.5%11862.5%
.ctor(...)100%11100%
get_Type()100%11100%
get_IsUnique()100%11100%
get_HasFilter()100%11100%
get_IsDisabled()100%11100%
get_KeyColumns()100%11100%
RenderType(...)97.72%4444100%
Shape(...)100%44100%
IsOrdinalCollation(...)100%11100%
NameParameters(...)83.33%66100%
Describe(...)18.75%1603250%
get_Type()100%11100%
.cctor()100%11100%
Equals(...)50%22100%
GetHashCode(...)100%11100%

File(s)

/_/src/Shared/SqlServerRelationVerifier.cs

#LineLine coverage
 1using Microsoft.Data.SqlClient;
 2using System.Globalization;
 3using System.Text;
 4
 5namespace AsyncResponse.Internal;
 6
 7/// <summary>
 8/// In-transaction catalog verification that every object a SQL Server store just ensured actually
 9/// IS what its DDL intended — the SQL Server counterpart of <c>PostgreSqlRelationVerifier</c>.
 10/// <para>
 11/// The stores guard their DDL with <c>IF OBJECT_ID(N'…', N'U') IS NULL</c>, which answers only
 12/// "is there a user table with this name". That leaves two silent failure modes. A name occupied
 13/// by a DIFFERENT object kind (a view, a synonym, a procedure) makes the guard fall through and
 14/// the CREATE fail with raw error 2714. A name occupied by ANOTHER component's user table makes
 15/// the guard skip creation entirely, and the store then fails at its first query on a column that
 16/// does not exist. Both are caught here with an actionable message instead.
 17/// </para>
 18/// <para>
 19/// Runs under the schema-keyed <c>sp_getapplock</c> the AsyncResponse SQL Server stores share, so
 20/// a sibling component's objects are either committed and visible or serialized behind this
 21/// transaction. Source-linked into the channel, transport, and durable-flow packages (separate
 22/// packages cannot share compiled code).
 23/// </para>
 24/// </summary>
 25internal static class SqlServerRelationVerifier
 26{
 27    /// <summary>
 28    /// One expected column: name, rendered type (<c>nvarchar(400)</c>, <c>nvarchar(max)</c>,
 29    /// <c>datetime2(7)</c>, …), and nullability. The fractional-seconds types state their scale:
 30    /// SQL Server ROUNDS on store, so a reduced-scale column is not a narrower clock but a
 31    /// different one — a stored timestamp can round BELOW an already-observed full-precision
 32    /// watermark and reorder the events the stores compare. <paramref name="RequiresBinaryCollation"/>
 33    /// marks the columns that store an identity the library compares ORDINALLY — correlation ids,
 34    /// flow ids, queue names. Under a case-insensitive column collation (the default in a great
 35    /// many SQL Server deployments) the database treats <c>foo</c> and <c>FOO</c> as the same key,
 36    /// so two distinct ids collide: lookups cross-match and primary keys reject the second id.
 37    /// </summary>
 38    /// <remarks>
 39    /// A <c>null</c> <c>Type</c> means the store does not constrain this column's type: it must
 40    /// exist with the declared nullability, and nothing more is compared or reported. Only a store
 41    /// whose queries stay correct under ANY storage type may declare one.
 42    /// <para>
 43    /// <c>DefaultExpression</c> is the exact <c>sys.default_constraints.definition</c> rendering,
 44    /// given for the columns the store never names on insert and therefore DEPENDS on:
 45    /// <c>(sysutcdatetime())</c>, <c>((0))</c>, <c>(N'{}')</c>. <c>null</c> means the store always
 46    /// supplies the value itself.
 47    /// </para>
 48    /// </remarks>
 49    internal readonly record struct ExpectedColumn(
 1297150        string Name,
 651451        string? Type,
 646852        bool Nullable,
 646253        bool RequiresBinaryCollation = false,
 645954        string? DefaultExpression = null);
 55
 56    /// <summary>
 57    /// One expected object: a user table (verified against <paramref name="Columns"/> when given),
 58    /// a sequence (verified <c>bigint</c>, increment 1, no cycle), or an index (verified to sit on
 59    /// <paramref name="OwningTable"/> as a non-unique, unfiltered, enabled rowstore index over
 60    /// exactly <paramref name="KeyColumns"/> in order).
 61    /// </summary>
 62    /// <remarks>
 63    /// <c>PrimaryKey</c> lists the key columns in order, when the store's correctness depends on
 64    /// them: the idempotent-publish insert and the per-id upsert rely on the primary key REJECTING
 65    /// a duplicate. A same-name table without it accepts every duplicate silently.
 66    /// </remarks>
 67    internal readonly record struct ExpectedObject(
 4348468        string Name,
 1374569        SqlServerObjectKind Kind,
 346770        ExpectedColumn[]? Columns = null,
 266371        string[]? PrimaryKey = null,
 307272        string? OwningTable = null,
 153273        string[]? KeyColumns = null);
 74
 75    /// <summary>
 76    /// Verifies every expected object, reporting missing ones as errors. Pass the DDL transaction
 77    /// so the checks see uncommitted work; pass <c>null</c> when the objects of interest are
 78    /// already committed. <see cref="ThrowDiagnosedCollisionAsync"/> runs the same checks after a
 79    /// FAILED batch, where absence is the failure's consequence rather than reportable evidence.
 80    /// </summary>
 81    public static Task VerifyAsync(
 82        SqlConnection connection,
 83        SqlTransaction? transaction,
 84        string schemaName,
 85        string componentName,
 86        IReadOnlyList<ExpectedObject> expected,
 87        CancellationToken cancellationToken)
 38288        => VerifyCoreAsync(connection, transaction, schemaName, componentName, expected, reportAbsence: true, cancellati
 89
 90    private static async Task VerifyCoreAsync(
 91        SqlConnection connection,
 92        SqlTransaction? transaction,
 93        string schemaName,
 94        string componentName,
 95        IReadOnlyList<ExpectedObject> expected,
 96        bool reportAbsence,
 97        CancellationToken cancellationToken)
 98    {
 38699        var actual = await LoadObjectKindsAsync(connection, transaction, schemaName, expected, cancellationToken).Config
 100
 101        // Diagnose in CAUSE order, not declaration order. When this runs after a failed DDL batch,
 102        // several expected objects are missing precisely BECAUSE one name was already taken — so
 103        // reporting "does not exist" first would name a victim and hide the culprit. Anything that
 104        // is present and wrong is checked first; absence is only reported once nothing present
 105        // explains it.
 6935106        foreach (var expectedObject in expected)
 107        {
 108            // Indexes live in sys.indexes, not sys.objects, and their names are per-table rather
 109            // than schema-wide — an unrelated object sharing an index's name collides with nothing.
 3082110            if (expectedObject.Kind == SqlServerObjectKind.Index)
 111                continue;
 112
 1542113            if (!actual.TryGetValue(expectedObject.Name, out var foundType))
 114                continue;
 115
 1531116            var expectedType = expectedObject.Kind == SqlServerObjectKind.Table ? "U" : "SO";
 1531117            if (!string.Equals(foundType, expectedType, StringComparison.Ordinal))
 118            {
 1119                throw new InvalidOperationException(
 1120                    $"The SQL Server {componentName} store expected '{schemaName}.{expectedObject.Name}' to be {Describe
 1121                    $"but the name is occupied by {Describe(foundType)}. The store's existence guard only looks for its 
 1122                    "so it either skipped creation or failed with error 2714. " + CollisionGuidance);
 123            }
 124        }
 125
 3465126        var present = expected.Where(e => actual.ContainsKey(e.Name)).ToArray();
 385127        await VerifySequencesAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Confi
 385128        await VerifyTableColumnsAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Co
 380129        await VerifyPrimaryKeysAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Con
 380130        await VerifyIndexesAsync(connection, transaction, schemaName, componentName, expected, reportAbsence, cancellati
 131
 132        // In diagnosis mode absence proves nothing: the failed batch rolled back whatever it did
 133        // create, so the expected objects are missing BECAUSE the batch failed — reporting one
 134        // would bury the real error (permissions, a full disk, a killed session) under a phantom
 135        // collision. Reaching this point there, the caller rethrows the original failure.
 380136        if (!reportAbsence)
 1137            return;
 138
 6822139        foreach (var expectedObject in expected)
 140        {
 141            // Index existence was already settled against sys.indexes in VerifyIndexesAsync.
 3032142            if (expectedObject.Kind == SqlServerObjectKind.Index)
 143                continue;
 144
 1516145            if (!actual.ContainsKey(expectedObject.Name))
 0146                throw new InvalidOperationException(
 0147                    $"The SQL Server {componentName} store expected '{schemaName}.{expectedObject.Name}' to exist after 
 148        }
 380149    }
 150
 151    /// <summary>
 152    /// Turns a FAILED DDL batch into the actionable diagnosis, or lets the original error stand.
 153    /// The batch can die before <see cref="VerifyAsync"/> ever runs — a name held by another
 154    /// component's table suppresses the guarded CREATE and the statements after it hit the wrong
 155    /// table; a name held by a view fails outright with error 2714. Re-running the checks on a
 156    /// FRESH connection (the colliding objects are somebody else's and already committed) recovers
 157    /// the precise reason. When they find nothing, the caller rethrows: a failure from permissions,
 158    /// a full disk, or a dropped connection is not a collision and must not be reported as one.
 159    /// </summary>
 160    public static async Task ThrowDiagnosedCollisionAsync(
 161        Func<CancellationToken, Task<SqlConnection>> openConnectionAsync,
 162        SqlException failure,
 163        string schemaName,
 164        string componentName,
 165        IReadOnlyList<ExpectedObject> expected,
 166        CancellationToken cancellationToken)
 167    {
 168        SqlConnection diagnosis;
 169        try
 170        {
 4171            diagnosis = await openConnectionAsync(cancellationToken).ConfigureAwait(false);
 4172        }
 0173        catch (SqlException)
 174        {
 0175            return; // The server is the problem, not the schema; the original error says so.
 176        }
 177
 4178        await using (diagnosis)
 179        {
 180            try
 181            {
 182                // reportAbsence: false — the failed batch's own objects were rolled back, so on
 183                // this fresh connection every expected object may legitimately be missing; only
 184                // something PRESENT and wrong is evidence of a collision.
 4185                await VerifyCoreAsync(diagnosis, transaction: null, schemaName, componentName, expected, reportAbsence: 
 1186            }
 187            catch (InvalidOperationException diagnosed)
 188            {
 3189                throw new InvalidOperationException(diagnosed.Message, failure);
 190            }
 191        }
 1192    }
 193
 194    private static async Task<Dictionary<string, string>> LoadObjectKindsAsync(
 195        SqlConnection connection,
 196        SqlTransaction? transaction,
 197        string schemaName,
 198        IReadOnlyList<ExpectedObject> expected,
 199        CancellationToken cancellationToken)
 200    {
 386201        await using var command = connection.CreateCommand();
 386202        command.Transaction = transaction;
 386203        command.CommandText =
 386204            $"""
 386205            SELECT o.name, RTRIM(o.type)
 386206            FROM sys.objects o
 386207            JOIN sys.schemas s ON s.schema_id = o.schema_id
 3088208            WHERE s.name = @schema AND o.name IN ({NameParameters(command, expected.Select(e => e.Name))});
 386209            """;
 386210        command.Parameters.AddWithValue("@schema", schemaName);
 211
 212        // OrdinalIgnoreCase, matching how the server itself matched: under a case-insensitive
 213        // catalog collation (the common default) the IN list above returns a foreign object whose
 214        // name differs from the configured spelling only in case — keyed ordinally, every
 215        // downstream lookup by the CONFIGURED spelling missed it, so the kind/column/PK checks
 216        // silently skipped the collision and the final absence loop misreported the object as
 217        // "does not exist".
 386218        var actual = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 386219        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
 1917220        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 1531221            actual[reader.GetString(0)] = reader.GetString(1);
 386222        return actual;
 386223    }
 224
 225    /// <summary>
 226    /// The ack sequence is a cross-process monotonic clock: a descending increment or a wrapping
 227    /// CYCLE would hand out values whose ORDER contradicts the ordering the watermark relies on.
 228    /// </summary>
 229    private static async Task VerifySequencesAsync(
 230        SqlConnection connection,
 231        SqlTransaction? transaction,
 232        string schemaName,
 233        string componentName,
 234        IReadOnlyList<ExpectedObject> expected,
 235        CancellationToken cancellationToken)
 236    {
 1915237        var sequences = expected.Where(e => e.Kind == SqlServerObjectKind.Sequence).ToArray();
 385238        if (sequences.Length == 0)
 3239            return;
 240
 382241        await using var command = connection.CreateCommand();
 382242        command.Transaction = transaction;
 382243        command.CommandText =
 382244            $"""
 382245            SELECT sq.name, t.name, CAST(sq.increment AS bigint), sq.is_cycling, CAST(sq.maximum_value AS bigint)
 382246            FROM sys.sequences sq
 382247            JOIN sys.schemas s ON s.schema_id = sq.schema_id
 382248            JOIN sys.types t ON t.user_type_id = sq.user_type_id
 382249            WHERE s.name = @schema AND sq.name IN ({NameParameters(command, sequences.Select(e => e.Name))});
 382250            """;
 382251        command.Parameters.AddWithValue("@schema", schemaName);
 252
 382253        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
 764254        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 255        {
 382256            var name = reader.GetString(0);
 382257            var type = reader.GetString(1);
 382258            var increment = reader.GetInt64(2);
 382259            var cycles = reader.GetBoolean(3);
 382260            var maximum = reader.GetInt64(4);
 382261            EvaluateSequence(schemaName, componentName, name, type, increment, cycles, maximum);
 262        }
 385263    }
 264
 265    /// <summary>
 266    /// The pure sequence decision over one loaded catalog row, split out so the unit suite can run
 267    /// it without a server. MAXVALUE is part of it (PostgreSQL-verifier parity): a restricted
 268    /// maximum passes the type/increment/cycle checks and then exhausts mid-production with
 269    /// error 11728, on the very path this verification exists to protect.
 270    /// </summary>
 271    internal static void EvaluateSequence(
 272        string schemaName,
 273        string componentName,
 274        string name,
 275        string type,
 276        long increment,
 277        bool cycles,
 278        long maximum)
 279    {
 386280        if (!string.Equals(type, "bigint", StringComparison.Ordinal) || increment != 1 || cycles || maximum != long.MaxV
 281        {
 2282            throw new InvalidOperationException(
 2283                $"The SQL Server {componentName} store's sequence '{schemaName}.{name}' exists but is not a monotonic co
 2284                $"expected bigint INCREMENT BY 1 NO CYCLE MAXVALUE {long.MaxValue}; found {type} INCREMENT BY {increment
 2285                $"{(cycles ? " CYCLE" : " NO CYCLE")} MAXVALUE {maximum.ToString(CultureInfo.InvariantCulture)}. Acknowl
 2286                "from this sequence, so a descending or wrapping sequence silently reorders delivery, and a restricted m
 2287                $"ALTER SEQUENCE {schemaName}.{name} INCREMENT BY 1 NO CYCLE NO MAXVALUE; (recreate it if the type is wr
 288        }
 384289    }
 290
 291    /// <summary>
 292    /// Column-level verification: a same-kind table occupying the name passes the object-kind
 293    /// check and fails only at the first query. Every DDL-declared column must exist with the
 294    /// declared type and nullability; identity columns must carry a case-sensitive collation; and
 295    /// extra columns are allowed only when an insert that does not name them can still succeed.
 296    /// </summary>
 297    private static async Task VerifyTableColumnsAsync(
 298        SqlConnection connection,
 299        SqlTransaction? transaction,
 300        string schemaName,
 301        string componentName,
 302        IReadOnlyList<ExpectedObject> expected,
 303        CancellationToken cancellationToken)
 304    {
 1915305        var tables = expected.Where(e => e.Kind == SqlServerObjectKind.Table && e.Columns is not null).ToArray();
 385306        if (tables.Length == 0)
 1307            return;
 308
 384309        await using var command = connection.CreateCommand();
 384310        command.Transaction = transaction;
 384311        command.CommandText =
 384312            $"""
 384313            SELECT o.name, c.name, t.name, c.max_length, c.scale, c.is_nullable, ISNULL(c.collation_name, N''),
 384314                   CASE WHEN c.default_object_id <> 0 OR c.is_identity = 1 OR c.is_computed = 1 THEN 1 ELSE 0 END,
 384315                   ISNULL(dc.definition, N'')
 384316            FROM sys.columns c
 384317            JOIN sys.objects o ON o.object_id = c.object_id
 384318            JOIN sys.schemas s ON s.schema_id = o.schema_id
 384319            JOIN sys.types t ON t.user_type_id = c.user_type_id
 384320            LEFT JOIN sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column
 1148321            WHERE s.name = @schema AND o.name IN ({NameParameters(command, tables.Select(e => e.Name))});
 384322            """;
 384323        command.Parameters.AddWithValue("@schema", schemaName);
 324
 325        // OrdinalIgnoreCase throughout, matching how the server itself matched (see the note on
 326        // LoadObjectKindsAsync): under a case-insensitive catalog collation the IN list above
 327        // returns rows whose table/column names may differ from the configured spelling only in
 328        // case — keyed ordinally, every lookup by the configured spelling missed them, so a
 329        // correctly-shaped case-variant table was rejected as "missing the column" while the
 330        // real shape checks were silently skipped.
 384331        var actual = new Dictionary<(string Table, string Column), ActualColumn>(TableColumnComparer.Instance);
 384332        await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
 333        {
 6898334            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 335            {
 6514336                actual[(reader.GetString(0), reader.GetString(1))] = new ActualColumn(
 6514337                    Type: RenderType(reader.GetString(2), reader.GetInt16(3), reader.GetByte(4)),
 6514338                    Nullable: reader.GetBoolean(5),
 6514339                    Collation: reader.GetString(6),
 6514340                    Writable: reader.GetInt32(7) == 1,
 6514341                    Default: reader.GetString(8));
 342            }
 343        }
 344
 384345        EvaluateTableColumns(schemaName, componentName, tables, actual);
 380346    }
 347
 348    /// <summary>
 349    /// The pure column decision, over catalog rows already loaded. Split out so the accept/reject
 350    /// table is exercised without a server, the way the PostgreSQL sibling's <c>Evaluate</c> is.
 351    /// </summary>
 352    internal static void EvaluateTableColumns(
 353        string schemaName,
 354        string componentName,
 355        IReadOnlyList<ExpectedObject> tables,
 356        Dictionary<(string Table, string Column), ActualColumn> actual)
 357    {
 3099358        foreach (var table in tables)
 359        {
 15243360            foreach (var column in table.Columns!)
 361            {
 6470362                if (!actual.TryGetValue((table.Name, column.Name), out var found))
 4363                    throw new InvalidOperationException(
 4364                        $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' exists but is missing
 4365                        $"'{column.Name}'{(column.Type is null ? "" : $" ({column.Type})")}; a same-name table from anot
 4366                        "or a partial manual creation occupies the name. " + CollisionGuidance);
 367
 368                // An unconstrained (null) type compares and reports nullability alone.
 6466369                if ((column.Type is { } expectedType && !string.Equals(found.Type, expectedType, StringComparison.Ordina
 6466370                    || found.Nullable != column.Nullable)
 371                {
 4372                    throw new InvalidOperationException(
 4373                        $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' exists but column '{c
 4374                        $"does not match the expected shape: expected {Shape(column.Type, column.Nullable)}; " +
 4375                        $"found {Shape(column.Type is null ? null : found.Type, found.Nullable)}. " + CollisionGuidance)
 376                }
 377
 6462378                if (column.RequiresBinaryCollation && !IsOrdinalCollation(found.Collation))
 3379                    throw new InvalidOperationException(
 3380                        $"The SQL Server {componentName} store's column '{schemaName}.{table.Name}.{column.Name}' uses t
 3381                        $"collation '{found.Collation}', which is not binary. That column stores an identity the library
 3382                        "ORDINALLY, and any non-binary collation folds something the library treats as distinct — case u
 3383                        "collation, accents under _AI, full-width forms under any collation without _WS. Distinct ids wo
 3384                        "collide on one key: lookups cross-match and the second id is rejected on insert. Recreate the t
 3385                        "deployments get COLLATE Latin1_General_100_BIN2 automatically), or ALTER the column to a _BIN2 
 3386                        "after dropping the keys and indexes that reference it.");
 387
 388                // A default the store RELIES on (it never names the column on insert) must exist
 389                // and must compute what the store expects: a missing one fails every insert with
 390                // error 515, and a different one silently changes behavior — a shifted created_at
 391                // moves every watermark comparison, a future available_at strands the job forever.
 6459392                if (column.DefaultExpression is { } expectedDefault
 6459393                    && !string.Equals(found.Default, expectedDefault, StringComparison.OrdinalIgnoreCase))
 394                {
 0395                    throw new InvalidOperationException(
 0396                        $"The SQL Server {componentName} store's column '{schemaName}.{table.Name}.{column.Name}' " +
 0397                        $"{(found.Default.Length == 0 ? "has no default" : $"defaults to {found.Default}")}, but the sto
 0398                        $"it on insert and depends on the default {expectedDefault}. " +
 0399                        (found.Default.Length == 0
 0400                            ? "Every insert would fail with error 515. "
 0401                            : "The rows would carry values the store's own time and visibility logic does not expect. ")
 0402                        CollisionGuidance);
 403                }
 404            }
 405
 406            // Extra columns are fine only when inserts that do not name them can still succeed.
 7602407            var expectedNames = table.Columns!.Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
 41000408            foreach (var ((tableName, columnName), found) in actual)
 409            {
 19354410                if (!string.Equals(tableName, table.Name, StringComparison.OrdinalIgnoreCase) || expectedNames.Contains(
 411                    continue;
 412
 0413                if (!found.Nullable && !found.Writable)
 0414                    throw new InvalidOperationException(
 0415                        $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' has an extra column '
 0416                        "that is NOT NULL without a default: every insert the store issues would fail with error 515, be
 0417                        "store cannot know to supply a value for it. " + CollisionGuidance);
 418            }
 419        }
 387420    }
 421
 422    /// <summary>
 423    /// The primary key is load-bearing, not decoration: the transport's insert-if-absent publish
 424    /// and the flow store's per-id upsert both rely on it to reject a duplicate. A same-name table
 425    /// carrying the right columns but no key (or a key over different columns) passes every other
 426    /// check here and then silently accepts duplicate rows.
 427    /// </summary>
 428    private static async Task VerifyPrimaryKeysAsync(
 429        SqlConnection connection,
 430        SqlTransaction? transaction,
 431        string schemaName,
 432        string componentName,
 433        IReadOnlyList<ExpectedObject> expected,
 434        CancellationToken cancellationToken)
 435    {
 1896436        var keyed = expected.Where(e => e.PrimaryKey is not null).ToArray();
 380437        if (keyed.Length == 0)
 1438            return;
 439
 379440        await using var command = connection.CreateCommand();
 379441        command.Transaction = transaction;
 379442        command.CommandText =
 379443            $"""
 379444            SELECT o.name, col.name, ic.key_ordinal
 379445            FROM sys.indexes i
 379446            JOIN sys.objects o ON o.object_id = i.object_id
 379447            JOIN sys.schemas s ON s.schema_id = o.schema_id
 379448            JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0
 379449            JOIN sys.columns col ON col.object_id = i.object_id AND col.column_id = ic.column_id
 379450            WHERE i.is_primary_key = 1 AND s.name = @schema
 1137451              AND o.name IN ({NameParameters(command, keyed.Select(e => e.Name))});
 379452            """;
 379453        command.Parameters.AddWithValue("@schema", schemaName);
 454
 455        // OrdinalIgnoreCase for the same reason as the column dictionary above: the IN list
 456        // matched case-insensitively, so an ordinal key would misreport a case-variant table as
 457        // "has no primary key" regardless of its real key.
 379458        var actual = new Dictionary<string, List<(byte Ordinal, string Column)>>(StringComparer.OrdinalIgnoreCase);
 379459        await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
 460        {
 2274461            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 462            {
 1895463                if (!actual.TryGetValue(reader.GetString(0), out var columns))
 1137464                    actual[reader.GetString(0)] = columns = [];
 1895465                columns.Add((reader.GetByte(2), reader.GetString(1)));
 466            }
 467        }
 468
 379469        EvaluatePrimaryKeys(schemaName, componentName, keyed, actual);
 380470    }
 471
 472    /// <summary>
 473    /// The pure primary-key decision over loaded catalog rows, split out (like
 474    /// <see cref="EvaluateIndexes"/>) so the unit suite can run it without a server. Only KEY
 475    /// columns take part — belt and braces with the query's <c>key_ordinal &gt; 0</c> filter:
 476    /// SQL Server lists a nonclustered primary key's clustering key at ordinal 0, and it is not
 477    /// part of the key, so counting it rejected the standard random-GUID layout (nonclustered PK
 478    /// over a clustered timestamp index) as "a primary key over (created_at, id)".
 479    /// </summary>
 480    internal static void EvaluatePrimaryKeys(
 481        string schemaName,
 482        string componentName,
 483        IReadOnlyList<ExpectedObject> keyed,
 484        Dictionary<string, List<(byte Ordinal, string Column)>> actual)
 485    {
 3052486        foreach (var table in keyed)
 487        {
 1143488            var found = actual.TryGetValue(table.Name, out var columns)
 5705489                ? columns.Where(c => c.Ordinal > 0).OrderBy(c => c.Ordinal).Select(c => c.Column).ToArray()
 1143490                : [];
 491
 1143492            if (!found.AsSpan().SequenceEqual(table.PrimaryKey!, StringComparer.OrdinalIgnoreCase))
 493            {
 4494                throw new InvalidOperationException(
 4495                    $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' " +
 4496                    $"{(found.Length == 0 ? "has no primary key" : $"has a primary key over ({string.Join(", ", found)})
 4497                    $"but the store's idempotent writes rely on a primary key over ({string.Join(", ", table.PrimaryKey!
 4498                    "duplicates. Without it a retried publish or a concurrent create is accepted twice instead of dedupl
 4499                    CollisionGuidance);
 500            }
 501        }
 381502    }
 503
 504    /// <summary>
 505    /// Index verification: the stores' index guard is name-only
 506    /// (<c>sys.indexes WHERE name = N'…' AND object_id = OBJECT_ID(…)</c>), so it accepts a
 507    /// same-name index with the WRONG definition — an operator-provisioned index keyed on other
 508    /// columns silently costs the store its seek, and every poll tick table-scans. The PostgreSQL
 509    /// sibling's 'i' checks, in the form SQL Server expresses them: rowstore (clustered or
 510    /// nonclustered both serve the seek), non-unique, unfiltered, enabled, and exactly the key
 511    /// columns in order. Included columns are extra capacity, not a shape change — the
 512    /// <c>key_ordinal &gt; 0</c> filter ignores them.
 513    /// </summary>
 514    private static async Task VerifyIndexesAsync(
 515        SqlConnection connection,
 516        SqlTransaction? transaction,
 517        string schemaName,
 518        string componentName,
 519        IReadOnlyList<ExpectedObject> expected,
 520        bool reportAbsence,
 521        CancellationToken cancellationToken)
 522    {
 3420523        var indexes = expected.Where(e => e.Kind == SqlServerObjectKind.Index).ToArray();
 380524        if (indexes.Length == 0)
 0525            return;
 526
 380527        await using var command = connection.CreateCommand();
 380528        command.Transaction = transaction;
 380529        command.CommandText =
 380530            $"""
 380531            SELECT i.name, o.name, i.type, i.is_unique, i.has_filter, i.is_disabled, ic.key_ordinal, col.name
 380532            FROM sys.indexes i
 380533            JOIN sys.objects o ON o.object_id = i.object_id
 380534            JOIN sys.schemas s ON s.schema_id = o.schema_id
 380535            LEFT JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal
 380536            LEFT JOIN sys.columns col ON col.object_id = i.object_id AND col.column_id = ic.column_id
 1520537            WHERE s.name = @schema AND i.name IN ({NameParameters(command, indexes.Select(e => e.Name))});
 380538            """;
 380539        command.Parameters.AddWithValue("@schema", schemaName);
 540
 541        // Keyed by (index, owning table): SQL Server index names are per-table, so a same-name
 542        // index on an UNRELATED table is not the expected index and not a collision either.
 543        // OrdinalIgnoreCase tuple comparer for the usual catalog-collation reason.
 380544        var actual = new Dictionary<(string Table, string Column), ActualIndex>(TableColumnComparer.Instance);
 380545        await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
 546        {
 2275547            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 548            {
 1895549                var key = (reader.GetString(0), reader.GetString(1));
 1895550                if (!actual.TryGetValue(key, out var index))
 551                {
 1516552                    actual[key] = index = new ActualIndex(
 1516553                        Type: reader.GetByte(2),
 1516554                        IsUnique: reader.GetBoolean(3),
 1516555                        HasFilter: reader.GetBoolean(4),
 1516556                        IsDisabled: reader.GetBoolean(5),
 1516557                        KeyColumns: []);
 558                }
 559
 1895560                if (!await reader.IsDBNullAsync(6, cancellationToken).ConfigureAwait(false))
 1895561                    index.KeyColumns.Add((reader.GetByte(6), reader.GetString(7)));
 1895562            }
 563        }
 564
 380565        EvaluateIndexes(schemaName, componentName, indexes, actual, reportAbsence);
 380566    }
 567
 568    /// <summary>
 569    /// The pure index decision, over catalog rows already loaded (server-free, like
 570    /// <see cref="EvaluateTableColumns"/>). Shape first, absence last: a present-but-wrong index
 571    /// is the culprit an absent sibling would otherwise mask.
 572    /// </summary>
 573    internal static void EvaluateIndexes(
 574        string schemaName,
 575        string componentName,
 576        IReadOnlyList<ExpectedObject> indexes,
 577        Dictionary<(string Table, string Column), ActualIndex> actual,
 578        bool reportAbsence)
 579    {
 3862580        foreach (var index in indexes)
 581        {
 1538582            if (!actual.TryGetValue((index.Name, index.OwningTable!), out var found))
 583                continue;
 584
 5372585            var foundColumns = found.KeyColumns.OrderBy(c => c.Ordinal).Select(c => c.Column).ToArray();
 1530586            if (found.Type is not (1 or 2) || found.IsUnique || found.HasFilter || found.IsDisabled
 1530587                || !foundColumns.AsSpan().SequenceEqual(index.KeyColumns!, StringComparer.OrdinalIgnoreCase))
 588            {
 10589                throw new InvalidOperationException(
 10590                    $"The SQL Server {componentName} store's index '{schemaName}.{index.OwningTable}.{index.Name}' exist
 10591                    $"match the expected definition: expected a non-unique, unfiltered rowstore index over " +
 10592                    $"({string.Join(", ", index.KeyColumns!)}); found {(found.IsDisabled ? "a disabled" : found.IsUnique
 10593                    $"{(found.HasFilter ? " filtered" : "")} {DescribeIndexType(found.Type)} index over ({string.Join(",
 10594                    "The store's existence guard only asks whether an index with this name exists on the table and guara
 10595                    "about its shape — drop or rename the existing index so the store can create the correct one.");
 596            }
 597        }
 598
 388599        if (!reportAbsence)
 3600            return;
 601
 3812602        foreach (var index in indexes)
 603        {
 1522604            if (!actual.ContainsKey((index.Name, index.OwningTable!)))
 2605                throw new InvalidOperationException(
 2606                    $"The SQL Server {componentName} store expected index '{schemaName}.{index.OwningTable}.{index.Name}
 2607                    "after schema creation, but it does not.");
 608        }
 383609    }
 610
 10611    private static string DescribeIndexType(byte type) => type switch
 10612    {
 0613        1 => "clustered",
 8614        2 => "nonclustered",
 2615        5 or 6 => "columnstore",
 0616        7 => "hash",
 0617        _ => $"type-{type}"
 10618    };
 619
 1532620    internal sealed record ActualIndex(
 1540621        byte Type,
 1536622        bool IsUnique,
 1536623        bool HasFilter,
 1534624        bool IsDisabled,
 4957625        List<(byte Ordinal, string Column)> KeyColumns);
 626
 627    /// <summary>
 628    /// Renders a <c>sys.types</c> row the way the DDL declares it. <c>max_length</c> is in bytes,
 629    /// so the Unicode types halve it, and -1 is the <c>(max)</c> sentinel. The fractional-seconds
 630    /// types render their SCALE instead: a bare <c>datetime2</c> is <c>datetime2(7)</c>, and
 631    /// dropping the digits made a reduced-scale column indistinguishable from a full-precision one
 632    /// — SQL Server ROUNDS on store, so <c>datetime2(0)</c> is not a coarser view of the same
 633    /// instant but a different one, which reorders the timestamps the stores compare.
 634    /// </summary>
 6538635    internal static string RenderType(string typeName, short maxLength, byte scale) => typeName switch
 6538636    {
 2306637        "nvarchar" or "nchar" => maxLength < 0 ? $"{typeName}(max)" : $"{typeName}({maxLength / 2})",
 4638        "varchar" or "char" or "varbinary" or "binary" => maxLength < 0 ? $"{typeName}(max)" : $"{typeName}({maxLength})
 2308639        "datetime2" or "datetimeoffset" or "time" => $"{typeName}({scale})",
 1920640        _ => typeName
 6538641    };
 642
 643    private static string Shape(string? type, bool nullable)
 8644        => $"{(type is null ? "" : type + " ")}{(nullable ? "NULL" : "NOT NULL")}";
 645
 646    // ONLY a binary collation compares by code point, which is what an ordinal contract requires.
 647    // A merely case-SENSITIVE collation is not enough: _CS_AI still folds accents (probed on SQL
 648    // Server 2022: 'cafe' = 'café'), and even _CS_AS is width-insensitive unless it also carries
 649    // _WS ('ab' = the full-width 'ab'). Two ids the engine considers different would still
 650    // collide on the key.
 651    private static bool IsOrdinalCollation(string collation)
 1141652        => collation.Contains("_BIN", StringComparison.OrdinalIgnoreCase);
 653
 654    private static string NameParameters(SqlCommand command, IEnumerable<string> names)
 655    {
 656        // sys.objects.name is sysname (an identifier), so the names are bound as parameters rather
 657        // than interpolated — the store's configured table names reach this method verbatim.
 1911658        var builder = new StringBuilder();
 1911659        var index = 0;
 18372660        foreach (var name in names)
 661        {
 7275662            if (index > 0)
 5364663                builder.Append(", ");
 664
 7275665            var parameter = $"@name{index.ToString(CultureInfo.InvariantCulture)}";
 7275666            builder.Append(parameter);
 7275667            command.Parameters.AddWithValue(parameter, name);
 7275668            index++;
 669        }
 670
 1911671        return index == 0 ? "NULL" : builder.ToString();
 672    }
 673
 2674    private static string Describe(string type) => type switch
 2675    {
 1676        "U" => "a user table",
 0677        "SO" => "a sequence",
 1678        "V" => "a view",
 0679        "SN" => "a synonym",
 0680        "P" => "a stored procedure",
 0681        "IF" or "TF" or "FN" => "a function",
 0682        _ => $"an object of type '{type}'"
 2683    };
 684
 685    private const string CollisionGuidance =
 686        "Give this component its own object names (or its own schema) so two AsyncResponse components cannot share one n
 687
 15212688    internal readonly record struct ActualColumn(string Type, bool Nullable, string Collation, bool Writable, string Def
 689
 690    /// <summary>Case-insensitive (table, column) tuple comparer — catalog name matching is
 691    /// case-insensitive under the common catalog collations, so the keys must be too.</summary>
 692    internal sealed class TableColumnComparer : IEqualityComparer<(string Table, string Column)>
 693    {
 12694        public static readonly TableColumnComparer Instance = new();
 695
 696        public bool Equals((string Table, string Column) x, (string Table, string Column) y)
 9895697            => string.Equals(x.Table, y.Table, StringComparison.OrdinalIgnoreCase)
 9895698                && string.Equals(x.Column, y.Column, StringComparison.OrdinalIgnoreCase);
 699
 700        public int GetHashCode((string Table, string Column) value)
 19100701            => HashCode.Combine(
 19100702                StringComparer.OrdinalIgnoreCase.GetHashCode(value.Table),
 19100703                StringComparer.OrdinalIgnoreCase.GetHashCode(value.Column));
 704    }
 705}
 706
 707/// <summary>The SQL Server object kinds an AsyncResponse store creates.</summary>
 708internal enum SqlServerObjectKind
 709{
 710    /// <summary>A user table (<c>sys.objects.type = 'U'</c>).</summary>
 711    Table = 0,
 712
 713    /// <summary>A sequence object (<c>sys.objects.type = 'SO'</c>).</summary>
 714    Sequence = 1,
 715
 716    /// <summary>An index, verified against <c>sys.indexes</c> (indexes are not in <c>sys.objects</c>).</summary>
 717    Index = 2
 718}

Methods/Properties

get_Name()
get_Type()
get_Nullable()
get_RequiresBinaryCollation()
get_DefaultExpression()
get_Name()
get_Kind()
get_Columns()
get_PrimaryKey()
get_OwningTable()
get_KeyColumns()
VerifyAsync(Microsoft.Data.SqlClient.SqlConnection,Microsoft.Data.SqlClient.SqlTransaction,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<AsyncResponse.Internal.SqlServerRelationVerifier/ExpectedObject>,System.Threading.CancellationToken)
VerifyCoreAsync()
ThrowDiagnosedCollisionAsync()
LoadObjectKindsAsync()
VerifySequencesAsync()
EvaluateSequence(System.String,System.String,System.String,System.String,System.Int64,System.Boolean,System.Int64)
VerifyTableColumnsAsync()
EvaluateTableColumns(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<AsyncResponse.Internal.SqlServerRelationVerifier/ExpectedObject>,System.Collections.Generic.Dictionary`2<System.ValueTuple`2<System.String,System.String>,AsyncResponse.Internal.SqlServerRelationVerifier/ActualColumn>)
VerifyPrimaryKeysAsync()
EvaluatePrimaryKeys(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<AsyncResponse.Internal.SqlServerRelationVerifier/ExpectedObject>,System.Collections.Generic.Dictionary`2<System.String,System.Collections.Generic.List`1<System.ValueTuple`2<System.Byte,System.String>>>)
VerifyIndexesAsync()
EvaluateIndexes(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<AsyncResponse.Internal.SqlServerRelationVerifier/ExpectedObject>,System.Collections.Generic.Dictionary`2<System.ValueTuple`2<System.String,System.String>,AsyncResponse.Internal.SqlServerRelationVerifier/ActualIndex>,System.Boolean)
DescribeIndexType(System.Byte)
.ctor(System.Byte,System.Boolean,System.Boolean,System.Boolean,System.Collections.Generic.List`1<System.ValueTuple`2<System.Byte,System.String>>)
get_Type()
get_IsUnique()
get_HasFilter()
get_IsDisabled()
get_KeyColumns()
RenderType(System.String,System.Int16,System.Byte)
Shape(System.String,System.Boolean)
IsOrdinalCollation(System.String)
NameParameters(Microsoft.Data.SqlClient.SqlCommand,System.Collections.Generic.IEnumerable`1<System.String>)
Describe(System.String)
get_Type()
.cctor()
Equals(System.ValueTuple`2<System.String,System.String>,System.ValueTuple`2<System.String,System.String>)
GetHashCode(System.ValueTuple`2<System.String,System.String>)