diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
index f25aaff7..7feb6a0f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs
@@ -48,6 +48,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// Column alias carrying the SQL type family name.
private const string DataTypeColumn = "DATA_TYPE";
+ ///
+ /// Column alias carrying YES/NO nullability. Every shipped dialect projects it (Oracle
+ /// normalises its Y/N); it used to be read by nothing (R06:S-5).
+ ///
+ private const string IsNullableColumn = "IS_NULLABLE";
+
/// The TABLE_TYPE value marking a view rather than a base table.
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.
///
- private Task> ReadColumnsAsync(
+ private Task> 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);
+ ///
+ /// Reads IS_NULLABLE as a tri-state: when a (custom) dialect does not
+ /// project it or the value is neither YES nor NO — "unknown" is said by omission, never
+ /// guessed.
+ ///
+ 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;
+ }
+
///
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// inside the gate wait so a dispose racing an in-flight call cannot be missed.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs
index 6be390f9..0bd53ad5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs
@@ -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");
}
///