Skip to content

Commit c9f661a

Browse files
committed
Add IsUniqueKey support for columns and enhance TVP handling
Introduces IsUniqueKey property to column models, metadata, and snapshot logic to track single-column unique keys. Enhances TVP parameter binding to treat empty collections as null, improves error messaging for missing SQL parameters, and updates related queries, writers, and analyzers to propagate the new property throughout the codebase.
1 parent bf963d4 commit c9f661a

19 files changed

Lines changed: 358 additions & 25 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# AGENTS
2+
3+
See `.github/copilot-instructions.md` for repository guidance.

docs/content/2.cli/commands/5.version.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ xtraq version [options]
1313
Typical output during the RC cycle:
1414

1515
```text
16-
Version: 1.0.8
16+
Version: 1.0.9
1717
```
1818

1919
## Options

src/Data/Models/Column.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ internal class Column
2828
[SqlFieldName("is_identity")]
2929
public int? IsIdentityRaw { get; set; }
3030

31+
[SqlFieldName("is_unique_key")]
32+
public bool IsUniqueKey { get; set; }
33+
3134
[SqlFieldName("user_type_name")]
3235
public string? UserTypeName { get; set; }
3336

src/Data/Queries/TableQueries.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,19 @@ CASE WHEN @catalogName IS NULL OR LTRIM(RTRIM(@catalogName)) = '' THEN CAST(NULL
282282
t.name AS system_type_name,
283283
IIF(t.name LIKE 'nvarchar%', c.max_length / 2, c.max_length) AS max_length,
284284
COLUMNPROPERTY(c.object_id, c.name, 'IsIdentity') AS is_identity,
285+
CASE WHEN EXISTS (
286+
SELECT 1
287+
FROM {sysCatalogPrefix}.indexes AS ix
288+
INNER JOIN {sysCatalogPrefix}.index_columns AS ic ON ic.object_id = ix.object_id AND ic.index_id = ix.index_id
289+
WHERE ix.object_id = c.object_id
290+
AND ix.is_unique = 1
291+
AND ix.is_hypothetical = 0
292+
AND ix.is_disabled = 0
293+
AND ic.column_id = c.column_id
294+
AND ic.key_ordinal > 0
295+
GROUP BY ix.index_id
296+
HAVING COUNT(*) = 1
297+
) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END AS is_unique_key,
285298
CASE WHEN c.is_computed = 1 THEN NULL ELSE t1.name END AS user_type_name,
286299
CASE WHEN c.is_computed = 1 THEN NULL ELSE s1.name END AS user_type_schema_name,
287300
t.name AS base_type_name,

src/Generators/ProceduresGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,10 +1700,10 @@ bool GroupPathIsNullable(string? path)
17001700
var typeNameLiteral = (ip.SqlTypeName ?? ip.Name).Replace("\"", "\\\"", StringComparison.Ordinal);
17011701
if (ip.IsNullable)
17021702
{
1703-
return $"{{ var prm = cmd.Parameters[\"@{ip.Name}\"]; var source = input.{ip.PropertyName}; if (source != null) {{ var tvp = TvpHelper.BuildRecords(source) ?? Array.Empty<Microsoft.Data.SqlClient.Server.SqlDataRecord>(); prm.Value = tvp; }} else {{ prm.Value = DBNull.Value; }} if (prm is Microsoft.Data.SqlClient.SqlParameter sp) {{ sp.SqlDbType = System.Data.SqlDbType.Structured; sp.TypeName ??= \"{typeNameLiteral}\"; }} }}";
1703+
return $"{{ var prm = cmd.Parameters[\"@{ip.Name}\"]; var source = input.{ip.PropertyName}; if (source != null) {{ var tvp = TvpHelper.BuildRecords(source); prm.Value = tvp; }} else {{ prm.Value = null; }} if (prm is Microsoft.Data.SqlClient.SqlParameter sp) {{ sp.SqlDbType = System.Data.SqlDbType.Structured; sp.TypeName ??= \"{typeNameLiteral}\"; }} }}";
17041704
}
17051705

1706-
return $"{{ var prm = cmd.Parameters[\"@{ip.Name}\"]; var source = input.{ip.PropertyName}; var tvp = TvpHelper.BuildRecords(source) ?? Array.Empty<Microsoft.Data.SqlClient.Server.SqlDataRecord>(); prm.Value = tvp; if (prm is Microsoft.Data.SqlClient.SqlParameter sp) {{ sp.SqlDbType = System.Data.SqlDbType.Structured; sp.TypeName ??= \"{typeNameLiteral}\"; }} }}";
1706+
return $"{{ var prm = cmd.Parameters[\"@{ip.Name}\"]; var source = input.{ip.PropertyName}; var tvp = TvpHelper.BuildRecords(source); prm.Value = tvp; if (prm is Microsoft.Data.SqlClient.SqlParameter sp) {{ sp.SqlDbType = System.Data.SqlDbType.Structured; sp.TypeName ??= \"{typeNameLiteral}\"; }} }}";
17071707
}
17081708
var valueExpr = ip.IsNullable ? $"(object?)input.{ip.PropertyName} ?? DBNull.Value" : $"input.{ip.PropertyName}";
17091709
return $"cmd.Parameters[\"@{ip.Name}\"].Value = {valueExpr};";

