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

Information
Class: AsyncResponse.Internal.SqlServerRelationVerifier
Assembly: AsyncResponse.DurableFlows.SqlServer
File(s): /_/src/Shared/SqlServerRelationVerifier.cs
Line coverage
66%
Covered lines: 200
Uncovered lines: 100
Coverable lines: 300
Total lines: 718
Line coverage: 66.6%
Branch coverage
67%
Covered branches: 150
Total branches: 222
Branch coverage: 67.5%
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()77.77%281868.18%
ThrowDiagnosedCollisionAsync()100%210%
LoadObjectKindsAsync()100%22100%
VerifySequencesAsync()25%14414.28%
EvaluateSequence(...)62.5%88100%
VerifyTableColumnsAsync()75%4496.29%
EvaluateTableColumns(...)68.42%2323848.78%
VerifyPrimaryKeysAsync()83.33%6695.65%
EvaluatePrimaryKeys(...)100%88100%
VerifyIndexesAsync()12.5%55810%
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(...)66.66%6690%
Describe(...)0%1056320%
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(
 193250        string Name,
 98151        string? Type,
 95952        bool Nullable,
 95353        bool RequiresBinaryCollation = false,
 95354        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(
 291168        string Name,
 81069        SqlServerObjectKind Kind,
 42970        ExpectedColumn[]? Columns = null,
 28071        string[]? PrimaryKey = null,
 3672        string? OwningTable = null,
 1673        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)
 13588        => 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    {
 13599        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.
 540106        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.
 135110            if (expectedObject.Kind == SqlServerObjectKind.Index)
 111                continue;
 112
 135113            if (!actual.TryGetValue(expectedObject.Name, out var foundType))
 114                continue;
 115
 135116            var expectedType = expectedObject.Kind == SqlServerObjectKind.Table ? "U" : "SO";
 135117            if (!string.Equals(foundType, expectedType, StringComparison.Ordinal))
 118            {
 0119                throw new InvalidOperationException(
 0120                    $"The SQL Server {componentName} store expected '{schemaName}.{expectedObject.Name}' to be {Describe
 0121                    $"but the name is occupied by {Describe(foundType)}. The store's existence guard only looks for its 
 0122                    "so it either skipped creation or failed with error 2714. " + CollisionGuidance);
 123            }
 124        }
 125
 270126        var present = expected.Where(e => actual.ContainsKey(e.Name)).ToArray();
 135127        await VerifySequencesAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Confi
 135128        await VerifyTableColumnsAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Co
 135129        await VerifyPrimaryKeysAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Con
 135130        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.
 135136        if (!reportAbsence)
 0137            return;
 138
 540139        foreach (var expectedObject in expected)
 140        {
 141            // Index existence was already settled against sys.indexes in VerifyIndexesAsync.
 135142            if (expectedObject.Kind == SqlServerObjectKind.Index)
 143                continue;
 144
 135145            if (!actual.ContainsKey(expectedObject.Name))
 0146                throw new InvalidOperationException(
 0147                    $"The SQL Server {componentName} store expected '{schemaName}.{expectedObject.Name}' to exist after 
 148        }
 135149    }
 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        {
 0171            diagnosis = await openConnectionAsync(cancellationToken).ConfigureAwait(false);
 0172        }
 0173        catch (SqlException)
 174        {
 0175            return; // The server is the problem, not the schema; the original error says so.
 176        }
 177
 0178        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.
 0185                await VerifyCoreAsync(diagnosis, transaction: null, schemaName, componentName, expected, reportAbsence: 
 0186            }
 187            catch (InvalidOperationException diagnosed)
 188            {
 0189                throw new InvalidOperationException(diagnosed.Message, failure);
 190            }
 191        }
 0192    }
 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    {
 135201        await using var command = connection.CreateCommand();
 135202        command.Transaction = transaction;
 135203        command.CommandText =
 135204            $"""
 135205            SELECT o.name, RTRIM(o.type)
 135206            FROM sys.objects o
 135207            JOIN sys.schemas s ON s.schema_id = o.schema_id
 135208            WHERE s.name = @schema AND o.name IN ({NameParameters(command, expected.Select(e => e.Name))});
 135209            """;
 135210        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".
 135218        var actual = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 135219        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
 270220        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 135221            actual[reader.GetString(0)] = reader.GetString(1);
 135222        return actual;
 135223    }
 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    {
 270237        var sequences = expected.Where(e => e.Kind == SqlServerObjectKind.Sequence).ToArray();
 135238        if (sequences.Length == 0)
 135239            return;
 240
 0241        await using var command = connection.CreateCommand();
 0242        command.Transaction = transaction;
 0243        command.CommandText =
 0244            $"""
 0245            SELECT sq.name, t.name, CAST(sq.increment AS bigint), sq.is_cycling, CAST(sq.maximum_value AS bigint)
 0246            FROM sys.sequences sq
 0247            JOIN sys.schemas s ON s.schema_id = sq.schema_id
 0248            JOIN sys.types t ON t.user_type_id = sq.user_type_id
 0249            WHERE s.name = @schema AND sq.name IN ({NameParameters(command, sequences.Select(e => e.Name))});
 0250            """;
 0251        command.Parameters.AddWithValue("@schema", schemaName);
 252
 0253        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
 0254        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 255        {
 0256            var name = reader.GetString(0);
 0257            var type = reader.GetString(1);
 0258            var increment = reader.GetInt64(2);
 0259            var cycles = reader.GetBoolean(3);
 0260            var maximum = reader.GetInt64(4);
 0261            EvaluateSequence(schemaName, componentName, name, type, increment, cycles, maximum);
 262        }
 135263    }
 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    {
 4280        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        }
 2289    }
 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    {
 270305        var tables = expected.Where(e => e.Kind == SqlServerObjectKind.Table && e.Columns is not null).ToArray();
 135306        if (tables.Length == 0)
 0307            return;
 308
 135309        await using var command = connection.CreateCommand();
 135310        command.Transaction = transaction;
 135311        command.CommandText =
 135312            $"""
 135313            SELECT o.name, c.name, t.name, c.max_length, c.scale, c.is_nullable, ISNULL(c.collation_name, N''),
 135314                   CASE WHEN c.default_object_id <> 0 OR c.is_identity = 1 OR c.is_computed = 1 THEN 1 ELSE 0 END,
 135315                   ISNULL(dc.definition, N'')
 135316            FROM sys.columns c
 135317            JOIN sys.objects o ON o.object_id = c.object_id
 135318            JOIN sys.schemas s ON s.schema_id = o.schema_id
 135319            JOIN sys.types t ON t.user_type_id = c.user_type_id
 135320            LEFT JOIN sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column
 135321            WHERE s.name = @schema AND o.name IN ({NameParameters(command, tables.Select(e => e.Name))});
 135322            """;
 135323        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.
 135331        var actual = new Dictionary<(string Table, string Column), ActualColumn>(TableColumnComparer.Instance);
 135332        await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
 333        {
 1080334            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 335            {
 945336                actual[(reader.GetString(0), reader.GetString(1))] = new ActualColumn(
 945337                    Type: RenderType(reader.GetString(2), reader.GetInt16(3), reader.GetByte(4)),
 945338                    Nullable: reader.GetBoolean(5),
 945339                    Collation: reader.GetString(6),
 945340                    Writable: reader.GetInt32(7) == 1,
 945341                    Default: reader.GetString(8));
 342            }
 343        }
 344
 135345        EvaluateTableColumns(schemaName, componentName, tables, actual);
 135346    }
 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    {
 590358        foreach (var table in tables)
 359        {
 2210360            foreach (var column in table.Columns!)
 361            {
 959362                if (!actual.TryGetValue((table.Name, column.Name), out var found))
 2363                    throw new InvalidOperationException(
 2364                        $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' exists but is missing
 2365                        $"'{column.Name}'{(column.Type is null ? "" : $" ({column.Type})")}; a same-name table from anot
 2366                        "or a partial manual creation occupies the name. " + CollisionGuidance);
 367
 368                // An unconstrained (null) type compares and reports nullability alone.
 957369                if ((column.Type is { } expectedType && !string.Equals(found.Type, expectedType, StringComparison.Ordina
 957370                    || 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
 953378                if (column.RequiresBinaryCollation && !IsOrdinalCollation(found.Collation))
 0379                    throw new InvalidOperationException(
 0380                        $"The SQL Server {componentName} store's column '{schemaName}.{table.Name}.{column.Name}' uses t
 0381                        $"collation '{found.Collation}', which is not binary. That column stores an identity the library
 0382                        "ORDINALLY, and any non-binary collation folds something the library treats as distinct — case u
 0383                        "collation, accents under _AI, full-width forms under any collation without _WS. Distinct ids wo
 0384                        "collide on one key: lookups cross-match and the second id is rejected on insert. Recreate the t
 0385                        "deployments get COLLATE Latin1_General_100_BIN2 automatically), or ALTER the column to a _BIN2 
 0386                        "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.
 953392                if (column.DefaultExpression is { } expectedDefault
 953393                    && !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.
 1096407            var expectedNames = table.Columns!.Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
 2192408            foreach (var ((tableName, columnName), found) in actual)
 409            {
 953410                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        }
 143420    }
 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    {
 270436        var keyed = expected.Where(e => e.PrimaryKey is not null).ToArray();
 135437        if (keyed.Length == 0)
 0438            return;
 439
 135440        await using var command = connection.CreateCommand();
 135441        command.Transaction = transaction;
 135442        command.CommandText =
 135443            $"""
 135444            SELECT o.name, col.name, ic.key_ordinal
 135445            FROM sys.indexes i
 135446            JOIN sys.objects o ON o.object_id = i.object_id
 135447            JOIN sys.schemas s ON s.schema_id = o.schema_id
 135448            JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0
 135449            JOIN sys.columns col ON col.object_id = i.object_id AND col.column_id = ic.column_id
 135450            WHERE i.is_primary_key = 1 AND s.name = @schema
 135451              AND o.name IN ({NameParameters(command, keyed.Select(e => e.Name))});
 135452            """;
 135453        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.
 135458        var actual = new Dictionary<string, List<(byte Ordinal, string Column)>>(StringComparer.OrdinalIgnoreCase);
 135459        await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
 460        {
 270461            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 462            {
 135463                if (!actual.TryGetValue(reader.GetString(0), out var columns))
 135464                    actual[reader.GetString(0)] = columns = [];
 135465                columns.Add((reader.GetByte(2), reader.GetString(1)));
 466            }
 467        }
 468
 135469        EvaluatePrimaryKeys(schemaName, componentName, keyed, actual);
 135470    }
 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    {
 560486        foreach (var table in keyed)
 487        {
 141488            var found = actual.TryGetValue(table.Name, out var columns)
 425489                ? columns.Where(c => c.Ordinal > 0).OrderBy(c => c.Ordinal).Select(c => c.Column).ToArray()
 141490                : [];
 491
 141492            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        }
 137502    }
 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    {
 270523        var indexes = expected.Where(e => e.Kind == SqlServerObjectKind.Index).ToArray();
 135524        if (indexes.Length == 0)
 135525            return;
 526
 0527        await using var command = connection.CreateCommand();
 0528        command.Transaction = transaction;
 0529        command.CommandText =
 0530            $"""
 0531            SELECT i.name, o.name, i.type, i.is_unique, i.has_filter, i.is_disabled, ic.key_ordinal, col.name
 0532            FROM sys.indexes i
 0533            JOIN sys.objects o ON o.object_id = i.object_id
 0534            JOIN sys.schemas s ON s.schema_id = o.schema_id
 0535            LEFT JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal
 0536            LEFT JOIN sys.columns col ON col.object_id = i.object_id AND col.column_id = ic.column_id
 0537            WHERE s.name = @schema AND i.name IN ({NameParameters(command, indexes.Select(e => e.Name))});
 0538            """;
 0539        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.
 0544        var actual = new Dictionary<(string Table, string Column), ActualIndex>(TableColumnComparer.Instance);
 0545        await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
 546        {
 0547            while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
 548            {
 0549                var key = (reader.GetString(0), reader.GetString(1));
 0550                if (!actual.TryGetValue(key, out var index))
 551                {
 0552                    actual[key] = index = new ActualIndex(
 0553                        Type: reader.GetByte(2),
 0554                        IsUnique: reader.GetBoolean(3),
 0555                        HasFilter: reader.GetBoolean(4),
 0556                        IsDisabled: reader.GetBoolean(5),
 0557                        KeyColumns: []);
 558                }
 559
 0560                if (!await reader.IsDBNullAsync(6, cancellationToken).ConfigureAwait(false))
 0561                    index.KeyColumns.Add((reader.GetByte(6), reader.GetString(7)));
 0562            }
 563        }
 564
 0565        EvaluateIndexes(schemaName, componentName, indexes, actual, reportAbsence);
 135566    }
 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    {
 62580        foreach (var index in indexes)
 581        {
 18582            if (!actual.TryGetValue((index.Name, index.OwningTable!), out var found))
 583                continue;
 584
 66585            var foundColumns = found.KeyColumns.OrderBy(c => c.Ordinal).Select(c => c.Column).ToArray();
 14586            if (found.Type is not (1 or 2) || found.IsUnique || found.HasFilter || found.IsDisabled
 14587                || !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
 8599        if (!reportAbsence)
 2600            return;
 601
 22602        foreach (var index in indexes)
 603        {
 6604            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        }
 4609    }
 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
 16620    internal sealed record ActualIndex(
 24621        byte Type,
 20622        bool IsUnique,
 20623        bool HasFilter,
 18624        bool IsDisabled,
 30625        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>
 969635    internal static string RenderType(string typeName, short maxLength, byte scale) => typeName switch
 969636    {
 411637        "nvarchar" or "nchar" => maxLength < 0 ? $"{typeName}(max)" : $"{typeName}({maxLength / 2})",
 4638        "varchar" or "char" or "varbinary" or "binary" => maxLength < 0 ? $"{typeName}(max)" : $"{typeName}({maxLength})
 415639        "datetime2" or "datetimeoffset" or "time" => $"{typeName}({scale})",
 139640        _ => typeName
 969641    };
 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)
 135652        => 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.
 405658        var builder = new StringBuilder();
 405659        var index = 0;
 1620660        foreach (var name in names)
 661        {
 405662            if (index > 0)
 0663                builder.Append(", ");
 664
 405665            var parameter = $"@name{index.ToString(CultureInfo.InvariantCulture)}";
 405666            builder.Append(parameter);
 405667            command.Parameters.AddWithValue(parameter, name);
 405668            index++;
 669        }
 670
 405671        return index == 0 ? "NULL" : builder.ToString();
 672    }
 673
 0674    private static string Describe(string type) => type switch
 0675    {
 0676        "U" => "a user table",
 0677        "SO" => "a sequence",
 0678        "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}'"
 0683    };
 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
 2047688    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    {
 6694        public static readonly TableColumnComparer Instance = new();
 695
 696        public bool Equals((string Table, string Column) x, (string Table, string Column) y)
 975697            => string.Equals(x.Table, y.Table, StringComparison.OrdinalIgnoreCase)
 975698                && string.Equals(x.Column, y.Column, StringComparison.OrdinalIgnoreCase);
 699
 700        public int GetHashCode((string Table, string Column) value)
 1956701            => HashCode.Combine(
 1956702                StringComparer.OrdinalIgnoreCase.GetHashCode(value.Table),
 1956703                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>)