feat(sql): surface IS_NULLABLE on browsed column attributes (R06:S-5)
Every dialect projects IS_NULLABLE and nothing read it. The browse session now reads it (tolerantly: a dialect that does not project it reads as unknown) and the picker's descriptor line says 'nullable' / 'NOT NULL', so an operator knows a cell can come back NULL (Uncertain, or Bad under nullIsBad) before committing the tag. The runtime catalog loader keeps reading names only - nothing at runtime consumes nullability. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -48,6 +48,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// <summary>Column alias carrying the SQL type family name.</summary>
|
||||
private const string DataTypeColumn = "DATA_TYPE";
|
||||
|
||||
/// <summary>
|
||||
/// Column alias carrying <c>YES</c>/<c>NO</c> nullability. Every shipped dialect projects it (Oracle
|
||||
/// normalises its <c>Y</c>/<c>N</c>); it used to be read by nothing (R06:S-5).
|
||||
/// </summary>
|
||||
private const string IsNullableColumn = "IS_NULLABLE";
|
||||
|
||||
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
|
||||
private const string ViewTableType = "VIEW";
|
||||
|
||||
@@ -158,7 +164,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
|
||||
var timestampColumn = PreferredTimestampColumn(columns);
|
||||
var timestampColumn = PreferredTimestampColumn([.. columns.Select(c => (c.Name, c.DataType))]);
|
||||
|
||||
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
|
||||
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
|
||||
@@ -191,7 +197,15 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
Name: column.Name,
|
||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||
IsArray: false,
|
||||
SecurityClass: ReadOnlySecurityClass,
|
||||
// The picker's descriptor line ("{type} · {SecurityClass}") — display only. Nullability is
|
||||
// what tells an operator a cell can come back NULL, which publishes Uncertain (or Bad under
|
||||
// nullIsBad), before they commit the tag.
|
||||
SecurityClass: column.IsNullable switch
|
||||
{
|
||||
true => ReadOnlySecurityClass + " · nullable",
|
||||
false => ReadOnlySecurityClass + " · NOT NULL",
|
||||
null => ReadOnlySecurityClass,
|
||||
},
|
||||
IsAlarm: false,
|
||||
AddressFields: addressFields),
|
||||
];
|
||||
@@ -356,7 +370,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
|
||||
/// relation, and neither is a reason to fail an operator's click.
|
||||
/// </summary>
|
||||
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
|
||||
private Task<List<(string Name, string DataType, bool? IsNullable)>> ReadColumnsAsync(
|
||||
string schema, string table, CancellationToken ct) =>
|
||||
QueryAsync(
|
||||
_dialect.ListColumnsSql,
|
||||
@@ -367,9 +381,30 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
},
|
||||
static reader => (
|
||||
Name: ReadString(reader, ColumnNameColumn),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
|
||||
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty,
|
||||
IsNullable: ReadNullability(reader)),
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>IS_NULLABLE</c> as a tri-state: <see langword="null"/> when a (custom) dialect does not
|
||||
/// project it or the value is neither <c>YES</c> nor <c>NO</c> — "unknown" is said by omission, never
|
||||
/// guessed.
|
||||
/// </summary>
|
||||
private static bool? ReadNullability(DbDataReader reader)
|
||||
{
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
if (!string.Equals(reader.GetName(i), IsNullableColumn, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (reader.IsDBNull(i)) return null;
|
||||
var text = reader.GetValue(i)?.ToString()?.Trim();
|
||||
if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one catalog query under the gate and projects every row. The disposed check happens
|
||||
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
|
||||
|
||||
@@ -78,7 +78,8 @@ public sealed class SqlBrowseSessionTests
|
||||
attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn);
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
attribute.IsArray.ShouldBeFalse();
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly");
|
||||
// R06:S-5: every dialect projects IS_NULLABLE and the picker now says so. The column is declared NULL.
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly · nullable");
|
||||
attribute.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -92,7 +93,10 @@ public sealed class SqlBrowseSessionTests
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn);
|
||||
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
|
||||
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
var attribute = attributes.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
// The NOT NULL control for the nullable case above — the descriptor tracks the catalog, not a constant.
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly · NOT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user