src/Metadata/TableMetadataCache.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ private CacheSnapshot Load(DateTime utcNow)
203203
MaxLength = resolved?.MaxLength ?? maxLen,
204204
Precision = resolved?.Precision ?? precision,
205205
Scale = resolved?.Scale ?? scale,
206-
IsIdentity = column.GetPropertyOrDefaultBool("IsIdentity")
206+
IsIdentity = column.GetPropertyOrDefaultBool("IsIdentity"),
207+
IsUniqueKey = column.GetPropertyOrDefaultBool("IsUniqueKey")
207208
});
208209
}
209210
}

src/Metadata/TableTypeMetadataProvider.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ public sealed class ColumnInfo
6868
/// <summary>
6969
/// Gets or sets a value indicating whether the column is an identity column.</summary>
7070
public bool IsIdentity { get; init; }
71+
/// <summary>
72+
/// Gets or sets a value indicating whether the column is uniquely keyed.</summary>
73+
public bool IsUniqueKey { get; init; }
7174
}
7275

7376
/// <summary>
@@ -159,7 +162,8 @@ public IReadOnlyList<TableTypeInfo> GetAll()
159162
MaxLength = resolved?.MaxLength ?? maxLen,
160163
Precision = resolved?.Precision ?? prec,
161164
Scale = resolved?.Scale ?? scale,
162-
IsIdentity = c.GetPropertyOrDefaultBool("IsIdentity")
165+
IsIdentity = c.GetPropertyOrDefaultBool("IsIdentity"),
166+
IsUniqueKey = c.GetPropertyOrDefaultBool("IsUniqueKey")
163167
});
164168
}
165169
}
@@ -243,7 +247,8 @@ public IReadOnlyList<TableTypeInfo> GetAll()
243247
MaxLength = resolved?.MaxLength ?? maxLen,
244248
Precision = resolved?.Precision ?? prec,
245249
Scale = resolved?.Scale ?? scale,
246-
IsIdentity = c.GetPropertyOrDefaultBool("IsIdentity")
250+
IsIdentity = c.GetPropertyOrDefaultBool("IsIdentity"),
251+
IsUniqueKey = c.GetPropertyOrDefaultBool("IsUniqueKey")
247252
});
248253
}
249254
}

src/Schema/EnhancedSchemaMetadataProvider.cs

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ internal sealed class ColumnMetadata
6262
public bool IsColumnSet { get; init; }
6363
public string? GeneratedAlwaysType { get; init; }
6464
public bool? IsIdentity { get; init; }
65+
public bool? IsUniqueKey { get; init; }
6566
}
6667

