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:
Joseph Doherty
2026-10-22 15:49:24 -04:00
parent 91b7e9541b
commit 7f5d959fb9
2 changed files with 45 additions and 6 deletions
@@ -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>