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:
@@ -63,6 +63,14 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// </summary>
|
||||
private const string ReadOnlySecurityClass = "ViewOnly";
|
||||
|
||||
/// <summary>
|
||||
/// The one SQL type family that maps to <see cref="DriverDataType.DateTime"/> while carrying <b>no
|
||||
/// time of day</b> — SQL Server's and PostgreSQL's <c>date</c>. Ranked below the time-bearing
|
||||
/// families by <see cref="PreferredTimestampColumn"/>; see <see cref="IsDateOnly"/> for the Oracle
|
||||
/// exception.
|
||||
/// </summary>
|
||||
private const string DateOnlyTypeFamily = "date";
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly ISqlDialect _dialect;
|
||||
|
||||
@@ -274,12 +282,23 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// row sorts last lexically. So the only input is
|
||||
/// <see cref="ISqlDialect.MapColumnType"/> reporting <see cref="DriverDataType.DateTime"/>, which is
|
||||
/// the same catalog metadata the browse tree already renders per column, on every dialect.</para>
|
||||
/// <para><b>Tie-break: FIRST in catalog order.</b> Every dialect's <see cref="ISqlDialect.ListColumnsSql"/>
|
||||
/// orders by ordinal position (<c>ORDINAL_POSITION</c> / <c>column_id</c>), so "first" means the
|
||||
/// earliest-declared date/time column and is stable across calls, connections and servers — an
|
||||
/// unordered "any candidate" would let two operators browsing the same table commit two differently
|
||||
/// bound tags. On the usual multi-candidate shape (a <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it
|
||||
/// picks the earlier-declared one, which is a preference and not a fact about the data.</para>
|
||||
/// <para><b>Ranked, because "maps to DateTime" includes DATE-ONLY types.</b>
|
||||
/// <see cref="ISqlDialect.MapColumnType"/> reports <see cref="DriverDataType.DateTime"/> for SQL
|
||||
/// Server's and PostgreSQL's <c>date</c> as well — and a wide-row read ordered by a date-only column
|
||||
/// is <c>ORDER BY <date> DESC</c> limited to one row, which <em>ties for every row written on
|
||||
/// the same day</em>: "the newest row" silently becomes storage order among the ties, and the reader
|
||||
/// cannot warn about it because the server has already discarded the others. So a time-bearing
|
||||
/// candidate always outranks a date-only one, wherever the two are declared.
|
||||
/// <b>Falling back to a date-only column is deliberate</b> rather than dropping those types from
|
||||
/// candidacy: a table whose only date/time column is a <c>date</c> stays bindable by browse (with a
|
||||
/// default the operator can see and change) instead of becoming un-committable.</para>
|
||||
/// <para><b>Tie-break within a rank: FIRST in catalog order.</b> Every dialect's
|
||||
/// <see cref="ISqlDialect.ListColumnsSql"/> orders by ordinal position (<c>ORDINAL_POSITION</c> /
|
||||
/// <c>column_id</c>), so "first" means the earliest-declared candidate and is stable across calls,
|
||||
/// connections and servers — an unordered "any candidate" would let two operators browsing the same
|
||||
/// table commit two differently bound tags. On the usual multi-candidate shape (a
|
||||
/// <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it picks the earlier-declared one, which is a preference
|
||||
/// and not a fact about the data.</para>
|
||||
/// <para><b>It is a visible, editable DEFAULT, not a hidden binding.</b> The commit writes it into the
|
||||
/// tag's <c>rowSelector.topByTimestamp</c>, where the typed Sql editor shows it and the operator can
|
||||
/// change it — to the other timestamp, or to a <c>whereColumn</c>/<c>whereValue</c> pair entirely.
|
||||
@@ -289,13 +308,42 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
/// </remarks>
|
||||
private string? PreferredTimestampColumn(IReadOnlyList<(string Name, string DataType)> columns)
|
||||
{
|
||||
// One pass, remembering the best date-only candidate seen so far: the first time-bearing column wins
|
||||
// outright, and the date-only one is only used if the walk ends without finding one.
|
||||
string? dateOnlyFallback = null;
|
||||
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(column.Name)) continue;
|
||||
if (_dialect.MapColumnType(column.DataType) == DriverDataType.DateTime) return column.Name;
|
||||
if (_dialect.MapColumnType(column.DataType) != DriverDataType.DateTime) continue;
|
||||
|
||||
if (!IsDateOnly(column.DataType)) return column.Name;
|
||||
dateOnlyFallback ??= column.Name;
|
||||
}
|
||||
|
||||
return null;
|
||||
return dateOnlyFallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a column's declared type family carries a date but no time of day — the case that makes an
|
||||
/// <c>ORDER BY … DESC</c> single-row read tie for a whole day's rows.
|
||||
/// </summary>
|
||||
/// <param name="sqlDataType">The bare SQL type family name, as the catalog reported it.</param>
|
||||
/// <returns><see langword="true"/> when the family is date-only on this dialect's backend.</returns>
|
||||
/// <remarks>
|
||||
/// <b>Provider-qualified, because the spelling is not the meaning.</b> <c>date</c> is date-only on SQL
|
||||
/// Server and PostgreSQL, but <b>Oracle's <c>DATE</c> carries a time-of-day component</b> — which is
|
||||
/// why <c>OracleDialect.MapColumnType</c> calls <see cref="DriverDataType.DateTime"/> the faithful
|
||||
/// mapping there rather than a widening. Demoting it on Oracle would change the pick on the backend
|
||||
/// where <c>DATE</c> is the ordinary timestamp type, for no gain.
|
||||
/// <para>The check is a browse-time <em>preference</em> and belongs here rather than on
|
||||
/// <see cref="ISqlDialect"/>: nothing on the poll path cares, and the type map is deliberately about
|
||||
/// what a value IS, not about what it is a good idea to sort by.</para>
|
||||
/// </remarks>
|
||||
private bool IsDateOnly(string sqlDataType)
|
||||
{
|
||||
if (_dialect.Provider == SqlProvider.Oracle) return false;
|
||||
return string.Equals(sqlDataType.Trim(), DateOnlyTypeFamily, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user