6768
/// <summary>
@@ -139,7 +140,8 @@ public EnhancedSchemaMetadataProvider(
139140
if (indexColumn != null)
140141
{
141142
_console.Verbose($"[enhanced-schema] Resolved {schema}.{tableName}.{columnName} from snapshot index");
142-
return MapIndexColumn(indexColumn, columnName);
143+
var mapped = MapIndexColumn(indexColumn, columnName);
144+
return await EnrichIndexColumnWithLiveAsync(mapped, schema, tableName, columnName, catalog, cancellationToken).ConfigureAwait(false);
143145
}
144146

145147
var indexColumns = await _snapshotIndexProvider.GetTableColumnsMetadataAsync(schema, tableName, cancellationToken).ConfigureAwait(false);
@@ -152,7 +154,8 @@ public EnhancedSchemaMetadataProvider(
152154
if (fallback != null)
153155
{
154156
_console.Verbose($"[enhanced-schema] Resolved {schema}.{tableName}.{columnName} from snapshot index (table scan)");
155-
return MapIndexColumn(fallback, columnName);
157+
var mapped = MapIndexColumn(fallback, columnName);
158+
return await EnrichIndexColumnWithLiveAsync(mapped, schema, tableName, columnName, catalog, cancellationToken).ConfigureAwait(false);
156159
}
157160
}
158161
catch (Exception ex)
@@ -197,7 +200,7 @@ public async Task<IReadOnlyList<ColumnMetadata>> GetTableColumnsAsync(string sch
197200
var indexColumns = await LoadColumnsFromSnapshotIndexAsync(schema, tableName, catalog, cancellationToken).ConfigureAwait(false);
198201
if (indexColumns.Count > 0)
199202
{
200-
return indexColumns;
203+
return await EnrichIndexColumnsWithLiveAsync(indexColumns, schema, tableName, catalog, cancellationToken).ConfigureAwait(false);
201204
}
202205

203206
return await LoadColumnsFromLiveFallbacksAsync(schema, tableName, catalog, cancellationToken).ConfigureAwait(false);
@@ -244,6 +247,129 @@ private async Task<IReadOnlyList<ColumnMetadata>> LoadColumnsFromSnapshotIndexAs
244247
return Array.Empty<ColumnMetadata>();
245248
}
246249

250+
private async Task<ColumnMetadata> EnrichIndexColumnWithLiveAsync(
251+
ColumnMetadata indexColumn,
252+
string schema,
253+
string tableName,
254+
string columnName,
255+
string? catalog,
256+
CancellationToken cancellationToken)
257+
{
258+
if (_dbContext == null)
259+
{
260+
return indexColumn;
261+
}
262+
263+
var liveColumn = await ResolveFromDatabaseAsync(schema, tableName, columnName, catalog, cancellationToken).ConfigureAwait(false);
264+
if (liveColumn == null)
265+
{
266+
return indexColumn;
267+
}
268+
269+
return MergeIndexAndLive(indexColumn, liveColumn);
270+
}
271+
272+
private async Task<IReadOnlyList<ColumnMetadata>> EnrichIndexColumnsWithLiveAsync(
273+
IReadOnlyList<ColumnMetadata> indexColumns,
274+
string schema,
275+
string tableName,
276+
string? catalog,
277+
CancellationToken cancellationToken)
278+
{
279+
if (_dbContext == null)
280+
{
281+
return indexColumns;
282+
}
283+
284+
var liveColumns = await GetTableColumnsFromLiveSourcesAsync(schema, tableName, catalog, cancellationToken).ConfigureAwait(false);
285+
if (liveColumns.Count == 0)
286+
{
287+
return indexColumns;
288+
}
289+
290+
var liveMap = new Dictionary<string, ColumnMetadata>(StringComparer.OrdinalIgnoreCase);
291+
foreach (var live in liveColumns)
292+
{
293+
if (live != null && !string.IsNullOrWhiteSpace(live.Name))
294+
{
295+
liveMap[live.Name] = live;
296+
}
297+
}
298+
299+
var merged = new List<ColumnMetadata>(Math.Max(indexColumns.Count, liveMap.Count));
300+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
301+
302+
foreach (var indexColumn in indexColumns)
303+
{
304+
if (indexColumn == null || string.IsNullOrWhiteSpace(indexColumn.Name))
305+
{
306+
continue;
307+
}
308+
309+
if (liveMap.TryGetValue(indexColumn.Name, out var live))
310+
{
311+
merged.Add(MergeIndexAndLive(indexColumn, live));
312+
}
313+
else
314+
{
315+
merged.Add(indexColumn);
316+
}
317+
318+
seen.Add(indexColumn.Name);
319+
}
320+
321+
foreach (var live in liveMap.Values)
322+
{
323+
if (live == null || string.IsNullOrWhiteSpace(live.Name) || seen.Contains(live.Name))
324+
{
325+
continue;
326+
}
327+
328+
merged.Add(live);
329+
}
330+
331+
_console.Verbose($"[enhanced-schema] Merged {indexColumns.Count} snapshot index column(s) with {liveMap.Count} live column(s) for {schema}.{tableName}");
332+
return merged;
333+
}
334+
335+
private static ColumnMetadata MergeIndexAndLive(ColumnMetadata indexColumn, ColumnMetadata liveColumn)
336+
{
337+
var name = !string.IsNullOrWhiteSpace(indexColumn.Name) ? indexColumn.Name : liveColumn.Name;
338+
var sqlTypeName = !string.IsNullOrWhiteSpace(indexColumn.SqlTypeName) ? indexColumn.SqlTypeName : liveColumn.SqlTypeName;
339+
var maxLength = indexColumn.MaxLength ?? liveColumn.MaxLength;
340+
var precision = indexColumn.Precision ?? liveColumn.Precision;
341+
var scale = indexColumn.Scale ?? liveColumn.Scale;
342+
var userTypeSchema = indexColumn.UserTypeSchema ?? liveColumn.UserTypeSchema;
343+
var userTypeName = indexColumn.UserTypeName ?? liveColumn.UserTypeName;
344+
345+
return new ColumnMetadata
346+
{
347+
Name = name ?? string.Empty,
348+
Catalog = liveColumn.Catalog ?? indexColumn.Catalog,
349+
SqlTypeName = sqlTypeName ?? string.Empty,
350+
IsNullable = liveColumn.IsNullable,
351+
MaxLength = maxLength,
352+
Precision = precision,
353+
Scale = scale,
354+
UserTypeSchema = userTypeSchema,
355+
UserTypeName = userTypeName,
356+
IsFromSnapshot = false,
357+
HasDefaultValue = liveColumn.HasDefaultValue,
358+
DefaultDefinition = liveColumn.DefaultDefinition,
359+
DefaultConstraintName = liveColumn.DefaultConstraintName,
360+
IsComputed = liveColumn.IsComputed,
361+
ComputedDefinition = liveColumn.ComputedDefinition,
362+
IsComputedPersisted = liveColumn.IsComputedPersisted,
363+
IsRowGuid = liveColumn.IsRowGuid,
364+
IsSparse = liveColumn.IsSparse,
365+
IsHidden = liveColumn.IsHidden,
366+
IsColumnSet = liveColumn.IsColumnSet,
367+
GeneratedAlwaysType = liveColumn.GeneratedAlwaysType,
368+
IsIdentity = liveColumn.IsIdentity ?? indexColumn.IsIdentity,
369+
IsUniqueKey = liveColumn.IsUniqueKey ?? indexColumn.IsUniqueKey
370+
};
371+
}
372+
247373
private async Task<IReadOnlyList<ColumnMetadata>> LoadColumnsFromLiveFallbacksAsync(string schema, string tableName, string? catalog, CancellationToken cancellationToken)
248374
{
249375
if (_dbContext == null)
@@ -403,7 +529,8 @@ private static ColumnMetadata MapSnapshotColumn(SnapshotTableColumn column, stri
403529
IsHidden = column.IsHidden == true,
404530
IsColumnSet = column.IsColumnSet == true,
405531
GeneratedAlwaysType = NormalizeGeneratedAlwaysType(column.GeneratedAlwaysType),
406-
IsIdentity = column.IsIdentity == true
532+
IsIdentity = column.IsIdentity == true,
533+
IsUniqueKey = column.IsUniqueKey == true
407534
};
408535
}
409536

@@ -463,7 +590,8 @@ private static ColumnMetadata MapDatabaseColumn(Column column, string? fallbackC
463590
IsHidden = column.IsHidden,
464591
IsColumnSet = column.IsColumnSet,
465592
GeneratedAlwaysType = NormalizeGeneratedAlwaysType(column.GeneratedAlwaysType),
466-
IsIdentity = isIdentity
593+
IsIdentity = isIdentity,
594+
IsUniqueKey = column.IsUniqueKey
467595
};
468596
}
469597

