| | | 1 | | using Npgsql; |
| | | 2 | | |
| | | 3 | | namespace AsyncResponse.Internal; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// In-transaction catalog verification that every relation a PostgreSQL store just ensured |
| | | 7 | | /// actually IS what its DDL intended. <c>CREATE ... IF NOT EXISTS</c> matches ANY relation with |
| | | 8 | | /// the name (tables, indexes, and sequences share one namespace per schema) and explicitly |
| | | 9 | | /// guarantees nothing about an existing object's shape — a same-name table missing operational |
| | | 10 | | /// columns, an index with different key columns, uniqueness, a predicate or access method, or a |
| | | 11 | | /// sequence with the wrong increment/cache/cycle/bounds is silently accepted by the DDL and |
| | | 12 | | /// fails (or corrupts ordering) only at runtime. Runs under the schema-keyed advisory DDL lock |
| | | 13 | | /// shared by every AsyncResponse PostgreSQL store, so other components' objects are either |
| | | 14 | | /// committed and visible or serialized behind this transaction. Source-linked into the channel, |
| | | 15 | | /// transport, and durable-flow packages (separate packages cannot share compiled code). |
| | | 16 | | /// </summary> |
| | | 17 | | internal static class PostgreSqlRelationVerifier |
| | | 18 | | { |
| | | 19 | | /// <summary> |
| | | 20 | | /// One expected table column: name, <c>format_type</c> rendering, nullability, and — for |
| | | 21 | | /// columns whose DDL declares a default the runtime relies on — the exact |
| | | 22 | | /// <c>pg_get_expr</c> rendering of that default. A merely EXISTING default is not enough: |
| | | 23 | | /// <c>created_at DEFAULT now() + interval '1 year'</c> silently shifts every timestamp the |
| | | 24 | | /// watermark and visibility logic compare, and <c>available_at</c> with a future default |
| | | 25 | | /// would strand transport jobs. |
| | | 26 | | /// <para> |
| | | 27 | | /// <paramref name="RequiresDeterministicCollation"/> marks the columns that store an identity |
| | | 28 | | /// the library compares ORDINALLY — correlation ids, queue names, flow ids. Under a |
| | | 29 | | /// non-deterministic ICU collation the database treats strings its own rules call equal as ONE |
| | | 30 | | /// key, so two distinct ids collide: lookups cross-match and a primary key rejects the second |
| | | 31 | | /// id. A type that carries no collation at all (<c>uuid</c>, <c>bigint</c>) reports none and |
| | | 32 | | /// always passes. |
| | | 33 | | /// </para> |
| | | 34 | | /// </summary> |
| | | 35 | | /// <remarks> |
| | | 36 | | /// The check reads the column's OWN collation. A DATABASE whose default collation is itself |
| | | 37 | | /// non-deterministic is out of scope: an uncollated declaration records the |
| | | 38 | | /// <c>pg_catalog."default"</c> entry, which is always marked deterministic whatever the |
| | | 39 | | /// cluster's locale, so the catalog cannot answer that question here. |
| | | 40 | | /// </remarks> |
| | | 41 | | internal readonly record struct ExpectedColumn( |
| | 4163 | 42 | | string Name, |
| | 2084 | 43 | | string Type, |
| | 2082 | 44 | | bool Nullable, |
| | 2082 | 45 | | bool RequiresDeterministicCollation = false, |
| | 2906 | 46 | | string? DefaultExpression = null); |
| | | 47 | | |
| | | 48 | | /// <summary> |
| | | 49 | | /// One expected relation: kind 'r' (table, verified against <paramref name="Columns"/> and |
| | | 50 | | /// <paramref name="PrimaryKey"/> when given), 'S' (sequence, verified <c>bigint</c>, |
| | | 51 | | /// increment 1, cache 1, no cycle, full positive range), or 'i' (index, verified to sit on |
| | | 52 | | /// <paramref name="OwningTable"/> as a plain — non-unique, non-partial, valid and ready — |
| | | 53 | | /// btree over exactly <paramref name="KeyColumns"/> in order). All relations must be |
| | | 54 | | /// permanent (not UNLOGGED or temporary). |
| | | 55 | | /// </summary> |
| | | 56 | | internal readonly record struct ExpectedRelation( |
| | 6539 | 57 | | string Name, |
| | 3864 | 58 | | char Kind, |
| | 1067 | 59 | | string? OwningTable = null, |
| | 416 | 60 | | string[]? KeyColumns = null, |
| | 885 | 61 | | ExpectedColumn[]? Columns = null, |
| | 231 | 62 | | string[]? PrimaryKey = null); |
| | | 63 | | |
| | | 64 | | public static async Task VerifyAsync( |
| | | 65 | | NpgsqlConnection connection, |
| | | 66 | | NpgsqlTransaction? transaction, |
| | | 67 | | string schemaName, |
| | | 68 | | string componentName, |
| | | 69 | | IReadOnlyList<ExpectedRelation> expected, |
| | | 70 | | CancellationToken cancellationToken) |
| | | 71 | | { |
| | 211 | 72 | | var relations = await LoadRelationsAsync(connection, transaction, schemaName, expected, cancellationToken).Confi |
| | 211 | 73 | | var columns = await LoadTableColumnsAsync(connection, transaction, schemaName, expected, cancellationToken).Conf |
| | 211 | 74 | | Evaluate(schemaName, componentName, expected, relations, columns); |
| | 206 | 75 | | } |
| | | 76 | | |
| | | 77 | | // Both primary-key columns and index key columns come from indkey sliced to indnkeyatts: |
| | | 78 | | // indkey lists key columns FOLLOWED by INCLUDE payload columns, and a covering |
| | | 79 | | // PRIMARY KEY (…) INCLUDE (…) enforces exactly the uniqueness the stores rely on — reading |
| | | 80 | | // the whole vector would reject it for carrying its payload columns. |
| | | 81 | | internal const string RelationQuery = |
| | | 82 | | """ |
| | | 83 | | SELECT c.relname, |
| | | 84 | | c.relkind::text, |
| | | 85 | | c.relpersistence::text, |
| | | 86 | | COALESCE(t.relname, ''), |
| | | 87 | | COALESCE(am.amname, ''), |
| | | 88 | | COALESCE(i.indisunique, false), |
| | | 89 | | i.indpred IS NOT NULL, |
| | | 90 | | COALESCE(i.indisvalid AND i.indisready, true), |
| | | 91 | | COALESCE((SELECT array_agg(a.attname ORDER BY k.ord) |
| | | 92 | | FROM unnest(i.indkey[0:i.indnkeyatts-1]) WITH ORDINALITY AS k(attnum, ord) |
| | | 93 | | JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum), '{}'), |
| | | 94 | | COALESCE(s.seqtypid::regtype::text, ''), |
| | | 95 | | COALESCE(s.seqincrement, 1), |
| | | 96 | | COALESCE(s.seqcache, 1), |
| | | 97 | | COALESCE(s.seqcycle, false), |
| | | 98 | | COALESCE(s.seqmax, 9223372036854775807), |
| | | 99 | | COALESCE((SELECT array_agg(a2.attname ORDER BY k2.ord) |
| | | 100 | | FROM pg_index pi |
| | | 101 | | CROSS JOIN LATERAL unnest(pi.indkey[0:pi.indnkeyatts-1]) WITH ORDINALITY AS k2(attnum, ord) |
| | | 102 | | JOIN pg_attribute a2 ON a2.attrelid = c.oid AND a2.attnum = k2.attnum |
| | | 103 | | WHERE pi.indrelid = c.oid AND pi.indisprimary), '{}') |
| | | 104 | | FROM pg_class c |
| | | 105 | | JOIN pg_namespace n ON n.oid = c.relnamespace |
| | | 106 | | LEFT JOIN pg_index i ON i.indexrelid = c.oid |
| | | 107 | | LEFT JOIN pg_class t ON t.oid = i.indrelid |
| | | 108 | | LEFT JOIN pg_am am ON am.oid = c.relam |
| | | 109 | | LEFT JOIN pg_sequence s ON s.seqrelid = c.oid |
| | | 110 | | WHERE n.nspname = @schema AND c.relname = ANY(@names); |
| | | 111 | | """; |
| | | 112 | | |
| | | 113 | | private static async Task<Dictionary<string, ActualRelation>> LoadRelationsAsync( |
| | | 114 | | NpgsqlConnection connection, |
| | | 115 | | NpgsqlTransaction? transaction, |
| | | 116 | | string schemaName, |
| | | 117 | | IReadOnlyList<ExpectedRelation> expected, |
| | | 118 | | CancellationToken cancellationToken) |
| | | 119 | | { |
| | 211 | 120 | | await using var verify = connection.CreateCommand(); |
| | 211 | 121 | | verify.Transaction = transaction; |
| | 211 | 122 | | verify.CommandText = RelationQuery; |
| | 211 | 123 | | verify.Parameters.AddWithValue("schema", schemaName); |
| | 842 | 124 | | verify.Parameters.AddWithValue("names", expected.Select(e => e.Name).ToArray()); |
| | | 125 | | |
| | 211 | 126 | | var actual = new Dictionary<string, ActualRelation>(StringComparer.Ordinal); |
| | 211 | 127 | | await using var reader = await verify.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); |
| | 841 | 128 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | | 129 | | { |
| | 630 | 130 | | actual[reader.GetString(0)] = new ActualRelation( |
| | 630 | 131 | | Kind: reader.GetString(1), |
| | 630 | 132 | | Persistence: reader.GetString(2), |
| | 630 | 133 | | OwningTable: reader.GetString(3), |
| | 630 | 134 | | AccessMethod: reader.GetString(4), |
| | 630 | 135 | | IsUnique: reader.GetBoolean(5), |
| | 630 | 136 | | HasPredicate: reader.GetBoolean(6), |
| | 630 | 137 | | IsValidAndReady: reader.GetBoolean(7), |
| | 630 | 138 | | KeyColumns: reader.GetFieldValue<string[]>(8), |
| | 630 | 139 | | SequenceType: reader.GetString(9), |
| | 630 | 140 | | SequenceIncrement: reader.GetInt64(10), |
| | 630 | 141 | | SequenceCache: reader.GetInt64(11), |
| | 630 | 142 | | SequenceCycles: reader.GetBoolean(12), |
| | 630 | 143 | | SequenceMax: reader.GetInt64(13), |
| | 630 | 144 | | PrimaryKey: reader.GetFieldValue<string[]>(14)); |
| | | 145 | | } |
| | | 146 | | |
| | 211 | 147 | | return actual; |
| | 211 | 148 | | } |
| | | 149 | | |
| | | 150 | | // The writable column is "writable without being named": a default (pg_attrdef also carries |
| | | 151 | | // stored generation expressions), an identity, or a generated column. Identity columns have |
| | | 152 | | // NO pg_attrdef row, so testing the rendered default alone would misread |
| | | 153 | | // GENERATED ... AS IDENTITY — which PostgreSQL populates on every insert — as unwritable. |
| | | 154 | | // format_type renders no collation, so the last two columns join it in separately; |
| | | 155 | | // attcollation is 0 for a type that cannot carry one, which the LEFT JOIN resolves to the |
| | | 156 | | // deterministic default rather than a missing row that would fail every flagged column. |
| | | 157 | | internal const string TableColumnQuery = |
| | | 158 | | """ |
| | | 159 | | SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod), a.attnotnull, |
| | | 160 | | COALESCE(pg_get_expr(ad.adbin, ad.adrelid), ''), |
| | | 161 | | ad.adrelid IS NOT NULL OR a.attidentity <> '' OR a.attgenerated <> '', |
| | | 162 | | COALESCE(co.collname, ''), |
| | | 163 | | COALESCE(co.collisdeterministic, true) |
| | | 164 | | FROM pg_class c |
| | | 165 | | JOIN pg_namespace n ON n.oid = c.relnamespace |
| | | 166 | | JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped |
| | | 167 | | LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum |
| | | 168 | | LEFT JOIN pg_collation co ON co.oid = a.attcollation |
| | | 169 | | WHERE n.nspname = @schema AND c.relname = ANY(@names); |
| | | 170 | | """; |
| | | 171 | | |
| | | 172 | | private static async Task<Dictionary<(string Table, string Column), ActualColumn>> LoadTableColumnsAsync( |
| | | 173 | | NpgsqlConnection connection, |
| | | 174 | | NpgsqlTransaction? transaction, |
| | | 175 | | string schemaName, |
| | | 176 | | IReadOnlyList<ExpectedRelation> expected, |
| | | 177 | | CancellationToken cancellationToken) |
| | | 178 | | { |
| | 211 | 179 | | var actual = new Dictionary<(string Table, string Column), ActualColumn>(); |
| | 842 | 180 | | var tables = expected.Where(e => e.Kind == 'r' && e.Columns is not null).ToArray(); |
| | 211 | 181 | | if (tables.Length == 0) |
| | 0 | 182 | | return actual; |
| | | 183 | | |
| | 211 | 184 | | await using var verify = connection.CreateCommand(); |
| | 211 | 185 | | verify.Transaction = transaction; |
| | 211 | 186 | | verify.CommandText = TableColumnQuery; |
| | 211 | 187 | | verify.Parameters.AddWithValue("schema", schemaName); |
| | 422 | 188 | | verify.Parameters.AddWithValue("names", tables.Select(t => t.Name).ToArray()); |
| | | 189 | | |
| | 211 | 190 | | await using var reader = await verify.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); |
| | 2313 | 191 | | while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) |
| | | 192 | | { |
| | 2102 | 193 | | actual[(reader.GetString(0), reader.GetString(1))] = new ActualColumn( |
| | 2102 | 194 | | Type: reader.GetString(2), |
| | 2102 | 195 | | NotNull: reader.GetBoolean(3), |
| | 2102 | 196 | | Default: reader.GetString(4), |
| | 2102 | 197 | | Writable: reader.GetBoolean(5), |
| | 2102 | 198 | | Collation: reader.GetString(6), |
| | 2102 | 199 | | DeterministicCollation: reader.GetBoolean(7)); |
| | | 200 | | } |
| | | 201 | | |
| | 211 | 202 | | return actual; |
| | 211 | 203 | | } |
| | | 204 | | |
| | | 205 | | internal static void Evaluate( |
| | | 206 | | string schemaName, |
| | | 207 | | string componentName, |
| | | 208 | | IReadOnlyList<ExpectedRelation> expected, |
| | | 209 | | Dictionary<string, ActualRelation> relations, |
| | | 210 | | Dictionary<(string Table, string Column), ActualColumn> columns) |
| | | 211 | | { |
| | | 212 | | // Diagnose in CAUSE order, not declaration order. A misprovisioned or colliding schema is |
| | | 213 | | // usually wrong in several ways at once — a foreign relation occupying one name is WHY a |
| | | 214 | | // dependent object was never created, and an operator who mis-shaped a table has typically |
| | | 215 | | // also forgotten an index — so reporting "does not exist" first would name a victim and |
| | | 216 | | // hide the culprit. Anything that is present and wrong is checked first; absence is only |
| | | 217 | | // reported once nothing present explains it. |
| | 1770 | 218 | | foreach (var relation in expected) |
| | | 219 | | { |
| | 655 | 220 | | if (!relations.TryGetValue(relation.Name, out var found)) |
| | | 221 | | continue; |
| | | 222 | | |
| | 650 | 223 | | if (found.Kind != relation.Kind.ToString() |
| | 650 | 224 | | || (relation.OwningTable is not null && !string.Equals(found.OwningTable, relation.OwningTable, StringCo |
| | | 225 | | { |
| | 3 | 226 | | var expectedDescription = relation.OwningTable is null |
| | 3 | 227 | | ? DescribeKind(relation.Kind.ToString()) |
| | 3 | 228 | | : $"an index on '{relation.OwningTable}'"; |
| | 3 | 229 | | var actualDescription = found.OwningTable.Length == 0 |
| | 3 | 230 | | ? DescribeKind(found.Kind) |
| | 3 | 231 | | : $"an index on '{found.OwningTable}'"; |
| | 3 | 232 | | throw new InvalidOperationException( |
| | 3 | 233 | | $"The PostgreSQL {componentName} store expected '{schemaName}.{relation.Name}' to be {expectedDescri |
| | 3 | 234 | | $"but the name is occupied by {actualDescription}, so CREATE ... IF NOT EXISTS silently skipped crea |
| | 3 | 235 | | CollisionGuidance); |
| | | 236 | | } |
| | | 237 | | |
| | 647 | 238 | | if (found.Persistence != "p") |
| | 0 | 239 | | throw new InvalidOperationException( |
| | 0 | 240 | | $"The PostgreSQL {componentName} store's relation '{schemaName}.{relation.Name}' exists but is not a |
| | 0 | 241 | | "(UNLOGGED or temporary): its content would not survive a crash or session end. Drop or convert it a |
| | | 242 | | |
| | 647 | 243 | | if (relation.Kind == 'S') |
| | 0 | 244 | | VerifySequence(schemaName, componentName, relation.Name, found); |
| | | 245 | | |
| | 647 | 246 | | if (relation.Kind == 'i' && relation.KeyColumns is { } keyColumns) |
| | | 247 | | { |
| | 416 | 248 | | if (!found.IsValidAndReady) |
| | 0 | 249 | | throw new InvalidOperationException( |
| | 0 | 250 | | $"The PostgreSQL {componentName} store's index '{schemaName}.{relation.Name}' exists but is inva |
| | 0 | 251 | | "(a failed CREATE INDEX CONCURRENTLY leaves such an index behind). Drop it and restart so the st |
| | | 252 | | |
| | 416 | 253 | | if (found.IsUnique || found.HasPredicate || found.AccessMethod != "btree" || !found.KeyColumns.AsSpan(). |
| | 1 | 254 | | throw new InvalidOperationException( |
| | 1 | 255 | | $"The PostgreSQL {componentName} store's index '{schemaName}.{relation.Name}' exists but does no |
| | 1 | 256 | | $"expected definition: expected a plain btree over ({string.Join(", ", keyColumns)}); found " + |
| | 1 | 257 | | $"{(found.IsUnique ? "a UNIQUE " : "a ")}{(found.HasPredicate ? "partial " : "")}{found.AccessMe |
| | 1 | 258 | | $"({string.Join(", ", found.KeyColumns)}). CREATE INDEX IF NOT EXISTS accepts ANY existing index |
| | 1 | 259 | | "guarantees nothing about its shape — drop or rename the existing index so the store can create |
| | | 260 | | } |
| | | 261 | | |
| | 646 | 262 | | if (relation.Kind == 'r' && relation.PrimaryKey is { } primaryKey && !found.PrimaryKey.AsSpan().SequenceEqua |
| | 2 | 263 | | throw new InvalidOperationException( |
| | 2 | 264 | | $"The PostgreSQL {componentName} store's table '{schemaName}.{relation.Name}' exists but its primary |
| | 2 | 265 | | $"({string.Join(", ", found.PrimaryKey)}) instead of ({string.Join(", ", primaryKey)}). " + Collisio |
| | | 266 | | } |
| | | 267 | | |
| | 227 | 268 | | EvaluateTableColumns(schemaName, componentName, expected, relations, columns); |
| | | 269 | | |
| | 1706 | 270 | | foreach (var relation in expected) |
| | | 271 | | { |
| | 634 | 272 | | if (!relations.ContainsKey(relation.Name)) |
| | 2 | 273 | | throw new InvalidOperationException( |
| | 2 | 274 | | $"The PostgreSQL {componentName} store expected '{schemaName}.{relation.Name}' to exist after schema |
| | 2 | 275 | | "but it does not. " + CollisionGuidance); |
| | | 276 | | } |
| | 218 | 277 | | } |
| | | 278 | | |
| | | 279 | | /// <summary> |
| | | 280 | | /// Column-level table verification: a same-kind table occupying the name — another |
| | | 281 | | /// component's, or a crafted one that happens to satisfy the index DDL — passes the relation |
| | | 282 | | /// check and fails only at the first INSERT/SELECT, or worse, silently changes runtime |
| | | 283 | | /// behavior. Every DDL-declared column must exist with the declared type and nullability; |
| | | 284 | | /// columns the DDL gives runtime-relied defaults must carry EXACTLY that default expression |
| | | 285 | | /// (a same-named default computing something else shifts every timestamp the store |
| | | 286 | | /// compares); identity columns must carry a deterministic collation (the SQL Server sibling's |
| | | 287 | | /// binary-collation rule, in the form PostgreSQL expresses it); and extra columns are allowed |
| | | 288 | | /// only when they are writable without being named |
| | | 289 | | /// (nullable, defaulted, identity, or generated) — an extra NOT NULL column the database |
| | | 290 | | /// cannot fill in itself fails every normal insert with 23502. |
| | | 291 | | /// </summary> |
| | | 292 | | private static void EvaluateTableColumns( |
| | | 293 | | string schemaName, |
| | | 294 | | string componentName, |
| | | 295 | | IReadOnlyList<ExpectedRelation> expected, |
| | | 296 | | Dictionary<string, ActualRelation> relations, |
| | | 297 | | Dictionary<(string Table, string Column), ActualColumn> columns) |
| | | 298 | | { |
| | 1729 | 299 | | foreach (var table in expected) |
| | | 300 | | { |
| | 641 | 301 | | if (table.Kind != 'r' || table.Columns is null || !relations.ContainsKey(table.Name)) |
| | | 302 | | continue; |
| | | 303 | | |
| | 4613 | 304 | | foreach (var column in table.Columns) |
| | | 305 | | { |
| | 2084 | 306 | | if (!columns.TryGetValue((table.Name, column.Name), out var found)) |
| | 2 | 307 | | throw new InvalidOperationException( |
| | 2 | 308 | | $"The PostgreSQL {componentName} store's table '{schemaName}.{table.Name}' exists but is missing |
| | 2 | 309 | | $"'{column.Name}' ({column.Type}); a same-name table from another component or a partial manual |
| | 2 | 310 | | "occupies the name. " + CollisionGuidance); |
| | | 311 | | |
| | 2082 | 312 | | if (!string.Equals(found.Type, column.Type, StringComparison.Ordinal) |
| | 2082 | 313 | | || found.NotNull == column.Nullable |
| | 2082 | 314 | | || (column.DefaultExpression is not null && !string.Equals(found.Default, column.DefaultExpression, |
| | | 315 | | { |
| | 0 | 316 | | throw new InvalidOperationException( |
| | 0 | 317 | | $"The PostgreSQL {componentName} store's table '{schemaName}.{table.Name}' exists but column '{c |
| | 0 | 318 | | $"does not match the expected shape: expected {column.Type}{(column.Nullable ? " NULL" : " NOT N |
| | 0 | 319 | | $"{(column.DefaultExpression is null ? "" : $" DEFAULT {column.DefaultExpression}")}; found {fou |
| | 0 | 320 | | $"{(found.NotNull ? " NOT NULL" : " NULL")}{(found.Default.Length == 0 ? " without a default" : |
| | 0 | 321 | | CollisionGuidance); |
| | | 322 | | } |
| | | 323 | | |
| | 2082 | 324 | | if (column.RequiresDeterministicCollation && !found.DeterministicCollation) |
| | 3 | 325 | | throw new InvalidOperationException( |
| | 3 | 326 | | $"The PostgreSQL {componentName} store's column '{schemaName}.{table.Name}.{column.Name}' uses t |
| | 3 | 327 | | $"collation '{found.Collation}', which is non-deterministic (collisdeterministic = false). That |
| | 3 | 328 | | "stores an identity the library compares ORDINALLY, and a non-deterministic collation folds what |
| | 3 | 329 | | "rules call equal — case, accents, or full-width forms, depending on the ICU rule — into one key |
| | 3 | 330 | | "ids would then collide: lookups cross-match and the second id is rejected on insert. ALTER the |
| | 3 | 331 | | "a deterministic collation (\"C\" compares by code point) after dropping the keys and indexes th |
| | 3 | 332 | | "reference it."); |
| | | 333 | | } |
| | | 334 | | |
| | | 335 | | // Extra columns are fine only when inserts that do not name them can still succeed. |
| | 2294 | 336 | | var expectedNames = table.Columns.Select(c => c.Name).ToHashSet(StringComparer.Ordinal); |
| | 4606 | 337 | | foreach (var ((tableName, columnName), found) in columns) |
| | | 338 | | { |
| | 2084 | 339 | | if (!string.Equals(tableName, table.Name, StringComparison.Ordinal) || expectedNames.Contains(columnName |
| | | 340 | | continue; |
| | | 341 | | |
| | 10 | 342 | | if (found.NotNull && !found.Writable) |
| | 2 | 343 | | throw new InvalidOperationException( |
| | 2 | 344 | | $"The PostgreSQL {componentName} store's table '{schemaName}.{table.Name}' has an extra column " |
| | 2 | 345 | | $"'{columnName}' that is NOT NULL without a default: every insert the store issues would fail wi |
| | 2 | 346 | | "not_null_violation (23502). Make the column nullable, give it a default, or drop it."); |
| | | 347 | | } |
| | | 348 | | } |
| | 220 | 349 | | } |
| | | 350 | | |
| | | 351 | | /// <summary> |
| | | 352 | | /// The ack sequence is a cross-process monotonic clock: delivery claims and waiter |
| | | 353 | | /// registrations draw from it and compare positions, so any property that lets drawn values |
| | | 354 | | /// go backwards or repeat silently corrupts the same-tick tie-breaker — a descending |
| | | 355 | | /// increment counts down, CYCLE wraps, and CACHE > 1 hands each session a private block |
| | | 356 | | /// so cross-session draw order no longer matches value order. |
| | | 357 | | /// </summary> |
| | | 358 | | private static void VerifySequence(string schemaName, string componentName, string name, ActualRelation found) |
| | | 359 | | { |
| | 0 | 360 | | if (found.SequenceType != "bigint" |
| | 0 | 361 | | || found.SequenceIncrement != 1 |
| | 0 | 362 | | || found.SequenceCache != 1 |
| | 0 | 363 | | || found.SequenceCycles |
| | 0 | 364 | | || found.SequenceMax != long.MaxValue) |
| | | 365 | | { |
| | 0 | 366 | | throw new InvalidOperationException( |
| | 0 | 367 | | $"The PostgreSQL {componentName} store's sequence '{schemaName}.{name}' exists but does not behave as th |
| | 0 | 368 | | $"cross-process monotonic clock: expected bigint, INCREMENT 1, CACHE 1, NO CYCLE, MAXVALUE {long.MaxValu |
| | 0 | 369 | | $"{found.SequenceType}, INCREMENT {found.SequenceIncrement}, CACHE {found.SequenceCache}, " + |
| | 0 | 370 | | $"{(found.SequenceCycles ? "CYCLE" : "NO CYCLE")}, MAXVALUE {found.SequenceMax}. Fix it with " + |
| | 0 | 371 | | $"ALTER SEQUENCE \"{schemaName}\".\"{name}\" AS bigint INCREMENT 1 CACHE 1 NO CYCLE NO MAXVALUE; and res |
| | | 372 | | } |
| | 0 | 373 | | } |
| | | 374 | | |
| | | 375 | | /// <summary> |
| | | 376 | | /// Guidance appended when the schema-object DDL itself fails on an occupied name |
| | | 377 | | /// (SQLSTATE 42809 wrong object type, or 42703 undefined column when a same-kind foreign |
| | | 378 | | /// table made IF NOT EXISTS skip the create and the dependent index DDL then referenced a |
| | | 379 | | /// column that table does not have). |
| | | 380 | | /// </summary> |
| | | 381 | | public static string DdlCollisionMessage(string componentName, string schemaName) |
| | 1 | 382 | | => $"The PostgreSQL {componentName} store could not create its schema objects in '{schemaName}' because a config |
| | 1 | 383 | | "derived name is occupied by an object of a different kind or shape. " + CollisionGuidance; |
| | | 384 | | |
| | | 385 | | private const string CollisionGuidance = |
| | | 386 | | "Tables, indexes, and sequences share one namespace per schema — across the channel, transport, and durable-flow |
| | | 387 | | "stores and any unrelated objects in it. Rename the configured tables so every configured and derived name stays |
| | | 388 | | "unique within the schema."; |
| | | 389 | | |
| | 5 | 390 | | private static string DescribeKind(string kind) => kind switch |
| | 5 | 391 | | { |
| | 3 | 392 | | "r" => "a table", |
| | 0 | 393 | | "i" => "an index", |
| | 2 | 394 | | "S" => "a sequence", |
| | 0 | 395 | | "v" => "a view", |
| | 0 | 396 | | "m" => "a materialized view", |
| | 0 | 397 | | _ => $"a relation of kind '{kind}'", |
| | 5 | 398 | | }; |
| | | 399 | | |
| | | 400 | | internal readonly record struct ActualRelation( |
| | 653 | 401 | | string Kind, |
| | 647 | 402 | | string Persistence, |
| | 419 | 403 | | string OwningTable, |
| | 417 | 404 | | string AccessMethod, |
| | 417 | 405 | | bool IsUnique, |
| | 417 | 406 | | bool HasPredicate, |
| | 416 | 407 | | bool IsValidAndReady, |
| | 417 | 408 | | string[] KeyColumns, |
| | 0 | 409 | | string SequenceType, |
| | 0 | 410 | | long SequenceIncrement, |
| | 0 | 411 | | long SequenceCache, |
| | 0 | 412 | | bool SequenceCycles, |
| | 0 | 413 | | long SequenceMax, |
| | 215 | 414 | | string[] PrimaryKey); |
| | | 415 | | |
| | | 416 | | internal readonly record struct ActualColumn( |
| | 2082 | 417 | | string Type, |
| | 2092 | 418 | | bool NotNull, |
| | 824 | 419 | | string Default, |
| | 8 | 420 | | bool Writable, |
| | 3 | 421 | | string Collation, |
| | 215 | 422 | | bool DeterministicCollation); |
| | | 423 | | } |