| | | 1 | | using Microsoft.Data.SqlClient; |
| | | 2 | | using System.Globalization; |
| | | 3 | | using System.Text; |
| | | 4 | | |
| | | 5 | | namespace 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> |
| | | 25 | | internal 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( |
| | 12971 | 50 | | string Name, |
| | 6514 | 51 | | string? Type, |
| | 6468 | 52 | | bool Nullable, |
| | 6462 | 53 | | bool RequiresBinaryCollation = false, |
| | 6459 | 54 | | 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( |
| | 43484 | 68 | | string Name, |
| | 13745 | 69 | | SqlServerObjectKind Kind, |
| | 3467 | 70 | | ExpectedColumn[]? Columns = null, |
| | 2663 | 71 | | string[]? PrimaryKey = null, |
| | 3072 | 72 | | string? OwningTable = null, |
| | 1532 | 73 | | 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) |
| | 382 | 88 | | => 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 | | { |
| | 386 | 99 | | 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. |
| | 6935 | 106 | | 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. |
| | 3082 | 110 | | if (expectedObject.Kind == SqlServerObjectKind.Index) |
| | | 111 | | continue; |
| | | 112 | | |
| | 1542 | 113 | | if (!actual.TryGetValue(expectedObject.Name, out var foundType)) |
| | | 114 | | continue; |
| | | 115 | | |
| | 1531 | 116 | | var expectedType = expectedObject.Kind == SqlServerObjectKind.Table ? "U" : "SO"; |
| | 1531 | 117 | | if (!string.Equals(foundType, expectedType, StringComparison.Ordinal)) |
| | | 118 | | { |
| | 1 | 119 | | throw new InvalidOperationException( |
| | 1 | 120 | | $"The SQL Server {componentName} store expected '{schemaName}.{expectedObject.Name}' to be {Describe |
| | 1 | 121 | | $"but the name is occupied by {Describe(foundType)}. The store's existence guard only looks for its |
| | 1 | 122 | | "so it either skipped creation or failed with error 2714. " + CollisionGuidance); |
| | | 123 | | } |
| | | 124 | | } |
| | | 125 | | |
| | 3465 | 126 | | var present = expected.Where(e => actual.ContainsKey(e.Name)).ToArray(); |
| | 385 | 127 | | await VerifySequencesAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Confi |
| | 385 | 128 | | await VerifyTableColumnsAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Co |
| | 380 | 129 | | await VerifyPrimaryKeysAsync(connection, transaction, schemaName, componentName, present, cancellationToken).Con |
| | 380 | 130 | | 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. |
| | 380 | 136 | | if (!reportAbsence) |
| | 1 | 137 | | return; |
| | | 138 | | |
| | 6822 | 139 | | foreach (var expectedObject in expected) |
| | | 140 | | { |
| | | 141 | | // Index existence was already settled against sys.indexes in VerifyIndexesAsync. |
| | 3032 | 142 | | if (expectedObject.Kind == SqlServerObjectKind.Index) |
| | | 143 | | continue; |
| | | 144 | | |
| | 1516 | 145 | | if (!actual.ContainsKey(expectedObject.Name)) |
| | 0 | 146 | | throw new InvalidOperationException( |
| | 0 | 147 | | $"The SQL Server {componentName} store expected '{schemaName}.{expectedObject.Name}' to exist after |
| | | 148 | | } |
| | 380 | 149 | | } |
| | | 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 | | { |
| | 4 | 171 | | diagnosis = await openConnectionAsync(cancellationToken).ConfigureAwait(false); |
| | 4 | 172 | | } |
| | 0 | 173 | | catch (SqlException) |
| | | 174 | | { |
| | 0 | 175 | | return; // The server is the problem, not the schema; the original error says so. |
| | | 176 | | } |
| | | 177 | | |
| | 4 | 178 | | 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. |
| | 4 | 185 | | await VerifyCoreAsync(diagnosis, transaction: null, schemaName, componentName, expected, reportAbsence: |
| | 1 | 186 | | } |
| | | 187 | | catch (InvalidOperationException diagnosed) |
| | | 188 | | { |
| | 3 | 189 | | throw new InvalidOperationException(diagnosed.Message, failure); |
| | | 190 | | } |
| | | 191 | | } |
| | 1 | 192 | | } |
| | | 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 | | { |
| | 386 | 201 | | await using var command = connection.CreateCommand(); |
| | 386 | 202 | | command.Transaction = transaction; |
| | 386 | 203 | | command.CommandText = |
| | 386 | 204 | | $""" |
| | 386 | 205 | | SELECT o.name, RTRIM(o.type) |
| | 386 | 206 | | FROM sys.objects o |
| | 386 | 207 | | JOIN sys.schemas s ON s.schema_id = o.schema_id |
| | 3088 | 208 | | WHERE s.name = @schema AND o.name IN ({NameParameters(command, expected.Select(e => e.Name))}); |
| | 386 | 209 | | """; |
| | 386 | 210 | | 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". |
| | 386 | 218 | | var actual = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| | 386 | 219 | | await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); |
| | 1917 | 220 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | 1531 | 221 | | actual[reader.GetString(0)] = reader.GetString(1); |
| | 386 | 222 | | return actual; |
| | 386 | 223 | | } |
| | | 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 | | { |
| | 1915 | 237 | | var sequences = expected.Where(e => e.Kind == SqlServerObjectKind.Sequence).ToArray(); |
| | 385 | 238 | | if (sequences.Length == 0) |
| | 3 | 239 | | return; |
| | | 240 | | |
| | 382 | 241 | | await using var command = connection.CreateCommand(); |
| | 382 | 242 | | command.Transaction = transaction; |
| | 382 | 243 | | command.CommandText = |
| | 382 | 244 | | $""" |
| | 382 | 245 | | SELECT sq.name, t.name, CAST(sq.increment AS bigint), sq.is_cycling, CAST(sq.maximum_value AS bigint) |
| | 382 | 246 | | FROM sys.sequences sq |
| | 382 | 247 | | JOIN sys.schemas s ON s.schema_id = sq.schema_id |
| | 382 | 248 | | JOIN sys.types t ON t.user_type_id = sq.user_type_id |
| | 382 | 249 | | WHERE s.name = @schema AND sq.name IN ({NameParameters(command, sequences.Select(e => e.Name))}); |
| | 382 | 250 | | """; |
| | 382 | 251 | | command.Parameters.AddWithValue("@schema", schemaName); |
| | | 252 | | |
| | 382 | 253 | | await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); |
| | 764 | 254 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | | 255 | | { |
| | 382 | 256 | | var name = reader.GetString(0); |
| | 382 | 257 | | var type = reader.GetString(1); |
| | 382 | 258 | | var increment = reader.GetInt64(2); |
| | 382 | 259 | | var cycles = reader.GetBoolean(3); |
| | 382 | 260 | | var maximum = reader.GetInt64(4); |
| | 382 | 261 | | EvaluateSequence(schemaName, componentName, name, type, increment, cycles, maximum); |
| | | 262 | | } |
| | 385 | 263 | | } |
| | | 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 | | { |
| | 386 | 280 | | if (!string.Equals(type, "bigint", StringComparison.Ordinal) || increment != 1 || cycles || maximum != long.MaxV |
| | | 281 | | { |
| | 2 | 282 | | throw new InvalidOperationException( |
| | 2 | 283 | | $"The SQL Server {componentName} store's sequence '{schemaName}.{name}' exists but is not a monotonic co |
| | 2 | 284 | | $"expected bigint INCREMENT BY 1 NO CYCLE MAXVALUE {long.MaxValue}; found {type} INCREMENT BY {increment |
| | 2 | 285 | | $"{(cycles ? " CYCLE" : " NO CYCLE")} MAXVALUE {maximum.ToString(CultureInfo.InvariantCulture)}. Acknowl |
| | 2 | 286 | | "from this sequence, so a descending or wrapping sequence silently reorders delivery, and a restricted m |
| | 2 | 287 | | $"ALTER SEQUENCE {schemaName}.{name} INCREMENT BY 1 NO CYCLE NO MAXVALUE; (recreate it if the type is wr |
| | | 288 | | } |
| | 384 | 289 | | } |
| | | 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 | | { |
| | 1915 | 305 | | var tables = expected.Where(e => e.Kind == SqlServerObjectKind.Table && e.Columns is not null).ToArray(); |
| | 385 | 306 | | if (tables.Length == 0) |
| | 1 | 307 | | return; |
| | | 308 | | |
| | 384 | 309 | | await using var command = connection.CreateCommand(); |
| | 384 | 310 | | command.Transaction = transaction; |
| | 384 | 311 | | command.CommandText = |
| | 384 | 312 | | $""" |
| | 384 | 313 | | SELECT o.name, c.name, t.name, c.max_length, c.scale, c.is_nullable, ISNULL(c.collation_name, N''), |
| | 384 | 314 | | CASE WHEN c.default_object_id <> 0 OR c.is_identity = 1 OR c.is_computed = 1 THEN 1 ELSE 0 END, |
| | 384 | 315 | | ISNULL(dc.definition, N'') |
| | 384 | 316 | | FROM sys.columns c |
| | 384 | 317 | | JOIN sys.objects o ON o.object_id = c.object_id |
| | 384 | 318 | | JOIN sys.schemas s ON s.schema_id = o.schema_id |
| | 384 | 319 | | JOIN sys.types t ON t.user_type_id = c.user_type_id |
| | 384 | 320 | | LEFT JOIN sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column |
| | 1148 | 321 | | WHERE s.name = @schema AND o.name IN ({NameParameters(command, tables.Select(e => e.Name))}); |
| | 384 | 322 | | """; |
| | 384 | 323 | | 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. |
| | 384 | 331 | | var actual = new Dictionary<(string Table, string Column), ActualColumn>(TableColumnComparer.Instance); |
| | 384 | 332 | | await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false)) |
| | | 333 | | { |
| | 6898 | 334 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | | 335 | | { |
| | 6514 | 336 | | actual[(reader.GetString(0), reader.GetString(1))] = new ActualColumn( |
| | 6514 | 337 | | Type: RenderType(reader.GetString(2), reader.GetInt16(3), reader.GetByte(4)), |
| | 6514 | 338 | | Nullable: reader.GetBoolean(5), |
| | 6514 | 339 | | Collation: reader.GetString(6), |
| | 6514 | 340 | | Writable: reader.GetInt32(7) == 1, |
| | 6514 | 341 | | Default: reader.GetString(8)); |
| | | 342 | | } |
| | | 343 | | } |
| | | 344 | | |
| | 384 | 345 | | EvaluateTableColumns(schemaName, componentName, tables, actual); |
| | 380 | 346 | | } |
| | | 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 | | { |
| | 3099 | 358 | | foreach (var table in tables) |
| | | 359 | | { |
| | 15243 | 360 | | foreach (var column in table.Columns!) |
| | | 361 | | { |
| | 6470 | 362 | | if (!actual.TryGetValue((table.Name, column.Name), out var found)) |
| | 4 | 363 | | throw new InvalidOperationException( |
| | 4 | 364 | | $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' exists but is missing |
| | 4 | 365 | | $"'{column.Name}'{(column.Type is null ? "" : $" ({column.Type})")}; a same-name table from anot |
| | 4 | 366 | | "or a partial manual creation occupies the name. " + CollisionGuidance); |
| | | 367 | | |
| | | 368 | | // An unconstrained (null) type compares and reports nullability alone. |
| | 6466 | 369 | | if ((column.Type is { } expectedType && !string.Equals(found.Type, expectedType, StringComparison.Ordina |
| | 6466 | 370 | | || found.Nullable != column.Nullable) |
| | | 371 | | { |
| | 4 | 372 | | throw new InvalidOperationException( |
| | 4 | 373 | | $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' exists but column '{c |
| | 4 | 374 | | $"does not match the expected shape: expected {Shape(column.Type, column.Nullable)}; " + |
| | 4 | 375 | | $"found {Shape(column.Type is null ? null : found.Type, found.Nullable)}. " + CollisionGuidance) |
| | | 376 | | } |
| | | 377 | | |
| | 6462 | 378 | | if (column.RequiresBinaryCollation && !IsOrdinalCollation(found.Collation)) |
| | 3 | 379 | | throw new InvalidOperationException( |
| | 3 | 380 | | $"The SQL Server {componentName} store's column '{schemaName}.{table.Name}.{column.Name}' uses t |
| | 3 | 381 | | $"collation '{found.Collation}', which is not binary. That column stores an identity the library |
| | 3 | 382 | | "ORDINALLY, and any non-binary collation folds something the library treats as distinct — case u |
| | 3 | 383 | | "collation, accents under _AI, full-width forms under any collation without _WS. Distinct ids wo |
| | 3 | 384 | | "collide on one key: lookups cross-match and the second id is rejected on insert. Recreate the t |
| | 3 | 385 | | "deployments get COLLATE Latin1_General_100_BIN2 automatically), or ALTER the column to a _BIN2 |
| | 3 | 386 | | "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. |
| | 6459 | 392 | | if (column.DefaultExpression is { } expectedDefault |
| | 6459 | 393 | | && !string.Equals(found.Default, expectedDefault, StringComparison.OrdinalIgnoreCase)) |
| | | 394 | | { |
| | 0 | 395 | | throw new InvalidOperationException( |
| | 0 | 396 | | $"The SQL Server {componentName} store's column '{schemaName}.{table.Name}.{column.Name}' " + |
| | 0 | 397 | | $"{(found.Default.Length == 0 ? "has no default" : $"defaults to {found.Default}")}, but the sto |
| | 0 | 398 | | $"it on insert and depends on the default {expectedDefault}. " + |
| | 0 | 399 | | (found.Default.Length == 0 |
| | 0 | 400 | | ? "Every insert would fail with error 515. " |
| | 0 | 401 | | : "The rows would carry values the store's own time and visibility logic does not expect. ") |
| | 0 | 402 | | CollisionGuidance); |
| | | 403 | | } |
| | | 404 | | } |
| | | 405 | | |
| | | 406 | | // Extra columns are fine only when inserts that do not name them can still succeed. |
| | 7602 | 407 | | var expectedNames = table.Columns!.Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); |
| | 41000 | 408 | | foreach (var ((tableName, columnName), found) in actual) |
| | | 409 | | { |
| | 19354 | 410 | | if (!string.Equals(tableName, table.Name, StringComparison.OrdinalIgnoreCase) || expectedNames.Contains( |
| | | 411 | | continue; |
| | | 412 | | |
| | 0 | 413 | | if (!found.Nullable && !found.Writable) |
| | 0 | 414 | | throw new InvalidOperationException( |
| | 0 | 415 | | $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' has an extra column ' |
| | 0 | 416 | | "that is NOT NULL without a default: every insert the store issues would fail with error 515, be |
| | 0 | 417 | | "store cannot know to supply a value for it. " + CollisionGuidance); |
| | | 418 | | } |
| | | 419 | | } |
| | 387 | 420 | | } |
| | | 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 | | { |
| | 1896 | 436 | | var keyed = expected.Where(e => e.PrimaryKey is not null).ToArray(); |
| | 380 | 437 | | if (keyed.Length == 0) |
| | 1 | 438 | | return; |
| | | 439 | | |
| | 379 | 440 | | await using var command = connection.CreateCommand(); |
| | 379 | 441 | | command.Transaction = transaction; |
| | 379 | 442 | | command.CommandText = |
| | 379 | 443 | | $""" |
| | 379 | 444 | | SELECT o.name, col.name, ic.key_ordinal |
| | 379 | 445 | | FROM sys.indexes i |
| | 379 | 446 | | JOIN sys.objects o ON o.object_id = i.object_id |
| | 379 | 447 | | JOIN sys.schemas s ON s.schema_id = o.schema_id |
| | 379 | 448 | | JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0 |
| | 379 | 449 | | JOIN sys.columns col ON col.object_id = i.object_id AND col.column_id = ic.column_id |
| | 379 | 450 | | WHERE i.is_primary_key = 1 AND s.name = @schema |
| | 1137 | 451 | | AND o.name IN ({NameParameters(command, keyed.Select(e => e.Name))}); |
| | 379 | 452 | | """; |
| | 379 | 453 | | 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. |
| | 379 | 458 | | var actual = new Dictionary<string, List<(byte Ordinal, string Column)>>(StringComparer.OrdinalIgnoreCase); |
| | 379 | 459 | | await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false)) |
| | | 460 | | { |
| | 2274 | 461 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | | 462 | | { |
| | 1895 | 463 | | if (!actual.TryGetValue(reader.GetString(0), out var columns)) |
| | 1137 | 464 | | actual[reader.GetString(0)] = columns = []; |
| | 1895 | 465 | | columns.Add((reader.GetByte(2), reader.GetString(1))); |
| | | 466 | | } |
| | | 467 | | } |
| | | 468 | | |
| | 379 | 469 | | EvaluatePrimaryKeys(schemaName, componentName, keyed, actual); |
| | 380 | 470 | | } |
| | | 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 > 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 | | { |
| | 3052 | 486 | | foreach (var table in keyed) |
| | | 487 | | { |
| | 1143 | 488 | | var found = actual.TryGetValue(table.Name, out var columns) |
| | 5705 | 489 | | ? columns.Where(c => c.Ordinal > 0).OrderBy(c => c.Ordinal).Select(c => c.Column).ToArray() |
| | 1143 | 490 | | : []; |
| | | 491 | | |
| | 1143 | 492 | | if (!found.AsSpan().SequenceEqual(table.PrimaryKey!, StringComparer.OrdinalIgnoreCase)) |
| | | 493 | | { |
| | 4 | 494 | | throw new InvalidOperationException( |
| | 4 | 495 | | $"The SQL Server {componentName} store's table '{schemaName}.{table.Name}' " + |
| | 4 | 496 | | $"{(found.Length == 0 ? "has no primary key" : $"has a primary key over ({string.Join(", ", found)}) |
| | 4 | 497 | | $"but the store's idempotent writes rely on a primary key over ({string.Join(", ", table.PrimaryKey! |
| | 4 | 498 | | "duplicates. Without it a retried publish or a concurrent create is accepted twice instead of dedupl |
| | 4 | 499 | | CollisionGuidance); |
| | | 500 | | } |
| | | 501 | | } |
| | 381 | 502 | | } |
| | | 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 > 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 | | { |
| | 3420 | 523 | | var indexes = expected.Where(e => e.Kind == SqlServerObjectKind.Index).ToArray(); |
| | 380 | 524 | | if (indexes.Length == 0) |
| | 0 | 525 | | return; |
| | | 526 | | |
| | 380 | 527 | | await using var command = connection.CreateCommand(); |
| | 380 | 528 | | command.Transaction = transaction; |
| | 380 | 529 | | command.CommandText = |
| | 380 | 530 | | $""" |
| | 380 | 531 | | SELECT i.name, o.name, i.type, i.is_unique, i.has_filter, i.is_disabled, ic.key_ordinal, col.name |
| | 380 | 532 | | FROM sys.indexes i |
| | 380 | 533 | | JOIN sys.objects o ON o.object_id = i.object_id |
| | 380 | 534 | | JOIN sys.schemas s ON s.schema_id = o.schema_id |
| | 380 | 535 | | LEFT JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal |
| | 380 | 536 | | LEFT JOIN sys.columns col ON col.object_id = i.object_id AND col.column_id = ic.column_id |
| | 1520 | 537 | | WHERE s.name = @schema AND i.name IN ({NameParameters(command, indexes.Select(e => e.Name))}); |
| | 380 | 538 | | """; |
| | 380 | 539 | | 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. |
| | 380 | 544 | | var actual = new Dictionary<(string Table, string Column), ActualIndex>(TableColumnComparer.Instance); |
| | 380 | 545 | | await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false)) |
| | | 546 | | { |
| | 2275 | 547 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | | 548 | | { |
| | 1895 | 549 | | var key = (reader.GetString(0), reader.GetString(1)); |
| | 1895 | 550 | | if (!actual.TryGetValue(key, out var index)) |
| | | 551 | | { |
| | 1516 | 552 | | actual[key] = index = new ActualIndex( |
| | 1516 | 553 | | Type: reader.GetByte(2), |
| | 1516 | 554 | | IsUnique: reader.GetBoolean(3), |
| | 1516 | 555 | | HasFilter: reader.GetBoolean(4), |
| | 1516 | 556 | | IsDisabled: reader.GetBoolean(5), |
| | 1516 | 557 | | KeyColumns: []); |
| | | 558 | | } |
| | | 559 | | |
| | 1895 | 560 | | if (!await reader.IsDBNullAsync(6, cancellationToken).ConfigureAwait(false)) |
| | 1895 | 561 | | index.KeyColumns.Add((reader.GetByte(6), reader.GetString(7))); |
| | 1895 | 562 | | } |
| | | 563 | | } |
| | | 564 | | |
| | 380 | 565 | | EvaluateIndexes(schemaName, componentName, indexes, actual, reportAbsence); |
| | 380 | 566 | | } |
| | | 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 | | { |
| | 3862 | 580 | | foreach (var index in indexes) |
| | | 581 | | { |
| | 1538 | 582 | | if (!actual.TryGetValue((index.Name, index.OwningTable!), out var found)) |
| | | 583 | | continue; |
| | | 584 | | |
| | 5372 | 585 | | var foundColumns = found.KeyColumns.OrderBy(c => c.Ordinal).Select(c => c.Column).ToArray(); |
| | 1530 | 586 | | if (found.Type is not (1 or 2) || found.IsUnique || found.HasFilter || found.IsDisabled |
| | 1530 | 587 | | || !foundColumns.AsSpan().SequenceEqual(index.KeyColumns!, StringComparer.OrdinalIgnoreCase)) |
| | | 588 | | { |
| | 10 | 589 | | throw new InvalidOperationException( |
| | 10 | 590 | | $"The SQL Server {componentName} store's index '{schemaName}.{index.OwningTable}.{index.Name}' exist |
| | 10 | 591 | | $"match the expected definition: expected a non-unique, unfiltered rowstore index over " + |
| | 10 | 592 | | $"({string.Join(", ", index.KeyColumns!)}); found {(found.IsDisabled ? "a disabled" : found.IsUnique |
| | 10 | 593 | | $"{(found.HasFilter ? " filtered" : "")} {DescribeIndexType(found.Type)} index over ({string.Join(", |
| | 10 | 594 | | "The store's existence guard only asks whether an index with this name exists on the table and guara |
| | 10 | 595 | | "about its shape — drop or rename the existing index so the store can create the correct one."); |
| | | 596 | | } |
| | | 597 | | } |
| | | 598 | | |
| | 388 | 599 | | if (!reportAbsence) |
| | 3 | 600 | | return; |
| | | 601 | | |
| | 3812 | 602 | | foreach (var index in indexes) |
| | | 603 | | { |
| | 1522 | 604 | | if (!actual.ContainsKey((index.Name, index.OwningTable!))) |
| | 2 | 605 | | throw new InvalidOperationException( |
| | 2 | 606 | | $"The SQL Server {componentName} store expected index '{schemaName}.{index.OwningTable}.{index.Name} |
| | 2 | 607 | | "after schema creation, but it does not."); |
| | | 608 | | } |
| | 383 | 609 | | } |
| | | 610 | | |
| | 10 | 611 | | private static string DescribeIndexType(byte type) => type switch |
| | 10 | 612 | | { |
| | 0 | 613 | | 1 => "clustered", |
| | 8 | 614 | | 2 => "nonclustered", |
| | 2 | 615 | | 5 or 6 => "columnstore", |
| | 0 | 616 | | 7 => "hash", |
| | 0 | 617 | | _ => $"type-{type}" |
| | 10 | 618 | | }; |
| | | 619 | | |
| | 1532 | 620 | | internal sealed record ActualIndex( |
| | 1540 | 621 | | byte Type, |
| | 1536 | 622 | | bool IsUnique, |
| | 1536 | 623 | | bool HasFilter, |
| | 1534 | 624 | | bool IsDisabled, |
| | 4957 | 625 | | 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> |
| | 6538 | 635 | | internal static string RenderType(string typeName, short maxLength, byte scale) => typeName switch |
| | 6538 | 636 | | { |
| | 2306 | 637 | | "nvarchar" or "nchar" => maxLength < 0 ? $"{typeName}(max)" : $"{typeName}({maxLength / 2})", |
| | 4 | 638 | | "varchar" or "char" or "varbinary" or "binary" => maxLength < 0 ? $"{typeName}(max)" : $"{typeName}({maxLength}) |
| | 2308 | 639 | | "datetime2" or "datetimeoffset" or "time" => $"{typeName}({scale})", |
| | 1920 | 640 | | _ => typeName |
| | 6538 | 641 | | }; |
| | | 642 | | |
| | | 643 | | private static string Shape(string? type, bool nullable) |
| | 8 | 644 | | => $"{(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) |
| | 1141 | 652 | | => 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. |
| | 1911 | 658 | | var builder = new StringBuilder(); |
| | 1911 | 659 | | var index = 0; |
| | 18372 | 660 | | foreach (var name in names) |
| | | 661 | | { |
| | 7275 | 662 | | if (index > 0) |
| | 5364 | 663 | | builder.Append(", "); |
| | | 664 | | |
| | 7275 | 665 | | var parameter = $"@name{index.ToString(CultureInfo.InvariantCulture)}"; |
| | 7275 | 666 | | builder.Append(parameter); |
| | 7275 | 667 | | command.Parameters.AddWithValue(parameter, name); |
| | 7275 | 668 | | index++; |
| | | 669 | | } |
| | | 670 | | |
| | 1911 | 671 | | return index == 0 ? "NULL" : builder.ToString(); |
| | | 672 | | } |
| | | 673 | | |
| | 2 | 674 | | private static string Describe(string type) => type switch |
| | 2 | 675 | | { |
| | 1 | 676 | | "U" => "a user table", |
| | 0 | 677 | | "SO" => "a sequence", |
| | 1 | 678 | | "V" => "a view", |
| | 0 | 679 | | "SN" => "a synonym", |
| | 0 | 680 | | "P" => "a stored procedure", |
| | 0 | 681 | | "IF" or "TF" or "FN" => "a function", |
| | 0 | 682 | | _ => $"an object of type '{type}'" |
| | 2 | 683 | | }; |
| | | 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 | | |
| | 15212 | 688 | | 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 | | { |
| | 12 | 694 | | public static readonly TableColumnComparer Instance = new(); |
| | | 695 | | |
| | | 696 | | public bool Equals((string Table, string Column) x, (string Table, string Column) y) |
| | 9895 | 697 | | => string.Equals(x.Table, y.Table, StringComparison.OrdinalIgnoreCase) |
| | 9895 | 698 | | && string.Equals(x.Column, y.Column, StringComparison.OrdinalIgnoreCase); |
| | | 699 | | |
| | | 700 | | public int GetHashCode((string Table, string Column) value) |
| | 19100 | 701 | | => HashCode.Combine( |
| | 19100 | 702 | | StringComparer.OrdinalIgnoreCase.GetHashCode(value.Table), |
| | 19100 | 703 | | StringComparer.OrdinalIgnoreCase.GetHashCode(value.Column)); |
| | | 704 | | } |
| | | 705 | | } |
| | | 706 | | |
| | | 707 | | /// <summary>The SQL Server object kinds an AsyncResponse store creates.</summary> |
| | | 708 | | internal 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 | | } |