@@ -485,7 +613,8 @@ private static ColumnMetadata MapIndexColumn(IndexColumnEntry column, string fal
485613
UserTypeSchema = userTypeSchema,
486614
UserTypeName = userTypeName,
487615
IsFromSnapshot = true,
488-
IsIdentity = column.IsIdentity
616+
IsIdentity = column.IsIdentity,
617+
IsUniqueKey = column.IsUniqueKey
489618
};
490619
}
491620

src/Services/SchemaSnapshotFileLayoutService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ void PruneFnColumns(List<SnapshotFunctionColumn> cols)
393393
}
394394
if (c.IsNullable == false) c.IsNullable = null;
395395
if (c.IsIdentity == false) c.IsIdentity = null;
396+
if (c.IsUniqueKey == false) c.IsUniqueKey = null;
396397
if (c.MaxLength.HasValue && c.MaxLength.Value == 0) c.MaxLength = null;
397398
if (c.Precision.HasValue && c.Precision.Value == 0) c.Precision = null;
398399
if (c.Scale.HasValue && c.Scale.Value == 0) c.Scale = null;

src/Services/SchemaSnapshotService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ private static string ComputeTableColumnsHash(IReadOnlyList<SnapshotTableColumn>
407407
FormatNumeric(column.Precision),
408408
FormatNumeric(column.Scale),
409409
FormatBool(column.IsIdentity),
410+
FormatBool(column.IsUniqueKey),
410411
FormatBool(column.HasDefaultValue),
411412
NormalizeExpression(column.DefaultDefinition),
412413
NormalizeDescriptor(column.DefaultConstraintName),
@@ -722,6 +723,7 @@ internal sealed class SnapshotTableColumn
722723
public bool? IsNullable { get; set; } // false values are pruned during persistence to match other models (writer implementation pending)
723724
public int? MaxLength { get; set; } // null when the length is zero or not applicable
724725
public bool? IsIdentity { get; set; } // persist true only
726+
public bool? IsUniqueKey { get; set; } // persist true only
725727
public int? Precision { get; set; }
726728
public int? Scale { get; set; }
727729
public bool? HasDefaultValue { get; set; }

0 commit comments

Comments
 (0)