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"); } ///