fix(sql-browser): rank the row-selector candidate so a date-only column is a last resort (Driver.Sql.Browser-001)
PreferredTimestampColumn nominated the first column mapping to DriverDataType .DateTime, and that set includes date-only types (SQL Server / PostgreSQL 'date'). A table whose first date/time column is a 'date' committed rowSelector.topByTimestamp = <date>, making the wide-row read ORDER BY <date> DESC LIMIT 1 - which ties for every row written that day, so 'the newest row' became storage order among the ties. Time-bearing candidates now outrank date-only ones; a date-only column is still stated when it is all the table has, so such a table stays bindable. Oracle's DATE carries a time of day and is exempt - pinned by a control dialect that differs from the SQLite one only in the Provider it reports.
This commit is contained in:
+64
@@ -0,0 +1,64 @@
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SqliteDialect"/> in every respect except that it reports <see cref="SqlProvider.Oracle"/>.
|
||||
/// It exists for exactly one assertion: <b>Oracle's <c>DATE</c> carries a time-of-day component</b>
|
||||
/// (<c>OracleDialect.MapColumnType</c> says so in as many words), so it is NOT the date-only type the
|
||||
/// row-selector ranking demotes on the other dialects, and an Oracle table whose <c>DATE</c> column is
|
||||
/// declared before its <c>TIMESTAMP</c> must still pick the <c>DATE</c> — the ordinary
|
||||
/// first-in-catalog-order tie-break.
|
||||
/// <para>Without this control, implementing the ranking provider-blind ("any type family spelled
|
||||
/// <c>date</c> is date-only") passes every other test in the suite while silently changing the pick on
|
||||
/// the one backend where the spelling means something else.</para>
|
||||
/// </summary>
|
||||
public sealed class OracleFlavouredSqliteDialect : ISqlDialect
|
||||
{
|
||||
private readonly SqliteDialect _inner = new();
|
||||
|
||||
/// <summary>The whole point of the type: an Oracle-shaped provider over the SQLite catalog.</summary>
|
||||
public SqlProvider Provider => SqlProvider.Oracle;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DbProviderFactory Factory => _inner.Factory;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string LivenessSql => _inner.LivenessSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitPrefix => _inner.SingleRowLimitPrefix;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string DefaultSchemaSql => _inner.DefaultSchemaSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListTablesSql => _inner.ListTablesSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListColumnsSql => _inner.ListColumnsSql;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string QuoteIdentifier(string ident) => _inner.QuoteIdentifier(ident);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ParameterMarker(string baseName) => _inner.ParameterMarker(baseName);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ParameterBindName(string baseName) => _inner.ParameterBindName(baseName);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ConfigureCommand(DbCommand command) => _inner.ConfigureCommand(command);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DriverDataType MapColumnType(string sqlDataType) => _inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
@@ -458,6 +458,83 @@ public sealed class SqlBrowseSessionTests
|
||||
.ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <b>date-only</b> column is a candidate of last resort, not the first pick. <c>date</c> maps to
|
||||
/// <see cref="DriverDataType.DateTime"/> on every dialect, so a candidacy rule that only asked the
|
||||
/// mapped type would commit <c>ORDER BY <date> DESC LIMIT 1</c> — which ties for every row
|
||||
/// written on the same day, making "the newest row" storage order among the ties. The reader cannot
|
||||
/// warn about it either: the server has already discarded the other rows.
|
||||
/// <para>The fixture's table declares the date-only column FIRST, so this fails against the plain
|
||||
/// first-in-catalog-order rule rather than passing by luck.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_prefersATimeBearingColumn_overAnEarlierDateOnlyOne()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema,
|
||||
SqliteBrowseFixture.DateOnlyFirstTable,
|
||||
SqliteBrowseFixture.DateOnlyFirstValueColumn);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
|
||||
SqliteBrowseFixture.DateOnlyFirstTimeColumn,
|
||||
"a time-bearing column outranks a date-only one even when the date-only one is declared first");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fallback half of the ranking: when date-only is ALL the table has, it is still stated. Dropping
|
||||
/// date-only types from candidacy outright would instead make such a table unbindable by browse, which
|
||||
/// is a bigger behaviour change than the defect being fixed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_stillStatesADateOnlyColumn_whenTheTableHasNothingBetter()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema,
|
||||
SqliteBrowseFixture.DateOnlySoleTable,
|
||||
SqliteBrowseFixture.DateOnlySoleValueColumn);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.DateOnlySoleColumn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsification control for the ranking: <b>Oracle's <c>DATE</c> carries a time-of-day
|
||||
/// component</b>, so it is not date-only there and must not be demoted. Same catalog, same columns,
|
||||
/// only the dialect's <see cref="ISqlDialect.Provider"/> differs — and the pick reverts to the
|
||||
/// ordinary first-in-catalog-order one.
|
||||
/// <para>A ranking implemented provider-blind ("any family spelled <c>date</c>") passes every other
|
||||
/// test in this file and fails only here.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_doesNotDemoteOraclesDATE_whichCarriesATimeOfDay()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync();
|
||||
await using var session = new SqlBrowseSession(db.Connection, new OracleFlavouredSqliteDialect());
|
||||
|
||||
var nodeId = SqlBrowseNodeId.ForColumn(
|
||||
SqliteDialect.MainSchema,
|
||||
SqliteBrowseFixture.DateOnlyFirstTable,
|
||||
SqliteBrowseFixture.DateOnlyFirstValueColumn);
|
||||
|
||||
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
|
||||
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
|
||||
|
||||
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
|
||||
SqliteBrowseFixture.DateOnlyFirstColumn,
|
||||
"Oracle's DATE is time-bearing, so the first-in-catalog-order tie-break applies unchanged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table with no date/time column states NO candidate — the key is absent, never blank — so the
|
||||
/// commit can tell "this table cannot be bound by browse" from "the session said nothing".
|
||||
|
||||
@@ -96,6 +96,40 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
|
||||
/// <summary>A <see cref="MultiTimeTable"/> value column, sitting BETWEEN the two timestamps.</summary>
|
||||
public const string MultiTimeValueColumn = "level";
|
||||
|
||||
/// <summary>
|
||||
/// A table whose FIRST date/time-typed column is <b>date-only</b>, with a genuinely time-bearing one
|
||||
/// declared after it. A date-only column maps to
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.DateTime"/> like any other, so a
|
||||
/// candidacy rule that only asked the mapped type would order the wide-row read by
|
||||
/// <see cref="DateOnlyFirstColumn"/> — one row per DAY, ties broken by storage order.
|
||||
/// </summary>
|
||||
public const string DateOnlyFirstTable = "DailyTotals";
|
||||
|
||||
/// <summary>The date-only column of <see cref="DateOnlyFirstTable"/>, declared first.</summary>
|
||||
public const string DateOnlyFirstColumn = "shift_day";
|
||||
|
||||
/// <summary>A <see cref="DateOnlyFirstTable"/> value column, sitting between the two candidates.</summary>
|
||||
public const string DateOnlyFirstValueColumn = "units";
|
||||
|
||||
/// <summary>
|
||||
/// The time-bearing column of <see cref="DateOnlyFirstTable"/>, declared LAST — the expected pick,
|
||||
/// even though the date-only one comes first in catalog order.
|
||||
/// </summary>
|
||||
public const string DateOnlyFirstTimeColumn = "logged_at";
|
||||
|
||||
/// <summary>
|
||||
/// A table whose ONLY date/time-typed column is date-only — the fallback control. Dropping date-only
|
||||
/// types from candidacy outright would make this table unbindable by browse; ranking keeps it bindable
|
||||
/// while still preferring a better column wherever one exists.
|
||||
/// </summary>
|
||||
public const string DateOnlySoleTable = "DayBook";
|
||||
|
||||
/// <summary>The sole (date-only) candidate of <see cref="DateOnlySoleTable"/>.</summary>
|
||||
public const string DateOnlySoleColumn = "book_day";
|
||||
|
||||
/// <summary>A <see cref="DateOnlySoleTable"/> value column.</summary>
|
||||
public const string DateOnlySoleValueColumn = "opening_balance";
|
||||
|
||||
private readonly string _databasePath;
|
||||
|
||||
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
|
||||
@@ -210,6 +244,17 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
|
||||
"{MultiTimeValueColumn}" REAL NULL,
|
||||
"{MultiTimeSecondColumn}" TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "{DateOnlyFirstTable}" (
|
||||
"{DateOnlyFirstColumn}" DATE NOT NULL,
|
||||
"{DateOnlyFirstValueColumn}" REAL NULL,
|
||||
"{DateOnlyFirstTimeColumn}" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "{DateOnlySoleTable}" (
|
||||
"{DateOnlySoleColumn}" DATE NOT NULL,
|
||||
"{DateOnlySoleValueColumn}" REAL NULL
|
||||
);
|
||||
""").ConfigureAwait(false);
|
||||
|
||||
// Separate statements: these names must be quoted by the seed itself, so keep them off the
|
||||
|
||||
Reference in New Issue
Block a user