From 4a98fc39fe53cbbff14ad64675febba250964320 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 20 Sep 2026 16:12:40 +0800 Subject: [PATCH 01/14] Record what ACE writes, and when, on a commit ACE never forces the OS cache to disk - no FlushFileBuffers on a statement, on an explicit commit or at close - and opens the file without write-through, so its durability is the OS cache plus the commit-byte protocol. Its commit-sync settings change only whether a write is issued inside the statement or after it returns. Two orderings hold in every mode: a page's lock is released only after that page has been written, and the connection's own commit slot is written immediately before the first page of a batch and again after the last. An explicit transaction writes nothing until it commits, and growth writes the new page's last byte before the page. The transactions design note claimed a commit fsyncs twice; it now records the measured protocol, and what LibRed does against it. Also note that how aggressively freed long-value pages are reclaimed is an engine setting, RecycleLVs, which ships as 1 on ACE and 0 on Jet 4. Co-Authored-By: Claude Opus 5 --- src/LibRed/docs/design/transactions.md | 38 +++++++++++++++++++--- src/LibRed/docs/format/long-values.md | 6 ++++ src/LibRed/docs/format/page-00-database.md | 7 ++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/LibRed/docs/design/transactions.md b/src/LibRed/docs/design/transactions.md index e6694f01..3603c2dd 100644 --- a/src/LibRed/docs/design/transactions.md +++ b/src/LibRed/docs/design/transactions.md @@ -193,17 +193,45 @@ interface ILockManager : IDisposable — which is our compatibility target, *not* SQL Server's "unqualified ROLLBACK unwinds all levels". A named `SAVE`/`ROLLBACK TRANSACTION ` addresses a specific frame. No new mechanism is needed: nesting is the Phase-1 savepoint stack, driven by the controller. -- **Durability:** commit flushes dirty pages then clears the commit-byte; a crash before +- **Durability:** commit writes dirty pages out (to the OS, as ACE does — §5) then clears + the commit-byte; a crash before the clear leaves the Jet "suspect" signal (later, with `JetLockManager`) → repair path. With the self-consistent manager, recovery is process-local (no cross-process crash interop claimed yet). ## 5. Flush / commit ordering (matching Jet, staying ACE-safe) -On commit, in order: (1) write all dirty data/index/LVAL/usage-map pages; (2) fsync; -(3) write the page-0 header/commit-state update; (4) fsync; (5) clear commit-byte / -release locks. Never leave a header pointing at pages that aren't durable. No structure -is written that Access cannot parse — the commit-byte table and lock offsets are the only +**What ACE does (measured: ACE 16 over OLE DB, Process Monitor).** ACE never forces the OS +cache to disk — no `FlushFileBuffers` on a statement, on an explicit `COMMIT`, or at close — +and it opens the file without write-through. Its durability is the OS file cache plus the +commit-byte protocol, nothing more. What its settings change is only *when* the write +reaches the OS: + +| Setting (registry default) | Effect | +| --- | --- | +| `ImplicitCommitSync` = **no** | A lone statement's writes are issued *after* the statement returns — deferred, and flushed to the OS when the next statement starts or the timeout fires | +| `UserCommitSync` = **yes** | An explicit `BEGIN…COMMIT` writes its pages inside `COMMIT`, before it returns | +| `FlushTransactionTimeout` = **500 ms** | How long a deferred write waits; overrides `SharedAsyncDelay` (50) and `ExclusiveAsyncDelay` (2000) | +| `PageTimeout` = **5000 ms** | How long another user's cached pages may stay stale | + +Two orderings hold in every mode, and they are the ones that matter for concurrency: + +- **Pages reach the OS before the lock is released.** A page's `.laccdb` lock is released + only after that page's `WriteFile`, never before. Cross-connection visibility therefore + rests on the write reaching the OS — which every handle on the machine reads through — + not on it reaching the disk. +- **The commit-slot write brackets the batch**: the connection's own slot at `0xE02` is + written immediately before the first page and again after the last + ([page-00 §2.2](../format/page-00-database.md)). An explicit transaction writes nothing + at all until its commit. +- File growth is a 1-byte write at the last byte of the new page, then the page itself. + +**What LibRed does.** The same, deliberately: a commit publishes every overlay page to the +OS under the publish lock, and neither a commit nor a close forces them to disk. An +autocommit statement writes its pages immediately rather than deferring them — stricter +than ACE's default, and the deferral is what the engine-settings work would add. Never +leave a header pointing at pages the OS has not been given. No structure is written that +Access cannot parse — the commit-byte table and lock offsets are the only concurrency-visible state, exactly as Jet uses them. ## 6. Phased implementation plan diff --git a/src/LibRed/docs/format/long-values.md b/src/LibRed/docs/format/long-values.md index e17e1cce..3b486891 100644 --- a/src/LibRed/docs/format/long-values.md +++ b/src/LibRed/docs/format/long-values.md @@ -129,6 +129,12 @@ LVAL pages are data pages (type `0x01`) whose owner field (`0x04`) is the ASCII > engine opens one per statement, so a failed reclamation rolls back with the statement. A direct > `LibRed.Core` caller that opens none gets the same non-atomic behaviour as any other multi-page write. +> **How aggressively the engine reclaims is a setting, and its default differs by engine.** Jet's +> `RecycleLVs` (the OLE DB property `Jet OLEDB:Recycle Long-Valued Pages`) controls whether freed LVAL pages +> are reclaimed aggressively; it ships as **1 on ACE** (both 14 and 16) and **0 on Jet 4.0**. So the same +> delete can leave different free/owned-map state behind depending on which engine wrote the file — worth +> pinning before treating an ACE-vs-Jet4 reclamation difference as a format one. + ### 3.3.2 Column usage-map list (trailing the index names) After the index names (in the TDEF body, §3.3) comes a list of per-**long-value-column** (memo/OLE) diff --git a/src/LibRed/docs/format/page-00-database.md b/src/LibRed/docs/format/page-00-database.md index 07d66f87..bae6ee9e 100644 --- a/src/LibRed/docs/format/page-00-database.md +++ b/src/LibRed/docs/format/page-00-database.md @@ -291,6 +291,13 @@ Four properties follow: seven-statement sequence is 722 → 729, exactly +7. The same five inserts with and without a `CREATE INDEX` differ by one. +**The slot write brackets every batch of page writes (verified).** A connection's writes reach the file as: +a 2-byte write to *its own* slot, then the data/index/usage-map pages, then a second 2-byte write to that same +slot. This holds for a lone statement, for an explicit transaction's commit, and for the final writes at close, +and it is the on-disk half of the mid-write signal above: the file carries the "this user is writing" state for +exactly as long as the pages are in flight. (The two values written are not themselves captured — only the +order and the 2-byte extent at the slot's offset.) + **Reopening does not reset it; compacting does.** The counter carries straight across a close and reopen (`…DA` before, `…DA` after). A DAO `CompactDatabase` writes a whole new file and its slot 1 starts at **256**, the idle value, then counts normally from there (744 → 256 → 260 after five inserts). From b9cee485977a02746f3543696287c0932656fcb3 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 20 Sep 2026 21:39:40 +0800 Subject: [PATCH 02/14] Serve ADO.NET's metadata surface from the catalog GetSchema served nothing, a reader could not describe its own columns, and a command took only text. All three now answer from the catalog and the planner, in the shapes ACE's OLE DB provider uses - verified row for row against it over Northwind: tables, columns, indexes, primary and foreign keys, the four constraint rowsets, statistics, views and procedures all match, and the two ACE serves through neither call (procedure parameters, view column usage) follow the OLE DB shapes. TABLE_TYPE comes from MSysObjects.Flags, a view's columns from planning its query - so a passed-through column reports the stored column behind it, facets and all - and a table's logical indexes carry the foreign-key direction byte, which is what lets an incoming relationship stay hidden as ACE hides it. The reader implements IDbColumnSchemaGenerator and GetSchemaTable from the same description: each output column carries the stored column it came from, so an alias keeps the stored name, a joined column names the other table, and a computed one stands alone as read-only. Key and base information is reported whether or not KeyInfo was asked for, since the round trip that flag exists to avoid is one this provider never makes. A command now accepts StoredProcedure and TableDirect, executing the named query with its arguments bound by name. An Access PARAMETERS clause ends in a semicolon, so the batch splitter no longer breaks it away from the query it declares for - which is the form every stored parameterized query reads back as. CommandBehavior is honoured rather than ignored: SchemaOnly plans a statement for its shape and runs none of it (measured: ACE leaves an INSERT's table untouched under it), SingleRow stops at the row already buffered, and CloseConnection closes the connection with the reader. Describing needs the executor to build a plan that reads nothing, so every leaf yields no rows and no seek key, offset or count is evaluated - which is also why a stored procedure can be described without the parameter values nobody supplied. Reporting a view's columns goes through the same path, where it used to sort and buffer the whole of any view with an ORDER BY. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedCommand.cs | 75 +- src/LibRed/LibRed.Ado/LibRedConnection.cs | 17 + src/LibRed/LibRed.Ado/LibRedDataReader.cs | 87 +- src/LibRed/LibRed.Ado/LibRedDbColumn.cs | 49 ++ src/LibRed/LibRed.Ado/LibRedSchema.cs | 299 +++++++ src/LibRed/LibRed.Core/Catalog/JetCatalog.cs | 20 +- .../LibRed.Core/Catalog/LogicalIndexDef.cs | 28 + src/LibRed/LibRed.Core/Catalog/TableDef.cs | 16 + src/LibRed/LibRed.Core/Catalog/ViewSpec.cs | 5 + .../LibRed.Core/Pages/TableDefinitionPage.cs | 16 +- .../LibRed.Engine/Execution/QueryExecutor.cs | 130 ++- .../LibRed.Engine/Execution/ResultSet.cs | 41 +- src/LibRed/LibRed.Engine/QueryEngine.cs | 50 +- .../LibRed.Engine/Schema/SchemaRowsets.cs | 788 ++++++++++++++++++ src/LibRed/LibRed.Sql/SqlKeywords.cs | 42 + src/LibRed/docs/format/data-types.md | 34 + src/LibRed/docs/format/system-catalog.md | 18 + test/LibRed.Ado.Tests/LibRedCommandTests.cs | 129 +++ .../LibRedDataReaderMetadataTests.cs | 91 ++ test/LibRed.Ado.Tests/LibRedSchemaTests.cs | 266 ++++++ .../ActionQueryProcedureAccessTests.cs | 2 +- 21 files changed, 2158 insertions(+), 45 deletions(-) create mode 100644 src/LibRed/LibRed.Ado/LibRedDbColumn.cs create mode 100644 src/LibRed/LibRed.Ado/LibRedSchema.cs create mode 100644 src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs create mode 100644 src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs create mode 100644 src/LibRed/LibRed.Sql/SqlKeywords.cs create mode 100644 test/LibRed.Ado.Tests/LibRedSchemaTests.cs diff --git a/src/LibRed/LibRed.Ado/LibRedCommand.cs b/src/LibRed/LibRed.Ado/LibRedCommand.cs index cf38f100..5b0e9517 100644 --- a/src/LibRed/LibRed.Ado/LibRedCommand.cs +++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs @@ -48,11 +48,16 @@ public override void Prepare() { } protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) { + // SchemaOnly asks what the command WOULD return: the reader carries the columns and no rows, and + // nothing runs — not the INSERT in a batch, and not the stored procedure a name stands for. + if (behavior.HasFlag(CommandBehavior.SchemaOnly)) + return new LibRedDataReader(DescribeBatch(), recordsAffected: -1, behavior, Connection); + // Route through Execute so the reader path also handles DML/DDL: EF Core runs inserts through // ExecuteReader and inspects RecordsAffected. A query yields rows (RecordsAffected -1); an // INSERT/CREATE runs and yields an empty result carrying its rows-affected count. Engine.CommandResult result = ExecuteBatch(); - return new LibRedDataReader(result.Rows, result.RecordsAffected); + return new LibRedDataReader(result.Rows, result.RecordsAffected, behavior, Connection); } /// @@ -70,7 +75,7 @@ private Engine.CommandResult ExecuteBatch() IReadOnlyDictionary parameters = BuildParameters(); Engine.CommandResult? last = null; - foreach (string statement in SplitStatements(CommandText)) + foreach (string statement in SplitStatements(StatementText())) { // A fragment holding no statement (only comments) is skipped rather than run: it must not become // the batch's last result, or `INSERT …; -- done` would report the comment's zero rows instead of @@ -104,15 +109,66 @@ private Engine.CommandResult ExecuteBatch() return last ?? new Engine.CommandResult(Engine.Execution.ResultSet.Empty, RecordsAffected: 0); } + /// + /// The shape the command's batch would return, running none of it. As in the + /// batch's result is its last statement's — and since nothing runs, that is the only one worth + /// describing. + /// + private Engine.Execution.ResultSet DescribeBatch() + { + ValidateTransaction(); + Engine.QueryEngine engine = RequireEngine(); + + string? last = SplitStatements(StatementText()).LastOrDefault(s => !engine.IsStatementless(s)); + return last is null + ? Engine.Execution.ResultSet.Empty + : engine.Describe(last, BuildParameters()); + } + + /// + /// The SQL this command runs, which for the two non-text command types is built from the name in + /// : a stored procedure — an Access stored query — is executed by name with each + /// of the command's parameters bound to the procedure's parameter of the same name, and a table is read + /// whole. A name is bracket-quoted, so one containing spaces works as it does in Access. + /// + private string StatementText() => CommandType switch + { + CommandType.Text => CommandText, + CommandType.TableDirect => $"SELECT * FROM {Quote(CommandText)}", + CommandType.StoredProcedure => BuildExecute(), + _ => throw new NotSupportedException($"CommandType.{CommandType} is not supported."), + }; + + /// An EXECUTE for the named stored query, naming each parameter so the order the caller + /// added them in does not matter. A procedure taking none runs bare. + private string BuildExecute() + { + var arguments = _parameters.Cast() + .Where(p => p.Direction is ParameterDirection.Input or ParameterDirection.InputOutput) + .Select(p => $"{Quote(p.ParameterName.TrimStart('@'))} = @{p.ParameterName.TrimStart('@')}") + .ToList(); + + return arguments.Count == 0 + ? $"EXECUTE {Quote(CommandText)}" + : $"EXECUTE {Quote(CommandText)} {string.Join(", ", arguments)}"; + } + + private static string Quote(string name) => $"[{name.Trim().Trim('[', ']')}]"; + /// /// Splits a batch on top-level ; separators, ignoring semicolons inside string literals /// ('…' / "…") and quoted identifiers ([…] / `…`). Blank statements /// (e.g. a trailing ;) are dropped. The single-statement common case returns one item. + /// An Access PARAMETERS …; clause is not a statement of its own: its semicolon + /// ends the clause, and the query it declares for follows. Such a fragment is carried onto the next one + /// so the pair reaches the engine as the one statement it is — the form every stored parameterized + /// query reads back as. /// public static IEnumerable SplitStatements(string sql) { int start = 0; char quote = '\0'; // the closing delimiter we're inside, or '\0' at top level + string prefix = string.Empty; // a PARAMETERS clause awaiting its query for (int i = 0; i < sql.Length; i++) { char c = sql[i]; @@ -125,15 +181,26 @@ public static IEnumerable SplitStatements(string sql) else if (c == ';') { string part = sql[start..i].Trim(); - if (part.Length > 0) yield return part; + if (part.Length > 0) + { + if (IsParametersClause(part)) prefix += part + "; "; + else { yield return prefix + part; prefix = string.Empty; } + } start = i + 1; } } string tail = sql[start..].Trim(); - if (tail.Length > 0) yield return tail; + // A clause with nothing after it is yielded as-is, so the engine reports it rather than the batch + // silently running nothing. + if (tail.Length > 0) yield return prefix + tail; + else if (prefix.Length > 0) yield return prefix.TrimEnd(' ', ';'); } + private static bool IsParametersClause(string statement) => + statement.StartsWith("PARAMETERS", StringComparison.OrdinalIgnoreCase) + && (statement.Length == "PARAMETERS".Length || char.IsWhiteSpace(statement["PARAMETERS".Length])); + private Engine.QueryEngine RequireEngine() => Connection?.Engine ?? throw new InvalidOperationException("Connection is not open."); diff --git a/src/LibRed/LibRed.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs index e7bde2f3..15d31cf1 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnection.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnection.cs @@ -239,6 +239,23 @@ public override void Close() OnStateChange(new StateChangeEventArgs(ConnectionState.Open, ConnectionState.Closed)); } + /// The names of the metadata collections this provider serves. + public override DataTable GetSchema() => GetSchema(LibRedSchema.MetaDataCollections, null); + + /// + public override DataTable GetSchema(string collectionName) => GetSchema(collectionName, null); + + /// A metadata collection, filtered by . The collections match + /// the ones ACE's OLE DB provider serves, column for column, so code written against that provider reads + /// the same metadata here. A restriction naming a catalog or schema matches everything: a Jet file holds + /// one nameless catalog and no schemas. + public override DataTable GetSchema(string collectionName, string?[]? restrictionValues) + { + if (_database is null || _state != ConnectionState.Open) + throw new InvalidOperationException("The connection must be open to read schema metadata."); + return LibRedSchema.Get(collectionName, restrictionValues, _database); + } + public override void ChangeDatabase(string databaseName) => throw new NotSupportedException("A Jet/ACE connection maps to a single file."); diff --git a/src/LibRed/LibRed.Ado/LibRedDataReader.cs b/src/LibRed/LibRed.Ado/LibRedDataReader.cs index fee4e8f6..3c4bbab1 100644 --- a/src/LibRed/LibRed.Ado/LibRedDataReader.cs +++ b/src/LibRed/LibRed.Ado/LibRedDataReader.cs @@ -1,26 +1,45 @@ using System.Collections; +using System.Collections.ObjectModel; +using System.Data; using System.Data.Common; using LibRed.Engine.Execution; namespace LibRed.Data; /// Forward-only reader projecting an engine as ADO.NET rows. -public sealed class LibRedDataReader : DbDataReader +public sealed class LibRedDataReader : DbDataReader, IDbColumnSchemaGenerator { private readonly ResultSet _result; private readonly IEnumerator _rows; private readonly int _recordsAffected; + private readonly bool _singleRow; + private readonly LibRedConnection? _ownedConnection; private object?[] _current = []; private bool _pendingFirst; private bool _hadRows; private bool _closed; /// Rows affected for a DML command; -1 for a query (ADO convention). - internal LibRedDataReader(ResultSet result, int recordsAffected = -1) + /// + /// The behavior the command was executed with. Two of its flags reach the reader: SingleRow caps the + /// result at the one row (the constructor has already buffered it, so nothing further is ever read), and + /// CloseConnection hands the reader 's lifetime — closing the reader + /// then closes it, as ACE does. SingleResult is already the only shape this reader has (a batch + /// returns its last statement's rows and NextResult is always false), SequentialAccess asks + /// for a restriction on a row that is already in memory, and the key and base-column information + /// KeyInfo asks for costs a catalog lookup that GetSchemaTable makes anyway — so those three + /// are accepted and need nothing. + /// + /// The connection to close when this reader closes, for CloseConnection. + internal LibRedDataReader( + ResultSet result, int recordsAffected = -1, + CommandBehavior behavior = CommandBehavior.Default, LibRedConnection? connection = null) { _result = result; _rows = result.Rows.GetEnumerator(); _recordsAffected = recordsAffected; + _singleRow = behavior.HasFlag(CommandBehavior.SingleRow); + _ownedConnection = behavior.HasFlag(CommandBehavior.CloseConnection) ? connection : null; // Buffer the first row eagerly so column types (GetFieldType/GetDataTypeName) are available // before the first Read — EF's BufferedDataReader reads that metadata before reading any rows. @@ -44,6 +63,7 @@ internal LibRedDataReader(ResultSet result, int recordsAffected = -1) public override bool Read() { if (_pendingFirst) { _pendingFirst = false; return true; } // yield the pre-buffered first row + if (_singleRow) return false; // that buffered row was the only one asked for if (!_rows.MoveNext()) return false; _current = _rows.Current; return true; @@ -111,6 +131,56 @@ public override Type GetFieldType(int ordinal) public override string GetDataTypeName(int ordinal) => GetFieldType(ordinal).Name; + /// The result's columns as s: each column's type, and for one read straight + /// from a table the stored column behind it — its table, its own name, and whether it is a key, unique, + /// an AutoNumber or computed. + public ReadOnlyCollection GetColumnSchema() => + new(_result.Columns.Select((c, i) => (DbColumn)new LibRedDbColumn(c, i)).ToList()); + + /// The same description in the older DataTable form, with the column set ADO.NET + /// defines for it. + public override DataTable GetSchemaTable() + { + var table = new DataTable("SchemaTable") { Locale = System.Globalization.CultureInfo.InvariantCulture }; + foreach ((string name, Type type) in new (string, Type)[] + { + (SchemaTableColumn.ColumnName, typeof(string)), (SchemaTableColumn.ColumnOrdinal, typeof(int)), + (SchemaTableColumn.ColumnSize, typeof(int)), (SchemaTableColumn.NumericPrecision, typeof(short)), + (SchemaTableColumn.NumericScale, typeof(short)), (SchemaTableColumn.IsUnique, typeof(bool)), + (SchemaTableColumn.IsKey, typeof(bool)), (SchemaTableOptionalColumn.BaseServerName, typeof(string)), + (SchemaTableOptionalColumn.BaseCatalogName, typeof(string)), (SchemaTableColumn.BaseColumnName, typeof(string)), + (SchemaTableColumn.BaseSchemaName, typeof(string)), (SchemaTableColumn.BaseTableName, typeof(string)), + (SchemaTableColumn.DataType, typeof(Type)), (SchemaTableColumn.AllowDBNull, typeof(bool)), + (SchemaTableColumn.ProviderType, typeof(int)), (SchemaTableColumn.IsAliased, typeof(bool)), + (SchemaTableColumn.IsExpression, typeof(bool)), (SchemaTableOptionalColumn.IsAutoIncrement, typeof(bool)), + (SchemaTableOptionalColumn.IsRowVersion, typeof(bool)), (SchemaTableOptionalColumn.IsHidden, typeof(bool)), + (SchemaTableColumn.IsLong, typeof(bool)), (SchemaTableOptionalColumn.IsReadOnly, typeof(bool)), + ("DataTypeName", typeof(string)), + }) + table.Columns.Add(name, type); + + for (int i = 0; i < _result.Columns.Count; i++) + { + ResultColumn c = _result.Columns[i]; + table.Rows.Add( + c.Name, i, (object?)c.Size ?? DBNull.Value, + c.Precision is { } p ? (short)p : DBNull.Value, c.Scale is { } s ? (short)s : DBNull.Value, + c.IsUnique, c.IsKey, + DBNull.Value, DBNull.Value, // a Jet file has no server or catalog + (object?)c.BaseColumnName ?? DBNull.Value, DBNull.Value, + (object?)c.BaseTableName ?? DBNull.Value, + c.ClrType, c.AllowNull, c.ProviderType, + // Aliased when the query renamed the stored column it reads. + c.BaseColumnName is not null && !string.Equals(c.BaseColumnName, c.Name, StringComparison.OrdinalIgnoreCase), + c.IsExpression, c.IsAutoIncrement, + false, false, // Jet has no rowversion, and hides no column + c.IsLong, c.IsReadOnly, + c.TypeName); + } + + return table; + } + public override bool GetBoolean(int ordinal) { var value = GetValue(ordinal); @@ -187,12 +257,19 @@ private static void ValidateCopyArguments(long dataOffset, int bufferLength, int public override IEnumerator GetEnumerator() => new DbEnumerator(this, closeReader: false); - public override void Close() => _closed = true; + /// Releases the cursors the rows are read through, and — under + /// — closes the connection they came from. + public override void Close() + { + if (_closed) return; + _closed = true; + _rows.Dispose(); + _ownedConnection?.Close(); + } protected override void Dispose(bool disposing) { - if (disposing) _rows.Dispose(); - Close(); + if (disposing) Close(); base.Dispose(disposing); } } diff --git a/src/LibRed/LibRed.Ado/LibRedDbColumn.cs b/src/LibRed/LibRed.Ado/LibRedDbColumn.cs new file mode 100644 index 00000000..d24be241 --- /dev/null +++ b/src/LibRed/LibRed.Ado/LibRedDbColumn.cs @@ -0,0 +1,49 @@ +using System.Data.Common; +using LibRed.Engine.Execution; + +namespace LibRed.Data; + +/// +/// One column of a result as — the modern half of GetSchemaTable, from the same +/// description. What the engine could not know is left null rather than guessed: a Jet file has no server, +/// catalog or schema, and nothing in it is a row version or a hidden column. +/// +internal sealed class LibRedDbColumn : DbColumn +{ + internal LibRedDbColumn(ResultColumn column, int ordinal) + { + ColumnName = column.Name; + ColumnOrdinal = ordinal; + ColumnSize = column.Size; + NumericPrecision = column.Precision; + NumericScale = column.Scale; + DataType = column.ClrType; + DataTypeName = column.TypeName; + AllowDBNull = column.AllowNull; + BaseColumnName = column.BaseColumnName; + BaseTableName = column.BaseTableName; + IsAliased = column.BaseColumnName is not null + && !string.Equals(column.BaseColumnName, column.Name, StringComparison.OrdinalIgnoreCase); + IsExpression = column.IsExpression; + IsAutoIncrement = column.IsAutoIncrement; + IsKey = column.IsKey; + IsUnique = column.IsUnique; + IsLong = column.IsLong; + IsReadOnly = column.IsReadOnly; + IsIdentity = column.IsAutoIncrement; + // Not applicable to a Jet file rather than unknown: no server, catalog or schema, no row versions, + // and every column a query returns is one the caller asked for. + BaseServerName = null; + BaseCatalogName = null; + BaseSchemaName = null; + IsHidden = false; + ProviderType = column.ProviderType; + } + + /// The OLE DB type code, the same one the Columns schema collection reports. Named as + /// GetSchemaTable names it, and reachable through the indexer as well as directly. + public int ProviderType { get; } + + public override object? this[string property] => + property == nameof(ProviderType) ? ProviderType : base[property]; +} diff --git a/src/LibRed/LibRed.Ado/LibRedSchema.cs b/src/LibRed/LibRed.Ado/LibRedSchema.cs new file mode 100644 index 00000000..e4892c4a --- /dev/null +++ b/src/LibRed/LibRed.Ado/LibRedSchema.cs @@ -0,0 +1,299 @@ +using System.Data; +using System.Data.Common; +using LibRed.Engine.Schema; +using LibRed.Sql; + +namespace LibRed.Data; + +/// +/// ADO.NET's metadata collections for an open connection. The catalog collections come from +/// , shaped as ACE's OLE DB provider serves them; the five collections the +/// framework defines (MetaDataCollections, DataSourceInformation, DataTypes, +/// Restrictions, ReservedWords) are built here, describing LibRed itself. +/// +internal static class LibRedSchema +{ + public const string MetaDataCollections = "MetaDataCollections"; + public const string DataSourceInformation = "DataSourceInformation"; + public const string DataTypes = "DataTypes"; + public const string Restrictions = "Restrictions"; + public const string ReservedWords = "ReservedWords"; + + /// Every collection served, the framework's own first, then the catalog ones — the order ACE + /// lists them in. + public static IReadOnlyList Names { get; } = + [MetaDataCollections, DataSourceInformation, DataTypes, Restrictions, ReservedWords, .. SchemaRowsets.Names]; + + /// How many identifier parts a collection's rows are named by, as ACE reports: a column takes + /// catalog, schema, table and column; a table one fewer. + private static readonly Dictionary IdentifierParts = new(StringComparer.OrdinalIgnoreCase) + { + ["Tables"] = 3, ["Columns"] = 4, ["Indexes"] = 4, ["Views"] = 3, ["Procedures"] = 3, + ["ForeignKeys"] = 3, ["PrimaryKeys"] = 3, ["TableConstraints"] = 3, ["KeyColumnUsage"] = 4, + ["ConstraintColumnUsage"] = 4, ["ReferentialConstraints"] = 3, ["CheckConstraints"] = 3, + ["Statistics"] = 3, ["ProcedureParameters"] = 4, ["ViewColumns"] = 4, + }; + + /// Each collection's restrictions, in ACE's order — note that Indexes takes the table name last, + /// after the index name and type. A restriction names the column its value filters. + private static readonly (string Collection, string Restriction)[] RestrictionList = + [ + ("Columns", "TABLE_CATALOG"), ("Columns", "TABLE_SCHEMA"), ("Columns", "TABLE_NAME"), ("Columns", "COLUMN_NAME"), + ("Indexes", "TABLE_CATALOG"), ("Indexes", "TABLE_SCHEMA"), ("Indexes", "INDEX_NAME"), ("Indexes", "TYPE"), + ("Indexes", "TABLE_NAME"), + ("Procedures", "PROCEDURE_CATALOG"), ("Procedures", "PROCEDURE_SCHEMA"), ("Procedures", "PROCEDURE_NAME"), + ("Procedures", "PROCEDURE_TYPE"), + ("Tables", "TABLE_CATALOG"), ("Tables", "TABLE_SCHEMA"), ("Tables", "TABLE_NAME"), ("Tables", "TABLE_TYPE"), + ("Views", "TABLE_CATALOG"), ("Views", "TABLE_SCHEMA"), ("Views", "TABLE_NAME"), + // The relational rowsets, restricted as OLE DB restricts them. + ("ForeignKeys", "PK_TABLE_CATALOG"), ("ForeignKeys", "PK_TABLE_SCHEMA"), ("ForeignKeys", "PK_TABLE_NAME"), + ("ForeignKeys", "FK_TABLE_CATALOG"), ("ForeignKeys", "FK_TABLE_SCHEMA"), ("ForeignKeys", "FK_TABLE_NAME"), + ("PrimaryKeys", "TABLE_CATALOG"), ("PrimaryKeys", "TABLE_SCHEMA"), ("PrimaryKeys", "TABLE_NAME"), + ("TableConstraints", "CONSTRAINT_CATALOG"), ("TableConstraints", "CONSTRAINT_SCHEMA"), + ("TableConstraints", "CONSTRAINT_NAME"), ("TableConstraints", "TABLE_CATALOG"), + ("TableConstraints", "TABLE_SCHEMA"), ("TableConstraints", "TABLE_NAME"), ("TableConstraints", "CONSTRAINT_TYPE"), + ("KeyColumnUsage", "CONSTRAINT_CATALOG"), ("KeyColumnUsage", "CONSTRAINT_SCHEMA"), + ("KeyColumnUsage", "CONSTRAINT_NAME"), ("KeyColumnUsage", "TABLE_CATALOG"), + ("KeyColumnUsage", "TABLE_SCHEMA"), ("KeyColumnUsage", "TABLE_NAME"), ("KeyColumnUsage", "COLUMN_NAME"), + ("ConstraintColumnUsage", "TABLE_CATALOG"), ("ConstraintColumnUsage", "TABLE_SCHEMA"), + ("ConstraintColumnUsage", "TABLE_NAME"), ("ConstraintColumnUsage", "COLUMN_NAME"), + ("ConstraintColumnUsage", "CONSTRAINT_CATALOG"), ("ConstraintColumnUsage", "CONSTRAINT_SCHEMA"), + ("ConstraintColumnUsage", "CONSTRAINT_NAME"), + ("ReferentialConstraints", "CONSTRAINT_CATALOG"), ("ReferentialConstraints", "CONSTRAINT_SCHEMA"), + ("ReferentialConstraints", "CONSTRAINT_NAME"), + ("CheckConstraints", "CONSTRAINT_CATALOG"), ("CheckConstraints", "CONSTRAINT_SCHEMA"), + ("CheckConstraints", "CONSTRAINT_NAME"), + ("Statistics", "TABLE_CATALOG"), ("Statistics", "TABLE_SCHEMA"), ("Statistics", "TABLE_NAME"), + ("ProcedureParameters", "PROCEDURE_CATALOG"), ("ProcedureParameters", "PROCEDURE_SCHEMA"), + ("ProcedureParameters", "PROCEDURE_NAME"), ("ProcedureParameters", "PARAMETER_NAME"), + ("ViewColumns", "VIEW_CATALOG"), ("ViewColumns", "VIEW_SCHEMA"), ("ViewColumns", "VIEW_NAME"), + ("ViewColumns", "COLUMN_NAME"), + ]; + + /// A Jet file holds one nameless catalog and no schemas, so these restrictions match everything + /// rather than filtering on the null every row carries. + private static bool IsCatalogOrSchema(string restriction) => + restriction.EndsWith("_CATALOG", StringComparison.Ordinal) || restriction.EndsWith("_SCHEMA", StringComparison.Ordinal); + + /// Builds a collection, filtered by (a null entry, or none at + /// all, matches everything). + public static DataTable Get(string collection, string?[]? restrictions, JetDatabase database) + { + string name = Names.FirstOrDefault(n => n.Equals(collection, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException($"There is no metadata collection named '{collection}'.", nameof(collection)); + + var allowed = RestrictionList.Where(r => r.Collection.Equals(name, StringComparison.OrdinalIgnoreCase)).ToList(); + if (restrictions is not null && restrictions.Length > allowed.Count) + throw new ArgumentException( + $"The '{name}' collection takes {allowed.Count} restrictions; {restrictions.Length} were supplied.", + nameof(restrictions)); + + return name switch + { + MetaDataCollections => BuildMetaDataCollections(), + DataSourceInformation => BuildDataSourceInformation(database), + DataTypes => BuildDataTypes(database), + Restrictions => BuildRestrictions(), + ReservedWords => BuildReservedWords(), + _ => BuildCatalogCollection(name, restrictions, database), + }; + } + + private static DataTable BuildCatalogCollection(string name, string?[]? restrictions, JetDatabase database) + { + var table = new DataTable(name) { Locale = System.Globalization.CultureInfo.InvariantCulture }; + IReadOnlyList columns = SchemaRowsets.ColumnsOf(name); + IReadOnlyList types = SchemaRowsets.ColumnTypesOf(name); + for (int i = 0; i < columns.Count; i++) + table.Columns.Add(columns[i], types[i]); + + var allowed = RestrictionList.Where(r => r.Collection.Equals(name, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (object?[] row in SchemaRowsets.Rows(name, database)) + { + if (!Matches(row, columns, allowed, restrictions)) continue; + table.Rows.Add(row.Select(v => v ?? DBNull.Value).ToArray()); + } + + return table; + } + + /// Whether a row passes every supplied restriction. Values compare as text, case-insensitively, + /// because Jet identifiers are. + private static bool Matches( + object?[] row, IReadOnlyList columns, List<(string Collection, string Restriction)> allowed, string?[]? restrictions) + { + if (restrictions is null) return true; + + for (int i = 0; i < restrictions.Length; i++) + { + if (restrictions[i] is not { } wanted) continue; + string restriction = allowed[i].Restriction; + if (IsCatalogOrSchema(restriction)) continue; + + int column = IndexOf(columns, restriction); + if (column < 0) continue; + if (!string.Equals(row[column]?.ToString(), wanted, StringComparison.OrdinalIgnoreCase)) return false; + } + + return true; + } + + private static int IndexOf(IReadOnlyList columns, string name) + { + for (int i = 0; i < columns.Count; i++) + if (columns[i].Equals(name, StringComparison.OrdinalIgnoreCase)) return i; + return -1; + } + + private static DataTable BuildMetaDataCollections() + { + var table = Empty(MetaDataCollections, + ("CollectionName", typeof(string)), ("NumberOfRestrictions", typeof(int)), ("NumberOfIdentifierParts", typeof(int))); + foreach (string name in Names) + table.Rows.Add(name, + RestrictionList.Count(r => r.Collection.Equals(name, StringComparison.OrdinalIgnoreCase)), + IdentifierParts.GetValueOrDefault(name)); + return table; + } + + private static DataTable BuildRestrictions() + { + var table = Empty(Restrictions, + ("CollectionName", typeof(string)), ("RestrictionName", typeof(string)), + ("RestrictionDefault", typeof(string)), ("RestrictionNumber", typeof(int))); + string? collection = null; + int number = 0; + foreach ((string name, string restriction) in RestrictionList) + { + number = name == collection ? number + 1 : 1; + collection = name; + table.Rows.Add(name, restriction, DBNull.Value, number); + } + return table; + } + + private static DataTable BuildReservedWords() + { + var table = Empty(ReservedWords, ("ReservedWord", typeof(string))); + foreach (string word in SqlKeywords.Reserved) + table.Rows.Add(word); + return table; + } + + private static DataTable BuildDataSourceInformation(JetDatabase database) + { + var table = Empty(DataSourceInformation, + ("CompositeIdentifierSeparatorPattern", typeof(string)), ("DataSourceProductName", typeof(string)), + ("DataSourceProductVersion", typeof(string)), ("DataSourceProductVersionNormalized", typeof(string)), + ("GroupByBehavior", typeof(GroupByBehavior)), ("IdentifierPattern", typeof(string)), + ("IdentifierCase", typeof(IdentifierCase)), ("OrderByColumnsInSelect", typeof(bool)), + ("ParameterMarkerFormat", typeof(string)), ("ParameterMarkerPattern", typeof(string)), + ("ParameterNameMaxLength", typeof(int)), ("ParameterNamePattern", typeof(string)), + ("QuotedIdentifierPattern", typeof(string)), ("QuotedIdentifierCase", typeof(IdentifierCase)), + ("StatementSeparatorPattern", typeof(string)), ("StringLiteralPattern", typeof(string)), + ("SupportedJoinOperators", typeof(SupportedJoinOperators))); + + table.Rows.Add( + DBNull.Value, // no catalog or schema, so nothing separates identifier parts + "LibRed", + FormatName(database.Format.Version), + // Sortable and fixed-width, as the collection requires: the file's format version byte, so a later + // format never sorts below an earlier one. + $"{(byte)database.Format.Version:00}.00.0000", + GroupByBehavior.MustContainAll, + @"[^ ][^\.!`\[\]]*", // as ACE describes a Jet identifier + IdentifierCase.Insensitive, + false, // ORDER BY may name a column the SELECT does not + "?", + @"\?", + 0, // parameters are positional, so a name has no length + DBNull.Value, + "`(([^`]|``)*)`", // backticks, doubled to escape one + IdentifierCase.Insensitive, + ";", + "'(([^']|'')*)'", + SupportedJoinOperators.Inner | SupportedJoinOperators.LeftOuter + | SupportedJoinOperators.RightOuter | SupportedJoinOperators.FullOuter); + return table; + } + + /// The types a column can have, as ACE lists them — its own type names, each with the OLE DB type + /// code, the CLR type it reads as, and the literal syntax — plus the two types ACE's own list never learned. + /// + private static DataTable BuildDataTypes(JetDatabase database) + { + var table = Empty(DataTypes, + ("TypeName", typeof(string)), ("ProviderDbType", typeof(int)), ("ColumnSize", typeof(long)), + ("CreateFormat", typeof(string)), ("CreateParameters", typeof(string)), ("DataType", typeof(string)), + ("IsAutoIncrementable", typeof(bool)), ("IsBestMatch", typeof(bool)), ("IsCaseSensitive", typeof(bool)), + ("IsFixedLength", typeof(bool)), ("IsFixedPrecisionScale", typeof(bool)), ("IsLong", typeof(bool)), + ("IsNullable", typeof(bool)), ("IsSearchable", typeof(bool)), ("IsSearchableWithLike", typeof(bool)), + ("IsUnsigned", typeof(bool)), ("MaximumScale", typeof(short)), ("MinimumScale", typeof(short)), + ("IsConcurrencyType", typeof(bool)), ("IsLiteralSupported", typeof(bool)), + ("LiteralPrefix", typeof(string)), ("LiteralSuffix", typeof(string)), ("NativeDataType", typeof(short))); + + void Add(string name, int providerType, long size, string? createParameters, Type clr, bool autoIncrement, + bool fixedLength, bool fixedPrecisionScale, bool isLong, bool nullable, bool unsigned, + short? maximumScale, short? minimumScale, string? prefix, string? suffix, short nativeType) => + table.Rows.Add(name, providerType, size, DBNull.Value, (object?)createParameters ?? DBNull.Value, + clr.FullName, autoIncrement, DBNull.Value, false, fixedLength, fixedPrecisionScale, isLong, + nullable, true, true, unsigned, + (object?)maximumScale ?? DBNull.Value, (object?)minimumScale ?? DBNull.Value, + DBNull.Value, DBNull.Value, (object?)prefix ?? DBNull.Value, (object?)suffix ?? DBNull.Value, nativeType); + + Add("Short", 2, 5, null, typeof(short), false, true, true, false, true, false, null, null, null, null, 2); + Add("Long", 3, 10, null, typeof(int), true, true, true, false, true, false, null, null, null, null, 3); + Add("Single", 4, 7, null, typeof(float), false, true, false, false, true, false, null, null, null, null, 4); + Add("Double", 5, 15, null, typeof(double), false, true, false, false, true, false, null, null, null, null, 5); + Add("Currency", 6, 19, null, typeof(decimal), false, true, true, false, true, false, null, null, null, null, 6); + Add("DateTime", 7, 8, null, typeof(DateTime), false, true, true, false, true, true, null, null, "#", "#", 7); + Add("Bit", 11, 2, null, typeof(bool), false, true, true, false, false, true, null, null, null, null, 11); + Add("Byte", 17, 3, null, typeof(byte), false, true, true, false, true, true, null, null, null, null, 17); + Add("GUID", 72, 16, null, typeof(Guid), false, true, true, false, true, true, null, null, null, null, 72); + Add("BigBinary", 204, 4000, null, typeof(byte[]), false, false, false, false, true, true, null, null, "0x", null, 128); + Add("LongBinary", 205, 1073741823, null, typeof(byte[]), false, false, true, true, true, true, null, null, "0x", null, 128); + Add("VarBinary", 204, 510, "max length", typeof(byte[]), false, false, true, false, true, true, null, null, "0x", null, 128); + Add("LongText", 203, 536870910, null, typeof(string), false, false, true, true, true, true, null, null, "'", "'", 130); + Add("VarChar", 202, 255, "max length", typeof(string), false, false, true, false, true, true, null, null, "'", "'", 130); + Add("Decimal", 131, 28, "precision,scale", typeof(decimal), false, true, true, false, true, false, 28, 0, null, null, 131); + + // The fixed-length text and binary forms, which ACE's list omits although the engine has both: a + // CHAR(n)/NCHAR(n) column is stored fixed, and so is BINARY(n) — even though ACE then reports the + // binary one as variable. Without these rows a fixed column's TYPE_NAME would have nothing to join to. + Add("Char", 130, 255, "max length", typeof(string), false, true, true, false, true, true, null, null, "'", "'", 130); + Add("Binary", 128, 510, "max length", typeof(byte[]), false, true, true, false, true, true, null, null, "0x", null, 128); + + // BIGINT and DATETIME2 arrived after ACE's OLE DB provider, whose list still omits them although it + // reports columns of both. Any ACCDB can hold them: a file too old for one is raised to the format it + // needs when the column is created, which is what Access itself does. A Jet 3/4 .mdb cannot, so an + // .mdb lists exactly the types ACE does. + if (database.Format.Version >= LibRed.Formats.JetVersion.Version12_2007) + { + Add("BigInt", 20, 19, null, typeof(long), false, true, true, false, true, false, null, null, null, null, 20); + Add("DateTime2", 135, 42, null, typeof(DateTime), false, true, true, false, true, true, null, null, "#", "#", 135); + } + + return table; + } + + /// The engine version the open file's format belongs to, named as Access names it. + private static string FormatName(LibRed.Formats.JetVersion version) => version switch + { + LibRed.Formats.JetVersion.Version3 => "Jet 3 (Access 97)", + LibRed.Formats.JetVersion.Version4 => "Jet 4 (Access 2000-2003)", + LibRed.Formats.JetVersion.Version12_2007 => "ACE 12 (Access 2007)", + LibRed.Formats.JetVersion.Version14_2010 => "ACE 14 (Access 2010)", + LibRed.Formats.JetVersion.Version15_2013 => "ACE 15 (Access 2013)", + LibRed.Formats.JetVersion.Version16_2016 => "ACE 16 (Access 2016)", + LibRed.Formats.JetVersion.Version17_2019 => "ACE 17 (Access 2019)", + _ => version.ToString(), + }; + + private static DataTable Empty(string name, params (string Name, Type Type)[] columns) + { + var table = new DataTable(name) { Locale = System.Globalization.CultureInfo.InvariantCulture }; + foreach ((string column, Type type) in columns) + table.Columns.Add(column, type); + return table; + } +} diff --git a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs index 33dedf89..44391f1b 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs @@ -36,7 +36,7 @@ public sealed class JetCatalog(PageChannel channel, int catalogPage = 2) private List? _relationships; private Dictionary? _views; private Dictionary? _actionQueries; - private Dictionary>? _queryParameters; + private Dictionary>? _queryParameters; private long _seenSchemaGeneration = channel.SchemaGeneration; /// All tables in the database (user and system). @@ -54,7 +54,7 @@ public sealed class JetCatalog(PageChannel channel, int catalogPage = 2) /// A stored query's declared parameter names in declaration order (its Attribute=2 rows). /// Used to bind an EXECUTE proc a, b's positional arguments to the procedure's named parameters. /// Empty for a query with no parameters. - public IReadOnlyDictionary> QueryParameters { get { EnsureFresh(); EnsureStoredQueries(); return _queryParameters!; } } + public IReadOnlyDictionary> QueryParameters { get { EnsureFresh(); EnsureStoredQueries(); return _queryParameters!; } } /// Drops the cached catalog so a freshly created table is picked up on next read. public void Invalidate(bool markChanged = true) @@ -120,6 +120,7 @@ private List LoadTables() || name.StartsWith('#'); TableDef definition = ReadTableDefinition(definitionPage, name, isSystem); + definition.ObjectFlags = flags; // Attach column DefaultValue and table CHECK properties from the extended-properties (LvProp) blob. if (row[lvpropIndex] is byte[] { Length: > 0 } blob) { @@ -199,7 +200,7 @@ private void EnsureStoredQueries() if (_views is not null) return; _views = new Dictionary(StringComparer.OrdinalIgnoreCase); _actionQueries = new Dictionary(StringComparer.OrdinalIgnoreCase); - _queryParameters = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _queryParameters = new Dictionary>(StringComparer.OrdinalIgnoreCase); TableDef? mqDef = FindTable("MSysQueries"); TableDef? objDef = FindTable("MSysObjects"); @@ -235,11 +236,16 @@ private void EnsureStoredQueries() _views[name] = sql; // The declared parameters (Attribute=2 rows), in declaration order, for EXECUTE positional binding. - var paramNames = rows.Where(r => r[attr] is byte b && b == StoredQueryFormat.AttrParameter) + // Each row's Flag is the parameter's Jet type code (0 for Access's untyped parameter). + var parameters = rows.Where(r => r[attr] is byte b && b == StoredQueryFormat.AttrParameter) .OrderBy(r => r[order] is byte[] ob && ob.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(ob) : 0) - .Select(r => r[n1] as string).Where(s => s is not null).Select(s => s!).ToList(); - if (paramNames.Count > 0) _queryParameters[name] = paramNames; + .Where(r => r[n1] is string) + .Select(r => new StoredQueryParameter( + (string)r[n1]!, + r[flag] is short f and not 0 ? (JetDataType)(byte)f : null)) + .ToList(); + if (parameters.Count > 0) _queryParameters[name] = parameters; } } @@ -444,6 +450,8 @@ private TableDef ReadTableDefinition(int definitionPage, string name, bool isSys DefinitionPage = definitionPage, Columns = tdef.Columns, Indexes = tdef.Indexes, + LogicalIndexes = tdef.LogicalIndexes, + RowCount = tdef.RowCount, VariableColumnCount = tdef.VariableColumnCount, ComplexAutoNumber = tdef.ComplexAutoNumber, IsSystem = isSystem, diff --git a/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs b/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs new file mode 100644 index 00000000..fa97f6b9 --- /dev/null +++ b/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs @@ -0,0 +1,28 @@ +namespace LibRed.Catalog; + +/// +/// One logical index: a name in the TDEF's logical-index list, pointing at the real index that stores its +/// entries. Several logical indexes share one real index — a foreign key's relationship name, the index the +/// designer named, and a primary key can all sit over the same B-tree — so (one per +/// real index) cannot represent them all. Access's own schema views list every logical index, which is why +/// the names are kept rather than collapsed to the one that wins. +/// +/// The logical index's name. +/// Which real index () stores it. +/// True when the entry is a foreign-key relationship rather than a named index. +/// True when this entry is the table's primary key. +/// The relationship's direction as the info block records it: 0 none, 1 incoming +/// (this table is the parent — the half Access names .r… and keeps out of its schema views), 2 outgoing +/// (this table holds the foreign key). +public sealed record LogicalIndexDef( + string Name, int RealIndexOrdinal, bool IsRelationship, bool IsPrimaryKey, byte ForeignKeyType) +{ + /// True for the parent side of a relationship, which Access hides. + public bool IsIncomingRelationship => ForeignKeyType == IncomingRelationship; + + /// of the parent side. + public const byte IncomingRelationship = 1; + + /// of the child side, the table holding the foreign key. + public const byte OutgoingRelationship = 2; +} diff --git a/src/LibRed/LibRed.Core/Catalog/TableDef.cs b/src/LibRed/LibRed.Core/Catalog/TableDef.cs index 5c6e4695..36ec18a4 100644 --- a/src/LibRed/LibRed.Core/Catalog/TableDef.cs +++ b/src/LibRed/LibRed.Core/Catalog/TableDef.cs @@ -15,6 +15,11 @@ public sealed class TableDef public IReadOnlyList Columns { get; init; } = []; public IReadOnlyList Indexes { get; init; } = []; + /// Every name the TDEF gives an index, including the relationship names that share a real index + /// with a named one — see . holds one entry per real + /// index and so carries only the name that won. + public IReadOnlyList LogicalIndexes { get; init; } = []; + /// /// The variable-column count from the TDEF header (0x2B) — a high-water mark, not a live /// count. It never decrements on DROP COLUMN, so it exceeds the number of variable columns still present @@ -45,9 +50,20 @@ public sealed class TableDef /// public string? ValidationText { get; internal set; } + /// The row count the TDEF header carries (0x10), which the engine keeps current as rows are + /// inserted and deleted. Reported as the table's cardinality in schema metadata, as Access reports it. + public int RowCount { get; init; } + /// True for the MSys* system tables. public bool IsSystem { get; init; } + /// The object's raw MSysObjects.Flags, kept as read so callers can classify an object the + /// way Access does rather than by name — the system bit (0x80000000), the hidden bit (0x08) + /// and the bits that keep an object out of the schema rowsets altogether are all in here. Set by the + /// catalog after the definition is decoded; zero for a table built without one (a test's synthetic + /// definition, say). + public uint ObjectFlags { get; internal set; } + public ColumnDef? FindColumn(string name) => Columns.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs b/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs index 3570c63f..9fa1558d 100644 --- a/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs +++ b/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs @@ -53,6 +53,11 @@ public sealed record ActionQuerySpec( /// explains why executing it throws. public sealed record StoredActionQuery(string? Sql, string? UnsupportedReason); +/// A parameter a stored query declares, in declaration order. is the Jet type +/// Access recorded for it, or null for its untyped parameter — the one Access renders as the keyword +/// Value. +public sealed record StoredQueryParameter(string Name, JetDataType? Type); + /// /// A view's decomposed "simple SELECT" — the columns, source tables, joins and WHERE (all verbatim text) — /// that Access stores as MSysQueries rows. Aggregates / GROUP BY / HAVING / ORDER BY are not permitted. diff --git a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs index 004715f8..452afc1d 100644 --- a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs +++ b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs @@ -40,6 +40,11 @@ public sealed class TableDefinitionPage : Page private readonly List _indexes = []; public IReadOnlyList Indexes => _indexes; + private readonly List _logicalIndexes = []; + /// Every logical index, in the order the TDEF lists them — including the relationship names that + /// share a real index with a named one, which keeps only one of. + public IReadOnlyList LogicalIndexes => _logicalIndexes; + private readonly Dictionary _longValueOwnedMaps = []; /// Per long-value (memo/OLE) column id → its owned-pages usage-map pointer (record row + /// page), from the §3.3.2 list after the index names. Used to record a newly allocated LVAL page. @@ -236,25 +241,30 @@ private void ReadLongValueMaps(PageBuffer buffer, int pos) private int ResolveIndexNames(PageBuffer buffer, int infoStart) { int logicalCount = LogicalIndexCount; // 0x2F — the logical-index (slot) count - var info = new (int DataNumber, bool IsRelationship, byte Type)[logicalCount]; + var info = new (int DataNumber, bool IsRelationship, byte Type, byte FkType)[logicalCount]; for (int i = 0; i < logicalCount; i++) { int block = infoStart + i * IndexBlockFormat.InfoBlockSize; info[i] = ( buffer.ReadInt32(block + IndexBlockFormat.InfoDataNumberOffset), buffer.ReadInt32(block + IndexBlockFormat.InfoFkTablePageOffset) != 0, - buffer.ReadByte(block + IndexBlockFormat.InfoTypeOffset)); + buffer.ReadByte(block + IndexBlockFormat.InfoTypeOffset), + buffer.ReadByte(block + IndexBlockFormat.InfoFkTypeOffset)); } int namePos = infoStart + logicalCount * IndexBlockFormat.InfoBlockSize; var priority = new int[_indexes.Count]; + _logicalIndexes.Clear(); for (int i = 0; i < logicalCount; i++) { (string name, namePos) = ReadName(buffer, namePos, $"logical index {i}"); - (int dataNumber, bool isRelationship, byte type) = info[i]; + (int dataNumber, bool isRelationship, byte type, byte fkType) = info[i]; if (dataNumber < 0 || dataNumber >= _indexes.Count) continue; + _logicalIndexes.Add(new LogicalIndexDef(name, dataNumber, isRelationship, + !isRelationship && type == IndexBlockFormat.TypePrimary, fkType)); + // Prefer a real index name over a relationship's; prefer the primary among real ones. int p = isRelationship ? 1 : type == IndexBlockFormat.TypePrimary ? 3 : 2; if (p > priority[dataNumber]) diff --git a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs index da5724aa..0e3f4a59 100644 --- a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs @@ -8,19 +8,33 @@ namespace LibRed.Engine.Execution; /// marks a Currency value, which shares with Decimal but calculates differently, and /// a Decimal's places. marks a column that is a bare NULL, which /// has no type of its own, unlike one whose type is merely unknown. +/// Where a column's values come from, when no stored column stands behind them: an expression over one +/// row, an aggregate over many, or the arms of a set operation. Schema metadata describes each differently. +/// +internal enum ColumnOrigin { Expression, Aggregate, SetOperation } + +/// The stored column this output passes through unchanged, where it does — carried so a +/// caller describing a query (the schema rowsets, for a view's columns) can report the declared type and its +/// length rather than only the CLR type. Null for anything computed. +/// What computes the column, where does not stand behind it. internal readonly record struct OutputColumn( - string? Qualifier, string Name, Type? ClrType = null, bool Currency = false, int? Scale = null, bool Null = false) + string? Qualifier, string Name, Type? ClrType = null, bool Currency = false, int? Scale = null, + bool Null = false, LibRed.Catalog.ColumnDef? Source = null, ColumnOrigin Origin = ColumnOrigin.Expression) { /// The output of a stored column. public static OutputColumn Of(string? qualifier, LibRed.Catalog.ColumnDef column) => new(qualifier, column.Name, Schema.JetClrTypeMap.ToClrType(column.Type), column.Type == LibRed.Catalog.JetDataType.Currency, - column.Type == LibRed.Catalog.JetDataType.FixedPoint ? column.Scale : null); + column.Type == LibRed.Catalog.JetDataType.FixedPoint ? column.Scale : null, + Source: column); - /// A computed column of , computed by . - public static OutputColumn Computed(string name, Type? clrType, NumberType type, Expression expression) => + /// A computed column of , computed by . + /// is the stored column it merely renames, where it is one. + public static OutputColumn Computed( + string name, Type? clrType, NumberType type, Expression expression, LibRed.Catalog.ColumnDef? source = null) => new(null, name, clrType, type.Class == NumberClass.Currency, - type.Class == NumberClass.Decimal ? type.Places : null, expression is LiteralExpression { Value: null }); + type.Class == NumberClass.Decimal ? type.Places : null, expression is LiteralExpression { Value: null }, + source); /// The column names, or null when none or more than one does (execution /// reports the ambiguous reference). @@ -98,20 +112,33 @@ public sealed class QueryExecutor : IScalarSubqueryRunner private DecorrelationGate Gate(SqlStatement query) => _gates.TryGetValue(query, out DecorrelationGate? gate) ? gate : _gates[query] = new DecorrelationGate(); - public QueryExecutor(JetDatabase database, IReadOnlyDictionary? parameters = null, SessionState? session = null) + /// + /// Builds the plan for its column shape alone — ADO's CommandBehavior.SchemaOnly. Every leaf yields + /// no rows and no seek key, offset or count expression is evaluated, so nothing is read from the file and a + /// parameter with no value supplied is not an error: a shape never depended on one. The columns are the same + /// ones the query would return, because those come from the catalog and the plan, never from a row. + /// + public QueryExecutor( + JetDatabase database, IReadOnlyDictionary? parameters = null, SessionState? session = null, + bool describing = false) { _database = database; _parameters = new ParameterBag(parameters); _session = session; + _describing = describing; } + private readonly bool _describing; + public ResultSet ExecuteQuery(PlanNode plan) { var (columns, rows) = Execute(plan, null); return new ResultSet( columns.Select(c => c.Name).ToList(), rows, - columns.Select(c => c.ClrType ?? typeof(object)).ToList()); + columns.Select(c => c.ClrType ?? typeof(object)).ToList(), + // Only a caller that asks for the schema pays to build it. + () => Schema.SchemaRowsets.Describe(columns, _database.Catalog)); } /// @@ -130,6 +157,25 @@ public ResultSet ExecuteQuery(PlanNode plan) PlanNode plan, EvalScope outer) => Execute(plan, outer); + /// The columns a plan produces, without reading a row: columns come back eagerly and rows lazily, + /// so describing a query costs only the planning. Used to report a view's columns in schema metadata. + internal IReadOnlyList DescribeQuery(PlanNode plan) => Execute(plan, null).Columns; + + /// The columns a join publishes. Joining something already collapsed — an aggregate, a DISTINCT, + /// a union — makes the whole join non-updatable, as Access counts updatability, so a stored column reached + /// through one no longer stands for a row anybody can write back to. + private static List JoinedColumns(IReadOnlyList left, IReadOnlyList right) + { + var columns = left.Concat(right).ToList(); + if (columns.TrueForAll(c => c.Origin == ColumnOrigin.Expression)) return columns; + + return columns + .Select(c => c is { Source: not null, Origin: ColumnOrigin.Expression } + ? c with { Origin = ColumnOrigin.Aggregate } + : c) + .ToList(); + } + /// Runs a FROM-less SELECT @@IDENTITY / SELECT @@ROWCOUNT: evaluates each system /// variable against the session state and yields a single row. Each output column is named by its alias, /// or the variable name if unaliased. @@ -450,7 +496,7 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) { case SingleRowNode: // FROM-less SELECT: one row, no columns — the projection above evaluates its constants once. - return ([], [new object?[0]]); + return ([], _describing ? [] : [new object?[0]]); case ValuesNode values: { @@ -466,6 +512,8 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) .Select((expr, i) => new OutputColumn(null, $"Expr{i + 1}", DeclaredType(expr, []))) .ToList(); + if (_describing) return (valueColumns, []); + var valueRows = values.Rows .Select(row => row.Select(evaluator.Evaluate).ToArray()) .ToList(); @@ -480,7 +528,7 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) var columns = Schema.InformationSchema.ColumnsOf(scan.Table) .Zip(Schema.InformationSchema.ColumnTypesOf(scan.Table), (name, type) => new OutputColumn(alias, name, type)).ToList(); - return (columns, Schema.InformationSchema.Rows(scan.Table, _database.Catalog)); + return (columns, _describing ? [] : Schema.InformationSchema.Rows(scan.Table, _database.Catalog)); } case ScanNode scan: @@ -488,7 +536,7 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) var table = _database.OpenTable(scan.Table); string alias = scan.Alias ?? scan.Table; var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); - return (columns, table.Rows()); + return (columns, _describing ? [] : table.Rows()); } case IndexSeekNode seek: @@ -496,6 +544,7 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) var table = _database.OpenTable(seek.Table); string alias = seek.Alias ?? seek.Table; var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); + if (_describing) return (columns, []); // Evaluate the key(s) in the outer scope (so an index-nested-loop join can key off the outer // row); a single-table seek's key is a constant/parameter. @@ -512,6 +561,7 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) var table = _database.OpenTable(range.Table); string alias = range.Alias ?? range.Table; var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); + if (_describing) return (columns, []); var evaluator = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); int col = range.Index.Columns[0].Column.Index; @@ -559,7 +609,8 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) { var (columns, rows) = Execute(sort.Input, outer); // As in LimitNode: the count is literal/parameter/arithmetic, so an empty row scope suffices. - int? bound = sort.Limit is { } lim + // Describing sorts nothing, so its count is not evaluated either. + int? bound = sort.Limit is { } lim && !_describing ? Convert.ToInt32(Eval([], [], outer).Evaluate(lim), System.Globalization.CultureInfo.InvariantCulture) : null; return (columns, SortRows(sort.Keys, columns, outer, rows, bound)); @@ -602,6 +653,9 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) case LimitNode limit: { var (columns, rows) = Execute(limit.Input, outer); + // A limit cannot change the shape, so describing leaves its counts unevaluated. + if (_describing) return (columns, rows); + // Counts are literal/parameter/arithmetic (no column refs), so an empty row scope suffices. var limitEval = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); @@ -643,7 +697,9 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) case DistinctNode distinct: { var (columns, rows) = Execute(distinct.Input, outer); - return (columns, Distinct(rows)); + // A distinct row stands for however many rows shared it, so its columns are no longer single + // stored values that could be written back — the same standing an aggregate's have. + return (columns.Select(c => c with { Origin = ColumnOrigin.Aggregate }).ToList(), Distinct(rows)); } case DistinctRowNode distinctRow: @@ -681,10 +737,16 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) /// private static OutputColumn SetOperationColumn(OutputColumn left, OutputColumn right) { + // Whatever the arms hold, the combined column is no longer any one stored column, so it carries no + // source: a caller describing the query sees a computed column, which is what it is. if (right.Null) - return left; + return left with { Source = null, Origin = ColumnOrigin.SetOperation }; if (left.Null) - return left with { ClrType = right.ClrType, Currency = right.Currency, Scale = right.Scale, Null = false }; + return left with + { + ClrType = right.ClrType, Currency = right.Currency, Scale = right.Scale, Null = false, + Source = null, Origin = ColumnOrigin.SetOperation, + }; // A Decimal column is Currency unless one side is a Decimal of its own, and keeps a scale both sides share. bool leftDecimal = left.ClrType == typeof(decimal) && !left.Currency; @@ -701,6 +763,8 @@ private static OutputColumn SetOperationColumn(OutputColumn left, OutputColumn r Scale = !isDecimal ? null : leftDecimal && rightDecimal ? (left.Scale == right.Scale ? left.Scale : null) : leftDecimal ? left.Scale : rightDecimal ? right.Scale : null, + Source = null, + Origin = ColumnOrigin.SetOperation, }; static Type AsInteger(Type type) => type == typeof(bool) ? typeof(short) : type; @@ -802,8 +866,16 @@ private ProjectionSchema ProjectionSchemaFor(ProjectNode project, IReadOnlyList< { string name = item.Alias ?? (item.Value is ColumnReference c ? c.Column : $"Expr{plan.Count + 1}"); NumberType type = ExpressionEvaluator.NumberTypeOf(item.Value, columns, e => DeclaredType(e, columns)); - plan.Add((OutputColumn.Computed(name, DeclaredType(item.Value, columns), type, item.Value), -1, item.Value, - type, ChoiceConversion(item.Value, columns))); + // A projection item that is just a column keeps what that column was: the stored column behind + // it, and where none stands behind it, what computed it — so a renamed union column still + // describes itself as one. + OutputColumn? referenced = item.Value is ColumnReference reference + ? OutputColumn.Find(columns, reference) + : null; + plan.Add(( + OutputColumn.Computed(name, DeclaredType(item.Value, columns), type, item.Value, referenced?.Source) + with { Origin = referenced?.Origin ?? ColumnOrigin.Expression }, + -1, item.Value, type, ChoiceConversion(item.Value, columns))); } } @@ -1201,7 +1273,7 @@ private static HashSet ContributingQualifiers( // Execute resolves columns eagerly and rows lazily, so for almost every node this reads nothing at all. var probeScope = new EvalScope(leftColumns, new object?[leftColumns.Count], outer); var (rightColumns, _) = Execute(apply.Right, probeScope); - var columns = leftColumns.Concat(rightColumns).ToList(); + var columns = JoinedColumns(leftColumns, rightColumns); IEnumerable Rows() { @@ -1289,7 +1361,7 @@ private static HashSet ContributingQualifiers( var (rightColumns, rightRowsEnum) = Execute(join.Right, outer); - var columns = leftColumns.Concat(rightColumns).ToList(); + var columns = JoinedColumns(leftColumns, rightColumns); var rightRows = rightRowsEnum.ToList(); // re-iterated per left row if (on is null && join.Kind != JoinKind.Cross) throw new NotSupportedException("Joins require an ON condition."); @@ -1356,7 +1428,7 @@ private static HashSet ContributingQualifiers( { var (leftColumns, leftRows) = Execute(join.Left, outer); var (rightColumns, rightRowsEnum) = Execute(join.Right, outer); - var joinColumns = leftColumns.Concat(rightColumns).ToList(); + var joinColumns = JoinedColumns(leftColumns, rightColumns); int leftWidth = leftColumns.Count, rightWidth = rightColumns.Count; Expression on = join.On; @@ -1846,10 +1918,22 @@ private static void CheckFrame(WindowFunction fn, WindowFrame frame) .Select(item => ExpressionEvaluator.NumberTypeOf(item.Value, columns, e => DeclaredType(e, columns))).ToList(); var outColumns = node.Projection .Select((item, i) => OutputColumn.Computed( - item.Alias ?? (item.Value is ColumnReference c ? c.Column : $"Expr{i + 1}"), - DeclaredType(item.Value, columns), - outTypes[i], - item.Value)) + item.Alias ?? (item.Value is ColumnReference c ? c.Column : $"Expr{i + 1}"), + DeclaredType(item.Value, columns), + outTypes[i], + item.Value, + // A grouping key that is just a column still passes that stored column through, keeping its + // declared type and length; anything holding an aggregate is computed over the group. + item.Value is ColumnReference reference ? OutputColumn.Find(columns, reference)?.Source : null) + with + { + // The grouping produces both its keys and its aggregates, so both count as drawn from many + // rows. An expression that merely evaluates per group (a concatenation of the key, say) is + // still computed from one value, and describes itself as one. + Origin = Aggregates(item.Value).Any() || item.Value is ColumnReference + ? ColumnOrigin.Aggregate + : ColumnOrigin.Expression, + }) .ToList(); var conversions = node.Projection.Select(item => ChoiceConversion(item.Value, columns)).ToList(); diff --git a/src/LibRed/LibRed.Engine/Execution/ResultSet.cs b/src/LibRed/LibRed.Engine/Execution/ResultSet.cs index d3711afd..c5aa7538 100644 --- a/src/LibRed/LibRed.Engine/Execution/ResultSet.cs +++ b/src/LibRed/LibRed.Engine/Execution/ResultSet.cs @@ -7,10 +7,14 @@ namespace LibRed.Engine.Execution; /// public sealed class ResultSet { + private readonly Func>? _describe; + private IReadOnlyList? _columns; + public ResultSet( IReadOnlyList columnNames, IEnumerable rows, - IReadOnlyList? columnTypes = null) + IReadOnlyList? columnTypes = null, + Func>? describe = null) { if (columnTypes is not null && columnTypes.Count != columnNames.Count) throw new ArgumentException("The number of column types must match the number of column names.", nameof(columnTypes)); @@ -18,8 +22,16 @@ public ResultSet( ColumnNames = columnNames; Rows = rows; ColumnTypes = columnTypes ?? Enumerable.Repeat(typeof(object), columnNames.Count).ToArray(); + _describe = describe; } + /// Everything known about each output column — the stored column behind it where there is one, + /// with its declared type and constraints. Described on demand, not per query: only a caller asking for + /// schema (GetSchemaTable, GetColumnSchema) pays for it. + public IReadOnlyList Columns => + _columns ??= _describe?.Invoke() + ?? ColumnNames.Select((name, i) => new ResultColumn(name, ColumnTypes[i])).ToList(); + public IReadOnlyList ColumnNames { get; } /// Declared CLR type for each output column. Unlike row-value inference, this remains @@ -31,3 +43,30 @@ public ResultSet( public static ResultSet Empty { get; } = new([], [], []); } + +/// +/// What is known about one column of a result: its name and CLR type always, and — where the value comes +/// straight from a stored column rather than being computed — that column's table, name, declared type and +/// the constraints on it. The ADO layer turns these into GetSchemaTable rows and DbColumns. +/// +/// The table the value is read from, or null when nothing stored stands behind it. +/// Its name in that table, which an alias in the query does not change. +/// The OLE DB type code, as the schema collections report it. +/// The provider's name for the type, as the DataTypes collection spells it. +public sealed record ResultColumn( + string Name, + Type ClrType, + string? BaseTableName = null, + string? BaseColumnName = null, + bool AllowNull = true, + bool IsExpression = false, + bool IsAutoIncrement = false, + bool IsKey = false, + bool IsUnique = false, + bool IsLong = false, + bool IsReadOnly = false, + int? Size = null, + int? Precision = null, + int? Scale = null, + int ProviderType = 0, + string TypeName = ""); diff --git a/src/LibRed/LibRed.Engine/QueryEngine.cs b/src/LibRed/LibRed.Engine/QueryEngine.cs index 4390b7d6..a9214c5e 100644 --- a/src/LibRed/LibRed.Engine/QueryEngine.cs +++ b/src/LibRed/LibRed.Engine/QueryEngine.cs @@ -77,6 +77,52 @@ private ResultSet ExecuteQueryCore(SqlStatement parsed, IReadOnlyDictionary + /// The shape would return, without running it — what ADO's + /// CommandBehavior.SchemaOnly asks for. A query is parsed, bound and planned, and its columns come + /// back with no rows: planning is what knows the shape, and rows are lazy, so nothing is ever read. A + /// statement that writes is not executed and describes as nothing (measured: ACE leaves an + /// INSERT's table untouched under SchemaOnly), and a stored procedure describes as the query it holds + /// rather than running it. + /// + public ResultSet Describe(string sql, IReadOnlyDictionary? parameters = null) + { + if (_parser.IsStatementless(sql)) return ResultSet.Empty; + + SqlStatement parsed = _parser.ParseStatement(sql); + // The shared scope always: describing reads the catalog and writes nothing, whatever the statement + // would have done had it run. + return _database.ReadConsistent(() => DescribeCore(parsed, parameters)); + } + + private ResultSet DescribeCore(SqlStatement parsed, IReadOnlyDictionary? parameters) + { + if (parsed is ExecuteStatement exec) + { + // A stored SELECT describes as its own (parameterized) text; a stored action query would have + // written, so it describes as nothing. Its arguments are never evaluated — values cannot change + // a shape. + if (_database.Catalog.Views.TryGetValue(exec.Procedure, out string? viewSql)) + return DescribeCore(_parser.ParseStatement(viewSql), parameters); + if (_database.Catalog.ActionQueries.ContainsKey(exec.Procedure)) return ResultSet.Empty; + throw new InvalidOperationException($"No stored procedure or query named '{exec.Procedure}'."); + } + + // Everything that writes — DML, DDL, a make-table SELECT, transaction control — returns no rows and + // does not run. + if (parsed is not (SelectStatement { Into: null } or SetOperationStatement or SystemVariableSelectStatement)) + return ResultSet.Empty; + + BoundStatement bound = _binder.Bind(ViewExpander.Expand(parsed, _database.Catalog.Views, _parser)); + var executor = new QueryExecutor(_database, parameters, _session, describing: true); + ResultSet shape = bound.Statement is SystemVariableSelectStatement sysSelect + ? executor.ExecuteSystemVariableSelect(sysSelect) + : executor.ExecuteQuery(PlanWithIndexes(bound)); + + // Its rows are lazy and so far untouched; dropping them is what guarantees they stay that way. + return new ResultSet(shape.ColumnNames, [], shape.ColumnTypes, () => shape.Columns); + } + public int ExecuteNonQuery(string sql, IReadOnlyDictionary? parameters = null) => Execute(sql, parameters).RecordsAffected; @@ -243,8 +289,8 @@ private CommandResult ExecuteProcedure(ExecuteStatement exec, IReadOnlyDictionar var executor = new QueryExecutor(_database, parameters, _session); var evaluator = new ExpressionEvaluator(new EvalScope([], [], null), executor, bag, _session); - IReadOnlyList paramNames = catalog.QueryParameters.TryGetValue(exec.Procedure, out var ns) - ? ns : []; + IReadOnlyList paramNames = catalog.QueryParameters.TryGetValue(exec.Procedure, out var declared) + ? declared.Select(p => p.Name).ToList() : []; // Access EXEC arguments take three shapes (EF emits all of them): // procParam = value → a NAMED argument (bind the proc's named parameter to the value) diff --git a/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs b/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs new file mode 100644 index 00000000..2f95b92b --- /dev/null +++ b/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs @@ -0,0 +1,788 @@ +using LibRed.Catalog; + +namespace LibRed.Engine.Schema; + +/// +/// The catalog as the ADO.NET metadata collections ACE's OLE DB provider serves from GetSchema, in the +/// same shapes: the same collection names, the same columns in the same order, and the same values — measured +/// against ACE 16 — so code written for that provider reads what it expects from LibRed. +/// +/// is a different surface: the Jet-dialect INFORMATION_SCHEMA.* +/// views EF's migrations query as SQL, with EFCore.Jet's ADOX-derived column sets. Both read the same +/// . LibRed.Ado shapes these rows into DataTables and applies each collection's +/// restrictions. +/// +public static class SchemaRowsets +{ + /// The collections served here. The first five are the ones ACE advertises through + /// GetSchema, spelled as it spells them; the rest are its relational rowsets, which ACE serves only + /// through GetOleDbSchemaTable — LibRed has no equivalent of that call, so they are named + /// collections here, in ACE's shapes. + public static IReadOnlyList Names { get; } = + [ + "Tables", "Columns", "Indexes", "Views", "Procedures", + "ForeignKeys", "PrimaryKeys", "TableConstraints", "KeyColumnUsage", "ConstraintColumnUsage", + "ReferentialConstraints", "CheckConstraints", "Statistics", + "ProcedureParameters", "ViewColumns", + ]; + + /// True if is one of (case-insensitively, as + /// ADO.NET matches collection names). + public static bool IsKnown(string collection) => + Names.Any(n => n.Equals(collection, StringComparison.OrdinalIgnoreCase)); + + /// The column names of a collection, in order. + public static IReadOnlyList ColumnsOf(string collection) => Canonical(collection) switch + { + "Tables" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "TABLE_TYPE", "TABLE_GUID", "DESCRIPTION", + "TABLE_PROPID", "DATE_CREATED", "DATE_MODIFIED"], + "Columns" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "COLUMN_NAME", "COLUMN_GUID", "COLUMN_PROPID", + "ORDINAL_POSITION", "COLUMN_HASDEFAULT", "COLUMN_DEFAULT", "COLUMN_FLAGS", "IS_NULLABLE", "DATA_TYPE", + "TYPE_GUID", "CHARACTER_MAXIMUM_LENGTH", "CHARACTER_OCTET_LENGTH", "NUMERIC_PRECISION", "NUMERIC_SCALE", + "DATETIME_PRECISION", "CHARACTER_SET_CATALOG", "CHARACTER_SET_SCHEMA", "CHARACTER_SET_NAME", + "COLLATION_CATALOG", "COLLATION_SCHEMA", "COLLATION_NAME", "DOMAIN_CATALOG", "DOMAIN_SCHEMA", + "DOMAIN_NAME", "DESCRIPTION", + // Past ACE's own shape. A provider may add columns to a collection (the SQL Server OLE DB provider + // adds IS_COMPUTED to this one, spelled exactly so). The rest are things no ACE rowset carries: the + // expression behind a calculated column (ACE has it only in DAO), a readable type name — this + // rowset otherwise names types only by code — and the AutoNumber flag with its parameters, which + // OLE DB omits entirely and ODBC smuggles into the type name. + "IS_COMPUTED", "COLUMN_EXPRESSION", "TYPE_NAME", "IS_AUTOINCREMENT", "SEED", "INCREMENT"], + "Indexes" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "INDEX_CATALOG", "INDEX_SCHEMA", "INDEX_NAME", + "PRIMARY_KEY", "UNIQUE", "CLUSTERED", "TYPE", "FILL_FACTOR", "INITIAL_SIZE", "NULLS", "SORT_BOOKMARKS", + "AUTO_UPDATE", "NULL_COLLATION", "ORDINAL_POSITION", "COLUMN_NAME", "COLUMN_GUID", "COLUMN_PROPID", + "COLLATION", "CARDINALITY", "PAGES", "FILTER_CONDITION", "INTEGRATED"], + "Views" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "VIEW_DEFINITION", "CHECK_OPTION", + "IS_UPDATABLE", "DESCRIPTION", "DATE_CREATED", "DATE_MODIFIED"], + "Procedures" => ["PROCEDURE_CATALOG", "PROCEDURE_SCHEMA", "PROCEDURE_NAME", "PROCEDURE_TYPE", + "PROCEDURE_DEFINITION", "DESCRIPTION", "DATE_CREATED", "DATE_MODIFIED"], + "ForeignKeys" => ["PK_TABLE_CATALOG", "PK_TABLE_SCHEMA", "PK_TABLE_NAME", "PK_COLUMN_NAME", + "PK_COLUMN_GUID", "PK_COLUMN_PROPID", "FK_TABLE_CATALOG", "FK_TABLE_SCHEMA", "FK_TABLE_NAME", + "FK_COLUMN_NAME", "FK_COLUMN_GUID", "FK_COLUMN_PROPID", "ORDINAL", "UPDATE_RULE", "DELETE_RULE", + "PK_NAME", "FK_NAME", "DEFERRABILITY"], + "PrimaryKeys" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "COLUMN_NAME", "COLUMN_GUID", + "COLUMN_PROPID", "ORDINAL", "PK_NAME"], + "TableConstraints" => ["CONSTRAINT_CATALOG", "CONSTRAINT_SCHEMA", "CONSTRAINT_NAME", "TABLE_CATALOG", + "TABLE_SCHEMA", "TABLE_NAME", "CONSTRAINT_TYPE", "IS_DEFERRABLE", "INITIALLY_DEFERRED", "DESCRIPTION"], + "KeyColumnUsage" => ["CONSTRAINT_CATALOG", "CONSTRAINT_SCHEMA", "CONSTRAINT_NAME", "TABLE_CATALOG", + "TABLE_SCHEMA", "TABLE_NAME", "COLUMN_NAME", "COLUMN_GUID", "COLUMN_PROPID", "ORDINAL_POSITION"], + "ConstraintColumnUsage" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "COLUMN_NAME", "COLUMN_GUID", + "COLUMN_PROPID", "CONSTRAINT_CATALOG", "CONSTRAINT_SCHEMA", "CONSTRAINT_NAME"], + "ReferentialConstraints" => ["CONSTRAINT_CATALOG", "CONSTRAINT_SCHEMA", "CONSTRAINT_NAME", + "UNIQUE_CONSTRAINT_CATALOG", "UNIQUE_CONSTRAINT_SCHEMA", "UNIQUE_CONSTRAINT_NAME", "MATCH_OPTION", + "UPDATE_RULE", "DELETE_RULE", "DESCRIPTION"], + "CheckConstraints" => ["CONSTRAINT_CATALOG", "CONSTRAINT_SCHEMA", "CONSTRAINT_NAME", "CHECK_CLAUSE", + "DESCRIPTION"], + "Statistics" => ["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "CARDINALITY"], + // ACE serves neither of these: it refuses the procedure-parameter rowset outright, and has no + // view-column one. Both follow the shapes the OLE DB and SQL Server providers document. + "ProcedureParameters" => ["PROCEDURE_CATALOG", "PROCEDURE_SCHEMA", "PROCEDURE_NAME", "PARAMETER_NAME", + "ORDINAL_POSITION", "PARAMETER_TYPE", "PARAMETER_HASDEFAULT", "PARAMETER_DEFAULT", "IS_NULLABLE", + "DATA_TYPE", "CHARACTER_MAXIMUM_LENGTH", "CHARACTER_OCTET_LENGTH", "NUMERIC_PRECISION", + "NUMERIC_SCALE", "DESCRIPTION", "TYPE_NAME", "LOCAL_TYPE_NAME"], + "ViewColumns" => ["VIEW_CATALOG", "VIEW_SCHEMA", "VIEW_NAME", "TABLE_CATALOG", "TABLE_SCHEMA", + "TABLE_NAME", "COLUMN_NAME"], + _ => throw new InvalidOperationException($"'{collection}' is not a schema collection."), + }; + + /// Declared CLR types corresponding to . A nullable value still reports its + /// underlying type, as ADO.NET metadata does; row nulls are represented by null. + public static IReadOnlyList ColumnTypesOf(string collection) => Canonical(collection) switch + { + "Tables" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(Guid), typeof(string), + typeof(long), typeof(DateTime), typeof(DateTime)], + "Columns" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(Guid), typeof(long), + typeof(long), typeof(bool), typeof(string), typeof(long), typeof(bool), typeof(int), + typeof(Guid), typeof(long), typeof(long), typeof(int), typeof(short), + typeof(long), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string), + typeof(bool), typeof(string), typeof(string), typeof(bool), typeof(int), typeof(int)], + "Indexes" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(string), typeof(string), + typeof(bool), typeof(bool), typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), + typeof(bool), typeof(int), typeof(long), typeof(string), typeof(Guid), typeof(long), + typeof(short), typeof(decimal), typeof(int), typeof(string), typeof(bool)], + "Views" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(bool), + typeof(bool), typeof(string), typeof(DateTime), typeof(DateTime)], + "Procedures" => [typeof(string), typeof(string), typeof(string), typeof(short), + typeof(string), typeof(string), typeof(DateTime), typeof(DateTime)], + "ForeignKeys" => [typeof(string), typeof(string), typeof(string), typeof(string), + typeof(Guid), typeof(long), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(Guid), typeof(long), typeof(long), typeof(string), typeof(string), + typeof(string), typeof(string), typeof(short)], + "PrimaryKeys" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(Guid), + typeof(long), typeof(long), typeof(string)], + "TableConstraints" => [typeof(string), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string), typeof(string), typeof(bool), typeof(bool), typeof(string)], + "KeyColumnUsage" => [typeof(string), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string), typeof(string), typeof(Guid), typeof(long), typeof(long)], + "ConstraintColumnUsage" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(Guid), + typeof(long), typeof(string), typeof(string), typeof(string)], + "ReferentialConstraints" => [typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string), typeof(string)], + "CheckConstraints" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(string)], + "Statistics" => [typeof(string), typeof(string), typeof(string), typeof(decimal)], + "ProcedureParameters" => [typeof(string), typeof(string), typeof(string), typeof(string), + typeof(int), typeof(int), typeof(bool), typeof(string), typeof(bool), + typeof(int), typeof(long), typeof(long), typeof(int), + typeof(short), typeof(string), typeof(string), typeof(string)], + "ViewColumns" => [typeof(string), typeof(string), typeof(string), typeof(string), typeof(string), + typeof(string), typeof(string)], + _ => throw new InvalidOperationException($"'{collection}' is not a schema collection."), + }; + + /// The rows of a collection, in order. A Jet file has no catalog or schema, + /// so every *_CATALOG / *_SCHEMA value is null, as ACE reports them. + public static IReadOnlyList Rows(string collection, JetDatabase database) + { + JetCatalog catalog = database.Catalog; + var rows = new List(); + switch (Canonical(collection)) + { + case "Tables": + // Tables and views together, ordered by name, as ACE returns them. A stored query with declared + // parameters is a procedure rather than a view, so it appears in neither this rowset nor Views. + foreach (TableDef t in catalog.Tables) + if (TableType(t) is { } type) + rows.Add([null, null, t.Name, type, null, null, null, null, null]); + foreach (string name in ViewNames(catalog)) + rows.Add([null, null, name, "VIEW", null, null, null, null, null]); + rows.Sort(ByName(2)); + break; + + case "Columns": + // Every object ACE lists columns for except the system tables: user tables, the Access-owned + // hidden tables, and views. + foreach (TableDef t in catalog.Tables) + { + if (TableType(t) is not { } type || type == "SYSTEM TABLE") continue; + foreach (ColumnDef c in t.Columns) + rows.Add([null, null, t.Name, c.Name, null, null, (long)(c.Index + 1), + c.DefaultValue is not null, c.DefaultValue, ColumnFlags(c), Nullable(c), + DataTypeCode(c), null, CharacterMaxLength(c), CharacterOctetLength(c), + NumericPrecision(c), NumericScale(c), DateTimePrecision(c), + null, null, null, null, null, null, null, null, null, null, + c.IsCalculated, c.CalculatedExpression, ProviderTypeName(c), + c.IsAutoNumber, c.IsAutoNumber ? c.Seed : null, c.IsAutoNumber ? c.Increment : null]); + } + + // A view's columns are the shape its query produces, which only planning the query can say. + // A column the query passes through unchanged reports the stored column behind it, facets and + // all; a computed one has only the type the expression yields. Either way the writability flag + // is WRITE rather than the WRITEUNKNOWN a stored column carries, as ACE reports a view's. + foreach (string view in ViewNames(catalog)) + { + var described = ViewColumns(database, view); + for (int i = 0; i < described.Count; i++) + { + var column = described[i]; + ColumnDef? c = column.Source; + bool nullable = c is not null ? ViewNullable(c) : column.ClrType != typeof(bool); + rows.Add(c is not null + ? [null, null, view, column.Name, null, null, (long)(i + 1), + false, null, ViewColumnFlags(c, column.Origin), nullable, + DataTypeCode(c), null, CharacterMaxLength(c), CharacterOctetLength(c), + NumericPrecision(c), NumericScale(c), DateTimePrecision(c), + null, null, null, null, null, null, null, null, null, null, + // A view column is computed when the view computes it, and also when it passes + // through a table's calculated column — which keeps its expression. An + // AutoNumber keeps its flag through a view, but not its seed: nothing is + // assigned through a view, so the next value belongs to the table. + c.IsCalculated, c.CalculatedExpression, ProviderTypeName(c), + c.IsAutoNumber, null, null] + : [null, null, view, column.Name, null, null, (long)(i + 1), + false, null, ComputedColumnFlags(column.ClrType, nullable, column.Origin), nullable, + ComputedDataTypeCode(column.ClrType, column.Currency), null, + ComputedTextLength(column.ClrType, column.Origin), + ComputedTextLength(column.ClrType, column.Origin) * 2, + ComputedPrecision(column.ClrType, column.Currency), (short?)column.Scale, + column.ClrType == typeof(DateTime) ? 0L : null, + null, null, null, null, null, null, null, null, null, null, + // Computed by the view; the expression's text is not reconstructed here. + true, null, ComputedTypeName(column.ClrType, column.Currency), false, null, null]); + } + } + break; + + case "Indexes": + // One row per index column, for the same objects as Columns minus the views (an index belongs + // to a table). Every LOGICAL index is listed, so a column covered by a named index, a primary + // key and a relationship appears once under each name, all reporting the one real index's + // columns and statistics — as ACE lists them. CARDINALITY is the distinct-entry count. + foreach (TableDef t in catalog.Tables) + { + if (TableType(t) is not { } type || type == "SYSTEM TABLE") continue; + foreach ((string name, IndexDef ix, bool isPrimaryKey) in LogicalIndexes(t)) + for (int i = 0; i < ix.Columns.Count; i++) + rows.Add([null, null, t.Name, null, null, name, isPrimaryKey, ix.IsUnique, false, + IndexTypeBtree, FillFactor, InitialSize, Nulls(ix), false, true, NullCollationLow, + (long)(i + 1), ix.Columns[i].Column.Name, null, null, + ix.Columns[i].Ascending ? CollationAscending : CollationDescending, + (decimal)ix.UniqueEntryCount, null, null, true]); + } + break; + + case "Views": + foreach (string name in ViewNames(catalog)) + rows.Add([null, null, name, catalog.Views[name], null, true, null, null, null]); + rows.Sort(ByName(2)); + break; + + case "Procedures": + // A stored query is a procedure when it declares parameters, and an action query always is. + // ACE's PROCEDURE_DEFINITION carries the PARAMETERS clause ahead of the statement; LibRed does + // not reconstruct the parameters' declared types yet, so the statement alone is reported. + foreach ((string name, string sql) in catalog.Views) + if (catalog.QueryParameters.ContainsKey(name)) + rows.Add([null, null, name, ProcedureTypeReturnsRows, sql, null, null, null]); + foreach ((string name, StoredActionQuery query) in catalog.ActionQueries) + rows.Add([null, null, name, ProcedureTypeReturnsRows, query.Sql, null, null, null]); + rows.Sort(ByName(2)); + break; + + case "ForeignKeys": + // One row per column pair, naming the parent's key on one side and the foreign key on the + // other. PK_NAME is the constraint the parent side is keyed on, which is its primary key + // unless the relationship references some other unique index. + foreach (ForeignKey fk in catalog.Relationships) + { + string? parentKey = ParentKeyName(fk, catalog); + for (int i = 0; i < fk.Columns.Count; i++) + rows.Add([null, null, fk.ReferencedTable, fk.Columns[i].ReferencedColumn, null, null, + null, null, fk.Table, fk.Columns[i].Column, null, null, (long)(i + 1), + RefAction(fk.CascadeUpdate, fk.UpdateSetNull), RefAction(fk.CascadeDelete, fk.DeleteSetNull), + parentKey, fk.Name, null]); + } + break; + + case "PrimaryKeys": + foreach (TableDef t in ConstraintTables(catalog)) + if (t.Indexes.FirstOrDefault(ix => ix.IsPrimaryKey) is { } pk) + for (int i = 0; i < pk.Columns.Count; i++) + rows.Add([null, null, t.Name, pk.Columns[i].Column.Name, null, null, (long)(i + 1), pk.Name]); + break; + + case "TableConstraints": + foreach ((TableDef table, string name, string kind, IndexDef? _, ForeignKey? __) in Constraints(catalog)) + rows.Add([null, null, name, null, null, table.Name, kind, false, false, null]); + break; + + case "KeyColumnUsage": + // Each constraint's own key columns: a foreign key's are the columns it constrains on this + // table. ACE agrees on all but two relationships in the Northwind corpus, where it reports the + // table's primary-key column instead of the one the relationship's index actually holds + // (Employees.ReportsTo read back as EmployeeID); the rule behind those two is not established, + // and the key's own columns are what the rowset is defined to carry. + foreach ((TableDef table, string name, string _, IndexDef? index, ForeignKey? fk) in Constraints(catalog)) + { + if (fk is not null) + for (int i = 0; i < fk.Columns.Count; i++) + rows.Add([null, null, name, null, null, table.Name, fk.Columns[i].Column, + null, null, (long)(i + 1)]); + else if (index is not null) + for (int i = 0; i < index.Columns.Count; i++) + rows.Add([null, null, name, null, null, table.Name, index.Columns[i].Column.Name, + null, null, (long)(i + 1)]); + } + break; + + case "ConstraintColumnUsage": + foreach ((TableDef table, string name, string _, IndexDef? index, ForeignKey? __) in Constraints(catalog)) + if (index is not null) + foreach (var column in index.Columns) + rows.Add([null, null, table.Name, column.Column.Name, null, null, null, null, name]); + break; + + case "ReferentialConstraints": + // ACE repeats the relationship's own name as the unique constraint it references, rather than + // naming the parent's key, and reports every Jet relationship as MATCH FULL — a row with some + // key columns null and others not is rejected rather than ignored. + foreach (ForeignKey fk in catalog.Relationships) + rows.Add([null, null, fk.Name, null, null, fk.Name, "FULL", + RefAction(fk.CascadeUpdate, fk.UpdateSetNull), RefAction(fk.CascadeDelete, fk.DeleteSetNull), null]); + break; + + case "CheckConstraints": + foreach (TableDef t in catalog.Tables) + { + if (TableType(t) is not { } type || type == "SYSTEM TABLE") continue; + foreach ((string name, string expression) in t.CheckConstraints) + rows.Add([null, null, name, expression, null]); + } + break; + + case "Statistics": + // Every table, system ones included, with the row count the TDEF carries. Views have no + // cardinality to report and ACE lists none. + foreach (TableDef t in catalog.Tables) + if (TableType(t) is not null) + rows.Add([null, null, t.Name, (decimal)t.RowCount]); + rows.Sort(ByName(2)); + break; + + case "ProcedureParameters": + // Each stored query's declared parameters, in declaration order. Access declares a parameter's + // type but neither a default nor whether it takes null, so those report as the rowset's + // "no default" and nullable. An untyped parameter — Access's `Value` — reports no type. + foreach ((string name, IReadOnlyList parameters) in catalog.QueryParameters) + for (int i = 0; i < parameters.Count; i++) + { + StoredQueryParameter p = parameters[i]; + rows.Add([null, null, name, p.Name, i + 1, ParameterTypeInput, false, null, true, + p.Type is { } type ? DataTypeCodeOf(type) : null, null, null, + p.Type is { } precisionType ? NumericPrecisionOf(precisionType) : null, null, null, + p.Type is { } named ? ProviderTypeName(named) : null, + p.Type is { } local ? ProviderTypeName(local) : null]); + } + rows.Sort(ByName(2)); + break; + + case "ViewColumns": + // Which stored column each of a view's columns comes from, where one does — the provenance the + // planner already tracks. A computed column has no base column and so no row here. + var owners = ColumnOwners(catalog); + foreach (string view in ViewNames(catalog)) + foreach (var column in ViewColumns(database, view)) + if (column.Source is { } source && owners.TryGetValue(source, out string? owner)) + rows.Add([null, null, view, null, null, owner, source.Name]); + break; + } + return rows; + } + + /// The tables whose constraints are reported: the same set whose columns are, so a system table's + /// constraints stay out of the rowsets as ACE keeps them. + private static IEnumerable ConstraintTables(JetCatalog catalog) => + catalog.Tables.Where(t => TableType(t) is { } type && type != "SYSTEM TABLE"); + + /// Every constraint on those tables: a primary key, each other unique index, and each foreign key + /// the table declares. A foreign key's columns come from the index backing it, so it reports the columns + /// it constrains. + private static IEnumerable<(TableDef Table, string Name, string Kind, IndexDef? Index, ForeignKey? ForeignKey)> + Constraints(JetCatalog catalog) + { + foreach (TableDef t in ConstraintTables(catalog)) + { + foreach (IndexDef ix in t.Indexes.Where(ix => ix.IsPrimaryKey)) + yield return (t, ix.Name, "PRIMARY KEY", ix, null); + foreach (IndexDef ix in t.Indexes.Where(ix => ix.IsUnique && !ix.IsPrimaryKey)) + yield return (t, ix.Name, "UNIQUE", ix, null); + + foreach (ForeignKey fk in catalog.Relationships.Where(fk => + fk.Table.Equals(t.Name, StringComparison.OrdinalIgnoreCase))) + yield return (t, fk.Name, "FOREIGN KEY", BackingIndex(fk, t), fk); + } + } + + /// The index on the child table that stores a relationship's key columns. + private static IndexDef? BackingIndex(ForeignKey fk, TableDef child) + { + var columns = fk.Columns.Select(c => c.Column).ToHashSet(StringComparer.OrdinalIgnoreCase); + return child.Indexes.FirstOrDefault(ix => + ix.Columns.Count == columns.Count && ix.Columns.All(c => columns.Contains(c.Column.Name))); + } + + /// The name of the parent-side key a relationship references — its primary key unless the + /// relationship names some other unique index. + private static string? ParentKeyName(ForeignKey fk, JetCatalog catalog) + { + TableDef? parent = catalog.Tables.FirstOrDefault(t => + t.Name.Equals(fk.ReferencedTable, StringComparison.OrdinalIgnoreCase)); + if (parent is null) return null; + + var columns = fk.Columns.Select(c => c.ReferencedColumn).ToHashSet(StringComparer.OrdinalIgnoreCase); + bool Covers(IndexDef ix) => ix.Columns.Count == columns.Count && ix.Columns.All(c => columns.Contains(c.Column.Name)); + return (parent.Indexes.FirstOrDefault(ix => ix.IsPrimaryKey && Covers(ix)) + ?? parent.Indexes.FirstOrDefault(ix => ix.IsUnique && Covers(ix)))?.Name; + } + + private static string RefAction(bool cascade, bool setNull) => + cascade ? "CASCADE" : setNull ? "SET NULL" : "NO ACTION"; + + // ACE's fixed index-rowset values, named rather than repeated: a Jet index is a non-clustered B-tree that + // sorts nulls low, is maintained by the engine, and reports the page-size initial allocation. + private const int IndexTypeBtree = 1; // DBPROPVAL_IT_BTREE + private const int FillFactor = 100; + private const int InitialSize = 4096; + private const int NullCollationLow = 4; // DBPROPVAL_NC_LOW + private const short CollationAscending = 1; // DB_COLLATION_ASC + private const short CollationDescending = 2; // DB_COLLATION_DESC + private const short ProcedureTypeReturnsRows = 3; // DB_PT_FUNCTION + private const int ParameterTypeInput = 1; // DBPARAMTYPE_INPUT — Access declares no other kind + + /// Describes a query's output columns for the ADO layer's GetSchemaTable / + /// GetColumnSchema: each column's type and, where a stored column stands behind it, that column's + /// table, declared facets and constraints. Same rules as the Columns collection, so a caller reading + /// a query's schema and one reading the table's metadata are told the same thing. + internal static IReadOnlyList Describe( + IReadOnlyList columns, JetCatalog catalog) + { + var owners = ColumnOwners(catalog); + var described = new List(columns.Count); + + foreach (Execution.OutputColumn column in columns) + { + Type clrType = column.ClrType ?? typeof(object); + if (column.Source is not { } c || !owners.TryGetValue(c, out string? table)) + { + described.Add(new Execution.ResultColumn( + column.Name, clrType, AllowNull: clrType != typeof(bool), IsExpression: true, IsReadOnly: true, + Size: (int?)ComputedTextLength(column.ClrType, column.Origin), + Precision: ComputedPrecision(column.ClrType, column.Currency), Scale: column.Scale, + ProviderType: ComputedDataTypeCode(column.ClrType, column.Currency), + TypeName: ComputedTypeName(column.ClrType, column.Currency))); + continue; + } + + TableDef? owner = catalog.Tables.FirstOrDefault(t => t.Name == table); + described.Add(new Execution.ResultColumn( + column.Name, clrType, table, c.Name, + AllowNull: Nullable(c), + IsExpression: false, + IsAutoIncrement: c.IsAutoNumber, + IsKey: owner is not null && owner.Indexes.Any(ix => ix.IsPrimaryKey && Covers(ix, c)), + IsUnique: owner is not null && owner.Indexes.Any(ix => ix.IsUnique && ix.Columns.Count == 1 && Covers(ix, c)), + IsLong: IsLong(c), + // A calculated column cannot be written, and neither can a value a query computed from one. + IsReadOnly: c.IsCalculated, + Size: (int?)CharacterMaxLength(c), + Precision: NumericPrecision(c), + Scale: NumericScale(c), + ProviderType: DataTypeCode(c), + TypeName: ProviderTypeName(c))); + } + + return described; + + static bool Covers(IndexDef index, ColumnDef column) => + index.Columns.Any(c => ReferenceEquals(c.Column, column)); + } + + /// Which table owns each column, by the column's own identity, so a query's output column can be + /// traced back to the table it came from. + private static Dictionary ColumnOwners(JetCatalog catalog) + { + var owners = new Dictionary(ReferenceEqualityComparer.Instance as IEqualityComparer + ?? EqualityComparer.Default); + foreach (TableDef t in catalog.Tables) + foreach (ColumnDef c in t.Columns) + owners[c] = t.Name; + return owners; + } + + /// The provider's name for a column's type, as the DataTypes collection spells it — which for text + /// and binary means naming the fixed-length forms apart from the variable ones, since the two are different + /// types there (Char/VarChar, Binary/VarBinary) even though ACE's own flags collapse binary into one. + /// + private static string ProviderTypeName(ColumnDef c) => EffectiveType(c) switch + { + JetDataType.Text => c.IsFixedLength ? "Char" : "VarChar", + JetDataType.Binary => c.IsFixedLength ? "Binary" : "VarBinary", + var type => ProviderTypeName(type), + }; + + /// The provider's own name for a type, as the DataTypes collection spells it. + private static string ProviderTypeName(JetDataType type) => type switch + { + JetDataType.Boolean => "Bit", + JetDataType.Byte => "Byte", + JetDataType.Int16 => "Short", + JetDataType.Int32 => "Long", + JetDataType.Int64 => "BigInt", + JetDataType.Single => "Single", + JetDataType.Double => "Double", + JetDataType.Currency => "Currency", + JetDataType.DateTime => "DateTime", + JetDataType.DateTimeExtended => "DateTime2", + JetDataType.Guid => "GUID", + JetDataType.FixedPoint => "Decimal", + JetDataType.Text => "VarChar", + JetDataType.Memo or JetDataType.Complex => "LongText", + JetDataType.Binary => "VarBinary", + JetDataType.Ole => "LongBinary", + _ => "VarBinary", + }; + + // MSysObjects.Flags decides what an object is called in the schema rowsets, the way Access classifies it — + // not its name (measured on ACE 16): the system bit makes it a SYSTEM TABLE, the hidden bit an ACCESS TABLE + // (the navigation-pane and resource tables), and an object carrying ExcludedFlags is not listed at all — + // the MSysComplexType_* tables, flags 0x80030000. + private const uint SystemFlag = 0x80000000; + private const uint HiddenFlag = 0x00000008; + private const uint ExcludedFlags = 0x00030000; + + /// What ACE calls this object in the schema rowsets, or null when it lists the object nowhere. + private static string? TableType(TableDef t) => + (t.ObjectFlags & ExcludedFlags) != 0 ? null + : (t.ObjectFlags & SystemFlag) != 0 ? "SYSTEM TABLE" + : (t.ObjectFlags & HiddenFlag) != 0 ? "ACCESS TABLE" + : "TABLE"; + + /// Each of the table's logical indexes paired with the real index that stores it, primary key + /// first and the rest by name, as ACE orders them. The parent half of a relationship is left out: Access + /// names it .r… and keeps it out of its own schema views. A table read from a definition that + /// carries no logical list (one LibRed built itself, say) falls back to its real indexes, one name each. + private static IEnumerable<(string Name, IndexDef Index, bool IsPrimaryKey)> LogicalIndexes(TableDef t) + { + IEnumerable<(string Name, IndexDef Index, bool IsPrimaryKey)> all = t.LogicalIndexes.Count > 0 + ? t.LogicalIndexes + .Where(l => !l.IsIncomingRelationship) + .Select(l => (l.Name, Index: t.Indexes.FirstOrDefault(ix => ix.RealIndexOrdinal == l.RealIndexOrdinal), l.IsPrimaryKey)) + .Where(x => x.Index is not null) + .Select(x => (x.Name, x.Index!, x.IsPrimaryKey)) + : t.Indexes.Select(ix => (ix.Name, ix, ix.IsPrimaryKey)); + + return all + .OrderByDescending(x => x.IsPrimaryKey) + .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase); + } + + /// The stored SELECT queries that are views: a query declaring parameters is a procedure. + private static IEnumerable ViewNames(JetCatalog catalog) => + catalog.Views.Keys.Where(name => !catalog.QueryParameters.ContainsKey(name)); + + private static Comparison ByName(int column) => + (left, right) => string.Compare((string?)left[column], (string?)right[column], StringComparison.OrdinalIgnoreCase); + + /// The OLE DB type code ACE reports for a column. A memo and a text column share one code, as do an + /// OLE and a binary column; what separates them is the long-value flag in . + private static int DataTypeCode(ColumnDef c) => DataTypeCodeOf(EffectiveType(c)); + + /// + private static int DataTypeCodeOf(JetDataType type) => type switch + { + JetDataType.Boolean => 11, // DBTYPE_BOOL + JetDataType.Byte => 17, // DBTYPE_UI1 + JetDataType.Int16 => 2, // DBTYPE_I2 + JetDataType.Int32 => 3, // DBTYPE_I4 — an AutoNumber reports this too + JetDataType.Single => 4, // DBTYPE_R4 + JetDataType.Double => 5, // DBTYPE_R8 + JetDataType.Currency => 6, // DBTYPE_CY + JetDataType.DateTime => 7, // DBTYPE_DATE + JetDataType.Guid => 72, // DBTYPE_GUID + JetDataType.FixedPoint => 131, // DBTYPE_NUMERIC + // DBTYPE_WSTR. A complex (multi-value / attachment) column reports as long text too — measured on + // MSysResources.Data, the only complex column in the corpus. + JetDataType.Text or JetDataType.Memo or JetDataType.Complex => 130, + // Measured against ACE 16, which reports these two through the codes their CLR types map to even + // though its own DataTypes list never learned them: DBTYPE_I8 and DBTYPE_DBTIMESTAMP. + JetDataType.Int64 => 20, + JetDataType.DateTimeExtended => 135, + _ => 128, // DBTYPE_BYTES — binary, OLE, and anything unmodelled + }; + + // DBCOLUMNFLAGS as ACE sets them: every column is deferrable, of unknown writability and may hold null; + // a fixed-length one adds ISFIXEDLENGTH, a nullable one ISNULLABLE, and a memo/OLE column ISLONG. + private const long FlagsEveryColumn = 0x02 | 0x40; + private const long FlagIsFixedLength = 0x10; + private const long FlagIsNullable = 0x20; + private const long FlagIsLong = 0x80; + + // A stored column reports its writability as unknown; a view's column reports it as writable, which is + // what ACE puts in the flags for one. + private const long FlagWriteUnknown = 0x08; + private const long FlagWrite = 0x04; + + /// The columns a view produces, from planning its query: each carries the stored column behind it + /// where it passes one through unchanged, and otherwise the type the expression yields. A view LibRed + /// cannot plan — one written in SQL it does not accept — reports no columns rather than failing the whole + /// collection. + private static IReadOnlyList ViewColumns(JetDatabase database, string view) + { + try + { + var plan = new QueryEngine(database).PlanFor($"SELECT * FROM [{view}]"); + // Describing: the plan is built for its shape alone, so reporting a view's columns reads no rows — + // it would otherwise sort and buffer the whole of any view with an ORDER BY — and a view with + // declared parameters describes without values nobody supplied. + return new Execution.QueryExecutor(database, describing: true).DescribeQuery(plan); + } + catch (Exception) + { + return []; + } + } + + /// A view's column is writable, where a stored one's writability is unknown — except an + /// AutoNumber, which no one writes, and except a column a grouping produces, which cannot be written back + /// through the view either; ACE reports both as unknown. + private static long ViewColumnFlags(ColumnDef c, Execution.ColumnOrigin origin) => + FlagsEveryColumn + | (c.IsAutoNumber || origin != Execution.ColumnOrigin.Expression ? FlagWriteUnknown : FlagWrite) + | (IsFixedLength(c) ? FlagIsFixedLength : 0) + | (ViewNullable(c) ? FlagIsNullable : 0) + | (IsLong(c) ? FlagIsLong : 0); + + /// Whether a view reports the column as nullable. A view carries the type's own nullability but + /// not the table's Required property, so a required text column reads as nullable through a view while an + /// AutoNumber or Yes/No column — which the type itself forbids nulls in — still does not. + private static bool ViewNullable(ColumnDef c) => !c.IsAutoNumber && c.Type != JetDataType.Boolean; + + /// A computed view column's flags. What a column is computed from changes them: a value read out + /// of one row is not writable at all and, when it is text, is reported as a long one of no declared length; + /// a value drawn from several rows — an aggregate, or the arms of a union — is text of Access's default + /// width whose writability is merely unknown. + private static long ComputedColumnFlags(Type? clrType, bool nullable, Execution.ColumnOrigin origin) + { + bool text = clrType == typeof(string); + bool fromManyRows = origin != Execution.ColumnOrigin.Expression; + return FlagsEveryColumn + | (fromManyRows ? FlagWriteUnknown : 0) + | (nullable ? FlagIsNullable : 0) + | (text || clrType == typeof(byte[]) ? 0 : FlagIsFixedLength) + | (text && !fromManyRows ? FlagIsLong : 0); + } + + /// The OLE DB type code for a computed column. Currency and Decimal share , + /// so the planner's own Currency marking picks between them. + private static int ComputedDataTypeCode(Type? clrType, bool currency) => clrType switch + { + null => 130, + _ when clrType == typeof(bool) => 11, + _ when clrType == typeof(byte) => 17, + _ when clrType == typeof(short) => 2, + _ when clrType == typeof(int) => 3, + _ when clrType == typeof(long) => 20, + _ when clrType == typeof(float) => 4, + _ when clrType == typeof(double) => 5, + _ when clrType == typeof(decimal) => currency ? 6 : 131, + _ when clrType == typeof(DateTime) => 7, + _ when clrType == typeof(Guid) => 72, + _ when clrType == typeof(byte[]) => 128, + _ => 130, + }; + + /// The provider type name for a computed column, from the type its expression yields. + private static string ComputedTypeName(Type? clrType, bool currency) => clrType switch + { + null => "VarChar", + _ when clrType == typeof(bool) => "Bit", + _ when clrType == typeof(byte) => "Byte", + _ when clrType == typeof(short) => "Short", + _ when clrType == typeof(int) => "Long", + _ when clrType == typeof(long) => "BigInt", + _ when clrType == typeof(float) => "Single", + _ when clrType == typeof(double) => "Double", + _ when clrType == typeof(decimal) => currency ? "Currency" : "Decimal", + _ when clrType == typeof(DateTime) => "DateTime", + _ when clrType == typeof(Guid) => "GUID", + _ when clrType == typeof(byte[]) => "VarBinary", + _ => "VarChar", + }; + + /// A computed text column's length: Access's default width where the value is drawn from several + /// rows, and none at all where an expression computed it from one — that column is reported as long text, + /// which has no declared length. + private static long? ComputedTextLength(Type? clrType, Execution.ColumnOrigin origin) => + clrType != typeof(string) ? null + : origin == Execution.ColumnOrigin.Expression ? 0L + : 255L; + + /// A computed column's numeric precision — the same per-type value a stored column of that type + /// reports. + private static int? ComputedPrecision(Type? clrType, bool currency) => clrType switch + { + _ when clrType == typeof(byte) => 3, + _ when clrType == typeof(short) => 5, + _ when clrType == typeof(int) => 10, + _ when clrType == typeof(long) => 19, + _ when clrType == typeof(float) => 7, + _ when clrType == typeof(double) => 15, + _ when clrType == typeof(decimal) => currency ? 19 : 28, + _ => null, + }; + + /// A stored column's flags. A calculated one carries no write bit at all — nothing writes it, as + /// with an expression in a view — where an ordinary column's writability is merely unknown. + private static long ColumnFlags(ColumnDef c) => + FlagsEveryColumn | (c.IsCalculated ? 0 : FlagWriteUnknown) + | (IsFixedLength(c) ? FlagIsFixedLength : 0) + | (Nullable(c) ? FlagIsNullable : 0) + | (IsLong(c) ? FlagIsLong : 0); + + /// Whether ACE calls the column fixed-length: a scalar type always is, whatever its descriptor + /// says (Access's own system tables carry Long columns the descriptor marks variable, and ACE still + /// reports them fixed); text and binary follow their descriptor, so a designer-made fixed-width text + /// column is fixed while one declared through SQL is not; and memo/OLE never are. + private static bool IsFixedLength(ColumnDef c) => EffectiveType(c) switch + { + JetDataType.Memo or JetDataType.Ole or JetDataType.Complex => false, + // A text column is fixed when its descriptor says so — CHAR(n) and NCHAR(n) are, TEXT(n)/VARCHAR(n) + // are not. A binary column never is here, whatever its descriptor: ACE reports BINARY(n) as variable, + // the same collapse ADOX makes in reporting every binary column as adVarBinary. + JetDataType.Text => c.IsFixedLength, + JetDataType.Binary => false, + // BIGINT and DATETIME2 postdate ACE's OLE DB provider, which describes both as variable-length with no + // precision although each is a fixed width on disk — measured, and matched here so the metadata agrees + // with what a caller reading through ACE would have been told. + JetDataType.Int64 or JetDataType.DateTimeExtended => false, + _ => true, + }; + + private static bool IsLong(ColumnDef c) => + EffectiveType(c) is JetDataType.Memo or JetDataType.Ole or JetDataType.Complex; + + /// The type a column reports as. A calculated column reports what its expression yields, which + /// need not be the type its descriptor declares for storage — Access keeps a Yes/No expression in a + /// two-byte integer, and reports it as Yes/No. + private static JetDataType EffectiveType(ColumnDef c) => + c.IsCalculated && c.CalculatedResultType is { } result ? result : c.Type; + + /// Whether a stored column reports as nullable. A complex column's values live outside the row, so + /// the AutoNumber flag its descriptor carries (for the complex id) says nothing about nulls; a calculated + /// one is as nullable as the type it yields. + private static bool Nullable(ColumnDef c) => + c.IsCalculated ? EffectiveType(c) != JetDataType.Boolean + : c.Type == JetDataType.Complex ? c.IsNullable + : JetStoreType.IsNullable(c); + + /// CHARACTER_MAXIMUM_LENGTH: the declared length in characters for text and bytes for binary, zero + /// for the unbounded memo/OLE types, and — as ACE reports it — two for a Yes/No column. + private static long? CharacterMaxLength(ColumnDef c) => EffectiveType(c) switch + { + JetDataType.Text or JetDataType.Binary => JetStoreType.MaxLength(c), + JetDataType.Memo or JetDataType.Ole or JetDataType.Complex => 0L, + JetDataType.Boolean => 2L, + _ => null, + }; + + /// CHARACTER_OCTET_LENGTH: the storage width of the same, so twice the character count for text. + private static long? CharacterOctetLength(ColumnDef c) => EffectiveType(c) switch + { + JetDataType.Text => JetStoreType.MaxLength(c) * 2L, + JetDataType.Binary => JetStoreType.MaxLength(c), + JetDataType.Memo or JetDataType.Ole or JetDataType.Complex => 0L, + _ => null, + }; + + private static int? NumericPrecision(ColumnDef c) => + EffectiveType(c) == JetDataType.FixedPoint ? c.Precision : NumericPrecisionOf(EffectiveType(c)); + + /// + private static int? NumericPrecisionOf(JetDataType type) => type switch + { + JetDataType.Byte => 3, + JetDataType.Int16 => 5, + JetDataType.Int32 => 10, + JetDataType.Single => 7, + JetDataType.Double => 15, + JetDataType.Currency => 19, + // No precision for BIGINT: ACE reports none, though the type holds 19 digits (see IsFixedLength). + // A DECIMAL's precision is declared per column, so the caller supplies it (see NumericPrecision); + // 28 is the type's own maximum, which is what a parameter of the type reports. + JetDataType.FixedPoint => 28, + _ => null, + }; + + private static short? NumericScale(ColumnDef c) => EffectiveType(c) == JetDataType.FixedPoint ? c.Scale : null; + + private static long? DateTimePrecision(ColumnDef c) => EffectiveType(c) == JetDataType.DateTime ? 0L : null; + + /// NULLS: whether the index refuses null keys, ignores them, or takes them as keys like any other. + private static int Nulls(IndexDef ix) => ix.Required ? 1 : ix.IgnoreNulls ? 2 : 0; + + private static string Canonical(string collection) => + Names.FirstOrDefault(n => n.Equals(collection, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"'{collection}' is not a schema collection."); +} diff --git a/src/LibRed/LibRed.Sql/SqlKeywords.cs b/src/LibRed/LibRed.Sql/SqlKeywords.cs new file mode 100644 index 00000000..dcbc9ca9 --- /dev/null +++ b/src/LibRed/LibRed.Sql/SqlKeywords.cs @@ -0,0 +1,42 @@ +using Antlr4.Runtime; +using LibRed.Sql.Grammar; + +namespace LibRed.Sql; + +/// +/// The words the SQL front end treats as keywords, taken from the grammar itself rather than a list kept +/// beside it: every lexer token whose name lexes back to that same token is a keyword rule (SELECT, +/// FROM, …), while a token named for a category (IDENT, a literal, whitespace) lexes as an +/// identifier and drops out. Served through ADO.NET's ReservedWords metadata collection. +/// +public static class SqlKeywords +{ + private static readonly Lazy> All = new(Collect); + + /// Every keyword, upper-case and sorted. + public static IReadOnlyList Reserved => All.Value; + + private static IReadOnlyList Collect() + { + var vocabulary = AccessSqlLexer.DefaultVocabulary; + var words = new List(); + + // Token types run 1..n in the lexer's own rule order; the vocabulary names them. + for (int type = 1; type <= AccessSqlLexer.ruleNames.Length; type++) + { + string? name = vocabulary.GetSymbolicName(type); + if (name is null || !name.All(char.IsAsciiLetterUpper)) continue; + + // The name is a keyword only if lexing it produces exactly that token. An identifier-shaped token + // name lexes to IDENT instead, and so is not one. + var lexer = new AccessSqlLexer(CharStreams.fromString(name)) { TokenFactory = CommonTokenFactory.Default }; + lexer.RemoveErrorListeners(); + IToken first = lexer.NextToken(); + if (first.Type == type && lexer.NextToken().Type == TokenConstants.EOF) + words.Add(name); + } + + words.Sort(StringComparer.Ordinal); + return words; + } +} diff --git a/src/LibRed/docs/format/data-types.md b/src/LibRed/docs/format/data-types.md index 6caac5b5..b9a33335 100644 --- a/src/LibRed/docs/format/data-types.md +++ b/src/LibRed/docs/format/data-types.md @@ -348,3 +348,37 @@ goes through the broken conversion. It is live in the ordinary `System.Data.OleD COM consumer: a materialised value from any month but January shifts back a month to a valid date, and only a January value throws the `ArgumentOutOfRangeException` above. So: predicates are right, corruption is silent outside January, and Access itself never reads through OLE DB. + +## Footnote — what the drivers know about `BIGINT` and `DATETIME2` + +*Driver behaviour, not file format. Both types postdate the drivers, and each layer was left at a different +moment, so what a caller is told depends entirely on which one it asks.* Measured against ACE 16 on a file the +engine raised to `0x06` when the columns were created: + +| | schema metadata | a value in a query | +| --- | --- | --- | +| **`BIGINT`**, OLE DB | `DATA_TYPE` **20** (`DBTYPE_I8`), but `COLUMN_FLAGS` **106** — variable length, no precision | correct: `Int64`, `DBTYPE_I8` | +| **`BIGINT`**, ODBC | `DATA_TYPE` **-8** (`SQL_WCHAR`), `TYPE_NAME` **CHAR**, size **4** | the 8 stored bytes as 4 UTF-16 characters — `9223372036854775807` reads back as `"￿￿￿翿"` | +| **`DATETIME2`**, OLE DB | `DATA_TYPE` **135**, `COLUMN_FLAGS` **106**, no precision | corrupt (above) | +| **`DATETIME2`**, ODBC | `DATA_TYPE` **-3** (`SQL_VARBINARY`), size **42** | the raw 42 bytes, uncorrupted | +| **both**, DAO | — | correct: `BIGINT` as `Int64` (field type **16**), `DATETIME2` as a full-precision string (field type **26**) | + +Two rules fall out. **ODBC describes what it does not know by its stored width**, through whichever generic +type fits — 8 bytes as 4 wide characters, 42 bytes as binary — and hands back exactly those bytes; it never +converts, so nothing is corrupted but nothing is decoded either. + +**Only materialisation is broken, and only in the driver.** Through ODBC, an `INSERT` or `UPDATE` of either +type stores the right value (read back from the file directly to confirm), `WHERE Big = 4294967297` and +`WHERE Stamp = #…#` both match, and anything the engine computes comes back correctly — `CStr(Big)` returns +`"4294967297"`, `Year(Stamp)` returns `2021`. It is only selecting the column itself, `MAX(Big)` included, that +yields the undecoded bytes. So a caller stuck on ODBC can use either type by never selecting it bare; a tool +that issues `SELECT *` gets nonsense with no error raised. **OLE DB knows both type codes** and reads a +`BIGINT` correctly, yet describes both columns with the generic "variable length, no precision" it gives a type +it has no entry for. Its `DataTypes` list never learned either type: it still reports the same 15 as it did for +Jet 4, so neither `BigInt` nor `DateTime2` appears in the types the provider claims to support. + +**DAO is the exception on both counts**: it has type codes for the two types and returns their values +correctly, and it is the only surface that knows a column is calculated — `Field.Properties("Expression")` +hands back the expression, which OLE DB, ODBC and ADOX all withhold while reporting the expression's result +type as if it were a stored one. The engine and DAO moved together; the interop layers did not, which is +consistent with Access never reading its own files through them. diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md index 46c7f430..494b039b 100644 --- a/src/LibRed/docs/format/system-catalog.md +++ b/src/LibRed/docs/format/system-catalog.md @@ -18,6 +18,15 @@ > creating the model's tables. Real user tables carry `Flags = 0x00000000`, so excluding the > system/hidden bits never drops a genuine table. + > **The same bits name the object kind in a schema rowset** (verified against ACE's `Tables`, which + > classifies every object by `Flags` rather than by name): the system bit (`0x80000000`) makes it a + > **`SYSTEM TABLE`** — the engine's own catalog, `MSysObjects` / `MSysQueries` / `MSysRelationships` / + > `MSysACEs` / `MSysComplexColumns`; the hidden bit (`0x08`) without it makes an **`ACCESS TABLE`** — + > Access's application tables, the nav-pane group and `MSysResources`; anything carrying `0x00030000` + > is **not listed at all** (the `MSysComplexType_*` tables, `Flags 0x80030000`); everything else is a + > **`TABLE`**, which is why `MSysAccessStorage` (`Flags = 0`) appears among the user tables despite its + > name. Stored queries are listed in the same rowset as **`VIEW`**. + **Writing a table object** (verified against Access-written rows). A complete user-table row sets: `Id` = TDEF page; `ParentId` = `0x0F000001` (the database's "Tables" container, constant); `Type` = `1`; `Name`; `Flags` = `0`; `Owner` = a 2-byte binary SID (`0x69 0x0C` for a @@ -320,6 +329,15 @@ > query with a leading `PARAMETERS name Type, …;` clause (the `0x02` rows) and lowers body references to a > declared name into engine parameters, so LibRed's own engine executes the stored procedure when values are supplied. > + > **A declared name wins over a column of the same name — and a `@` prefix does not distinguish them.** + > Measured against ACE 12 on Northwind: *every* unqualified occurrence of a declared parameter name is the + > parameter, whether written bare or as `@name`, even where the query's own table has a column by that name. + > Only a table-qualified reference is read as the column. Northwind's own `CustOrdersOrders` — declared + > `CustomerID Text(5)`, body `WHERE CustomerID = @CustomerID` — is therefore a tautology in ACE and returns + > all 830 orders for any supplied value; a body written `Orders.CustomerID = [CustomerID]` returns the 6 that + > match. LibRed reproduces all four combinations exactly, so the "obvious" fix of resolving the left-hand + > name to the column would be a divergence, not a repair. + > > **Complex-column system tables (ACE 12+ only).** Access 2007 introduced multi-value and attachment > columns, and with them `MSysComplexColumns` (the registry) plus nine `MSysComplexType_*` flat storage > tables. **Jet 4 has none of them.** As the engine creates them (verified, DAO-created ACE 12 database): diff --git a/test/LibRed.Ado.Tests/LibRedCommandTests.cs b/test/LibRed.Ado.Tests/LibRedCommandTests.cs index 1f2cad20..a15b31ba 100644 --- a/test/LibRed.Ado.Tests/LibRedCommandTests.cs +++ b/test/LibRed.Ado.Tests/LibRedCommandTests.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Data.Common; using LibRed.Data; using Xunit; @@ -573,9 +574,137 @@ public void Bare_system_variable_select_needs_no_from_clause() [InlineData("SELECT ';'", 1)] // semicolon inside a string literal [InlineData("SELECT `a;b` FROM T; SELECT 2", 2)] // semicolon inside a backtick identifier [InlineData("SELECT [a;b] FROM T", 1)] // semicolon inside a bracket identifier + [InlineData("PARAMETERS [p] TEXT; SELECT * FROM T WHERE C = [p]", 1)] // the clause belongs to its query + [InlineData("PARAMETERS [p] TEXT; SELECT 1; SELECT 2", 2)] public void SplitStatements_splits_only_on_top_level_semicolons(string sql, int expected) => Assert.Equal(expected, LibRedCommand.SplitStatements(sql).Count()); + [Fact] + public void SchemaOnly_returns_the_columns_and_no_rows() + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT OrderID, CustomerID FROM Orders ORDER BY OrderID"; + + using var reader = cmd.ExecuteReader(CommandBehavior.SchemaOnly); + Assert.Equal(2, reader.FieldCount); + Assert.False(reader.HasRows); + Assert.False(reader.Read()); + Assert.Equal(-1, reader.RecordsAffected); + + // The schema is the one the query would have returned, stored column and all. + var columns = reader.GetColumnSchema(); + Assert.Equal(["OrderID", "CustomerID"], columns.Select(c => c.ColumnName)); + Assert.Equal("Orders", columns[0].BaseTableName); + Assert.True(columns[0].IsKey); + } + + [Fact] + public void SchemaOnly_describes_a_stored_procedure_without_running_it() + { + // No parameter value is supplied: a shape never depended on one, so describing must not ask for it. + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandText = "CustOrdersOrders"; + + using var reader = cmd.ExecuteReader(CommandBehavior.SchemaOnly); + Assert.Equal( + ["OrderID", "OrderDate", "RequiredDate", "ShippedDate"], + reader.GetColumnSchema().Select(c => c.ColumnName)); + Assert.False(reader.Read()); + } + + [Fact] + public void SingleRow_stops_after_the_first_row() + { + using var conn = OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT OrderID FROM Orders"; + + using var reader = cmd.ExecuteReader(CommandBehavior.SingleRow); + Assert.True(reader.HasRows); + Assert.True(reader.Read()); + Assert.False(reader.Read()); // the rest of the 830 are never read + } + + [Fact] + public void CloseConnection_closes_the_connection_with_the_reader() + { + var conn = OpenConnection(); + try + { + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT OrderID FROM Orders"; + using var reader = cmd.ExecuteReader(CommandBehavior.CloseConnection); + Assert.True(reader.Read()); + Assert.Equal(ConnectionState.Open, conn.State); + } + + Assert.Equal(ConnectionState.Closed, conn.State); + } + finally { conn.Dispose(); } + } + + [Fact] + public void A_reader_without_CloseConnection_leaves_the_connection_open() + { + using var conn = OpenConnection(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT OrderID FROM Orders"; + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read()); + } + + Assert.Equal(ConnectionState.Open, conn.State); + } + + [Fact] + public void SchemaOnly_does_not_run_a_statement_that_writes() + { + // Measured against ACE: an INSERT executed under SchemaOnly leaves its table untouched. + string path = Path.Combine(Path.GetTempPath(), $"libred-schemaonly-{Guid.NewGuid():N}.accdb"); + File.Copy(Northwind, path); + try + { + using var conn = new LibRedConnection($"Data Source={path}"); + conn.Open(); + + int before = Shippers(conn); + using (var write = conn.CreateCommand()) + { + write.CommandText = + "INSERT INTO Shippers (CompanyName) VALUES ('SchemaOnly'); SELECT @@IDENTITY AS `Id`"; + using var reader = write.ExecuteReader(CommandBehavior.SchemaOnly); + // The batch describes as its last statement, which is the SELECT. + Assert.Equal("Id", Assert.Single(reader.GetColumnSchema()).ColumnName); + Assert.False(reader.Read()); + } + + Assert.Equal(before, Shippers(conn)); + } + finally { try { File.Delete(path); } catch (IOException) { } } + + static int Shippers(LibRedConnection conn) + { + using var count = conn.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM Shippers"; + return Convert.ToInt32(count.ExecuteScalar()); + } + } + + [Fact] + public void SplitStatements_keeps_a_PARAMETERS_clause_with_its_query() + { + // Access ends the clause with a semicolon, so splitting there hands the parser a fragment that is + // not a statement — which is how every stored parameterized query reads back. + Assert.Equal( + "PARAMETERS [p] TEXT; SELECT * FROM T WHERE C = [p]", + Assert.Single(LibRedCommand.SplitStatements("PARAMETERS [p] TEXT; SELECT * FROM T WHERE C = [p]"))); + } + [Fact] public void Rolled_back_transaction_undoes_its_writes() { diff --git a/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs b/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs index a5d2aa75..02eef3cf 100644 --- a/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs +++ b/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs @@ -1,3 +1,5 @@ +using System.Data; +using System.Data.Common; using LibRed.Data; using Xunit; @@ -77,6 +79,95 @@ public void Empty_computed_projection_reports_known_expression_types() Assert.Equal(typeof(bool), reader.GetFieldType(2)); } + /// A query whose four columns are each a different kind: a stored key column, an aliased stored + /// column, one reached through a join, and one the query computes. + private const string MixedProvenance = + "SELECT o.OrderID, o.CustomerID AS Cust, c.CompanyName, o.Freight * 2 AS Doubled " + + "FROM Orders AS o INNER JOIN Customers AS c ON o.CustomerID = c.CustomerID"; + + [Fact] + public void GetColumnSchema_traces_each_column_back_to_its_stored_column() + { + using var connection = new LibRedConnection($"Data Source={Northwind}"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = MixedProvenance; + + using DbDataReader reader = command.ExecuteReader(); + Assert.True(reader.CanGetColumnSchema()); // the reader implements IDbColumnSchemaGenerator + var columns = reader.GetColumnSchema(); + + DbColumn key = columns[0]; + Assert.Equal("Orders", key.BaseTableName); + Assert.Equal("OrderID", key.BaseColumnName); + Assert.Equal(typeof(int), key.DataType); + Assert.Equal("Long", key.DataTypeName); + Assert.True(key.IsKey); + Assert.True(key.IsAutoIncrement); + Assert.True(key.IsIdentity); + Assert.False(key.AllowDBNull); + Assert.False(key.IsAliased); + + DbColumn aliased = columns[1]; + Assert.Equal("Cust", aliased.ColumnName); + Assert.Equal("CustomerID", aliased.BaseColumnName); // the alias does not change the stored name + Assert.True(aliased.IsAliased); + Assert.Equal(5, aliased.ColumnSize); + Assert.False(aliased.IsExpression); + + DbColumn joined = columns[2]; + Assert.Equal("Customers", joined.BaseTableName); // the other side of the join + Assert.Equal(40, joined.ColumnSize); + + DbColumn computed = columns[3]; + Assert.Equal("Doubled", computed.ColumnName); + Assert.True(computed.IsExpression); + Assert.True(computed.IsReadOnly); + Assert.Null(computed.BaseTableName); // nothing stored stands behind it + Assert.Null(computed.BaseColumnName); + + // Not applicable to a file rather than unknown. + Assert.All(columns, c => Assert.Null(c.BaseServerName)); + Assert.All(columns, c => Assert.Null(c.BaseSchemaName)); + Assert.All(columns, c => Assert.False(c.IsHidden)); + } + + [Fact] + public void GetSchemaTable_reports_the_same_facts_in_the_DataTable_form() + { + using var connection = new LibRedConnection($"Data Source={Northwind}"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = MixedProvenance; + + using DbDataReader reader = command.ExecuteReader(); + DataTable schema = reader.GetSchemaTable()!; + + Assert.Equal(4, schema.Rows.Count); + Assert.Contains(SchemaTableColumn.ProviderType, schema.Columns.Cast().Select(c => c.ColumnName)); + + DataRow key = schema.Rows[0]; + Assert.Equal("OrderID", key[SchemaTableColumn.ColumnName]); + Assert.Equal(0, key[SchemaTableColumn.ColumnOrdinal]); + Assert.Equal(3, key[SchemaTableColumn.ProviderType]); // OLE DB DBTYPE_I4, as the Columns collection reports it + Assert.Equal(typeof(int), key[SchemaTableColumn.DataType]); + Assert.Equal("Long", key["DataTypeName"]); + Assert.True((bool)key[SchemaTableColumn.IsKey]); + Assert.True((bool)key[SchemaTableOptionalColumn.IsAutoIncrement]); + Assert.False((bool)key[SchemaTableColumn.AllowDBNull]); + + // A Jet file has no server, catalog or schema, no row versions, and hides no column. + Assert.Equal(DBNull.Value, key[SchemaTableOptionalColumn.BaseServerName]); + Assert.Equal(DBNull.Value, key[SchemaTableOptionalColumn.BaseCatalogName]); + Assert.Equal(DBNull.Value, key[SchemaTableColumn.BaseSchemaName]); + Assert.False((bool)key[SchemaTableOptionalColumn.IsRowVersion]); + Assert.False((bool)key[SchemaTableOptionalColumn.IsHidden]); + + DataRow computed = schema.Rows[3]; + Assert.True((bool)computed[SchemaTableColumn.IsExpression]); + Assert.Equal(DBNull.Value, computed[SchemaTableColumn.BaseTableName]); + } + [Fact] public void Null_aggregate_result_preserves_its_argument_type() { diff --git a/test/LibRed.Ado.Tests/LibRedSchemaTests.cs b/test/LibRed.Ado.Tests/LibRedSchemaTests.cs new file mode 100644 index 00000000..a860b6c5 --- /dev/null +++ b/test/LibRed.Ado.Tests/LibRedSchemaTests.cs @@ -0,0 +1,266 @@ +using System.Data; +using LibRed.Data; +using Xunit; + +namespace LibRed.Ado.Tests; + +/// +/// The metadata collections GetSchema serves. The expected values are Northwind's, and the shapes are +/// ACE's: where ACE serves the same collection, these rows match it column for column (verified against the +/// ACE 12 OLE DB provider over this same file). +/// +public class LibRedSchemaTests +{ + private static readonly string Northwind = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); + + private static LibRedConnection OpenConnection() + { + var connection = new LibRedConnection($"Data Source={Northwind}"); + connection.Open(); + return connection; + } + + private static DataTable Schema(string collection, params string?[] restrictions) + { + using LibRedConnection connection = OpenConnection(); + return restrictions.Length == 0 + ? connection.GetSchema(collection) + : connection.GetSchema(collection, restrictions); + } + + [Fact] + public void MetaDataCollections_lists_the_framework_collections_first() + { + DataTable collections = Schema("MetaDataCollections"); + + var names = collections.Rows.Cast().Select(r => (string)r["CollectionName"]).ToList(); + Assert.Equal( + ["MetaDataCollections", "DataSourceInformation", "DataTypes", "Restrictions", "ReservedWords"], + names.Take(5)); + Assert.Equal(20, names.Count); + Assert.Contains("ViewColumns", names); + } + + [Theory] + [InlineData("MetaDataCollections", 20)] + [InlineData("DataSourceInformation", 1)] + [InlineData("DataTypes", 19)] + [InlineData("Restrictions", 67)] + [InlineData("ReservedWords", 122)] + [InlineData("Tables", 41)] + [InlineData("Columns", 228)] // every table's columns and every view's output columns + [InlineData("Indexes", 69)] + [InlineData("Views", 17)] + [InlineData("Procedures", 6)] // the stored queries that declare parameters + [InlineData("ForeignKeys", 15)] + [InlineData("PrimaryKeys", 21)] + [InlineData("TableConstraints", 36)] + [InlineData("KeyColumnUsage", 41)] + [InlineData("ConstraintColumnUsage", 41)] + [InlineData("ReferentialConstraints", 15)] + [InlineData("CheckConstraints", 0)] // Jet has no CHECK constraints + [InlineData("Statistics", 24)] + [InlineData("ProcedureParameters", 9)] + [InlineData("ViewColumns", 90)] + public void Collection_has_the_rows_Northwind_holds(string collection, int expected) => + Assert.Equal(expected, Schema(collection).Rows.Count); + + [Fact] + public void Tables_separates_the_four_kinds_of_object() + { + // TABLE_TYPE comes from MSysObjects.Flags: Access's own nav-pane tables are ACCESS TABLE, the MSys* + // catalog is SYSTEM TABLE, and a stored SELECT is a VIEW rather than a table of its own. + var byType = Schema("Tables").Rows.Cast() + .ToLookup(r => (string)r["TABLE_TYPE"], r => (string)r["TABLE_NAME"]); + + Assert.Equal(14, byType["TABLE"].Count()); + Assert.Equal(17, byType["VIEW"].Count()); + Assert.Equal(5, byType["SYSTEM TABLE"].Count()); + Assert.Equal(5, byType["ACCESS TABLE"].Count()); + + Assert.Contains("Orders", byType["TABLE"]); + Assert.Contains("Invoices", byType["VIEW"]); + Assert.Contains("MSysObjects", byType["SYSTEM TABLE"]); + } + + [Fact] + public void Columns_describes_an_AutoNumber_primary_key() + { + DataRow column = Assert.Single(Schema("Columns", null, null, "Orders", "OrderID").Rows.Cast()); + + Assert.Equal(1L, column["ORDINAL_POSITION"]); + Assert.Equal(3, column["DATA_TYPE"]); // OLE DB DBTYPE_I4 + Assert.Equal("Long", column["TYPE_NAME"]); + Assert.Equal(10, column["NUMERIC_PRECISION"]); + Assert.False((bool)column["IS_NULLABLE"]); + Assert.False((bool)column["IS_COMPUTED"]); + Assert.True((bool)column["IS_AUTOINCREMENT"]); + Assert.Equal(1, Convert.ToInt32(column["INCREMENT"])); + // MAYDEFER | WRITEUNKNOWN | ISFIXEDLENGTH | MAYBENULL — the flags ACE reports for a stored column. + Assert.Equal(0x02L | 0x08L | 0x10L | 0x40L, column["COLUMN_FLAGS"]); + } + + [Fact] + public void Columns_reports_a_views_output_columns() + { + // A view's columns are the shape its query produces, which only planning the query can say. + var invoices = Schema("Columns", null, null, "Invoices", null).Rows.Cast() + .Select(r => (string)r["COLUMN_NAME"]).ToList(); + + Assert.Equal(26, invoices.Count); + Assert.Contains("ExtendedPrice", invoices); // computed by the view + Assert.Contains("Salesperson", invoices); // an expression over two stored columns + } + + [Fact] + public void ViewColumns_names_the_stored_column_behind_each_one() + { + // This is OLE DB's view-column *usage*: which stored column a view's column reads, so an alias reports + // the column's own name and a computed column has no row at all. + var usage = Schema("ViewColumns", null, null, "Invoices", null).Rows.Cast() + .Select(r => ((string)r["TABLE_NAME"], (string)r["COLUMN_NAME"])).ToList(); + + Assert.Equal(24, usage.Count); + Assert.Contains(("Orders", "OrderID"), usage); + // CustomerName and ShipperName are both aliases of a CompanyName column, from different tables. + Assert.Contains(("Customers", "CompanyName"), usage); + Assert.Contains(("Shippers", "CompanyName"), usage); + Assert.DoesNotContain(usage, u => u.Item2 is "ExtendedPrice" or "Salesperson"); + } + + [Fact] + public void Indexes_describes_a_primary_key_index() + { + DataRow index = Assert.Single(Schema("Indexes", null, null, null, null, "Shippers").Rows.Cast()); + + Assert.Equal("PK_Shippers", index["INDEX_NAME"]); + Assert.True((bool)index["PRIMARY_KEY"]); + Assert.True((bool)index["UNIQUE"]); + Assert.False((bool)index["CLUSTERED"]); + Assert.Equal("ShipperID", index["COLUMN_NAME"]); + Assert.Equal(1L, index["ORDINAL_POSITION"]); + Assert.Equal(3L, Convert.ToInt64(index["CARDINALITY"])); + } + + [Fact] + public void PrimaryKeys_and_ForeignKeys_report_a_relationship_from_both_ends() + { + DataRow key = Assert.Single(Schema("PrimaryKeys", null, null, "Orders").Rows.Cast()); + Assert.Equal("OrderID", key["COLUMN_NAME"]); + Assert.Equal("PK_Orders", key["PK_NAME"]); + + DataRow relation = Assert.Single( + Schema("ForeignKeys", null, null, "Customers", null, null, "Orders").Rows.Cast()); + Assert.Equal("CustomerID", relation["PK_COLUMN_NAME"]); + Assert.Equal("CustomerID", relation["FK_COLUMN_NAME"]); + Assert.Equal("FK_Orders_Customers", relation["FK_NAME"]); + // Jet enforces the rules itself rather than declaring them, so an unenforced rule reads as NO ACTION. + Assert.Equal("NO ACTION", relation["UPDATE_RULE"]); + Assert.Equal("NO ACTION", relation["DELETE_RULE"]); + } + + [Fact] + public void TableConstraints_names_the_primary_key_constraint() + { + DataRow constraint = Assert.Single( + Schema("TableConstraints", null, null, null, null, null, "Shippers", null).Rows.Cast()); + + Assert.Equal("PK_Shippers", constraint["CONSTRAINT_NAME"]); + Assert.Equal("PRIMARY KEY", constraint["CONSTRAINT_TYPE"]); + Assert.False((bool)constraint["IS_DEFERRABLE"]); + } + + [Fact] + public void Statistics_reports_a_tables_row_count() + { + DataRow statistic = Assert.Single(Schema("Statistics", null, null, "Orders").Rows.Cast()); + Assert.Equal(830m, statistic["CARDINALITY"]); + } + + [Fact] + public void ProcedureParameters_describes_a_stored_querys_declared_parameter() + { + DataRow parameter = Assert.Single( + Schema("ProcedureParameters", null, null, "CustOrdersOrders", null).Rows.Cast()); + + Assert.Equal("CustomerID", parameter["PARAMETER_NAME"]); + Assert.Equal(1, parameter["ORDINAL_POSITION"]); + Assert.Equal(1, parameter["PARAMETER_TYPE"]); // input + Assert.Equal(130, parameter["DATA_TYPE"]); // DBTYPE_WSTR + Assert.Equal("VarChar", parameter["TYPE_NAME"]); + } + + [Fact] + public void Procedures_carry_the_parameters_clause_ahead_of_their_statement() + { + DataRow procedure = Assert.Single( + Schema("Procedures", null, null, "CustOrdersOrders", null).Rows.Cast()); + + string definition = (string)procedure["PROCEDURE_DEFINITION"]; + Assert.StartsWith("PARAMETERS [CustomerID] TEXT;", definition); + Assert.Contains("FROM [Orders]", definition); + } + + [Fact] + public void DataTypes_names_every_type_the_file_can_hold() + { + var types = Schema("DataTypes").Rows.Cast() + .ToDictionary(r => (string)r["TypeName"], r => (int)r["ProviderDbType"]); + + Assert.Equal(19, types.Count); + Assert.Equal(3, types["Long"]); + Assert.Equal(6, types["Currency"]); + // Fixed-length text and binary are types of their own, told apart from the variable-length forms. + Assert.Equal(130, types["Char"]); + Assert.Equal(128, types["Binary"]); + // ACE 12 and up only: the engine has these two even though its own OLE DB and ODBC drivers cannot + // materialise them. + Assert.Equal(20, types["BigInt"]); + Assert.Equal(135, types["DateTime2"]); + } + + [Fact] + public void Restrictions_are_declared_for_the_collections_that_take_them() + { + var restrictions = Schema("Restrictions").Rows.Cast() + .ToLookup(r => (string)r["CollectionName"], r => (string)r["RestrictionName"]); + + Assert.Equal(["TABLE_CATALOG", "TABLE_SCHEMA", "TABLE_NAME", "COLUMN_NAME"], restrictions["Columns"]); + // Indexes takes the table name LAST, after the index name and type, as OLE DB orders it. + Assert.Equal("TABLE_NAME", restrictions["Indexes"].Last()); + } + + [Fact] + public void A_restriction_filters_the_rows() + { + var columns = Schema("Columns", null, null, "Shippers", null).Rows.Cast().ToList(); + + Assert.Equal(3, columns.Count); + Assert.All(columns, c => Assert.Equal("Shippers", c["TABLE_NAME"])); + } + + [Fact] + public void A_catalog_or_schema_restriction_matches_everything() + { + // A Jet file holds one nameless catalog and no schemas, so filtering on the null every row carries + // would return nothing at all. + Assert.Equal(3, Schema("Columns", "anything", "anything", "Shippers", null).Rows.Count); + } + + [Fact] + public void An_unknown_collection_or_too_many_restrictions_is_rejected() + { + using LibRedConnection connection = OpenConnection(); + + Assert.Throws(() => connection.GetSchema("NoSuchCollection")); + // Tables takes four restrictions. + Assert.Throws(() => connection.GetSchema("Tables", [null, null, null, null, null])); + } + + [Fact] + public void Schema_needs_an_open_connection() + { + using var connection = new LibRedConnection($"Data Source={Northwind}"); + Assert.Throws(() => connection.GetSchema("Tables")); + } +} diff --git a/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs b/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs index 91aabb2b..73b8ce81 100644 --- a/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs +++ b/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs @@ -119,7 +119,7 @@ public void Ace_parameterized_update_retains_parameter_order_while_remaining_exp } using var db = JetDatabase.Open(path); - Assert.Equal(["pTitle", "pCountry"], db.Catalog.QueryParameters["UpdateByCountry"]); + Assert.Equal(["pTitle", "pCountry"], db.Catalog.QueryParameters["UpdateByCountry"].Select(p => p.Name)); StoredActionQuery query = db.Catalog.ActionQueries["UpdateByCountry"]; Assert.Null(query.Sql); Assert.Contains("UPDATE", query.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); From 27ec3dcbc3bd0babb73aefd1fc71ae714bcab9a0 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 21 Sep 2026 00:02:12 +0800 Subject: [PATCH 03/14] Read and write every stored query kind Access stores A stored action query was read back as executable SQL for only two of its kinds, and written for the same two. The rest - UPDATE, DELETE, make-table, and an append fed by a SELECT - were recognised, named and refused, although the engine could run every one of those statements already. They are recognised because Access stores them the way it stores a view: one row per source table, one per join condition, one for the WHERE, one per declared parameter. Only the action row and the meaning of the column rows differ - an assignment names its target column, qualified when the update runs over a join; an append names the column it fills; a DELETE keeps the verbatim `table.*` when the statement names one, and nothing at all when it does not. So the rebuild now shares the clause builders with the view path, and every kind reads back and runs by name. Writing them is the same arrangement in reverse, and what it writes is row for row what ACE writes for the same statement, object flags included, which is how each kind is checked. The MSysObjects flags are DAO's QueryDef.Type in the low byte - not the action row's own numbering, which differs. A declared parameter now survives the round trip. It could not before: its size never reached the type mapper, so Text(50) was stored as a memo, and its facets were dropped, so Access rendered Text(255) back. Those facets live in the parameter row's LvExtra - a length for text, precision and scale packed together for a decimal, nothing for the types that record neither, a sized binary included. Elsewhere in the row set LvExtra holds uninitialised bytes, so nothing reads or writes it there. The parser lowered PARAMETERS names in a SELECT and an INSERT but not in an UPDATE or a DELETE, which left a parameterized update comparing a column against itself. A procedure or view body with no FROM at all - `SELECT 1 AS n`, which Access stores and opens - threw NullReferenceException out of the decomposer, and would have been dropped silently on the way back in. Access stores it as any other query minus the table rows; so does LibRed now. ACE will open such a query but not use it as a source, on its own files as much as on ours. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Catalog/JetCatalog.cs | 297 ++- src/LibRed/LibRed.Core/Catalog/ViewSpec.cs | 52 +- .../LibRed.Core/Formats/StoredQueryFormat.cs | 25 + src/LibRed/LibRed.Core/Storage/ViewCreator.cs | 157 +- .../Execution/StatementExecutor.cs | 53 +- .../LibRed.Engine/Schema/SchemaRowsets.cs | 11 +- src/LibRed/LibRed.Sql/Ast/Statements.cs | 28 +- src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 | 8 +- .../Grammar/Generated/AccessSqlBaseVisitor.cs | 2 +- .../Grammar/Generated/AccessSqlLexer.cs | 2 +- .../Grammar/Generated/AccessSqlParser.cs | 2172 +++++++++-------- .../Grammar/Generated/AccessSqlVisitor.cs | 2 +- src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 124 +- src/LibRed/docs/format/system-catalog.md | 71 +- test/LibRed.Ado.Tests/LibRedSchemaTests.cs | 6 +- .../ActionQueryProcedureAccessTests.cs | 93 +- .../StoredQueryKindAccessTests.cs | 33 +- .../StoredActionQueryExecutionAccessTests.cs | 165 ++ .../StoredActionQueryWriteAccessTests.cs | 252 ++ .../CreateProcedureTests.cs | 84 +- test/LibRed.Engine.Tests/CreateViewTests.cs | 23 + 21 files changed, 2342 insertions(+), 1318 deletions(-) create mode 100644 test/LibRed.Engine.AccessTests/StoredActionQueryExecutionAccessTests.cs create mode 100644 test/LibRed.Engine.AccessTests/StoredActionQueryWriteAccessTests.cs diff --git a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs index 44391f1b..fbe125d4 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs @@ -209,7 +209,8 @@ private void EnsureStoredQueries() // Group MSysQueries rows by ObjectId. var mq = mqDef.Columns; int oid = ColumnIndex(mq, "ObjectId"), attr = ColumnIndex(mq, "Attribute"), expr = ColumnIndex(mq, "Expression"), - flag = ColumnIndex(mq, "Flag"), n1 = ColumnIndex(mq, "Name1"), n2 = ColumnIndex(mq, "Name2"), order = ColumnIndex(mq, "Order"); + flag = ColumnIndex(mq, "Flag"), n1 = ColumnIndex(mq, "Name1"), n2 = ColumnIndex(mq, "Name2"), order = ColumnIndex(mq, "Order"), + lvExtra = ColumnIndex(mq, "LvExtra"); var byObject = new Dictionary>(); foreach (object?[] row in new Table(_channel, mqDef).Rows()) if (row[oid] is int id) @@ -231,8 +232,8 @@ private void EnsureStoredQueries() object?[]? operation = rows.FirstOrDefault(r => r[attr] is byte b && b == StoredQueryFormat.AttrOperation); short kind = operation?[flag] is short k ? k : StoredQueryFormat.OperationSelect; if (operation is not null && kind != StoredQueryFormat.OperationSelect) - _actionQueries[name] = ReconstructAction(rows, attr, expr, flag, n1, n2, order); - else if (Reconstruct(rows, attr, expr, flag, n1, n2, order) is { } sql) + _actionQueries[name] = ReconstructAction(rows, attr, expr, flag, n1, n2, order, lvExtra); + else if (Reconstruct(rows, attr, expr, flag, n1, n2, order, lvExtra) is { } sql) _views[name] = sql; // The declared parameters (Attribute=2 rows), in declaration order, for EXECUTE positional binding. @@ -241,19 +242,30 @@ private void EnsureStoredQueries() .OrderBy(r => r[order] is byte[] ob && ob.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(ob) : 0) .Where(r => r[n1] is string) - .Select(r => new StoredQueryParameter( - (string)r[n1]!, - r[flag] is short f and not 0 ? (JetDataType)(byte)f : null)) + .Select(r => + { + JetDataType? type = r[flag] is short f and not 0 ? (JetDataType)(byte)f : null; + // The declared facets ride in LvExtra: a length for text, precision and scale packed + // together for a decimal. + var (size, precision, scale) = type is { } t + ? StoredQueryFormat.UnpackParameterFacets(t, r[lvExtra] as int?) + : (null, null, null); + return new StoredQueryParameter((string)r[n1]!, type, size, precision, scale); + }) .ToList(); if (parameters.Count > 0) _queryParameters[name] = parameters; } } - /// Rebuilds a stored action query's executable SQL from its MSysQueries rows. Handles the kinds - /// LibRed can execute (CREATE/DROP TABLE, INSERT … VALUES); other kinds return an unsupported reason. - private static StoredActionQuery ReconstructAction(List rows, int attr, int expr, int flag, int n1, int n2, int order) + /// Rebuilds a stored action query's executable SQL from its MSysQueries rows. Handles every kind + /// whose statement LibRed's engine can run — CREATE/DROP TABLE, INSERT (from VALUES or from a SELECT), + /// UPDATE, DELETE and make-table; the rest return an unsupported reason. + private static StoredActionQuery ReconstructAction( + List rows, int attr, int expr, int flag, int n1, int n2, int order, int lvExtra) { static int Ord(object? v) => v is byte[] b && b.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(b) : 0; + List OfAttr(byte a) => rows.Where(r => r[attr] is byte b && b == a).OrderBy(r => Ord(r[order])).ToList(); + object?[]? action = rows.FirstOrDefault(r => r[attr] is byte b && b == StoredQueryFormat.AttrOperation); if (action is null) return new StoredActionQuery(null, "The stored action query has no action row."); @@ -262,94 +274,119 @@ private static StoredActionQuery ReconstructAction(List rows, int att // The whole DDL statement is in Expression (Access stored it with a leading space). return new StoredActionQuery((action[expr] as string)?.TrimStart(), null); - if (kind == StoredQueryFormat.ActionAppend) + // Every remaining kind keeps its sources, predicate and declared parameters where a SELECT keeps them, + // so they are read the same way. A query with no FROM source at all is an INSERT … VALUES, which has + // none by definition. + var source = BuildFromClause(rows, attr, expr, flag, n1, n2, order); + string? where = source is { } s ? WhereClause(rows, attr, expr, order, s.Extra) : null; + string Where() => where is null ? "" : $" WHERE {where}"; + var columns = OfAttr(StoredQueryFormat.AttrColumn); + string declared = ParametersClause(rows, attr, flag, n1, order, lvExtra); + + switch (kind) { - // INSERT … VALUES: only literal-value columns (Flag 0x8000) and no FROM source. An INSERT … - // SELECT (table rows present / Flag-0 columns) is stored but LibRed does not execute it yet. - if (rows.Any(r => r[attr] is byte b && b == StoredQueryFormat.AttrTable)) - return new StoredActionQuery(null, "INSERT … SELECT stored queries are not executed by LibRed yet."); - var cols = rows.Where(r => r[attr] is byte b && b == StoredQueryFormat.AttrColumn).OrderBy(r => Ord(r[order])).ToList(); - if (cols.Count == 0 || cols.Any(r => r[flag] is short cf && cf != StoredQueryFormat.AppendValueFlag)) - return new StoredActionQuery(null, "This append query shape is not executed by LibRed yet."); - - string target = action[n1] as string ?? ""; - string columns = string.Join(", ", cols.Select(r => $"[{r[n2] as string}]")); - string values = string.Join(", ", cols.Select(r => r[expr] as string ?? "NULL")); - return new StoredActionQuery($"INSERT INTO [{target}] ({columns}) VALUES ({values})", null); + case StoredQueryFormat.ActionAppend: + { + string target = Quote(action[n1] as string ?? ""); + // A literal-value column (Flag 0x8000) is an INSERT … VALUES; a Flag-0 one reads its value + // from the query's own FROM source, which makes it an INSERT … SELECT. Both name the target + // column in Name2 and hold the value's text in Expression. + if (columns.Count == 0) + return new StoredActionQuery(null, "An append query with no columns is not executed by LibRed."); + + string targetColumns = string.Join(", ", columns.Select(r => Quote(r[n2] as string ?? ""))); + string values = string.Join(", ", columns.Select(r => r[expr] as string ?? "NULL")); + + if (columns.All(r => r[flag] is short cf && cf == StoredQueryFormat.AppendValueFlag)) + return source is null + ? new StoredActionQuery($"{declared}INSERT INTO {target} ({targetColumns}) VALUES ({values})", null) + : new StoredActionQuery(null, "An append query cannot take both literal values and a source."); + + return source is { } appendSource + ? new StoredActionQuery( + $"{declared}INSERT INTO {target} ({targetColumns}) SELECT {values} FROM {appendSource.From}{Where()}", null) + : new StoredActionQuery(null, "An append query with no values and no source is not executed by LibRed."); + } + + case StoredQueryFormat.ActionUpdate when source is { } updateSource: + { + // One column row per assignment: Name2 is the target column — qualified when the update runs + // over a join — and Expression is the new value. + if (columns.Count == 0) + return new StoredActionQuery(null, "An update query with no assignments is not executed by LibRed."); + string assignments = string.Join(", ", + columns.Select(r => $"{Qualified(r[n2] as string ?? "")} = {r[expr] as string ?? "NULL"}")); + return new StoredActionQuery($"{declared}UPDATE {updateSource.From} SET {assignments}{Where()}", null); + } + + case StoredQueryFormat.ActionDelete when source is { } deleteSource: + { + // Access writes `DELETE .* FROM …` when the query names the table's columns and + // `DELETE * FROM …` when it doesn't; the column row holds that `
.*` verbatim. + string what = columns.Count > 0 ? columns[0][expr] as string ?? "*" : "*"; + return new StoredActionQuery($"{declared}DELETE {what} FROM {deleteSource.From}{Where()}", null); + } + + case StoredQueryFormat.ActionMakeTable when source is { } intoSource: + { + // The target is on the action row; a target in ANOTHER database file (Name2) is a shape + // LibRed has no statement for. + if (action[n2] is string external && external.Length > 0) + return new StoredActionQuery(null, $"A make-table query writing into '{external}' is not executed by LibRed."); + + string selected = columns.Count == 0 + ? "*" + : string.Join(", ", columns.Select(r => + (r[n1] as string) is { } alias ? $"{r[expr] as string} AS {Quote(alias)}" : r[expr] as string ?? "")); + var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); + string grouping = groupBy.Count > 0 ? $" GROUP BY {string.Join(", ", groupBy)}" : ""; + return new StoredActionQuery( + $"{declared}SELECT {selected} INTO {Quote(action[n1] as string ?? "")} FROM {intoSource.From}{Where()}{grouping}", null); + } } // Everything else is stored but not executed. Name the kind: "not supported" that doesn't say what it // is leaves a caller no way to tell an unimplemented feature from an unreadable file. - string what = kind switch + string reason = kind switch { - StoredQueryFormat.ActionUpdate => "UPDATE", - StoredQueryFormat.ActionDelete => "DELETE", - StoredQueryFormat.ActionMakeTable => "Make-table (SELECT … INTO)", - StoredQueryFormat.ActionCrosstab => "Crosstab (TRANSFORM)", - StoredQueryFormat.ActionPassThrough => "Pass-through", - StoredQueryFormat.ActionUnion => "UNION", - _ => $"Kind-{kind}", + StoredQueryFormat.ActionUpdate or StoredQueryFormat.ActionDelete or StoredQueryFormat.ActionMakeTable => + "The stored action query names no table.", + StoredQueryFormat.ActionCrosstab => "Crosstab (TRANSFORM) stored queries are not executed by LibRed yet.", + StoredQueryFormat.ActionPassThrough => "Pass-through stored queries are not executed by LibRed yet.", + StoredQueryFormat.ActionUnion => "UNION stored queries are not executed by LibRed yet.", + _ => $"Kind-{kind} stored queries are not executed by LibRed yet.", }; - return new StoredActionQuery(null, $"{what} stored queries are not executed by LibRed yet."); + return new StoredActionQuery(null, reason); } - /// Rebuilds a simple-SELECT view's SQL from its MSysQueries rows; null if it uses an - /// attribute we don't reconstruct (a non-simple query). - private static string? Reconstruct(List rows, int attr, int expr, int flag, int n1, int n2, int order) + /// Bracket-quotes a stored name, so one with a space in it survives. + private static string Quote(string name) => $"[{name}]"; + + /// Bracket-quotes a name that may already be table-qualified, quoting each part — a stored + /// assignment names its target as Orders.ShipCountry when the update runs over a join, and + /// quoting that whole string would make it one nonexistent column. + private static string Qualified(string name) => + name.Contains('.') ? string.Join(".", name.Split('.').Select(Quote)) : Quote(name); + + /// + /// A stored query's FROM clause, and the join conditions that could not go in it. Access stores a query's + /// sources the same way whatever its kind — one 0x05 row per table and one 0x07 per join + /// condition, flat, with no grouping — so a SELECT, an UPDATE, a DELETE and an append-from-SELECT all + /// rebuild their sources through here. Null when the query names no table at all. + /// + private static (string From, List Extra)? BuildFromClause( + List rows, int attr, int expr, int flag, int n1, int n2, int order) { static int Ord(object? v) => v is byte[] b && b.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(b) : 0; IEnumerable OfAttr(byte a) => rows.Where(r => r[attr] is byte b && b == a).OrderBy(r => Ord(r[order])); - // Bail out if the query uses attributes beyond a simple SELECT: a HAVING clause (0x0A), a pass-through - // connection string (0x04) or complex-type data (0x0C) all mean this is not a shape we can render. - // AttrOperation is in the list because a SELECT may carry one — the caller has already checked that - // its kind IS SELECT, so reaching here with any other kind is impossible. - var known = new byte[] { StoredQueryFormat.AttrType, StoredQueryFormat.AttrOperation, StoredQueryFormat.AttrParameter, StoredQueryFormat.AttrOption, StoredQueryFormat.AttrTable, StoredQueryFormat.AttrColumn, StoredQueryFormat.AttrJoin, StoredQueryFormat.AttrWhere, StoredQueryFormat.AttrGroupBy, StoredQueryFormat.AttrOrderBy, 0xFF }; - if (rows.Any(r => r[attr] is byte b && !known.Contains(b))) return null; - - // Declared parameters (a stored procedure): each Attribute=2 row is Name1=name, Flag=Jet type code. - // Emitted as a leading PARAMETERS clause so the parser lowers body references to them as parameters. - var parameters = OfAttr(StoredQueryFormat.AttrParameter) - .Select(r => (Name: r[n1] as string, Code: r[flag] is short f ? (byte)f : (byte)0)) - .Where(p => p.Name is not null) - .Select(p => $"[{p.Name}] {AccessTypeName(p.Code)}") - .ToList(); - - // A column row's Name1 (when present) is its output alias. - var columns = OfAttr(StoredQueryFormat.AttrColumn) - .Select(r => (r[n1] as string) is { } a ? $"{r[expr] as string} AS [{a}]" : r[expr] as string ?? "").ToList(); // A derived-table source has its subquery SQL in Expression and no Name1; a named table uses Name1. var tables = OfAttr(StoredQueryFormat.AttrTable) .Select(r => (Table: r[n1] as string ?? "", Alias: r[n2] as string, Sub: r[n1] is null ? r[expr] as string : null)).ToList(); if (tables.Count == 0) return null; - // No column rows at all is Access's "SELECT *" -- the shape every auto-generated form/report - // record-source query takes. Treating it as unreconstructable dropped those queries silently. - if (columns.Count == 0) columns.Add("*"); - - // The option bits are cumulative and can share one row, so test each as a bit across all of them. - short options = 0; - foreach (object?[] r in OfAttr(StoredQueryFormat.AttrOption)) - if (r[flag] is short f) options |= f; - - // DISTINCT and DISTINCTROW are separate bits and separate keywords: DISTINCT dedupes output rows, - // DISTINCTROW dedupes by the underlying contributing rows. Emitting one for the other changes results. - bool distinct = (options & StoredQueryFormat.FlagDistinct) != 0; - bool distinctRow = (options & StoredQueryFormat.FlagDistinctRow) != 0; - // TOP n: an AttrOption row with the TOP bit; the count is in Name1. The PERCENT bit rides alongside - // it (48 = TOP PERCENT), and dropping it turns "TOP 10 PERCENT" into "TOP 10" -- silently wrong. - string? top = OfAttr(StoredQueryFormat.AttrOption) - .Where(r => r[flag] is short f && (f & StoredQueryFormat.FlagTop) != 0) - .Select(r => r[n1] as string).FirstOrDefault(); - bool percent = (options & StoredQueryFormat.FlagPercent) != 0; - // ORDER BY: one AttrOrderBy row per key (Expression = column, Name1 = "d" for descending). - var orderBy = OfAttr(StoredQueryFormat.AttrOrderBy) - .Select(r => (r[expr] as string ?? "") + (string.Equals(r[n1] as string, "d", StringComparison.OrdinalIgnoreCase) ? " DESC" : "")) - .Where(s => s.Length > 0).ToList(); var joins = OfAttr(StoredQueryFormat.AttrJoin) .Select(r => (Cond: r[expr] as string ?? "", Kind: r[flag] is short f ? f : (short)1, Left: r[n1] as string ?? "", Right: r[n2] as string ?? "")).ToList(); - string? where = OfAttr(StoredQueryFormat.AttrWhere).Select(r => r[expr] as string).FirstOrDefault(); - var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); static string Ident(string s) => $"[{s}]"; static string Render((string Table, string? Alias, string? Sub) t) => @@ -390,21 +427,111 @@ static string Render((string Table, string? Alias, string? Sub) t) => from.Append($", {Render(t)}"); // Joins whose two tables were both already in scope become extra WHERE conditions (a cyclic graph). - // The stored predicate is only parenthesised when something is being ANDed onto it — wrapping a lone - // predicate adds a paren pair Access never wrote, which is a needless difference from its own SQL. - var extra = pending.Select(j => j.Cond).ToList(); - string? whereClause = - where is null ? (extra.Count > 0 ? string.Join(" AND ", extra) : null) + return (from.ToString(), pending.Select(j => j.Cond).ToList()); + } + + /// The leading PARAMETERS name Type, …; clause a query with declared parameters (its + /// 0x02 rows: Name1=name, Flag=Jet type code) is rebuilt with, so the parser lowers references to + /// those names in the body into engine parameters; empty for a query declaring none. An action query + /// declares them exactly as a SELECT does. + private static string ParametersClause(List rows, int attr, int flag, int n1, int order, int lvExtra) + { + static int Ord(object? v) => v is byte[] b && b.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(b) : 0; + var parameters = rows + .Where(r => r[attr] is byte b && b == StoredQueryFormat.AttrParameter) + .OrderBy(r => Ord(r[order])) + .Select(r => (Name: r[n1] as string, Code: r[flag] is short f ? (byte)f : (byte)0, Extra: r[lvExtra] as int?)) + .Where(p => p.Name is not null) + .Select(p => $"[{p.Name}] {AccessTypeName(p.Code)}{Facets(p.Code, p.Extra)}") + .ToList(); + + return parameters.Count == 0 ? "" : $"PARAMETERS {string.Join(", ", parameters)}; "; + } + + /// A declared parameter's facets as the type name's suffix — (50) for a text length, + /// (18,4) for a decimal's precision and scale — so the rebuilt clause declares what was declared. + /// Empty where the row records none. + private static string Facets(byte code, int? lvExtra) + { + if (code == 0) return ""; // the untyped parameter, rendered as the keyword Value + var (size, precision, scale) = StoredQueryFormat.UnpackParameterFacets((JetDataType)code, lvExtra); + return size is { } length ? $"({length})" + : precision is { } p ? $"({p},{scale ?? 0})" + : ""; + } + + /// The WHERE clause: the query's stored predicate (the 0x08 row) and any join condition + /// could not place, ANDed together. The stored predicate is only + /// parenthesised when something is being ANDed onto it — wrapping a lone predicate adds a paren pair + /// Access never wrote, which is a needless difference from its own SQL. + private static string? WhereClause(List rows, int attr, int expr, int order, List extra) + { + static int Ord(object? v) => v is byte[] b && b.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(b) : 0; + string? where = rows + .Where(r => r[attr] is byte b && b == StoredQueryFormat.AttrWhere) + .OrderBy(r => Ord(r[order])) + .Select(r => r[expr] as string).FirstOrDefault(); + + return where is null ? (extra.Count > 0 ? string.Join(" AND ", extra) : null) : extra.Count == 0 ? where : string.Join(" AND ", extra.Prepend($"({where})")); + } + + /// Rebuilds a simple-SELECT view's SQL from its MSysQueries rows; null if it uses an + /// attribute we don't reconstruct (a non-simple query). + private static string? Reconstruct( + List rows, int attr, int expr, int flag, int n1, int n2, int order, int lvExtra) + { + static int Ord(object? v) => v is byte[] b && b.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(b) : 0; + IEnumerable OfAttr(byte a) => rows.Where(r => r[attr] is byte b && b == a).OrderBy(r => Ord(r[order])); + + // Bail out if the query uses attributes beyond a simple SELECT: a HAVING clause (0x0A), a pass-through + // connection string (0x04) or complex-type data (0x0C) all mean this is not a shape we can render. + // AttrOperation is in the list because a SELECT may carry one — the caller has already checked that + // its kind IS SELECT, so reaching here with any other kind is impossible. + var known = new byte[] { StoredQueryFormat.AttrType, StoredQueryFormat.AttrOperation, StoredQueryFormat.AttrParameter, StoredQueryFormat.AttrOption, StoredQueryFormat.AttrTable, StoredQueryFormat.AttrColumn, StoredQueryFormat.AttrJoin, StoredQueryFormat.AttrWhere, StoredQueryFormat.AttrGroupBy, StoredQueryFormat.AttrOrderBy, 0xFF }; + if (rows.Any(r => r[attr] is byte b && !known.Contains(b))) return null; + + // A column row's Name1 (when present) is its output alias. + var columns = OfAttr(StoredQueryFormat.AttrColumn) + .Select(r => (r[n1] as string) is { } a ? $"{r[expr] as string} AS [{a}]" : r[expr] as string ?? "").ToList(); + // A query with no table rows has no FROM clause, which Access allows and stores this way (`SELECT 1 + // AS n`). One with neither tables nor columns says nothing at all, and is not a query we can rebuild. + var source = BuildFromClause(rows, attr, expr, flag, n1, n2, order); + if (source is null && columns.Count == 0) return null; + // No column rows at all is Access's "SELECT *" -- the shape every auto-generated form/report + // record-source query takes. Treating it as unreconstructable dropped those queries silently. + if (columns.Count == 0) columns.Add("*"); + + // The option bits are cumulative and can share one row, so test each as a bit across all of them. + short options = 0; + foreach (object?[] r in OfAttr(StoredQueryFormat.AttrOption)) + if (r[flag] is short f) options |= f; + + // DISTINCT and DISTINCTROW are separate bits and separate keywords: DISTINCT dedupes output rows, + // DISTINCTROW dedupes by the underlying contributing rows. Emitting one for the other changes results. + bool distinct = (options & StoredQueryFormat.FlagDistinct) != 0; + bool distinctRow = (options & StoredQueryFormat.FlagDistinctRow) != 0; + // TOP n: an AttrOption row with the TOP bit; the count is in Name1. The PERCENT bit rides alongside + // it (48 = TOP PERCENT), and dropping it turns "TOP 10 PERCENT" into "TOP 10" -- silently wrong. + string? top = OfAttr(StoredQueryFormat.AttrOption) + .Where(r => r[flag] is short f && (f & StoredQueryFormat.FlagTop) != 0) + .Select(r => r[n1] as string).FirstOrDefault(); + bool percent = (options & StoredQueryFormat.FlagPercent) != 0; + // ORDER BY: one AttrOrderBy row per key (Expression = column, Name1 = "d" for descending). + var orderBy = OfAttr(StoredQueryFormat.AttrOrderBy) + .Select(r => (r[expr] as string ?? "") + (string.Equals(r[n1] as string, "d", StringComparison.OrdinalIgnoreCase) ? " DESC" : "")) + .Where(s => s.Length > 0).ToList(); + var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); + string? whereClause = WhereClause(rows, attr, expr, order, source?.Extra ?? []); - var sql = new System.Text.StringBuilder(); - if (parameters.Count > 0) sql.Append("PARAMETERS ").Append(string.Join(", ", parameters)).Append("; "); + var sql = new System.Text.StringBuilder(ParametersClause(rows, attr, flag, n1, order, lvExtra)); sql.Append("SELECT "); if (distinctRow) sql.Append("DISTINCTROW "); else if (distinct) sql.Append("DISTINCT "); if (top is not null) sql.Append("TOP ").Append(top).Append(percent ? " PERCENT " : " "); - sql.Append(string.Join(", ", columns)).Append(" FROM ").Append(from); + sql.Append(string.Join(", ", columns)); + if (source is { } from) sql.Append(" FROM ").Append(from.From); if (whereClause is not null) sql.Append(" WHERE ").Append(whereClause); if (groupBy.Count > 0) sql.Append(" GROUP BY ").Append(string.Join(", ", groupBy)); if (orderBy.Count > 0) sql.Append(" ORDER BY ").Append(string.Join(", ", orderBy)); diff --git a/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs b/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs index 9fa1558d..0bed843f 100644 --- a/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs +++ b/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs @@ -16,9 +16,14 @@ public sealed record ViewJoinSpec(ViewJoinType Kind, string Condition, string Le /// row Expression + Name1). public sealed record ViewColumnSpec(string Expression, string? Alias); -/// A declared parameter of a stored (procedure) query: its name and Jet type code, stored as an -/// MSysQueries Attribute=2 row (Name1 = name, Flag = ). -public sealed record ViewParameterSpec(string Name, byte TypeCode); +/// +/// A declared parameter of a stored (procedure) query: its name and Jet type code, stored as an MSysQueries +/// Attribute=2 row (Name1 = name, Flag = ). The row's LvExtra carries +/// the declared facets, and Access renders the query's PARAMETERS clause from it — see +/// for which types have one and how a decimal's precision +/// and scale pack into the single value. +/// +public sealed record ViewParameterSpec(string Name, byte TypeCode, int? Size = null, int? Scale = null); /// An ORDER BY key: verbatim sort expression + direction, stored as an MSysQueries /// Attribute=0x0B row (Expression = the column, Name1 = "d" when ). @@ -30,23 +35,41 @@ public enum ActionQueryKind { /// CREATE TABLE / DROP TABLE etc. — the whole SQL text is stored verbatim. DataDefinition, - /// INSERT INTO … — a target table plus the appended columns. + /// INSERT INTO … — a target table plus the appended columns, from VALUES or from a SELECT. Append, + /// UPDATE … SET — the assignments in , over the + /// 's sources. + Update, + /// DELETE — the 's sources and WHERE, and optionally the + /// table.* target Access stores when the query names one. + Delete, + /// SELECT … INTO — a SELECT whose target table is on the action row. + MakeTable, } -/// One appended column of an INSERT query: the target column and the verbatim value/source -/// expression, stored as an Attribute=0x06 row (Name2 = column, Expression = value). +/// One column/value pair of an action query, stored as an Attribute=0x06 row (Name2 = the +/// column, Expression = the value): an appended column of an INSERT — whose value is a literal for the VALUES +/// form and a source expression for the SELECT form — or one assignment of an UPDATE, whose +/// is table-qualified when the update runs over a join. public sealed record AppendColumnSpec(string Column, string ValueExpression); -/// A stored action query (a CREATE PROCEDURE body that is not a SELECT). A -/// query carries its whole ; an -/// query carries a and its appended -/// (VALUES mode — literal expressions per column). +/// +/// A stored action query (a CREATE PROCEDURE body that is not a plain SELECT). A +/// query carries its whole and nothing +/// else — Access does not decompose it. Every other kind stores its sources, joins and WHERE exactly as a view +/// does, in , and differs only in the action row and in what its column rows mean: +/// holds an append's columns or an update's assignments, +/// the table an append or make-table writes into, and the body's own columns are a make-table's output list. +/// are declared exactly as a parameterized SELECT declares them. +/// public sealed record ActionQuerySpec( ActionQueryKind Kind, string? DdlSql = null, string? TargetTable = null, - IReadOnlyList? Values = null); + IReadOnlyList? Values = null, + ViewSpec? Body = null, + IReadOnlyList? Parameters = null, + string? DeleteTarget = null); /// A stored action query read back from the catalog. is the reconstructed, /// executable statement when LibRed supports the kind; otherwise it is null and @@ -55,8 +78,11 @@ public sealed record StoredActionQuery(string? Sql, string? UnsupportedReason); /// A parameter a stored query declares, in declaration order. is the Jet type /// Access recorded for it, or null for its untyped parameter — the one Access renders as the keyword -/// Value. -public sealed record StoredQueryParameter(string Name, JetDataType? Type); +/// Value. is a text parameter's declared length and +/// / a decimal's, all read back from the row's +/// LvExtra; they are null for a type that declares none. +public sealed record StoredQueryParameter( + string Name, JetDataType? Type, int? Size = null, int? Precision = null, int? Scale = null); /// /// A view's decomposed "simple SELECT" — the columns, source tables, joins and WHERE (all verbatim text) — diff --git a/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs b/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs index ebe768d0..20ef22fb 100644 --- a/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs +++ b/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs @@ -1,3 +1,5 @@ +using LibRed.Catalog; + namespace LibRed.Formats; /// @@ -56,4 +58,27 @@ internal static class StoredQueryFormat /// AttrColumn Flag bit marking an appended literal value. public const short AppendValueFlag = unchecked((short)0x8000); + + /// + /// The LvExtra value for a declared parameter's facets, or null for a type that carries none. + /// Measured against ACE: a Text(50) parameter stores the length, 50; a Decimal(18,4) packs + /// both into one value, (scale << 16) | precision = 262162; and a sized Binary(10) + /// stores nothing at all, as every type without a declared size does. Access renders the query's + /// PARAMETERS clause from this, so a parameter with no value here reads back as Text(255). + /// + public static int? PackParameterFacets(JetDataType type, int? size, int? scale) => type switch + { + JetDataType.Text => size, + JetDataType.FixedPoint when size is { } precision => (scale ?? 0) << 16 | precision & 0xFFFF, + _ => null, + }; + + /// A declared parameter's facets back out of its LvExtra — the inverse of + /// . A text parameter reports its length as the size; a decimal reports + /// the precision and scale packed into the one value. + public static (int? Size, int? Precision, int? Scale) UnpackParameterFacets(JetDataType type, int? lvExtra) => + lvExtra is not { } packed ? (null, null, null) + : type == JetDataType.FixedPoint ? (null, packed & 0xFFFF, packed >> 16) + : type == JetDataType.Text ? (packed, null, null) + : (null, null, null); } diff --git a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs index be6b0945..fc125c4f 100644 --- a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs @@ -13,8 +13,15 @@ namespace LibRed.Storage; /// public sealed class ViewCreator(PageChannel channel, JetCatalog catalog) { - private const int ViewFlags = 0x10000000; // a SELECT query / view - private const int AppendFlags = 0x10000040; // an INSERT (append) query + // MSysObjects.Flags for a stored query: 0x10000000 plus the kind, and the kind byte is DAO's own + // QueryDef.Type value (verified vs ACE for all six: crosstab 16, delete 32, update 48, append 64, + // make-table 80, data-definition 96) — not the Flag the MSysQueries action row carries, which numbers + // the kinds differently. + private const int ViewFlags = 0x10000000; // a SELECT query / view (DAO type 0) + private const int DeleteFlags = 0x10000020; + private const int UpdateFlags = 0x10000030; + private const int AppendFlags = 0x10000040; // an INSERT (append) query + private const int MakeTableFlags = 0x10000050; private const int DataDefinitionFlags = 0x10000060; // a CREATE/DROP TABLE (data-definition) query private static readonly byte[] DefaultOwner = [0x69, 0x0C]; private static readonly byte[] AdminSid = [0x68, 0x0C]; @@ -42,7 +49,15 @@ public void Create(string name, ViewSpec spec) /// Persists a stored action query (a non-SELECT CREATE PROCEDURE body) byte-faithfully. public void CreateAction(string name, ActionQuerySpec spec) { - int flags = spec.Kind == ActionQueryKind.DataDefinition ? DataDefinitionFlags : AppendFlags; + int flags = spec.Kind switch + { + ActionQueryKind.DataDefinition => DataDefinitionFlags, + ActionQueryKind.Append => AppendFlags, + ActionQueryKind.Update => UpdateFlags, + ActionQueryKind.Delete => DeleteFlags, + ActionQueryKind.MakeTable => MakeTableFlags, + _ => throw new NotSupportedException($"Action query kind {spec.Kind} is not stored yet."), + }; int objectId = AllocateQueryObject(name, flags); AddActionRows(objectId, spec); } @@ -135,21 +150,112 @@ private void AddActionRows(int objectId, ActionQuerySpec spec) Row(mq, objectId, StoredQueryFormat.AttrType, order: 1, flag: StoredQueryFormat.QueryTypeSelect); Row(mq, objectId, StoredQueryFormat.AttrEnd, order: 1); + AddParameterRows(mq, objectId, spec.Parameters); + if (spec.Kind == ActionQueryKind.DataDefinition) { // The whole DDL statement is stored verbatim in one row; Access records it with a leading space. - Row(mq, objectId, StoredQueryFormat.AttrOperation, order: 1, flag: StoredQueryFormat.ActionDdl, expression: " " + spec.DdlSql); + // Nothing is decomposed: a data-definition query has no sources, columns or predicate. + Row(mq, objectId, StoredQueryFormat.AttrOperation, order: 1, flag: StoredQueryFormat.ActionDdl, + expression: " " + spec.DdlSql); + return; } - else + + // The action row carries the kind, and the target table for the two kinds that write into one. + short kind = spec.Kind switch + { + ActionQueryKind.Append => StoredQueryFormat.ActionAppend, + ActionQueryKind.Update => StoredQueryFormat.ActionUpdate, + ActionQueryKind.Delete => StoredQueryFormat.ActionDelete, + ActionQueryKind.MakeTable => StoredQueryFormat.ActionMakeTable, + _ => throw new NotSupportedException($"Action query kind {spec.Kind} is not stored yet."), + }; + Row(mq, objectId, StoredQueryFormat.AttrOperation, order: 1, flag: kind, + name1: spec.Kind is ActionQueryKind.Append or ActionQueryKind.MakeTable ? spec.TargetTable : null); + + // Sources first: Access processes the rows in order, and a derived-table source defines an alias the + // column expressions reference (the same reason the view path writes tables before columns). + AddSourceRows(mq, objectId, spec.Body); + + var values = spec.Values ?? []; + switch (spec.Kind) { - Row(mq, objectId, StoredQueryFormat.AttrOperation, order: 1, flag: StoredQueryFormat.ActionAppend, name1: spec.TargetTable); - // Each appended column: Name2 = target column, Expression = the (literal) value; the 0x8000 flag - // marks a VALUES append (as opposed to an INSERT … SELECT, whose columns carry Flag 0). - var values = spec.Values ?? []; - for (int i = 0; i < values.Count; i++) - Row(mq, objectId, StoredQueryFormat.AttrColumn, order: i + 1, flag: StoredQueryFormat.AppendValueFlag, - expression: values[i].ValueExpression, name2: values[i].Column); + case ActionQueryKind.Append: + // Name2 = target column, Expression = the value; the 0x8000 flag marks an INSERT … VALUES, + // where a column fed by the query's own source carries Flag 0. + for (int i = 0; i < values.Count; i++) + Row(mq, objectId, StoredQueryFormat.AttrColumn, order: i + 1, + flag: spec.Body is null ? StoredQueryFormat.AppendValueFlag : (short)0, + expression: values[i].ValueExpression, name2: values[i].Column); + break; + + case ActionQueryKind.Update: + // One row per SET assignment, stored exactly as an append's columns are. + for (int i = 0; i < values.Count; i++) + Row(mq, objectId, StoredQueryFormat.AttrColumn, order: i + 1, flag: 0, + expression: values[i].ValueExpression, name2: values[i].Column); + break; + + case ActionQueryKind.Delete: + // `DELETE t.* FROM …` keeps that target verbatim in a single column row; `DELETE FROM …` + // stores no column row at all, and Access renders it back as `DELETE * FROM …`. + if (spec.DeleteTarget is { } target) + Row(mq, objectId, StoredQueryFormat.AttrColumn, order: 1, flag: 0, expression: target); + break; + + case ActionQueryKind.MakeTable: + // An ordinary output list: Expression = the column, Name1 = its alias. + var columns = spec.Body?.Columns ?? []; + for (int i = 0; i < columns.Count; i++) + Row(mq, objectId, StoredQueryFormat.AttrColumn, order: i + 1, flag: 0, + expression: columns[i].Expression, name1: columns[i].Alias); + break; } + + AddJoinAndWhereRows(mq, objectId, spec.Body); + } + + /// The 0x02 parameter rows, in declaration order — written identically for a view and for + /// an action query. + private void AddParameterRows(TableDef mq, int objectId, IReadOnlyList? parameters) + { + for (int i = 0; i < (parameters?.Count ?? 0); i++) + { + ViewParameterSpec p = parameters![i]; + Row(mq, objectId, StoredQueryFormat.AttrParameter, order: i + 1, + flag: p.TypeCode, name1: p.Name, + lvExtra: StoredQueryFormat.PackParameterFacets((JetDataType)p.TypeCode, p.Size, p.Scale)); + } + } + + /// The 0x05 FROM rows: a named table in Name1 (alias in Name2), or a derived table whose + /// subquery SQL goes in Expression with Name1 empty. + private void AddSourceRows(TableDef mq, int objectId, ViewSpec? body) + { + var tables = body?.Tables ?? []; + for (int i = 0; i < tables.Count; i++) + { + ViewTableSpec t = tables[i]; + if (t.SubquerySql is { } sub) + Row(mq, objectId, StoredQueryFormat.AttrTable, order: i + 1, expression: sub, name2: t.Alias); + else + Row(mq, objectId, StoredQueryFormat.AttrTable, order: i + 1, name1: t.Table, name2: t.Alias); + } + } + + /// The 0x07 join rows (condition, kind, and the two tables the condition names) and the + /// single 0x08 WHERE row. + private void AddJoinAndWhereRows(TableDef mq, int objectId, ViewSpec? body) + { + var joins = body?.Joins ?? []; + for (int i = 0; i < joins.Count; i++) + { + ViewJoinSpec j = joins[i]; + Row(mq, objectId, StoredQueryFormat.AttrJoin, order: i + 1, flag: (short)j.Kind, + expression: j.Condition, name1: j.LeftAlias, name2: j.RightAlias); + } + if (body?.Where is { } where) + Row(mq, objectId, StoredQueryFormat.AttrWhere, order: 1, expression: where); } private void AddQueryRows(int objectId, ViewSpec spec) @@ -165,9 +271,7 @@ private void AddQueryRows(int objectId, ViewSpec spec) Row(mq, objectId, StoredQueryFormat.AttrType, order: 1, flag: StoredQueryFormat.QueryTypeSelect); Row(mq, objectId, StoredQueryFormat.AttrEnd, order: 1); // Declared parameters (CREATE PROCEDURE) come right after the End row, before the tables. - for (int i = 0; i < (spec.Parameters?.Count ?? 0); i++) - Row(mq, objectId, StoredQueryFormat.AttrParameter, order: i + 1, - flag: spec.Parameters![i].TypeCode, name1: spec.Parameters[i].Name); + AddParameterRows(mq, objectId, spec.Parameters); // DISTINCT and TOP are both StoredQueryFormat.AttrOption (0x03) rows, distinguished by their Flag bits; a TOP row also // carries the count in Name1. The bits are cumulative, so Access can put both on one row -- writing // them separately is equally valid and keeps the two spec fields independent here. @@ -177,25 +281,11 @@ private void AddQueryRows(int objectId, ViewSpec spec) Row(mq, objectId, StoredQueryFormat.AttrOption, order: flagOrder++, flag: StoredQueryFormat.FlagDistinct); if (spec.Top is { } top) Row(mq, objectId, StoredQueryFormat.AttrOption, order: flagOrder++, flag: StoredQueryFormat.FlagTop, name1: top.ToString(System.Globalization.CultureInfo.InvariantCulture)); - for (int i = 0; i < spec.Tables.Count; i++) - { - ViewTableSpec t = spec.Tables[i]; - // A derived table stores its subquery SQL in Expression (Name1 empty); a named table uses Name1. - if (t.SubquerySql is { } sub) - Row(mq, objectId, StoredQueryFormat.AttrTable, order: i + 1, expression: sub, name2: t.Alias); - else - Row(mq, objectId, StoredQueryFormat.AttrTable, order: i + 1, name1: t.Table, name2: t.Alias); - } + AddSourceRows(mq, objectId, spec); for (int i = 0; i < spec.Columns.Count; i++) Row(mq, objectId, StoredQueryFormat.AttrColumn, order: i + 1, flag: 0, expression: spec.Columns[i].Expression, name1: spec.Columns[i].Alias); - for (int i = 0; i < spec.Joins.Count; i++) - { - ViewJoinSpec j = spec.Joins[i]; - Row(mq, objectId, StoredQueryFormat.AttrJoin, order: i + 1, flag: (short)j.Kind, expression: j.Condition, name1: j.LeftAlias, name2: j.RightAlias); - } - if (spec.Where is { } where) - Row(mq, objectId, StoredQueryFormat.AttrWhere, order: 1, expression: where); + AddJoinAndWhereRows(mq, objectId, spec); for (int i = 0; i < (spec.GroupBy?.Count ?? 0); i++) Row(mq, objectId, StoredQueryFormat.AttrGroupBy, order: i + 1, flag: 0, expression: spec.GroupBy![i]); for (int i = 0; i < (spec.OrderBy?.Count ?? 0); i++) @@ -204,7 +294,8 @@ private void AddQueryRows(int objectId, ViewSpec spec) } private void Row(TableDef mq, int objectId, byte attribute, int order, - short? flag = null, string? expression = null, string? name1 = null, string? name2 = null) + short? flag = null, string? expression = null, string? name1 = null, string? name2 = null, + int? lvExtra = null) { var values = new object?[mq.Columns.Count]; SetByName(mq, values, "ObjectId", objectId); @@ -216,6 +307,8 @@ private void Row(TableDef mq, int objectId, byte attribute, int order, if (expression is not null) SetByName(mq, values, "Expression", expression); if (name1 is not null) SetByName(mq, values, "Name1", name1); if (name2 is not null) SetByName(mq, values, "Name2", name2); + // A declared parameter's length; ACE writes it here and renders the PARAMETERS clause from it. + if (lvExtra is { } extra) SetByName(mq, values, "LvExtra", extra); new RowInserter(_channel, mq).Insert(values, updateIndexes: true); } diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index 0bad7974..b1ce53d6 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -420,16 +420,27 @@ private int ExecuteCreateProcedure(CreateProcedureStatement statement) // hand, and a BIGINT/DATETIME2 parameter on an older format is left to fail. Upgrading a whole // database for a saved query's parameter type is a bigger claim than anything measured — what ACE // does with a new-type parameter in MSysQueries has not been probed, unlike the column case. - var parameters = statement.Parameters - .Select(p => new ViewParameterSpec( - p.Name, - (byte)AccessTypeMapper.ToColumnSpec( - new ColumnDefinition(p.Name, p.TypeName, null, null, false, false), _database.Format.Version).Type)) - .ToList(); - _database.CreateView(statement.Name, BuildViewSpec(statement.Definition) with { Parameters = parameters }); + _database.CreateView( + statement.Name, + BuildViewSpec(statement.Definition) with { Parameters = BuildParameterSpecs(statement.Parameters) }); return 0; } + /// The declared parameters of a stored query as the rows that hold them: each name with the Jet + /// type code its declared Access type maps to. An action query declares them exactly as a SELECT does. + private IReadOnlyList? BuildParameterSpecs(IReadOnlyList? parameters) => + parameters is null or { Count: 0 } + ? null + : parameters + .Select(p => new ViewParameterSpec( + p.Name, + // The declared size decides the type code as well as being stored: Text(50) is a Text + // parameter (code 10) where a bare Text is a memo (12), which is what ACE records. + (byte)AccessTypeMapper.ToColumnSpec( + new ColumnDefinition(p.Name, p.TypeName, p.Size, p.Scale, false, false), _database.Format.Version).Type, + p.Size, p.Scale)) + .ToList(); + private int ExecuteAlterTable(AlterTableStatement statement) => statement.Action switch { // ADD CONSTRAINT … PRIMARY KEY (cols): a primary key is a unique, primary index named after the @@ -664,10 +675,30 @@ private int AddForeignKey(string table, ForeignKeyConstraint fk) private int ExecuteCreateActionProcedure(CreateActionProcedureStatement statement) { - ActionQuerySpec spec = statement.Kind == ProcedureActionKind.DataDefinition - ? new ActionQuerySpec(ActionQueryKind.DataDefinition, DdlSql: statement.DdlSql) - : new ActionQuerySpec(ActionQueryKind.Append, TargetTable: statement.TargetTable, - Values: statement.AppendColumns!.Select(c => new AppendColumnSpec(c.Column, c.ValueExpression)).ToList()); + if (statement.Kind == ProcedureActionKind.DataDefinition) + { + _database.CreateActionQuery( + statement.Name, new ActionQuerySpec(ActionQueryKind.DataDefinition, DdlSql: statement.DdlSql)); + return 0; + } + + // Every other kind stores its sources, joins and WHERE the way a view stores them, so the body maps + // through the same builder; only the action row and the meaning of the column rows differ. + var spec = new ActionQuerySpec( + statement.Kind switch + { + ProcedureActionKind.Append => ActionQueryKind.Append, + ProcedureActionKind.Update => ActionQueryKind.Update, + ProcedureActionKind.Delete => ActionQueryKind.Delete, + ProcedureActionKind.MakeTable => ActionQueryKind.MakeTable, + _ => throw new NotSupportedException($"A {statement.Kind} procedure body is not stored yet."), + }, + TargetTable: statement.TargetTable, + Values: statement.AppendColumns?.Select(c => new AppendColumnSpec(c.Column, c.ValueExpression)).ToList(), + Body: statement.Body is { } body ? BuildViewSpec(body) : null, + Parameters: BuildParameterSpecs(statement.Parameters), + DeleteTarget: statement.DeleteTarget); + _database.CreateActionQuery(statement.Name, spec); return 0; } diff --git a/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs b/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs index 2f95b92b..ac5c0ae0 100644 --- a/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs +++ b/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs @@ -329,9 +329,16 @@ public static bool IsKnown(string collection) => for (int i = 0; i < parameters.Count; i++) { StoredQueryParameter p = parameters[i]; + // The declared facets where the parameter row records them: a text length (reported + // in characters and in bytes, two per character as the Columns collection reports a + // column's), and a decimal's precision and scale. A type that records none falls back + // to the precision its type implies, as a column of it would report. rows.Add([null, null, name, p.Name, i + 1, ParameterTypeInput, false, null, true, - p.Type is { } type ? DataTypeCodeOf(type) : null, null, null, - p.Type is { } precisionType ? NumericPrecisionOf(precisionType) : null, null, null, + p.Type is { } type ? DataTypeCodeOf(type) : null, + p.Size is { } size ? (long)size : null, + p.Size is { } octets ? (long)octets * 2 : null, + p.Precision ?? (p.Type is { } precisionType ? NumericPrecisionOf(precisionType) : null), + p.Scale is { } scale ? (short)scale : null, null, p.Type is { } named ? ProviderTypeName(named) : null, p.Type is { } local ? ProviderTypeName(local) : null]); } diff --git a/src/LibRed/LibRed.Sql/Ast/Statements.cs b/src/LibRed/LibRed.Sql/Ast/Statements.cs index 89f7137e..73603091 100644 --- a/src/LibRed/LibRed.Sql/Ast/Statements.cs +++ b/src/LibRed/LibRed.Sql/Ast/Statements.cs @@ -232,8 +232,10 @@ public sealed record CreateViewStatement( ViewDefinition Definition, string QuerySql) : SqlStatement; -/// A CREATE PROCEDURE parameter: a name and its declared Access SQL type name. -public sealed record ProcedureParameter(string Name, string TypeName); +/// A CREATE PROCEDURE parameter: a name and its declared Access SQL type, with the +/// and it declares. The size also decides the type code — +/// Text(50) is a Text parameter where a bare Text is a memo — and both are stored alongside it. +public sealed record ProcedureParameter(string Name, string TypeName, int? Size = null, int? Scale = null); /// CREATE PROCEDURE name [param datatype, …] AS select — a parameterized stored query. Stored like /// a view (the decomposed ) plus a parameter row per declared parameter. @@ -244,21 +246,31 @@ public sealed record CreateProcedureStatement( string QuerySql) : SqlStatement; /// The kind of non-SELECT (action) CREATE PROCEDURE body. -public enum ProcedureActionKind { DataDefinition, Append } +public enum ProcedureActionKind { DataDefinition, Append, Update, Delete, MakeTable } -/// One appended column of an INSERT procedure body: the target column and the verbatim value text. +/// One column/value pair of an action procedure body: an appended column of an INSERT and its +/// verbatim value text, or one assignment of an UPDATE (whose is table-qualified +/// when the update runs over a join). Access stores both the same way. public sealed record AppendColumn(string Column, string ValueExpression); -/// A CREATE PROCEDURE whose body is an action query (not a SELECT). A +/// +/// A CREATE PROCEDURE whose body is an action query (not a plain SELECT). A /// body carries the whole -/// (CREATE/DROP TABLE); an body carries the -/// and its appended . +/// (CREATE/DROP TABLE) and nothing else — Access stores it verbatim. Every other kind carries its sources, +/// joins and WHERE in , exactly as a view carries them, plus what its own kind needs: +/// are an INSERT's columns or an UPDATE's assignments, +/// is the table an INSERT or a make-table writes into, and +/// is the table.* a DELETE names when it names one. +/// public sealed record CreateActionProcedureStatement( string Name, ProcedureActionKind Kind, string? DdlSql, string? TargetTable, - IReadOnlyList? AppendColumns) : SqlStatement; + IReadOnlyList? AppendColumns, + ViewDefinition? Body = null, + string? DeleteTarget = null, + IReadOnlyList? Parameters = null) : SqlStatement; /// One action of an ALTER TABLE statement (Access allows exactly one per statement). public abstract record AlterTableAction; diff --git a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 index 509fe49a..7dd9ebb2 100644 --- a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 +++ b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 @@ -123,9 +123,11 @@ alterTableAction | RENAME INDEX index=identifier TO newName=identifier # RenameIndexAction ; -// A procedure body is any statement Access allows. We store/execute the ones we know (SELECT, INSERT, -// CREATE TABLE); other action queries (UPDATE/DELETE/DROP/…) are rejected by the builder. -procedureBody : queryExpression | insertStatement | createTableStatement ; +// A procedure body is any statement Access allows. We store the ones we can decompose into the MSysQueries +// rows Access writes for them — a SELECT (a plain view, or a make-table when it has an INTO), an INSERT from +// either VALUES or a SELECT, an UPDATE, a DELETE, and CREATE TABLE (stored verbatim). The kinds we cannot +// store (crosstab, pass-through, UNION) are rejected by the builder. +procedureBody : queryExpression | insertStatement | updateStatement | deleteStatement | createTableStatement ; // CREATE [UNIQUE] INDEX name ON table (field [ASC|DESC], …) [WITH {PRIMARY|DISALLOW NULL|IGNORE NULL}] createIndexStatement diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs index 0401ab57..00128594 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from D:/toolkits/efcorejetlibred/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 by ANTLR 4.13.1 +// Generated from AccessSql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs index a4257e17..af6909e0 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from D:/toolkits/efcorejetlibred/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 by ANTLR 4.13.1 +// Generated from AccessSql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs index d70f31e8..1bd40cd7 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from D:/toolkits/efcorejetlibred/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 by ANTLR 4.13.1 +// Generated from AccessSql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 @@ -2130,6 +2130,12 @@ [System.Diagnostics.DebuggerNonUserCode] public QueryExpressionContext queryExpr [System.Diagnostics.DebuggerNonUserCode] public InsertStatementContext insertStatement() { return GetRuleContext(0); } + [System.Diagnostics.DebuggerNonUserCode] public UpdateStatementContext updateStatement() { + return GetRuleContext(0); + } + [System.Diagnostics.DebuggerNonUserCode] public DeleteStatementContext deleteStatement() { + return GetRuleContext(0); + } [System.Diagnostics.DebuggerNonUserCode] public CreateTableStatementContext createTableStatement() { return GetRuleContext(0); } @@ -2151,7 +2157,7 @@ public ProcedureBodyContext procedureBody() { ProcedureBodyContext _localctx = new ProcedureBodyContext(Context, State); EnterRule(_localctx, 36, RULE_procedureBody); try { - State = 433; + State = 435; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case SELECT: @@ -2170,10 +2176,24 @@ public ProcedureBodyContext procedureBody() { insertStatement(); } break; - case CREATE: + case UPDATE: EnterOuterAlt(_localctx, 3); { State = 432; + updateStatement(); + } + break; + case DELETE: + EnterOuterAlt(_localctx, 4); + { + State = 433; + deleteStatement(); + } + break; + case CREATE: + EnterOuterAlt(_localctx, 5); + { + State = 434; createTableStatement(); } break; @@ -2243,56 +2263,56 @@ public CreateIndexStatementContext createIndexStatement() { try { EnterOuterAlt(_localctx, 1); { - State = 435; - Match(CREATE); State = 437; + Match(CREATE); + State = 439; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==UNIQUE) { { - State = 436; + State = 438; _localctx.unique = Match(UNIQUE); } } - State = 439; + State = 441; Match(INDEX); - State = 440; + State = 442; _localctx.name = identifier(); - State = 441; + State = 443; Match(ON); - State = 442; + State = 444; _localctx.table = identifier(); - State = 443; + State = 445; Match(LPAREN); - State = 444; + State = 446; indexColumn(); - State = 449; + State = 451; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 445; + State = 447; Match(COMMA); - State = 446; + State = 448; indexColumn(); } } - State = 451; + State = 453; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 452; + State = 454; Match(RPAREN); - State = 455; + State = 457; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==WITH) { { - State = 453; + State = 455; Match(WITH); - State = 454; + State = 456; withOption(); } } @@ -2393,18 +2413,18 @@ public DropStatementContext dropStatement() { DropStatementContext _localctx = new DropStatementContext(Context, State); EnterRule(_localctx, 40, RULE_dropStatement); try { - State = 472; + State = 474; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,37,Context) ) { case 1: _localctx = new DropTableStatementContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 457; + State = 459; Match(DROP); - State = 458; + State = 460; Match(TABLE); - State = 459; + State = 461; ((DropTableStatementContext)_localctx).table = identifier(); } break; @@ -2412,15 +2432,15 @@ public DropStatementContext dropStatement() { _localctx = new DropIndexStatementContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 460; + State = 462; Match(DROP); - State = 461; + State = 463; Match(INDEX); - State = 462; + State = 464; ((DropIndexStatementContext)_localctx).index = identifier(); - State = 463; + State = 465; Match(ON); - State = 464; + State = 466; ((DropIndexStatementContext)_localctx).table = identifier(); } break; @@ -2428,11 +2448,11 @@ public DropStatementContext dropStatement() { _localctx = new DropProcedureStatementContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 466; + State = 468; Match(DROP); - State = 467; + State = 469; Match(PROCEDURE); - State = 468; + State = 470; ((DropProcedureStatementContext)_localctx).proc = identifier(); } break; @@ -2440,11 +2460,11 @@ public DropStatementContext dropStatement() { _localctx = new DropViewStatementContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 469; + State = 471; Match(DROP); - State = 470; + State = 472; Match(VIEW); - State = 471; + State = 473; ((DropViewStatementContext)_localctx).view = identifier(); } break; @@ -2490,14 +2510,14 @@ public IndexColumnContext indexColumn() { try { EnterOuterAlt(_localctx, 1); { - State = 474; - _localctx.col = identifier(); State = 476; + _localctx.col = identifier(); + State = 478; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 475; + State = 477; _localctx.dir = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==ASC || _la==DESC) ) { @@ -2573,14 +2593,14 @@ public WithOptionContext withOption() { WithOptionContext _localctx = new WithOptionContext(Context, State); EnterRule(_localctx, 44, RULE_withOption); try { - State = 483; + State = 485; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case PRIMARY: _localctx = new WithPrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 478; + State = 480; Match(PRIMARY); } break; @@ -2588,9 +2608,9 @@ public WithOptionContext withOption() { _localctx = new WithDisallowNullContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 479; + State = 481; Match(DISALLOW); - State = 480; + State = 482; Match(NULL); } break; @@ -2598,9 +2618,9 @@ public WithOptionContext withOption() { _localctx = new WithIgnoreNullContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 481; + State = 483; Match(IGNORE); - State = 482; + State = 484; Match(NULL); } break; @@ -2657,31 +2677,31 @@ public ColumnDefinitionContext columnDefinition() { try { EnterOuterAlt(_localctx, 1); { - State = 485; + State = 487; _localctx.name = identifier(); - State = 486; - dataType(); State = 488; + dataType(); + State = 490; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 487; + State = 489; calculatedClause(); } } - State = 493; + State = 495; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==NOT || ((((_la - 73)) & ~0x3f) == 0 && ((1L << (_la - 73)) & 17197213717L) != 0)) { { { - State = 490; + State = 492; columnConstraint(); } } - State = 495; + State = 497; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -2725,13 +2745,13 @@ public CalculatedClauseContext calculatedClause() { try { EnterOuterAlt(_localctx, 1); { - State = 496; + State = 498; Match(AS); - State = 497; + State = 499; Match(LPAREN); - State = 498; + State = 500; expression(0); - State = 499; + State = 501; Match(RPAREN); } } @@ -2790,7 +2810,7 @@ public DataTypeContext dataType() { try { EnterOuterAlt(_localctx, 1); { - State = 503; + State = 505; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case RANGE: @@ -2811,61 +2831,61 @@ public DataTypeContext dataType() { case BACKTICK_ID: case IDENTIFIER: { - State = 501; + State = 503; _localctx.typeName = identifier(); } break; case IDENTITY: { - State = 502; + State = 504; _localctx.identityType = Match(IDENTITY); } break; default: throw new NoViableAltException(this); } - State = 506; + State = 508; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,43,Context) ) { case 1: { - State = 505; + State = 507; _localctx.extra = identifier(); } break; } - State = 509; + State = 511; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (((((_la - 108)) & ~0x3f) == 0 && ((1L << (_la - 108)) & 7696581410815L) != 0)) { { - State = 508; + State = 510; _localctx.extra2 = identifier(); } } - State = 519; + State = 521; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==LPAREN) { { - State = 511; + State = 513; Match(LPAREN); - State = 512; + State = 514; _localctx.size = signedInteger(); - State = 515; + State = 517; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==COMMA) { { - State = 513; + State = 515; Match(COMMA); - State = 514; + State = 516; _localctx.scale = signedInteger(); } } - State = 517; + State = 519; Match(RPAREN); } } @@ -2907,17 +2927,17 @@ public SignedIntegerContext signedInteger() { try { EnterOuterAlt(_localctx, 1); { - State = 522; + State = 524; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==MINUS) { { - State = 521; + State = 523; Match(MINUS); } } - State = 524; + State = 526; Match(INTEGER_LITERAL); } } @@ -3108,16 +3128,16 @@ public ColumnConstraintContext columnConstraint() { EnterRule(_localctx, 54, RULE_columnConstraint); int _la; try { - State = 595; + State = 597; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,59,Context) ) { case 1: _localctx = new NotNullConstraintContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 526; + State = 528; Match(NOT); - State = 527; + State = 529; Match(NULL); } break; @@ -3125,7 +3145,7 @@ public ColumnConstraintContext columnConstraint() { _localctx = new NullableConstraintContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 528; + State = 530; Match(NULL); } break; @@ -3133,9 +3153,9 @@ public ColumnConstraintContext columnConstraint() { _localctx = new DefaultConstraintContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 529; + State = 531; Match(DEFAULT); - State = 530; + State = 532; expression(0); } break; @@ -3143,9 +3163,9 @@ public ColumnConstraintContext columnConstraint() { _localctx = new CompressionConstraintContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 531; + State = 533; Match(WITH); - State = 532; + State = 534; _la = TokenStream.LA(1); if ( !(_la==COMPRESSION || _la==COMP) ) { ErrorHandler.RecoverInline(this); @@ -3160,25 +3180,25 @@ public ColumnConstraintContext columnConstraint() { _localctx = new CheckColumnConstraintContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 535; + State = 537; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 533; + State = 535; Match(CONSTRAINT); - State = 534; + State = 536; ((CheckColumnConstraintContext)_localctx).cname = identifier(); } } - State = 537; + State = 539; Match(CHECK); - State = 538; + State = 540; Match(LPAREN); - State = 539; + State = 541; checkBody(); - State = 540; + State = 542; Match(RPAREN); } break; @@ -3186,28 +3206,28 @@ public ColumnConstraintContext columnConstraint() { _localctx = new PrimaryKeyConstraintContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 544; + State = 546; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 542; + State = 544; Match(CONSTRAINT); - State = 543; + State = 545; ((PrimaryKeyConstraintContext)_localctx).cname = identifier(); } } - State = 546; + State = 548; Match(PRIMARY); - State = 547; - Match(KEY); State = 549; + Match(KEY); + State = 551; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CLUSTERED || _la==NONCLUSTERED) { { - State = 548; + State = 550; clusteredOption(); } } @@ -3218,26 +3238,26 @@ public ColumnConstraintContext columnConstraint() { _localctx = new UniqueColumnConstraintContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 553; + State = 555; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 551; + State = 553; Match(CONSTRAINT); - State = 552; + State = 554; ((UniqueColumnConstraintContext)_localctx).cname = identifier(); } } - State = 555; - Match(UNIQUE); State = 557; + Match(UNIQUE); + State = 559; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CLUSTERED || _la==NONCLUSTERED) { { - State = 556; + State = 558; clusteredOption(); } } @@ -3248,65 +3268,65 @@ public ColumnConstraintContext columnConstraint() { _localctx = new ColumnReferencesConstraintContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 561; + State = 563; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 559; + State = 561; Match(CONSTRAINT); - State = 560; + State = 562; ((ColumnReferencesConstraintContext)_localctx).cname = identifier(); } } - State = 563; + State = 565; Match(REFERENCES); - State = 564; + State = 566; ((ColumnReferencesConstraintContext)_localctx).refTable = identifier(); - State = 576; + State = 578; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==LPAREN) { { - State = 565; + State = 567; Match(LPAREN); - State = 566; + State = 568; ((ColumnReferencesConstraintContext)_localctx)._identifier = identifier(); ((ColumnReferencesConstraintContext)_localctx)._refColumns.Add(((ColumnReferencesConstraintContext)_localctx)._identifier); - State = 571; + State = 573; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 567; + State = 569; Match(COMMA); - State = 568; + State = 570; ((ColumnReferencesConstraintContext)_localctx)._identifier = identifier(); ((ColumnReferencesConstraintContext)_localctx)._refColumns.Add(((ColumnReferencesConstraintContext)_localctx)._identifier); } } - State = 573; + State = 575; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 574; + State = 576; Match(RPAREN); } } - State = 581; + State = 583; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==ON) { { { - State = 578; + State = 580; foreignKeyAction(); } } - State = 583; + State = 585; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -3316,30 +3336,30 @@ public ColumnConstraintContext columnConstraint() { _localctx = new IdentityConstraintContext(_localctx); EnterOuterAlt(_localctx, 9); { - State = 584; + State = 586; Match(IDENTITY); - State = 593; + State = 595; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==LPAREN) { { - State = 585; + State = 587; Match(LPAREN); - State = 586; + State = 588; ((IdentityConstraintContext)_localctx).seed = signedInteger(); - State = 589; + State = 591; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==COMMA) { { - State = 587; + State = 589; Match(COMMA); - State = 588; + State = 590; ((IdentityConstraintContext)_localctx).increment = signedInteger(); } } - State = 591; + State = 593; Match(RPAREN); } } @@ -3502,62 +3522,62 @@ public TableConstraintContext tableConstraint() { EnterRule(_localctx, 56, RULE_tableConstraint); int _la; try { - State = 686; + State = 688; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,73,Context) ) { case 1: _localctx = new PrimaryKeyTableConstraintContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 599; + State = 601; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 597; + State = 599; Match(CONSTRAINT); - State = 598; + State = 600; ((PrimaryKeyTableConstraintContext)_localctx).name = identifier(); } } - State = 601; + State = 603; Match(PRIMARY); - State = 602; - Match(KEY); State = 604; + Match(KEY); + State = 606; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CLUSTERED || _la==NONCLUSTERED) { { - State = 603; + State = 605; clusteredOption(); } } - State = 606; + State = 608; Match(LPAREN); - State = 607; + State = 609; ((PrimaryKeyTableConstraintContext)_localctx)._identifier = identifier(); ((PrimaryKeyTableConstraintContext)_localctx)._columns.Add(((PrimaryKeyTableConstraintContext)_localctx)._identifier); - State = 612; + State = 614; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 608; + State = 610; Match(COMMA); - State = 609; + State = 611; ((PrimaryKeyTableConstraintContext)_localctx)._identifier = identifier(); ((PrimaryKeyTableConstraintContext)_localctx)._columns.Add(((PrimaryKeyTableConstraintContext)_localctx)._identifier); } } - State = 614; + State = 616; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 615; + State = 617; Match(RPAREN); } break; @@ -3565,53 +3585,53 @@ public TableConstraintContext tableConstraint() { _localctx = new UniqueTableConstraintContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 619; + State = 621; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 617; + State = 619; Match(CONSTRAINT); - State = 618; + State = 620; ((UniqueTableConstraintContext)_localctx).name = identifier(); } } - State = 621; - Match(UNIQUE); State = 623; + Match(UNIQUE); + State = 625; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CLUSTERED || _la==NONCLUSTERED) { { - State = 622; + State = 624; clusteredOption(); } } - State = 625; + State = 627; Match(LPAREN); - State = 626; + State = 628; ((UniqueTableConstraintContext)_localctx)._identifier = identifier(); ((UniqueTableConstraintContext)_localctx)._columns.Add(((UniqueTableConstraintContext)_localctx)._identifier); - State = 631; + State = 633; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 627; + State = 629; Match(COMMA); - State = 628; + State = 630; ((UniqueTableConstraintContext)_localctx)._identifier = identifier(); ((UniqueTableConstraintContext)_localctx)._columns.Add(((UniqueTableConstraintContext)_localctx)._identifier); } } - State = 633; + State = 635; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 634; + State = 636; Match(RPAREN); } break; @@ -3619,105 +3639,105 @@ public TableConstraintContext tableConstraint() { _localctx = new ForeignKeyTableConstraintContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 638; + State = 640; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 636; + State = 638; Match(CONSTRAINT); - State = 637; + State = 639; ((ForeignKeyTableConstraintContext)_localctx).name = identifier(); } } - State = 640; + State = 642; Match(FOREIGN); - State = 641; + State = 643; Match(KEY); - State = 644; + State = 646; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NO) { { - State = 642; + State = 644; ((ForeignKeyTableConstraintContext)_localctx).noIndex = Match(NO); - State = 643; + State = 645; Match(INDEX); } } - State = 646; + State = 648; Match(LPAREN); - State = 647; + State = 649; ((ForeignKeyTableConstraintContext)_localctx)._identifier = identifier(); ((ForeignKeyTableConstraintContext)_localctx)._columns.Add(((ForeignKeyTableConstraintContext)_localctx)._identifier); - State = 652; + State = 654; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 648; + State = 650; Match(COMMA); - State = 649; + State = 651; ((ForeignKeyTableConstraintContext)_localctx)._identifier = identifier(); ((ForeignKeyTableConstraintContext)_localctx)._columns.Add(((ForeignKeyTableConstraintContext)_localctx)._identifier); } } - State = 654; + State = 656; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 655; + State = 657; Match(RPAREN); - State = 656; + State = 658; Match(REFERENCES); - State = 657; + State = 659; ((ForeignKeyTableConstraintContext)_localctx).refTable = identifier(); - State = 669; + State = 671; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==LPAREN) { { - State = 658; + State = 660; Match(LPAREN); - State = 659; + State = 661; ((ForeignKeyTableConstraintContext)_localctx)._identifier = identifier(); ((ForeignKeyTableConstraintContext)_localctx)._refColumns.Add(((ForeignKeyTableConstraintContext)_localctx)._identifier); - State = 664; + State = 666; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 660; + State = 662; Match(COMMA); - State = 661; + State = 663; ((ForeignKeyTableConstraintContext)_localctx)._identifier = identifier(); ((ForeignKeyTableConstraintContext)_localctx)._refColumns.Add(((ForeignKeyTableConstraintContext)_localctx)._identifier); } } - State = 666; + State = 668; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 667; + State = 669; Match(RPAREN); } } - State = 674; + State = 676; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==ON) { { { - State = 671; + State = 673; foreignKeyAction(); } } - State = 676; + State = 678; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -3727,25 +3747,25 @@ public TableConstraintContext tableConstraint() { _localctx = new CheckTableConstraintContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 679; + State = 681; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==CONSTRAINT) { { - State = 677; + State = 679; Match(CONSTRAINT); - State = 678; + State = 680; ((CheckTableConstraintContext)_localctx).name = identifier(); } } - State = 681; + State = 683; Match(CHECK); - State = 682; + State = 684; Match(LPAREN); - State = 683; + State = 685; checkBody(); - State = 684; + State = 686; Match(RPAREN); } break; @@ -3798,12 +3818,12 @@ public CheckBodyContext checkBody() { try { EnterOuterAlt(_localctx, 1); { - State = 695; + State = 697; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & -2L) != 0) || ((((_la - 64)) & ~0x3f) == 0 && ((1L << (_la - 64)) & -1L) != 0) || ((((_la - 128)) & ~0x3f) == 0 && ((1L << (_la - 128)) & 67108607L) != 0)) { { - State = 693; + State = 695; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case SELECT: @@ -3958,7 +3978,7 @@ public CheckBodyContext checkBody() { case LINE_COMMENT: case BLOCK_COMMENT: { - State = 688; + State = 690; _la = TokenStream.LA(1); if ( _la <= 0 || (_la==LPAREN || _la==RPAREN) ) { ErrorHandler.RecoverInline(this); @@ -3971,11 +3991,11 @@ public CheckBodyContext checkBody() { break; case LPAREN: { - State = 689; + State = 691; Match(LPAREN); - State = 690; + State = 692; checkBody(); - State = 691; + State = 693; Match(RPAREN); } break; @@ -3983,7 +4003,7 @@ public CheckBodyContext checkBody() { throw new NoViableAltException(this); } } - State = 697; + State = 699; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4046,18 +4066,18 @@ public ForeignKeyActionContext foreignKeyAction() { ForeignKeyActionContext _localctx = new ForeignKeyActionContext(Context, State); EnterRule(_localctx, 60, RULE_foreignKeyAction); try { - State = 704; + State = 706; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,76,Context) ) { case 1: _localctx = new OnUpdateActionContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 698; + State = 700; Match(ON); - State = 699; + State = 701; Match(UPDATE); - State = 700; + State = 702; referentialAction(); } break; @@ -4065,11 +4085,11 @@ public ForeignKeyActionContext foreignKeyAction() { _localctx = new OnDeleteActionContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 701; + State = 703; Match(ON); - State = 702; + State = 704; Match(DELETE); - State = 703; + State = 705; referentialAction(); } break; @@ -4157,14 +4177,14 @@ public ReferentialActionContext referentialAction() { ReferentialActionContext _localctx = new ReferentialActionContext(Context, State); EnterRule(_localctx, 62, RULE_referentialAction); try { - State = 714; + State = 716; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,77,Context) ) { case 1: _localctx = new CascadeActionContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 706; + State = 708; Match(CASCADE); } break; @@ -4172,9 +4192,9 @@ public ReferentialActionContext referentialAction() { _localctx = new NoActionActionContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 707; + State = 709; Match(NO); - State = 708; + State = 710; Match(ACTION); } break; @@ -4182,7 +4202,7 @@ public ReferentialActionContext referentialAction() { _localctx = new RestrictActionContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 709; + State = 711; Match(RESTRICT); } break; @@ -4190,9 +4210,9 @@ public ReferentialActionContext referentialAction() { _localctx = new SetNullActionContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 710; + State = 712; Match(SET); - State = 711; + State = 713; Match(NULL); } break; @@ -4200,9 +4220,9 @@ public ReferentialActionContext referentialAction() { _localctx = new SetDefaultActionContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 712; + State = 714; Match(SET); - State = 713; + State = 715; Match(DEFAULT); } break; @@ -4270,73 +4290,73 @@ public InsertStatementContext insertStatement() { try { EnterOuterAlt(_localctx, 1); { - State = 716; + State = 718; Match(INSERT); - State = 717; + State = 719; Match(INTO); - State = 718; + State = 720; _localctx.table = identifier(); - State = 746; + State = 748; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case SELECT: case VALUES: case LPAREN: { - State = 730; + State = 732; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,79,Context) ) { case 1: { - State = 719; + State = 721; Match(LPAREN); - State = 720; + State = 722; _localctx._identifier = identifier(); _localctx._columns.Add(_localctx._identifier); - State = 725; + State = 727; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 721; + State = 723; Match(COMMA); - State = 722; + State = 724; _localctx._identifier = identifier(); _localctx._columns.Add(_localctx._identifier); } } - State = 727; + State = 729; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 728; + State = 730; Match(RPAREN); } break; } - State = 742; + State = 744; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,81,Context) ) { case 1: { - State = 732; + State = 734; Match(VALUES); - State = 733; + State = 735; rowValues(); - State = 738; + State = 740; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 734; + State = 736; Match(COMMA); - State = 735; + State = 737; rowValues(); } } - State = 740; + State = 742; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4344,7 +4364,7 @@ public InsertStatementContext insertStatement() { break; case 2: { - State = 741; + State = 743; _localctx.source = queryExpression(); } break; @@ -4353,9 +4373,9 @@ public InsertStatementContext insertStatement() { break; case DEFAULT: { - State = 744; + State = 746; Match(DEFAULT); - State = 745; + State = 747; Match(VALUES); } break; @@ -4409,27 +4429,27 @@ public RowValuesContext rowValues() { try { EnterOuterAlt(_localctx, 1); { - State = 748; + State = 750; Match(LPAREN); - State = 749; + State = 751; rowValue(); - State = 754; + State = 756; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 750; + State = 752; Match(COMMA); - State = 751; + State = 753; rowValue(); } } - State = 756; + State = 758; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 757; + State = 759; Match(RPAREN); } } @@ -4467,13 +4487,13 @@ public RowValueContext rowValue() { RowValueContext _localctx = new RowValueContext(Context, State); EnterRule(_localctx, 68, RULE_rowValue); try { - State = 761; + State = 763; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case DEFAULT: EnterOuterAlt(_localctx, 1); { - State = 759; + State = 761; Match(DEFAULT); } break; @@ -4519,7 +4539,7 @@ public RowValueContext rowValue() { case IDENTIFIER: EnterOuterAlt(_localctx, 2); { - State = 760; + State = 762; expression(0); } break; @@ -4578,40 +4598,40 @@ public QueryExpressionContext queryExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 763; + State = 765; queryTerm(); - State = 769; + State = 771; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 117093590311632896L) != 0)) { { { - State = 764; + State = 766; setOperator(); - State = 765; + State = 767; queryTerm(); } } - State = 771; + State = 773; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 773; + State = 775; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ORDER) { { - State = 772; + State = 774; orderByClause(); } } - State = 776; + State = 778; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OFFSET || _la==FETCH) { { - State = 775; + State = 777; offsetFetchClause(); } } @@ -4694,14 +4714,14 @@ public QueryTermContext queryTerm() { EnterRule(_localctx, 72, RULE_queryTerm); int _la; try { - State = 792; + State = 794; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case SELECT: _localctx = new SelectTermContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 778; + State = 780; querySpecification(); } break; @@ -4709,11 +4729,11 @@ public QueryTermContext queryTerm() { _localctx = new ParenTermContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 779; + State = 781; Match(LPAREN); - State = 780; + State = 782; queryExpression(); - State = 781; + State = 783; Match(RPAREN); } break; @@ -4721,23 +4741,23 @@ public QueryTermContext queryTerm() { _localctx = new ValuesTermContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 783; + State = 785; Match(VALUES); - State = 784; + State = 786; rowValues(); - State = 789; + State = 791; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 785; + State = 787; Match(COMMA); - State = 786; + State = 788; rowValues(); } } - State = 791; + State = 793; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4782,20 +4802,20 @@ public SetOperatorContext setOperator() { EnterRule(_localctx, 74, RULE_setOperator); int _la; try { - State = 800; + State = 802; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case UNION: EnterOuterAlt(_localctx, 1); { - State = 794; - Match(UNION); State = 796; + Match(UNION); + State = 798; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ALL) { { - State = 795; + State = 797; Match(ALL); } } @@ -4805,14 +4825,14 @@ public SetOperatorContext setOperator() { case INTERSECT: EnterOuterAlt(_localctx, 2); { - State = 798; + State = 800; Match(INTERSECT); } break; case EXCEPT: EnterOuterAlt(_localctx, 3); { - State = 799; + State = 801; Match(EXCEPT); } break; @@ -4881,78 +4901,78 @@ public QuerySpecificationContext querySpecification() { try { EnterOuterAlt(_localctx, 1); { - State = 802; - Match(SELECT); State = 804; + Match(SELECT); + State = 806; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 18014450049089536L) != 0)) { { - State = 803; + State = 805; _localctx.predicate = selectPredicate(); } } - State = 807; + State = 809; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TOP) { { - State = 806; + State = 808; topClause(); } } - State = 809; + State = 811; selectList(); - State = 812; + State = 814; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==INTO) { { - State = 810; + State = 812; Match(INTO); - State = 811; + State = 813; _localctx.into = identifier(); } } - State = 815; + State = 817; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==FROM) { { - State = 814; + State = 816; fromClause(); } } - State = 818; + State = 820; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==WHERE) { { - State = 817; + State = 819; whereClause(); } } - State = 821; + State = 823; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==GROUP) { { - State = 820; + State = 822; groupByClause(); } } - State = 824; + State = 826; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==HAVING) { { - State = 823; + State = 825; havingClause(); } } @@ -4995,7 +5015,7 @@ public SelectPredicateContext selectPredicate() { try { EnterOuterAlt(_localctx, 1); { - State = 826; + State = 828; _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 18014450049089536L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -5051,25 +5071,25 @@ public GroupByClauseContext groupByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 828; + State = 830; Match(GROUP); - State = 829; + State = 831; Match(BY); - State = 830; + State = 832; expression(0); - State = 835; + State = 837; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 831; + State = 833; Match(COMMA); - State = 832; + State = 834; expression(0); } } - State = 837; + State = 839; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5111,9 +5131,9 @@ public HavingClauseContext havingClause() { try { EnterOuterAlt(_localctx, 1); { - State = 838; + State = 840; Match(HAVING); - State = 839; + State = 841; expression(0); } } @@ -5168,18 +5188,18 @@ public TopClauseContext topClause() { int _alt; EnterOuterAlt(_localctx, 1); { - State = 841; + State = 843; Match(TOP); - State = 842; + State = 844; topOperand(); - State = 847; + State = 849; ErrorHandler.Sync(this); _alt = Interpreter.AdaptivePredict(TokenStream,100,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { { { - State = 843; + State = 845; _la = TokenStream.LA(1); if ( !(_la==PLUS || _la==MINUS) ) { ErrorHandler.RecoverInline(this); @@ -5188,21 +5208,21 @@ public TopClauseContext topClause() { ErrorHandler.ReportMatch(this); Consume(); } - State = 844; + State = 846; topOperand(); } } } - State = 849; + State = 851; ErrorHandler.Sync(this); _alt = Interpreter.AdaptivePredict(TokenStream,100,Context); } - State = 851; + State = 853; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==PERCENT) { { - State = 850; + State = 852; _localctx.percent = Match(PERCENT); } } @@ -5246,31 +5266,31 @@ public TopOperandContext topOperand() { TopOperandContext _localctx = new TopOperandContext(Context, State); EnterRule(_localctx, 86, RULE_topOperand); try { - State = 859; + State = 861; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: EnterOuterAlt(_localctx, 1); { - State = 853; + State = 855; Match(INTEGER_LITERAL); } break; case PARAM: EnterOuterAlt(_localctx, 2); { - State = 854; + State = 856; Match(PARAM); } break; case LPAREN: EnterOuterAlt(_localctx, 3); { - State = 855; + State = 857; Match(LPAREN); - State = 856; + State = 858; expression(0); - State = 857; + State = 859; Match(RPAREN); } break; @@ -5328,26 +5348,26 @@ public OffsetFetchClauseContext offsetFetchClause() { EnterRule(_localctx, 88, RULE_offsetFetchClause); int _la; try { - State = 878; + State = 880; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case OFFSET: EnterOuterAlt(_localctx, 1); { - State = 861; + State = 863; Match(OFFSET); - State = 862; + State = 864; _localctx.offset = expression(0); - State = 863; + State = 865; rowKeyword(); - State = 870; + State = 872; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==FETCH) { { - State = 864; + State = 866; Match(FETCH); - State = 865; + State = 867; _la = TokenStream.LA(1); if ( !(_la==NEXT || _la==FIRST) ) { ErrorHandler.RecoverInline(this); @@ -5356,11 +5376,11 @@ public OffsetFetchClauseContext offsetFetchClause() { ErrorHandler.ReportMatch(this); Consume(); } - State = 866; + State = 868; _localctx.limit = expression(0); - State = 867; + State = 869; rowKeyword(); - State = 868; + State = 870; Match(ONLY); } } @@ -5370,9 +5390,9 @@ public OffsetFetchClauseContext offsetFetchClause() { case FETCH: EnterOuterAlt(_localctx, 2); { - State = 872; + State = 874; Match(FETCH); - State = 873; + State = 875; _la = TokenStream.LA(1); if ( !(_la==NEXT || _la==FIRST) ) { ErrorHandler.RecoverInline(this); @@ -5381,11 +5401,11 @@ public OffsetFetchClauseContext offsetFetchClause() { ErrorHandler.ReportMatch(this); Consume(); } - State = 874; + State = 876; _localctx.limit = expression(0); - State = 875; + State = 877; rowKeyword(); - State = 876; + State = 878; Match(ONLY); } break; @@ -5428,7 +5448,7 @@ public RowKeywordContext rowKeyword() { try { EnterOuterAlt(_localctx, 1); { - State = 880; + State = 882; _la = TokenStream.LA(1); if ( !(_la==ROWS || _la==ROW) ) { ErrorHandler.RecoverInline(this); @@ -5481,13 +5501,13 @@ public SelectListContext selectList() { EnterRule(_localctx, 92, RULE_selectList); int _la; try { - State = 891; + State = 893; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: EnterOuterAlt(_localctx, 1); { - State = 882; + State = 884; Match(STAR); } break; @@ -5533,21 +5553,21 @@ public SelectListContext selectList() { case IDENTIFIER: EnterOuterAlt(_localctx, 2); { - State = 883; + State = 885; selectItem(); - State = 888; + State = 890; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 884; + State = 886; Match(COMMA); - State = 885; + State = 887; selectItem(); } } - State = 890; + State = 892; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5619,18 +5639,18 @@ public SelectItemContext selectItem() { EnterRule(_localctx, 94, RULE_selectItem); int _la; try { - State = 904; + State = 906; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,109,Context) ) { case 1: _localctx = new QualifiedStarSelectItemContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 893; + State = 895; ((QualifiedStarSelectItemContext)_localctx).qualifier = identifier(); - State = 894; + State = 896; Match(DOT); - State = 895; + State = 897; Match(STAR); } break; @@ -5638,24 +5658,24 @@ public SelectItemContext selectItem() { _localctx = new ExpressionSelectItemContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 897; + State = 899; expression(0); - State = 902; + State = 904; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 108)) & ~0x3f) == 0 && ((1L << (_la - 108)) & 7696581410815L) != 0)) { { - State = 899; + State = 901; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 898; + State = 900; Match(AS); } } - State = 901; + State = 903; ((ExpressionSelectItemContext)_localctx).alias = identifier(); } } @@ -5708,23 +5728,23 @@ public FromClauseContext fromClause() { try { EnterOuterAlt(_localctx, 1); { - State = 906; + State = 908; Match(FROM); - State = 907; + State = 909; tableSource(); - State = 912; + State = 914; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 908; + State = 910; Match(COMMA); - State = 909; + State = 911; tableSource(); } } - State = 914; + State = 916; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5772,19 +5792,19 @@ public TableSourceContext tableSource() { try { EnterOuterAlt(_localctx, 1); { - State = 915; + State = 917; tablePrimary(); - State = 919; + State = 921; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 137455468544L) != 0)) { { { - State = 916; + State = 918; joinClause(); } } - State = 921; + State = 923; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5871,31 +5891,31 @@ public TablePrimaryContext tablePrimary() { EnterRule(_localctx, 100, RULE_tablePrimary); int _la; try { - State = 942; + State = 944; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,116,Context) ) { case 1: _localctx = new NamedTablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 922; + State = 924; ((NamedTablePrimaryContext)_localctx).table = identifier(); - State = 927; + State = 929; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 108)) & ~0x3f) == 0 && ((1L << (_la - 108)) & 7696581410815L) != 0)) { { - State = 924; + State = 926; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 923; + State = 925; Match(AS); } } - State = 926; + State = 928; ((NamedTablePrimaryContext)_localctx).alias = identifier(); } } @@ -5906,28 +5926,28 @@ public TablePrimaryContext tablePrimary() { _localctx = new SubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 929; + State = 931; Match(LPAREN); - State = 930; + State = 932; queryExpression(); - State = 931; + State = 933; Match(RPAREN); - State = 936; + State = 938; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 108)) & ~0x3f) == 0 && ((1L << (_la - 108)) & 7696581410815L) != 0)) { { - State = 933; + State = 935; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 932; + State = 934; Match(AS); } } - State = 935; + State = 937; ((SubqueryPrimaryContext)_localctx).alias = identifier(); } } @@ -5938,11 +5958,11 @@ public TablePrimaryContext tablePrimary() { _localctx = new ParenJoinPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 938; + State = 940; Match(LPAREN); - State = 939; + State = 941; tableSource(); - State = 940; + State = 942; Match(RPAREN); } break; @@ -6039,22 +6059,22 @@ public JoinClauseContext joinClause() { JoinClauseContext _localctx = new JoinClauseContext(Context, State); EnterRule(_localctx, 102, RULE_joinClause); try { - State = 959; + State = 961; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,117,Context) ) { case 1: _localctx = new ConditionalJoinContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 944; + State = 946; joinType(); - State = 945; + State = 947; Match(JOIN); - State = 946; + State = 948; tablePrimary(); - State = 947; + State = 949; Match(ON); - State = 948; + State = 950; expression(0); } break; @@ -6062,11 +6082,11 @@ public JoinClauseContext joinClause() { _localctx = new CrossJoinContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 950; + State = 952; Match(CROSS); - State = 951; + State = 953; Match(JOIN); - State = 952; + State = 954; tablePrimary(); } break; @@ -6074,11 +6094,11 @@ public JoinClauseContext joinClause() { _localctx = new CrossApplyContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 953; + State = 955; Match(CROSS); - State = 954; + State = 956; Match(APPLY); - State = 955; + State = 957; tablePrimary(); } break; @@ -6086,11 +6106,11 @@ public JoinClauseContext joinClause() { _localctx = new OuterApplyContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 956; + State = 958; Match(OUTER); - State = 957; + State = 959; Match(APPLY); - State = 958; + State = 960; tablePrimary(); } break; @@ -6169,7 +6189,7 @@ public JoinTypeContext joinType() { EnterRule(_localctx, 104, RULE_joinType); int _la; try { - State = 976; + State = 978; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INNER: @@ -6177,12 +6197,12 @@ public JoinTypeContext joinType() { _localctx = new InnerJoinContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 962; + State = 964; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==INNER) { { - State = 961; + State = 963; Match(INNER); } } @@ -6193,14 +6213,14 @@ public JoinTypeContext joinType() { _localctx = new LeftJoinContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 964; - Match(LEFT); State = 966; + Match(LEFT); + State = 968; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 965; + State = 967; Match(OUTER); } } @@ -6211,14 +6231,14 @@ public JoinTypeContext joinType() { _localctx = new RightJoinContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 968; - Match(RIGHT); State = 970; + Match(RIGHT); + State = 972; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 969; + State = 971; Match(OUTER); } } @@ -6229,14 +6249,14 @@ public JoinTypeContext joinType() { _localctx = new FullJoinContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 972; - Match(FULL); State = 974; + Match(FULL); + State = 976; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 973; + State = 975; Match(OUTER); } } @@ -6283,9 +6303,9 @@ public WhereClauseContext whereClause() { try { EnterOuterAlt(_localctx, 1); { - State = 978; + State = 980; Match(WHERE); - State = 979; + State = 981; expression(0); } } @@ -6334,25 +6354,25 @@ public OrderByClauseContext orderByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 981; + State = 983; Match(ORDER); - State = 982; + State = 984; Match(BY); - State = 983; + State = 985; orderByItem(); - State = 988; + State = 990; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 984; + State = 986; Match(COMMA); - State = 985; + State = 987; orderByItem(); } } - State = 990; + State = 992; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -6397,14 +6417,14 @@ public OrderByItemContext orderByItem() { try { EnterOuterAlt(_localctx, 1); { - State = 991; - expression(0); State = 993; + expression(0); + State = 995; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 992; + State = 994; _localctx.dir = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==ASC || _la==DESC) ) { @@ -6847,7 +6867,7 @@ private ExpressionContext expression(int _p) { int _alt; EnterOuterAlt(_localctx, 1); { - State = 1001; + State = 1003; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case PLUS: @@ -6857,7 +6877,7 @@ private ExpressionContext expression(int _p) { Context = _localctx; _prevctx = _localctx; - State = 996; + State = 998; ((NegateExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==PLUS || _la==MINUS) ) { @@ -6867,7 +6887,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 997; + State = 999; expression(19); } break; @@ -6877,7 +6897,7 @@ private ExpressionContext expression(int _p) { _localctx = new NotExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 998; + State = 1000; ((NotExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==NOT || _la==BNOT) ) { @@ -6887,7 +6907,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 999; + State = 1001; expression(7); } break; @@ -6931,7 +6951,7 @@ private ExpressionContext expression(int _p) { _localctx = new PrimaryExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 1000; + State = 1002; primary(); } break; @@ -6939,7 +6959,7 @@ private ExpressionContext expression(int _p) { throw new NoViableAltException(this); } Context.Stop = TokenStream.LT(-1); - State = 1087; + State = 1089; ErrorHandler.Sync(this); _alt = Interpreter.AdaptivePredict(TokenStream,133,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { @@ -6948,7 +6968,7 @@ private ExpressionContext expression(int _p) { TriggerExitRuleEvent(); _prevctx = _localctx; { - State = 1085; + State = 1087; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,132,Context) ) { case 1: @@ -6956,11 +6976,11 @@ private ExpressionContext expression(int _p) { _localctx = new PowExprContext(new ExpressionContext(_parentctx, _parentState)); ((PowExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1003; + State = 1005; if (!(Precpred(Context, 20))) throw new FailedPredicateException(this, "Precpred(Context, 20)"); - State = 1004; + State = 1006; Match(CARET); - State = 1005; + State = 1007; ((PowExprContext)_localctx).right = expression(21); } break; @@ -6969,9 +6989,9 @@ private ExpressionContext expression(int _p) { _localctx = new MulDivExprContext(new ExpressionContext(_parentctx, _parentState)); ((MulDivExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1006; + State = 1008; if (!(Precpred(Context, 18))) throw new FailedPredicateException(this, "Precpred(Context, 18)"); - State = 1007; + State = 1009; ((MulDivExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==STAR || _la==SLASH) ) { @@ -6981,7 +7001,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 1008; + State = 1010; ((MulDivExprContext)_localctx).right = expression(19); } break; @@ -6990,11 +7010,11 @@ private ExpressionContext expression(int _p) { _localctx = new IntDivExprContext(new ExpressionContext(_parentctx, _parentState)); ((IntDivExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1009; + State = 1011; if (!(Precpred(Context, 17))) throw new FailedPredicateException(this, "Precpred(Context, 17)"); - State = 1010; + State = 1012; ((IntDivExprContext)_localctx).op = Match(BACKSLASH); - State = 1011; + State = 1013; ((IntDivExprContext)_localctx).right = expression(18); } break; @@ -7003,11 +7023,11 @@ private ExpressionContext expression(int _p) { _localctx = new ModExprContext(new ExpressionContext(_parentctx, _parentState)); ((ModExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1012; + State = 1014; if (!(Precpred(Context, 16))) throw new FailedPredicateException(this, "Precpred(Context, 16)"); - State = 1013; + State = 1015; ((ModExprContext)_localctx).op = Match(MOD); - State = 1014; + State = 1016; ((ModExprContext)_localctx).right = expression(17); } break; @@ -7016,9 +7036,9 @@ private ExpressionContext expression(int _p) { _localctx = new AddSubExprContext(new ExpressionContext(_parentctx, _parentState)); ((AddSubExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1015; + State = 1017; if (!(Precpred(Context, 15))) throw new FailedPredicateException(this, "Precpred(Context, 15)"); - State = 1016; + State = 1018; ((AddSubExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==PLUS || _la==MINUS) ) { @@ -7028,7 +7048,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 1017; + State = 1019; ((AddSubExprContext)_localctx).right = expression(16); } break; @@ -7037,11 +7057,11 @@ private ExpressionContext expression(int _p) { _localctx = new ConcatExprContext(new ExpressionContext(_parentctx, _parentState)); ((ConcatExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1018; + State = 1020; if (!(Precpred(Context, 14))) throw new FailedPredicateException(this, "Precpred(Context, 14)"); - State = 1019; + State = 1021; ((ConcatExprContext)_localctx).op = Match(AMP); - State = 1020; + State = 1022; ((ConcatExprContext)_localctx).right = expression(15); } break; @@ -7050,9 +7070,9 @@ private ExpressionContext expression(int _p) { _localctx = new ComparisonExprContext(new ExpressionContext(_parentctx, _parentState)); ((ComparisonExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1021; + State = 1023; if (!(Precpred(Context, 13))) throw new FailedPredicateException(this, "Precpred(Context, 13)"); - State = 1022; + State = 1024; ((ComparisonExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(((((_la - 129)) & ~0x3f) == 0 && ((1L << (_la - 129)) & 63L) != 0)) ) { @@ -7062,7 +7082,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 1023; + State = 1025; ((ComparisonExprContext)_localctx).right = expression(14); } break; @@ -7071,25 +7091,25 @@ private ExpressionContext expression(int _p) { _localctx = new BetweenExprContext(new ExpressionContext(_parentctx, _parentState)); ((BetweenExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1024; - if (!(Precpred(Context, 12))) throw new FailedPredicateException(this, "Precpred(Context, 12)"); State = 1026; + if (!(Precpred(Context, 12))) throw new FailedPredicateException(this, "Precpred(Context, 12)"); + State = 1028; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 1025; + State = 1027; ((BetweenExprContext)_localctx).not = Match(NOT); } } - State = 1028; + State = 1030; Match(BETWEEN); - State = 1029; + State = 1031; ((BetweenExprContext)_localctx).lo = expression(0); - State = 1030; + State = 1032; Match(AND); - State = 1031; + State = 1033; ((BetweenExprContext)_localctx).hi = expression(13); } break; @@ -7098,21 +7118,21 @@ private ExpressionContext expression(int _p) { _localctx = new LikeExprContext(new ExpressionContext(_parentctx, _parentState)); ((LikeExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1033; - if (!(Precpred(Context, 11))) throw new FailedPredicateException(this, "Precpred(Context, 11)"); State = 1035; + if (!(Precpred(Context, 11))) throw new FailedPredicateException(this, "Precpred(Context, 11)"); + State = 1037; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 1034; + State = 1036; ((LikeExprContext)_localctx).not = Match(NOT); } } - State = 1037; + State = 1039; Match(LIKE); - State = 1038; + State = 1040; ((LikeExprContext)_localctx).right = expression(12); } break; @@ -7121,9 +7141,9 @@ private ExpressionContext expression(int _p) { _localctx = new AndExprContext(new ExpressionContext(_parentctx, _parentState)); ((AndExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1039; + State = 1041; if (!(Precpred(Context, 6))) throw new FailedPredicateException(this, "Precpred(Context, 6)"); - State = 1040; + State = 1042; ((AndExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==AND || _la==BAND) ) { @@ -7133,7 +7153,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 1041; + State = 1043; ((AndExprContext)_localctx).right = expression(7); } break; @@ -7142,9 +7162,9 @@ private ExpressionContext expression(int _p) { _localctx = new OrExprContext(new ExpressionContext(_parentctx, _parentState)); ((OrExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1042; + State = 1044; if (!(Precpred(Context, 5))) throw new FailedPredicateException(this, "Precpred(Context, 5)"); - State = 1043; + State = 1045; ((OrExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==OR || _la==BOR) ) { @@ -7154,7 +7174,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 1044; + State = 1046; ((OrExprContext)_localctx).right = expression(6); } break; @@ -7163,9 +7183,9 @@ private ExpressionContext expression(int _p) { _localctx = new XorExprContext(new ExpressionContext(_parentctx, _parentState)); ((XorExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1045; + State = 1047; if (!(Precpred(Context, 4))) throw new FailedPredicateException(this, "Precpred(Context, 4)"); - State = 1046; + State = 1048; ((XorExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==XOR || _la==BXOR) ) { @@ -7175,7 +7195,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 1047; + State = 1049; ((XorExprContext)_localctx).right = expression(5); } break; @@ -7184,11 +7204,11 @@ private ExpressionContext expression(int _p) { _localctx = new EqvExprContext(new ExpressionContext(_parentctx, _parentState)); ((EqvExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1048; + State = 1050; if (!(Precpred(Context, 3))) throw new FailedPredicateException(this, "Precpred(Context, 3)"); - State = 1049; + State = 1051; ((EqvExprContext)_localctx).op = Match(EQV); - State = 1050; + State = 1052; ((EqvExprContext)_localctx).right = expression(4); } break; @@ -7197,11 +7217,11 @@ private ExpressionContext expression(int _p) { _localctx = new ImpExprContext(new ExpressionContext(_parentctx, _parentState)); ((ImpExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1051; + State = 1053; if (!(Precpred(Context, 2))) throw new FailedPredicateException(this, "Precpred(Context, 2)"); - State = 1052; + State = 1054; ((ImpExprContext)_localctx).op = Match(IMP); - State = 1053; + State = 1055; ((ImpExprContext)_localctx).right = expression(3); } break; @@ -7210,25 +7230,25 @@ private ExpressionContext expression(int _p) { _localctx = new InSubqueryExprContext(new ExpressionContext(_parentctx, _parentState)); ((InSubqueryExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1054; - if (!(Precpred(Context, 10))) throw new FailedPredicateException(this, "Precpred(Context, 10)"); State = 1056; + if (!(Precpred(Context, 10))) throw new FailedPredicateException(this, "Precpred(Context, 10)"); + State = 1058; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 1055; + State = 1057; ((InSubqueryExprContext)_localctx).not = Match(NOT); } } - State = 1058; + State = 1060; Match(IN); - State = 1059; + State = 1061; Match(LPAREN); - State = 1060; + State = 1062; ((InSubqueryExprContext)_localctx).sub = queryExpression(); - State = 1061; + State = 1063; Match(RPAREN); } break; @@ -7237,43 +7257,43 @@ private ExpressionContext expression(int _p) { _localctx = new InExprContext(new ExpressionContext(_parentctx, _parentState)); ((InExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1063; - if (!(Precpred(Context, 9))) throw new FailedPredicateException(this, "Precpred(Context, 9)"); State = 1065; + if (!(Precpred(Context, 9))) throw new FailedPredicateException(this, "Precpred(Context, 9)"); + State = 1067; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 1064; + State = 1066; ((InExprContext)_localctx).not = Match(NOT); } } - State = 1067; + State = 1069; Match(IN); - State = 1068; + State = 1070; Match(LPAREN); - State = 1069; + State = 1071; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); - State = 1074; + State = 1076; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 1070; + State = 1072; Match(COMMA); - State = 1071; + State = 1073; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); } } - State = 1076; + State = 1078; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 1077; + State = 1079; Match(RPAREN); } break; @@ -7282,28 +7302,28 @@ private ExpressionContext expression(int _p) { _localctx = new IsNullExprContext(new ExpressionContext(_parentctx, _parentState)); ((IsNullExprContext)_localctx).operand = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 1079; + State = 1081; if (!(Precpred(Context, 8))) throw new FailedPredicateException(this, "Precpred(Context, 8)"); - State = 1080; - Match(IS); State = 1082; + Match(IS); + State = 1084; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 1081; + State = 1083; ((IsNullExprContext)_localctx).not = Match(NOT); } } - State = 1084; + State = 1086; Match(NULL); } break; } } } - State = 1089; + State = 1091; ErrorHandler.Sync(this); _alt = Interpreter.AdaptivePredict(TokenStream,133,Context); } @@ -7449,14 +7469,14 @@ public PrimaryContext primary() { PrimaryContext _localctx = new PrimaryContext(Context, State); EnterRule(_localctx, 114, RULE_primary); try { - State = 1109; + State = 1111; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,134,Context) ) { case 1: _localctx = new LiteralPrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 1090; + State = 1092; literal(); } break; @@ -7464,7 +7484,7 @@ public PrimaryContext primary() { _localctx = new CasePrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 1091; + State = 1093; caseExpression(); } break; @@ -7472,7 +7492,7 @@ public PrimaryContext primary() { _localctx = new FunctionCallPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 1092; + State = 1094; functionCall(); } break; @@ -7480,7 +7500,7 @@ public PrimaryContext primary() { _localctx = new ColumnPrimaryContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 1093; + State = 1095; columnRef(); } break; @@ -7488,7 +7508,7 @@ public PrimaryContext primary() { _localctx = new ParamPrimaryContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 1094; + State = 1096; Match(PARAM); } break; @@ -7496,7 +7516,7 @@ public PrimaryContext primary() { _localctx = new SystemVariablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 1095; + State = 1097; Match(SYSVAR); } break; @@ -7504,13 +7524,13 @@ public PrimaryContext primary() { _localctx = new ExistsPrimaryContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 1096; + State = 1098; Match(EXISTS); - State = 1097; + State = 1099; Match(LPAREN); - State = 1098; + State = 1100; queryExpression(); - State = 1099; + State = 1101; Match(RPAREN); } break; @@ -7518,11 +7538,11 @@ public PrimaryContext primary() { _localctx = new ScalarSubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 1101; + State = 1103; Match(LPAREN); - State = 1102; + State = 1104; queryExpression(); - State = 1103; + State = 1105; Match(RPAREN); } break; @@ -7530,11 +7550,11 @@ public PrimaryContext primary() { _localctx = new ParenPrimaryContext(_localctx); EnterOuterAlt(_localctx, 9); { - State = 1105; + State = 1107; Match(LPAREN); - State = 1106; + State = 1108; expression(0); - State = 1107; + State = 1109; Match(RPAREN); } break; @@ -7590,45 +7610,45 @@ public CaseExpressionContext caseExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 1111; - Match(CASE); State = 1113; + Match(CASE); + State = 1115; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 284775660683520L) != 0) || ((((_la - 103)) & ~0x3f) == 0 && ((1L << (_la - 103)) & 281341858414589L) != 0)) { { - State = 1112; + State = 1114; _localctx.operand = expression(0); } } - State = 1116; + State = 1118; ErrorHandler.Sync(this); _la = TokenStream.LA(1); do { { { - State = 1115; + State = 1117; caseWhen(); } } - State = 1118; + State = 1120; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } while ( _la==WHEN ); - State = 1122; + State = 1124; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ELSE) { { - State = 1120; + State = 1122; Match(ELSE); - State = 1121; + State = 1123; _localctx.elseResult = expression(0); } } - State = 1124; + State = 1126; Match(END); } } @@ -7674,13 +7694,13 @@ public CaseWhenContext caseWhen() { try { EnterOuterAlt(_localctx, 1); { - State = 1126; + State = 1128; Match(WHEN); - State = 1127; + State = 1129; _localctx.condition = expression(0); - State = 1128; + State = 1130; Match(THEN); - State = 1129; + State = 1131; _localctx.result = expression(0); } } @@ -7753,16 +7773,16 @@ public FunctionCallContext functionCall() { try { EnterOuterAlt(_localctx, 1); { - State = 1131; + State = 1133; _localctx.name = functionName(); - State = 1132; + State = 1134; Match(LPAREN); - State = 1145; + State = 1147; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: { - State = 1133; + State = 1135; _localctx.star = Match(STAR); } break; @@ -7809,31 +7829,31 @@ public FunctionCallContext functionCall() { case IDENTIFIER: { { - State = 1135; + State = 1137; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==DISTINCT) { { - State = 1134; + State = 1136; _localctx.distinct = Match(DISTINCT); } } - State = 1137; + State = 1139; expression(0); - State = 1142; + State = 1144; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 1138; + State = 1140; Match(COMMA); - State = 1139; + State = 1141; expression(0); } } - State = 1144; + State = 1146; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -7845,56 +7865,56 @@ public FunctionCallContext functionCall() { default: break; } - State = 1147; - Match(RPAREN); State = 1149; + Match(RPAREN); + State = 1151; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,141,Context) ) { case 1: { - State = 1148; + State = 1150; withinGroup(); } break; } - State = 1152; + State = 1154; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,142,Context) ) { case 1: { - State = 1151; + State = 1153; filterClause(); } break; } - State = 1162; + State = 1164; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,145,Context) ) { case 1: { - State = 1155; + State = 1157; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==FROM) { { - State = 1154; + State = 1156; nthRowFrom(); } } - State = 1158; + State = 1160; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==IGNORE || _la==RESPECT) { { - State = 1157; + State = 1159; nullTreatment(); } } - State = 1160; + State = 1162; Match(OVER); - State = 1161; + State = 1163; windowSpecification(); } break; @@ -7939,7 +7959,7 @@ public FunctionNameContext functionName() { FunctionNameContext _localctx = new FunctionNameContext(Context, State); EnterRule(_localctx, 122, RULE_functionName); try { - State = 1170; + State = 1172; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case RANGE: @@ -7961,42 +7981,42 @@ public FunctionNameContext functionName() { case IDENTIFIER: EnterOuterAlt(_localctx, 1); { - State = 1164; + State = 1166; identifier(); } break; case LEFT: EnterOuterAlt(_localctx, 2); { - State = 1165; + State = 1167; Match(LEFT); } break; case RIGHT: EnterOuterAlt(_localctx, 3); { - State = 1166; + State = 1168; Match(RIGHT); } break; case ASC: EnterOuterAlt(_localctx, 4); { - State = 1167; + State = 1169; Match(ASC); } break; case FIRST: EnterOuterAlt(_localctx, 5); { - State = 1168; + State = 1170; Match(FIRST); } break; case PARTITION: EnterOuterAlt(_localctx, 6); { - State = 1169; + State = 1171; Match(PARTITION); } break; @@ -8045,19 +8065,19 @@ public ColumnRefContext columnRef() { try { EnterOuterAlt(_localctx, 1); { - State = 1175; + State = 1177; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,147,Context) ) { case 1: { - State = 1172; + State = 1174; _localctx.qualifier = identifier(); - State = 1173; + State = 1175; Match(DOT); } break; } - State = 1177; + State = 1179; _localctx.name = identifier(); } } @@ -8097,27 +8117,27 @@ public IdentifierContext identifier() { IdentifierContext _localctx = new IdentifierContext(Context, State); EnterRule(_localctx, 126, RULE_identifier); try { - State = 1183; + State = 1185; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case IDENTIFIER: EnterOuterAlt(_localctx, 1); { - State = 1179; + State = 1181; Match(IDENTIFIER); } break; case BRACKET_ID: EnterOuterAlt(_localctx, 2); { - State = 1180; + State = 1182; Match(BRACKET_ID); } break; case BACKTICK_ID: EnterOuterAlt(_localctx, 3); { - State = 1181; + State = 1183; Match(BACKTICK_ID); } break; @@ -8137,7 +8157,7 @@ public IdentifierContext identifier() { case FILTER: EnterOuterAlt(_localctx, 4); { - State = 1182; + State = 1184; nonReservedKeyword(); } break; @@ -8264,14 +8284,14 @@ public LiteralContext literal() { LiteralContext _localctx = new LiteralContext(Context, State); EnterRule(_localctx, 128, RULE_literal); try { - State = 1194; + State = 1196; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: _localctx = new IntLiteralContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 1185; + State = 1187; Match(INTEGER_LITERAL); } break; @@ -8279,7 +8299,7 @@ public LiteralContext literal() { _localctx = new NumberLiteralContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 1186; + State = 1188; Match(NUMBER_LITERAL); } break; @@ -8287,7 +8307,7 @@ public LiteralContext literal() { _localctx = new HexLiteralContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 1187; + State = 1189; Match(HEX_LITERAL); } break; @@ -8295,7 +8315,7 @@ public LiteralContext literal() { _localctx = new StringLiteralContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 1188; + State = 1190; Match(STRING_LITERAL); } break; @@ -8303,7 +8323,7 @@ public LiteralContext literal() { _localctx = new DateLiteralContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 1189; + State = 1191; Match(DATE_LITERAL); } break; @@ -8311,7 +8331,7 @@ public LiteralContext literal() { _localctx = new GuidLiteralContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 1190; + State = 1192; Match(GUID_LITERAL); } break; @@ -8319,7 +8339,7 @@ public LiteralContext literal() { _localctx = new TrueLiteralContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 1191; + State = 1193; Match(TRUE); } break; @@ -8327,7 +8347,7 @@ public LiteralContext literal() { _localctx = new FalseLiteralContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 1192; + State = 1194; Match(FALSE); } break; @@ -8335,7 +8355,7 @@ public LiteralContext literal() { _localctx = new NullLiteralContext(_localctx); EnterOuterAlt(_localctx, 9); { - State = 1193; + State = 1195; Match(NULL); } break; @@ -8409,21 +8429,21 @@ public TransactionStatementContext transactionStatement() { EnterRule(_localctx, 130, RULE_transactionStatement); int _la; try { - State = 1208; + State = 1210; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BEGIN: _localctx = new BeginTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 1196; - Match(BEGIN); State = 1198; + Match(BEGIN); + State = 1200; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1197; + State = 1199; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -8441,14 +8461,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new CommitTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 1200; - Match(COMMIT); State = 1202; + Match(COMMIT); + State = 1204; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1201; + State = 1203; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -8466,14 +8486,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new RollbackTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 1204; - Match(ROLLBACK); State = 1206; + Match(ROLLBACK); + State = 1208; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1205; + State = 1207; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -8527,9 +8547,9 @@ public StandaloneExpressionContext standaloneExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 1210; + State = 1212; expression(0); - State = 1211; + State = 1213; Match(Eof); } } @@ -8588,61 +8608,61 @@ public WindowSpecificationContext windowSpecification() { try { EnterOuterAlt(_localctx, 1); { - State = 1213; + State = 1215; Match(LPAREN); - State = 1224; + State = 1226; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==PARTITION) { { - State = 1214; + State = 1216; Match(PARTITION); - State = 1215; + State = 1217; Match(BY); - State = 1216; + State = 1218; _localctx._expression = expression(0); _localctx._partition.Add(_localctx._expression); - State = 1221; + State = 1223; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 1217; + State = 1219; Match(COMMA); - State = 1218; + State = 1220; _localctx._expression = expression(0); _localctx._partition.Add(_localctx._expression); } } - State = 1223; + State = 1225; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } } } - State = 1227; + State = 1229; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ORDER) { { - State = 1226; + State = 1228; orderByClause(); } } - State = 1230; + State = 1232; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (((((_la - 49)) & ~0x3f) == 0 && ((1L << (_la - 49)) & 1729382256910270465L) != 0)) { { - State = 1229; + State = 1231; windowFrame(); } } - State = 1232; + State = 1234; Match(RPAREN); } } @@ -8681,7 +8701,7 @@ public ClusteredOptionContext clusteredOption() { try { EnterOuterAlt(_localctx, 1); { - State = 1234; + State = 1236; _la = TokenStream.LA(1); if ( !(_la==CLUSTERED || _la==NONCLUSTERED) ) { ErrorHandler.RecoverInline(this); @@ -8744,7 +8764,7 @@ public WindowFrameContext windowFrame() { try { EnterOuterAlt(_localctx, 1); { - State = 1236; + State = 1238; _localctx.unit = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(((((_la - 49)) & ~0x3f) == 0 && ((1L << (_la - 49)) & 1729382256910270465L) != 0)) ) { @@ -8754,18 +8774,18 @@ public WindowFrameContext windowFrame() { ErrorHandler.ReportMatch(this); Consume(); } - State = 1243; + State = 1245; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BETWEEN: { - State = 1237; + State = 1239; Match(BETWEEN); - State = 1238; + State = 1240; _localctx.start = frameBound(); - State = 1239; + State = 1241; Match(AND); - State = 1240; + State = 1242; _localctx.end = frameBound(); } break; @@ -8810,21 +8830,21 @@ public WindowFrameContext windowFrame() { case BACKTICK_ID: case IDENTIFIER: { - State = 1242; + State = 1244; _localctx.start = frameBound(); } break; default: throw new NoViableAltException(this); } - State = 1247; + State = 1249; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==EXCLUDE) { { - State = 1245; + State = 1247; Match(EXCLUDE); - State = 1246; + State = 1248; _localctx.exclusion = frameExclusion(); } } @@ -8872,15 +8892,15 @@ public FrameBoundContext frameBound() { EnterRule(_localctx, 140, RULE_frameBound); int _la; try { - State = 1256; + State = 1258; ErrorHandler.Sync(this); switch ( Interpreter.AdaptivePredict(TokenStream,160,Context) ) { case 1: EnterOuterAlt(_localctx, 1); { - State = 1249; + State = 1251; Match(UNBOUNDED); - State = 1250; + State = 1252; _localctx.direction = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==PRECEDING || _la==FOLLOWING) ) { @@ -8895,18 +8915,18 @@ public FrameBoundContext frameBound() { case 2: EnterOuterAlt(_localctx, 2); { - State = 1251; + State = 1253; Match(CURRENT); - State = 1252; + State = 1254; Match(ROW); } break; case 3: EnterOuterAlt(_localctx, 3); { - State = 1253; + State = 1255; _localctx.offset = expression(0); - State = 1254; + State = 1256; _localctx.direction = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==PRECEDING || _la==FOLLOWING) ) { @@ -8956,38 +8976,38 @@ public FrameExclusionContext frameExclusion() { FrameExclusionContext _localctx = new FrameExclusionContext(Context, State); EnterRule(_localctx, 142, RULE_frameExclusion); try { - State = 1264; + State = 1266; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case CURRENT: EnterOuterAlt(_localctx, 1); { - State = 1258; + State = 1260; Match(CURRENT); - State = 1259; + State = 1261; Match(ROW); } break; case GROUP: EnterOuterAlt(_localctx, 2); { - State = 1260; + State = 1262; Match(GROUP); } break; case TIES: EnterOuterAlt(_localctx, 3); { - State = 1261; + State = 1263; Match(TIES); } break; case NO: EnterOuterAlt(_localctx, 4); { - State = 1262; + State = 1264; Match(NO); - State = 1263; + State = 1265; Match(OTHERS); } break; @@ -9042,7 +9062,7 @@ public NonReservedKeywordContext nonReservedKeyword() { try { EnterOuterAlt(_localctx, 1); { - State = 1266; + State = 1268; _la = TokenStream.LA(1); if ( !(((((_la - 108)) & ~0x3f) == 0 && ((1L << (_la - 108)) & 16383L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -9093,15 +9113,15 @@ public FilterClauseContext filterClause() { try { EnterOuterAlt(_localctx, 1); { - State = 1268; + State = 1270; Match(FILTER); - State = 1269; + State = 1271; Match(LPAREN); - State = 1270; + State = 1272; Match(WHERE); - State = 1271; + State = 1273; _localctx.condition = expression(0); - State = 1272; + State = 1274; Match(RPAREN); } } @@ -9144,15 +9164,15 @@ public WithinGroupContext withinGroup() { try { EnterOuterAlt(_localctx, 1); { - State = 1274; + State = 1276; Match(WITHIN); - State = 1275; + State = 1277; Match(GROUP); - State = 1276; + State = 1278; Match(LPAREN); - State = 1277; + State = 1279; orderByClause(); - State = 1278; + State = 1280; Match(RPAREN); } } @@ -9193,9 +9213,9 @@ public NthRowFromContext nthRowFrom() { try { EnterOuterAlt(_localctx, 1); { - State = 1280; + State = 1282; Match(FROM); - State = 1281; + State = 1283; _localctx.edge = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==FIRST || _la==LAST) ) { @@ -9244,7 +9264,7 @@ public NullTreatmentContext nullTreatment() { try { EnterOuterAlt(_localctx, 1); { - State = 1283; + State = 1285; _localctx.treatment = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==IGNORE || _la==RESPECT) ) { @@ -9254,7 +9274,7 @@ public NullTreatmentContext nullTreatment() { ErrorHandler.ReportMatch(this); Consume(); } - State = 1284; + State = 1286; Match(NULLS); } } @@ -9299,7 +9319,7 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { } private static int[] _serializedATN = { - 4,1,153,1287,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2, + 4,1,153,1289,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2, 7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14, 2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21, 2,22,7,22,2,23,7,23,2,24,7,24,2,25,7,25,2,26,7,26,2,27,7,27,2,28,7,28, @@ -9330,449 +9350,451 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { 386,8,17,10,17,12,17,389,9,17,1,17,1,17,3,17,393,8,17,1,17,1,17,1,17,1, 17,1,17,1,17,1,17,3,17,402,8,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17, 1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17, - 1,17,1,17,1,17,3,17,429,8,17,1,18,1,18,1,18,3,18,434,8,18,1,19,1,19,3, - 19,438,8,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,5,19,448,8,19,10,19, - 12,19,451,9,19,1,19,1,19,1,19,3,19,456,8,19,1,20,1,20,1,20,1,20,1,20,1, - 20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,3,20,473,8,20,1,21,1,21, - 3,21,477,8,21,1,22,1,22,1,22,1,22,1,22,3,22,484,8,22,1,23,1,23,1,23,3, - 23,489,8,23,1,23,5,23,492,8,23,10,23,12,23,495,9,23,1,24,1,24,1,24,1,24, - 1,24,1,25,1,25,3,25,504,8,25,1,25,3,25,507,8,25,1,25,3,25,510,8,25,1,25, - 1,25,1,25,1,25,3,25,516,8,25,1,25,1,25,3,25,520,8,25,1,26,3,26,523,8,26, - 1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,536,8,27,1, - 27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,545,8,27,1,27,1,27,1,27,3,27,550, - 8,27,1,27,1,27,3,27,554,8,27,1,27,1,27,3,27,558,8,27,1,27,1,27,3,27,562, - 8,27,1,27,1,27,1,27,1,27,1,27,1,27,5,27,570,8,27,10,27,12,27,573,9,27, - 1,27,1,27,3,27,577,8,27,1,27,5,27,580,8,27,10,27,12,27,583,9,27,1,27,1, - 27,1,27,1,27,1,27,3,27,590,8,27,1,27,1,27,3,27,594,8,27,3,27,596,8,27, - 1,28,1,28,3,28,600,8,28,1,28,1,28,1,28,3,28,605,8,28,1,28,1,28,1,28,1, - 28,5,28,611,8,28,10,28,12,28,614,9,28,1,28,1,28,1,28,1,28,3,28,620,8,28, - 1,28,1,28,3,28,624,8,28,1,28,1,28,1,28,1,28,5,28,630,8,28,10,28,12,28, - 633,9,28,1,28,1,28,1,28,1,28,3,28,639,8,28,1,28,1,28,1,28,1,28,3,28,645, - 8,28,1,28,1,28,1,28,1,28,5,28,651,8,28,10,28,12,28,654,9,28,1,28,1,28, - 1,28,1,28,1,28,1,28,1,28,5,28,663,8,28,10,28,12,28,666,9,28,1,28,1,28, - 3,28,670,8,28,1,28,5,28,673,8,28,10,28,12,28,676,9,28,1,28,1,28,3,28,680, - 8,28,1,28,1,28,1,28,1,28,1,28,3,28,687,8,28,1,29,1,29,1,29,1,29,1,29,5, - 29,694,8,29,10,29,12,29,697,9,29,1,30,1,30,1,30,1,30,1,30,1,30,3,30,705, - 8,30,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31,3,31,715,8,31,1,32,1,32,1, - 32,1,32,1,32,1,32,1,32,5,32,724,8,32,10,32,12,32,727,9,32,1,32,1,32,3, - 32,731,8,32,1,32,1,32,1,32,1,32,5,32,737,8,32,10,32,12,32,740,9,32,1,32, - 3,32,743,8,32,1,32,1,32,3,32,747,8,32,1,33,1,33,1,33,1,33,5,33,753,8,33, - 10,33,12,33,756,9,33,1,33,1,33,1,34,1,34,3,34,762,8,34,1,35,1,35,1,35, - 1,35,5,35,768,8,35,10,35,12,35,771,9,35,1,35,3,35,774,8,35,1,35,3,35,777, - 8,35,1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,36,5,36,788,8,36,10,36, - 12,36,791,9,36,3,36,793,8,36,1,37,1,37,3,37,797,8,37,1,37,1,37,3,37,801, - 8,37,1,38,1,38,3,38,805,8,38,1,38,3,38,808,8,38,1,38,1,38,1,38,3,38,813, - 8,38,1,38,3,38,816,8,38,1,38,3,38,819,8,38,1,38,3,38,822,8,38,1,38,3,38, - 825,8,38,1,39,1,39,1,40,1,40,1,40,1,40,1,40,5,40,834,8,40,10,40,12,40, - 837,9,40,1,41,1,41,1,41,1,42,1,42,1,42,1,42,5,42,846,8,42,10,42,12,42, - 849,9,42,1,42,3,42,852,8,42,1,43,1,43,1,43,1,43,1,43,1,43,3,43,860,8,43, - 1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,3,44,871,8,44,1,44,1,44,1, - 44,1,44,1,44,1,44,3,44,879,8,44,1,45,1,45,1,46,1,46,1,46,1,46,5,46,887, - 8,46,10,46,12,46,890,9,46,3,46,892,8,46,1,47,1,47,1,47,1,47,1,47,1,47, - 3,47,900,8,47,1,47,3,47,903,8,47,3,47,905,8,47,1,48,1,48,1,48,1,48,5,48, - 911,8,48,10,48,12,48,914,9,48,1,49,1,49,5,49,918,8,49,10,49,12,49,921, - 9,49,1,50,1,50,3,50,925,8,50,1,50,3,50,928,8,50,1,50,1,50,1,50,1,50,3, - 50,934,8,50,1,50,3,50,937,8,50,1,50,1,50,1,50,1,50,3,50,943,8,50,1,51, - 1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51, - 3,51,960,8,51,1,52,3,52,963,8,52,1,52,1,52,3,52,967,8,52,1,52,1,52,3,52, - 971,8,52,1,52,1,52,3,52,975,8,52,3,52,977,8,52,1,53,1,53,1,53,1,54,1,54, - 1,54,1,54,1,54,5,54,987,8,54,10,54,12,54,990,9,54,1,55,1,55,3,55,994,8, - 55,1,56,1,56,1,56,1,56,1,56,1,56,3,56,1002,8,56,1,56,1,56,1,56,1,56,1, + 1,17,1,17,1,17,3,17,429,8,17,1,18,1,18,1,18,1,18,1,18,3,18,436,8,18,1, + 19,1,19,3,19,440,8,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,5,19,450, + 8,19,10,19,12,19,453,9,19,1,19,1,19,1,19,3,19,458,8,19,1,20,1,20,1,20, + 1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,3,20,475,8, + 20,1,21,1,21,3,21,479,8,21,1,22,1,22,1,22,1,22,1,22,3,22,486,8,22,1,23, + 1,23,1,23,3,23,491,8,23,1,23,5,23,494,8,23,10,23,12,23,497,9,23,1,24,1, + 24,1,24,1,24,1,24,1,25,1,25,3,25,506,8,25,1,25,3,25,509,8,25,1,25,3,25, + 512,8,25,1,25,1,25,1,25,1,25,3,25,518,8,25,1,25,1,25,3,25,522,8,25,1,26, + 3,26,525,8,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3, + 27,538,8,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,547,8,27,1,27,1,27, + 1,27,3,27,552,8,27,1,27,1,27,3,27,556,8,27,1,27,1,27,3,27,560,8,27,1,27, + 1,27,3,27,564,8,27,1,27,1,27,1,27,1,27,1,27,1,27,5,27,572,8,27,10,27,12, + 27,575,9,27,1,27,1,27,3,27,579,8,27,1,27,5,27,582,8,27,10,27,12,27,585, + 9,27,1,27,1,27,1,27,1,27,1,27,3,27,592,8,27,1,27,1,27,3,27,596,8,27,3, + 27,598,8,27,1,28,1,28,3,28,602,8,28,1,28,1,28,1,28,3,28,607,8,28,1,28, + 1,28,1,28,1,28,5,28,613,8,28,10,28,12,28,616,9,28,1,28,1,28,1,28,1,28, + 3,28,622,8,28,1,28,1,28,3,28,626,8,28,1,28,1,28,1,28,1,28,5,28,632,8,28, + 10,28,12,28,635,9,28,1,28,1,28,1,28,1,28,3,28,641,8,28,1,28,1,28,1,28, + 1,28,3,28,647,8,28,1,28,1,28,1,28,1,28,5,28,653,8,28,10,28,12,28,656,9, + 28,1,28,1,28,1,28,1,28,1,28,1,28,1,28,5,28,665,8,28,10,28,12,28,668,9, + 28,1,28,1,28,3,28,672,8,28,1,28,5,28,675,8,28,10,28,12,28,678,9,28,1,28, + 1,28,3,28,682,8,28,1,28,1,28,1,28,1,28,1,28,3,28,689,8,28,1,29,1,29,1, + 29,1,29,1,29,5,29,696,8,29,10,29,12,29,699,9,29,1,30,1,30,1,30,1,30,1, + 30,1,30,3,30,707,8,30,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31,3,31,717, + 8,31,1,32,1,32,1,32,1,32,1,32,1,32,1,32,5,32,726,8,32,10,32,12,32,729, + 9,32,1,32,1,32,3,32,733,8,32,1,32,1,32,1,32,1,32,5,32,739,8,32,10,32,12, + 32,742,9,32,1,32,3,32,745,8,32,1,32,1,32,3,32,749,8,32,1,33,1,33,1,33, + 1,33,5,33,755,8,33,10,33,12,33,758,9,33,1,33,1,33,1,34,1,34,3,34,764,8, + 34,1,35,1,35,1,35,1,35,5,35,770,8,35,10,35,12,35,773,9,35,1,35,3,35,776, + 8,35,1,35,3,35,779,8,35,1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,36,5, + 36,790,8,36,10,36,12,36,793,9,36,3,36,795,8,36,1,37,1,37,3,37,799,8,37, + 1,37,1,37,3,37,803,8,37,1,38,1,38,3,38,807,8,38,1,38,3,38,810,8,38,1,38, + 1,38,1,38,3,38,815,8,38,1,38,3,38,818,8,38,1,38,3,38,821,8,38,1,38,3,38, + 824,8,38,1,38,3,38,827,8,38,1,39,1,39,1,40,1,40,1,40,1,40,1,40,5,40,836, + 8,40,10,40,12,40,839,9,40,1,41,1,41,1,41,1,42,1,42,1,42,1,42,5,42,848, + 8,42,10,42,12,42,851,9,42,1,42,3,42,854,8,42,1,43,1,43,1,43,1,43,1,43, + 1,43,3,43,862,8,43,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,3,44,873, + 8,44,1,44,1,44,1,44,1,44,1,44,1,44,3,44,881,8,44,1,45,1,45,1,46,1,46,1, + 46,1,46,5,46,889,8,46,10,46,12,46,892,9,46,3,46,894,8,46,1,47,1,47,1,47, + 1,47,1,47,1,47,3,47,902,8,47,1,47,3,47,905,8,47,3,47,907,8,47,1,48,1,48, + 1,48,1,48,5,48,913,8,48,10,48,12,48,916,9,48,1,49,1,49,5,49,920,8,49,10, + 49,12,49,923,9,49,1,50,1,50,3,50,927,8,50,1,50,3,50,930,8,50,1,50,1,50, + 1,50,1,50,3,50,936,8,50,1,50,3,50,939,8,50,1,50,1,50,1,50,1,50,3,50,945, + 8,50,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51, + 1,51,1,51,3,51,962,8,51,1,52,3,52,965,8,52,1,52,1,52,3,52,969,8,52,1,52, + 1,52,3,52,973,8,52,1,52,1,52,3,52,977,8,52,3,52,979,8,52,1,53,1,53,1,53, + 1,54,1,54,1,54,1,54,1,54,5,54,989,8,54,10,54,12,54,992,9,54,1,55,1,55, + 3,55,996,8,55,1,56,1,56,1,56,1,56,1,56,1,56,3,56,1004,8,56,1,56,1,56,1, 56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1, - 56,1,56,1,56,1,56,1,56,3,56,1027,8,56,1,56,1,56,1,56,1,56,1,56,1,56,1, - 56,3,56,1036,8,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1, - 56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,3,56,1057,8,56,1,56,1,56,1, - 56,1,56,1,56,1,56,1,56,3,56,1066,8,56,1,56,1,56,1,56,1,56,1,56,5,56,1073, - 8,56,10,56,12,56,1076,9,56,1,56,1,56,1,56,1,56,1,56,3,56,1083,8,56,1,56, - 5,56,1086,8,56,10,56,12,56,1089,9,56,1,57,1,57,1,57,1,57,1,57,1,57,1,57, - 1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,3,57,1110, - 8,57,1,58,1,58,3,58,1114,8,58,1,58,4,58,1117,8,58,11,58,12,58,1118,1,58, - 1,58,3,58,1123,8,58,1,58,1,58,1,59,1,59,1,59,1,59,1,59,1,60,1,60,1,60, - 1,60,3,60,1136,8,60,1,60,1,60,1,60,5,60,1141,8,60,10,60,12,60,1144,9,60, - 3,60,1146,8,60,1,60,1,60,3,60,1150,8,60,1,60,3,60,1153,8,60,1,60,3,60, - 1156,8,60,1,60,3,60,1159,8,60,1,60,1,60,3,60,1163,8,60,1,61,1,61,1,61, - 1,61,1,61,1,61,3,61,1171,8,61,1,62,1,62,1,62,3,62,1176,8,62,1,62,1,62, - 1,63,1,63,1,63,1,63,3,63,1184,8,63,1,64,1,64,1,64,1,64,1,64,1,64,1,64, - 1,64,1,64,3,64,1195,8,64,1,65,1,65,3,65,1199,8,65,1,65,1,65,3,65,1203, - 8,65,1,65,1,65,3,65,1207,8,65,3,65,1209,8,65,1,66,1,66,1,66,1,67,1,67, - 1,67,1,67,1,67,1,67,5,67,1220,8,67,10,67,12,67,1223,9,67,3,67,1225,8,67, - 1,67,3,67,1228,8,67,1,67,3,67,1231,8,67,1,67,1,67,1,68,1,68,1,69,1,69, - 1,69,1,69,1,69,1,69,1,69,3,69,1244,8,69,1,69,1,69,3,69,1248,8,69,1,70, - 1,70,1,70,1,70,1,70,1,70,1,70,3,70,1257,8,70,1,71,1,71,1,71,1,71,1,71, - 1,71,3,71,1265,8,71,1,72,1,72,1,73,1,73,1,73,1,73,1,73,1,73,1,74,1,74, - 1,74,1,74,1,74,1,74,1,75,1,75,1,75,1,76,1,76,1,76,1,76,0,1,112,77,0,2, - 4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52, - 54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100, - 102,104,106,108,110,112,114,116,118,120,122,124,126,128,130,132,134,136, - 138,140,142,144,146,148,150,152,0,21,1,0,101,102,1,0,103,104,1,0,93,94, - 1,0,135,136,2,0,34,35,54,54,1,0,126,127,1,0,47,48,1,0,49,50,2,0,8,8,15, - 15,1,0,122,123,1,0,129,134,2,0,6,6,12,12,2,0,7,7,13,13,2,0,9,9,14,14,1, - 0,62,63,2,0,87,87,89,89,2,0,49,49,108,109,1,0,111,112,1,0,108,121,2,0, - 48,48,118,118,2,0,96,96,119,119,1466,0,155,1,0,0,0,2,178,1,0,0,0,4,200, - 1,0,0,0,6,202,1,0,0,0,8,214,1,0,0,0,10,235,1,0,0,0,12,239,1,0,0,0,14,259, - 1,0,0,0,16,268,1,0,0,0,18,275,1,0,0,0,20,286,1,0,0,0,22,310,1,0,0,0,24, - 329,1,0,0,0,26,357,1,0,0,0,28,359,1,0,0,0,30,364,1,0,0,0,32,366,1,0,0, - 0,34,428,1,0,0,0,36,433,1,0,0,0,38,435,1,0,0,0,40,472,1,0,0,0,42,474,1, - 0,0,0,44,483,1,0,0,0,46,485,1,0,0,0,48,496,1,0,0,0,50,503,1,0,0,0,52,522, - 1,0,0,0,54,595,1,0,0,0,56,686,1,0,0,0,58,695,1,0,0,0,60,704,1,0,0,0,62, - 714,1,0,0,0,64,716,1,0,0,0,66,748,1,0,0,0,68,761,1,0,0,0,70,763,1,0,0, - 0,72,792,1,0,0,0,74,800,1,0,0,0,76,802,1,0,0,0,78,826,1,0,0,0,80,828,1, - 0,0,0,82,838,1,0,0,0,84,841,1,0,0,0,86,859,1,0,0,0,88,878,1,0,0,0,90,880, - 1,0,0,0,92,891,1,0,0,0,94,904,1,0,0,0,96,906,1,0,0,0,98,915,1,0,0,0,100, - 942,1,0,0,0,102,959,1,0,0,0,104,976,1,0,0,0,106,978,1,0,0,0,108,981,1, - 0,0,0,110,991,1,0,0,0,112,1001,1,0,0,0,114,1109,1,0,0,0,116,1111,1,0,0, - 0,118,1126,1,0,0,0,120,1131,1,0,0,0,122,1170,1,0,0,0,124,1175,1,0,0,0, - 126,1183,1,0,0,0,128,1194,1,0,0,0,130,1208,1,0,0,0,132,1210,1,0,0,0,134, - 1213,1,0,0,0,136,1234,1,0,0,0,138,1236,1,0,0,0,140,1256,1,0,0,0,142,1264, - 1,0,0,0,144,1266,1,0,0,0,146,1268,1,0,0,0,148,1274,1,0,0,0,150,1280,1, - 0,0,0,152,1283,1,0,0,0,154,156,3,18,9,0,155,154,1,0,0,0,155,156,1,0,0, - 0,156,171,1,0,0,0,157,172,3,2,1,0,158,172,3,20,10,0,159,172,3,38,19,0, - 160,172,3,22,11,0,161,172,3,24,12,0,162,172,3,32,16,0,163,172,3,40,20, - 0,164,172,3,64,32,0,165,172,3,8,4,0,166,172,3,12,6,0,167,172,3,130,65, - 0,168,172,3,6,3,0,169,172,3,14,7,0,170,172,3,70,35,0,171,157,1,0,0,0,171, - 158,1,0,0,0,171,159,1,0,0,0,171,160,1,0,0,0,171,161,1,0,0,0,171,162,1, - 0,0,0,171,163,1,0,0,0,171,164,1,0,0,0,171,165,1,0,0,0,171,166,1,0,0,0, - 171,167,1,0,0,0,171,168,1,0,0,0,171,169,1,0,0,0,171,170,1,0,0,0,172,174, - 1,0,0,0,173,175,5,139,0,0,174,173,1,0,0,0,174,175,1,0,0,0,175,176,1,0, - 0,0,176,177,5,0,0,1,177,1,1,0,0,0,178,180,5,32,0,0,179,181,5,8,0,0,180, - 179,1,0,0,0,180,181,1,0,0,0,181,182,1,0,0,0,182,183,5,31,0,0,183,184,5, - 135,0,0,184,185,3,70,35,0,185,186,5,136,0,0,186,187,5,33,0,0,187,188,3, - 4,2,0,188,3,1,0,0,0,189,201,3,20,10,0,190,201,3,38,19,0,191,201,3,22,11, - 0,192,201,3,24,12,0,193,201,3,32,16,0,194,201,3,40,20,0,195,201,3,64,32, - 0,196,201,3,8,4,0,197,201,3,12,6,0,198,201,3,6,3,0,199,201,3,70,35,0,200, - 189,1,0,0,0,200,190,1,0,0,0,200,191,1,0,0,0,200,192,1,0,0,0,200,193,1, - 0,0,0,200,194,1,0,0,0,200,195,1,0,0,0,200,196,1,0,0,0,200,197,1,0,0,0, - 200,198,1,0,0,0,200,199,1,0,0,0,201,5,1,0,0,0,202,203,7,0,0,0,203,212, - 3,126,63,0,204,209,3,112,56,0,205,206,5,137,0,0,206,208,3,112,56,0,207, - 205,1,0,0,0,208,211,1,0,0,0,209,207,1,0,0,0,209,210,1,0,0,0,210,213,1, - 0,0,0,211,209,1,0,0,0,212,204,1,0,0,0,212,213,1,0,0,0,213,7,1,0,0,0,214, - 215,5,79,0,0,215,220,3,98,49,0,216,217,5,137,0,0,217,219,3,98,49,0,218, - 216,1,0,0,0,219,222,1,0,0,0,220,218,1,0,0,0,220,221,1,0,0,0,221,223,1, - 0,0,0,222,220,1,0,0,0,223,224,5,83,0,0,224,229,3,10,5,0,225,226,5,137, - 0,0,226,228,3,10,5,0,227,225,1,0,0,0,228,231,1,0,0,0,229,227,1,0,0,0,229, - 230,1,0,0,0,230,233,1,0,0,0,231,229,1,0,0,0,232,234,3,106,53,0,233,232, - 1,0,0,0,233,234,1,0,0,0,234,9,1,0,0,0,235,236,3,124,62,0,236,237,5,129, - 0,0,237,238,3,112,56,0,238,11,1,0,0,0,239,245,5,78,0,0,240,241,3,126,63, - 0,241,242,5,138,0,0,242,243,5,122,0,0,243,246,1,0,0,0,244,246,5,122,0, - 0,245,240,1,0,0,0,245,244,1,0,0,0,245,246,1,0,0,0,246,247,1,0,0,0,247, - 248,5,2,0,0,248,253,3,98,49,0,249,250,5,137,0,0,250,252,3,98,49,0,251, - 249,1,0,0,0,252,255,1,0,0,0,253,251,1,0,0,0,253,254,1,0,0,0,254,257,1, - 0,0,0,255,253,1,0,0,0,256,258,3,106,53,0,257,256,1,0,0,0,257,258,1,0,0, - 0,258,13,1,0,0,0,259,260,5,1,0,0,260,265,3,16,8,0,261,262,5,137,0,0,262, - 264,3,16,8,0,263,261,1,0,0,0,264,267,1,0,0,0,265,263,1,0,0,0,265,266,1, - 0,0,0,266,15,1,0,0,0,267,265,1,0,0,0,268,273,5,140,0,0,269,271,5,5,0,0, - 270,269,1,0,0,0,270,271,1,0,0,0,271,272,1,0,0,0,272,274,3,126,63,0,273, - 270,1,0,0,0,273,274,1,0,0,0,274,17,1,0,0,0,275,276,5,100,0,0,276,281,3, - 28,14,0,277,278,5,137,0,0,278,280,3,28,14,0,279,277,1,0,0,0,280,283,1, - 0,0,0,281,279,1,0,0,0,281,282,1,0,0,0,282,284,1,0,0,0,283,281,1,0,0,0, - 284,285,5,139,0,0,285,19,1,0,0,0,286,288,5,57,0,0,287,289,5,91,0,0,288, - 287,1,0,0,0,288,289,1,0,0,0,289,290,1,0,0,0,290,291,5,58,0,0,291,292,3, - 126,63,0,292,293,5,135,0,0,293,298,3,46,23,0,294,295,5,137,0,0,295,297, - 3,46,23,0,296,294,1,0,0,0,297,300,1,0,0,0,298,296,1,0,0,0,298,299,1,0, - 0,0,299,305,1,0,0,0,300,298,1,0,0,0,301,302,5,137,0,0,302,304,3,56,28, - 0,303,301,1,0,0,0,304,307,1,0,0,0,305,303,1,0,0,0,305,306,1,0,0,0,306, - 308,1,0,0,0,307,305,1,0,0,0,308,309,5,136,0,0,309,21,1,0,0,0,310,311,5, - 57,0,0,311,312,5,98,0,0,312,324,3,126,63,0,313,314,5,135,0,0,314,319,3, - 126,63,0,315,316,5,137,0,0,316,318,3,126,63,0,317,315,1,0,0,0,318,321, - 1,0,0,0,319,317,1,0,0,0,319,320,1,0,0,0,320,322,1,0,0,0,321,319,1,0,0, - 0,322,323,5,136,0,0,323,325,1,0,0,0,324,313,1,0,0,0,324,325,1,0,0,0,325, - 326,1,0,0,0,326,327,5,5,0,0,327,328,3,70,35,0,328,23,1,0,0,0,329,330,5, - 57,0,0,330,331,5,99,0,0,331,333,3,126,63,0,332,334,3,26,13,0,333,332,1, - 0,0,0,333,334,1,0,0,0,334,335,1,0,0,0,335,336,5,5,0,0,336,337,3,36,18, - 0,337,25,1,0,0,0,338,339,5,135,0,0,339,344,3,28,14,0,340,341,5,137,0,0, - 341,343,3,28,14,0,342,340,1,0,0,0,343,346,1,0,0,0,344,342,1,0,0,0,344, - 345,1,0,0,0,345,347,1,0,0,0,346,344,1,0,0,0,347,348,5,136,0,0,348,358, - 1,0,0,0,349,354,3,28,14,0,350,351,5,137,0,0,351,353,3,28,14,0,352,350, - 1,0,0,0,353,356,1,0,0,0,354,352,1,0,0,0,354,355,1,0,0,0,355,358,1,0,0, - 0,356,354,1,0,0,0,357,338,1,0,0,0,357,349,1,0,0,0,358,27,1,0,0,0,359,360, - 3,30,15,0,360,361,3,50,25,0,361,29,1,0,0,0,362,365,3,126,63,0,363,365, - 5,141,0,0,364,362,1,0,0,0,364,363,1,0,0,0,365,31,1,0,0,0,366,367,5,64, - 0,0,367,368,5,58,0,0,368,369,3,126,63,0,369,370,3,34,17,0,370,33,1,0,0, - 0,371,373,5,67,0,0,372,374,5,69,0,0,373,372,1,0,0,0,373,374,1,0,0,0,374, - 375,1,0,0,0,375,429,3,46,23,0,376,377,5,67,0,0,377,429,3,56,28,0,378,380, - 5,64,0,0,379,381,5,69,0,0,380,379,1,0,0,0,380,381,1,0,0,0,381,382,1,0, - 0,0,382,383,3,126,63,0,383,387,3,50,25,0,384,386,3,54,27,0,385,384,1,0, - 0,0,386,389,1,0,0,0,387,385,1,0,0,0,387,388,1,0,0,0,388,429,1,0,0,0,389, - 387,1,0,0,0,390,392,5,64,0,0,391,393,5,69,0,0,392,391,1,0,0,0,392,393, - 1,0,0,0,393,394,1,0,0,0,394,395,3,126,63,0,395,396,5,83,0,0,396,397,5, - 84,0,0,397,398,3,112,56,0,398,429,1,0,0,0,399,401,5,64,0,0,400,402,5,69, - 0,0,401,400,1,0,0,0,401,402,1,0,0,0,402,403,1,0,0,0,403,404,3,126,63,0, - 404,405,5,68,0,0,405,406,5,84,0,0,406,429,1,0,0,0,407,408,5,68,0,0,408, - 409,5,69,0,0,409,429,3,126,63,0,410,411,5,68,0,0,411,412,5,75,0,0,412, - 429,3,126,63,0,413,414,5,65,0,0,414,415,5,66,0,0,415,429,3,126,63,0,416, - 417,5,65,0,0,417,418,5,69,0,0,418,419,3,126,63,0,419,420,5,66,0,0,420, - 421,3,126,63,0,421,429,1,0,0,0,422,423,5,65,0,0,423,424,5,90,0,0,424,425, - 3,126,63,0,425,426,5,66,0,0,426,427,3,126,63,0,427,429,1,0,0,0,428,371, - 1,0,0,0,428,376,1,0,0,0,428,378,1,0,0,0,428,390,1,0,0,0,428,399,1,0,0, - 0,428,407,1,0,0,0,428,410,1,0,0,0,428,413,1,0,0,0,428,416,1,0,0,0,428, - 422,1,0,0,0,429,35,1,0,0,0,430,434,3,70,35,0,431,434,3,64,32,0,432,434, - 3,20,10,0,433,430,1,0,0,0,433,431,1,0,0,0,433,432,1,0,0,0,434,37,1,0,0, - 0,435,437,5,57,0,0,436,438,5,86,0,0,437,436,1,0,0,0,437,438,1,0,0,0,438, - 439,1,0,0,0,439,440,5,90,0,0,440,441,3,126,63,0,441,442,5,25,0,0,442,443, - 3,126,63,0,443,444,5,135,0,0,444,449,3,42,21,0,445,446,5,137,0,0,446,448, - 3,42,21,0,447,445,1,0,0,0,448,451,1,0,0,0,449,447,1,0,0,0,449,450,1,0, - 0,0,450,452,1,0,0,0,451,449,1,0,0,0,452,455,5,136,0,0,453,454,5,92,0,0, - 454,456,3,44,22,0,455,453,1,0,0,0,455,456,1,0,0,0,456,39,1,0,0,0,457,458, - 5,68,0,0,458,459,5,58,0,0,459,473,3,126,63,0,460,461,5,68,0,0,461,462, - 5,90,0,0,462,463,3,126,63,0,463,464,5,25,0,0,464,465,3,126,63,0,465,473, - 1,0,0,0,466,467,5,68,0,0,467,468,5,99,0,0,468,473,3,126,63,0,469,470,5, - 68,0,0,470,471,5,98,0,0,471,473,3,126,63,0,472,457,1,0,0,0,472,460,1,0, - 0,0,472,466,1,0,0,0,472,469,1,0,0,0,473,41,1,0,0,0,474,476,3,126,63,0, - 475,477,7,1,0,0,476,475,1,0,0,0,476,477,1,0,0,0,477,43,1,0,0,0,478,484, - 5,73,0,0,479,480,5,95,0,0,480,484,5,107,0,0,481,482,5,96,0,0,482,484,5, - 107,0,0,483,478,1,0,0,0,483,479,1,0,0,0,483,481,1,0,0,0,484,45,1,0,0,0, - 485,486,3,126,63,0,486,488,3,50,25,0,487,489,3,48,24,0,488,487,1,0,0,0, - 488,489,1,0,0,0,489,493,1,0,0,0,490,492,3,54,27,0,491,490,1,0,0,0,492, - 495,1,0,0,0,493,491,1,0,0,0,493,494,1,0,0,0,494,47,1,0,0,0,495,493,1,0, - 0,0,496,497,5,5,0,0,497,498,5,135,0,0,498,499,3,112,56,0,499,500,5,136, - 0,0,500,49,1,0,0,0,501,504,3,126,63,0,502,504,5,88,0,0,503,501,1,0,0,0, - 503,502,1,0,0,0,504,506,1,0,0,0,505,507,3,126,63,0,506,505,1,0,0,0,506, - 507,1,0,0,0,507,509,1,0,0,0,508,510,3,126,63,0,509,508,1,0,0,0,509,510, - 1,0,0,0,510,519,1,0,0,0,511,512,5,135,0,0,512,515,3,52,26,0,513,514,5, - 137,0,0,514,516,3,52,26,0,515,513,1,0,0,0,515,516,1,0,0,0,516,517,1,0, - 0,0,517,518,5,136,0,0,518,520,1,0,0,0,519,511,1,0,0,0,519,520,1,0,0,0, - 520,51,1,0,0,0,521,523,5,127,0,0,522,521,1,0,0,0,522,523,1,0,0,0,523,524, - 1,0,0,0,524,525,5,143,0,0,525,53,1,0,0,0,526,527,5,8,0,0,527,596,5,107, - 0,0,528,596,5,107,0,0,529,530,5,84,0,0,530,596,3,112,56,0,531,532,5,92, - 0,0,532,596,7,2,0,0,533,534,5,75,0,0,534,536,3,126,63,0,535,533,1,0,0, - 0,535,536,1,0,0,0,536,537,1,0,0,0,537,538,5,97,0,0,538,539,5,135,0,0,539, - 540,3,58,29,0,540,541,5,136,0,0,541,596,1,0,0,0,542,543,5,75,0,0,543,545, - 3,126,63,0,544,542,1,0,0,0,544,545,1,0,0,0,545,546,1,0,0,0,546,547,5,73, - 0,0,547,549,5,74,0,0,548,550,3,136,68,0,549,548,1,0,0,0,549,550,1,0,0, - 0,550,596,1,0,0,0,551,552,5,75,0,0,552,554,3,126,63,0,553,551,1,0,0,0, - 553,554,1,0,0,0,554,555,1,0,0,0,555,557,5,86,0,0,556,558,3,136,68,0,557, - 556,1,0,0,0,557,558,1,0,0,0,558,596,1,0,0,0,559,560,5,75,0,0,560,562,3, - 126,63,0,561,559,1,0,0,0,561,562,1,0,0,0,562,563,1,0,0,0,563,564,5,77, - 0,0,564,576,3,126,63,0,565,566,5,135,0,0,566,571,3,126,63,0,567,568,5, - 137,0,0,568,570,3,126,63,0,569,567,1,0,0,0,570,573,1,0,0,0,571,569,1,0, - 0,0,571,572,1,0,0,0,572,574,1,0,0,0,573,571,1,0,0,0,574,575,5,136,0,0, - 575,577,1,0,0,0,576,565,1,0,0,0,576,577,1,0,0,0,577,581,1,0,0,0,578,580, - 3,60,30,0,579,578,1,0,0,0,580,583,1,0,0,0,581,579,1,0,0,0,581,582,1,0, - 0,0,582,596,1,0,0,0,583,581,1,0,0,0,584,593,5,88,0,0,585,586,5,135,0,0, - 586,589,3,52,26,0,587,588,5,137,0,0,588,590,3,52,26,0,589,587,1,0,0,0, - 589,590,1,0,0,0,590,591,1,0,0,0,591,592,5,136,0,0,592,594,1,0,0,0,593, - 585,1,0,0,0,593,594,1,0,0,0,594,596,1,0,0,0,595,526,1,0,0,0,595,528,1, - 0,0,0,595,529,1,0,0,0,595,531,1,0,0,0,595,535,1,0,0,0,595,544,1,0,0,0, - 595,553,1,0,0,0,595,561,1,0,0,0,595,584,1,0,0,0,596,55,1,0,0,0,597,598, - 5,75,0,0,598,600,3,126,63,0,599,597,1,0,0,0,599,600,1,0,0,0,600,601,1, - 0,0,0,601,602,5,73,0,0,602,604,5,74,0,0,603,605,3,136,68,0,604,603,1,0, - 0,0,604,605,1,0,0,0,605,606,1,0,0,0,606,607,5,135,0,0,607,612,3,126,63, - 0,608,609,5,137,0,0,609,611,3,126,63,0,610,608,1,0,0,0,611,614,1,0,0,0, - 612,610,1,0,0,0,612,613,1,0,0,0,613,615,1,0,0,0,614,612,1,0,0,0,615,616, - 5,136,0,0,616,687,1,0,0,0,617,618,5,75,0,0,618,620,3,126,63,0,619,617, - 1,0,0,0,619,620,1,0,0,0,620,621,1,0,0,0,621,623,5,86,0,0,622,624,3,136, - 68,0,623,622,1,0,0,0,623,624,1,0,0,0,624,625,1,0,0,0,625,626,5,135,0,0, - 626,631,3,126,63,0,627,628,5,137,0,0,628,630,3,126,63,0,629,627,1,0,0, - 0,630,633,1,0,0,0,631,629,1,0,0,0,631,632,1,0,0,0,632,634,1,0,0,0,633, - 631,1,0,0,0,634,635,5,136,0,0,635,687,1,0,0,0,636,637,5,75,0,0,637,639, - 3,126,63,0,638,636,1,0,0,0,638,639,1,0,0,0,639,640,1,0,0,0,640,641,5,76, - 0,0,641,644,5,74,0,0,642,643,5,85,0,0,643,645,5,90,0,0,644,642,1,0,0,0, - 644,645,1,0,0,0,645,646,1,0,0,0,646,647,5,135,0,0,647,652,3,126,63,0,648, - 649,5,137,0,0,649,651,3,126,63,0,650,648,1,0,0,0,651,654,1,0,0,0,652,650, - 1,0,0,0,652,653,1,0,0,0,653,655,1,0,0,0,654,652,1,0,0,0,655,656,5,136, - 0,0,656,657,5,77,0,0,657,669,3,126,63,0,658,659,5,135,0,0,659,664,3,126, - 63,0,660,661,5,137,0,0,661,663,3,126,63,0,662,660,1,0,0,0,663,666,1,0, - 0,0,664,662,1,0,0,0,664,665,1,0,0,0,665,667,1,0,0,0,666,664,1,0,0,0,667, - 668,5,136,0,0,668,670,1,0,0,0,669,658,1,0,0,0,669,670,1,0,0,0,670,674, - 1,0,0,0,671,673,3,60,30,0,672,671,1,0,0,0,673,676,1,0,0,0,674,672,1,0, - 0,0,674,675,1,0,0,0,675,687,1,0,0,0,676,674,1,0,0,0,677,678,5,75,0,0,678, - 680,3,126,63,0,679,677,1,0,0,0,679,680,1,0,0,0,680,681,1,0,0,0,681,682, - 5,97,0,0,682,683,5,135,0,0,683,684,3,58,29,0,684,685,5,136,0,0,685,687, - 1,0,0,0,686,599,1,0,0,0,686,619,1,0,0,0,686,638,1,0,0,0,686,679,1,0,0, - 0,687,57,1,0,0,0,688,694,8,3,0,0,689,690,5,135,0,0,690,691,3,58,29,0,691, - 692,5,136,0,0,692,694,1,0,0,0,693,688,1,0,0,0,693,689,1,0,0,0,694,697, - 1,0,0,0,695,693,1,0,0,0,695,696,1,0,0,0,696,59,1,0,0,0,697,695,1,0,0,0, - 698,699,5,25,0,0,699,700,5,79,0,0,700,705,3,62,31,0,701,702,5,25,0,0,702, - 703,5,78,0,0,703,705,3,62,31,0,704,698,1,0,0,0,704,701,1,0,0,0,705,61, - 1,0,0,0,706,715,5,80,0,0,707,708,5,85,0,0,708,715,5,82,0,0,709,715,5,81, - 0,0,710,711,5,83,0,0,711,715,5,107,0,0,712,713,5,83,0,0,713,715,5,84,0, - 0,714,706,1,0,0,0,714,707,1,0,0,0,714,709,1,0,0,0,714,710,1,0,0,0,714, - 712,1,0,0,0,715,63,1,0,0,0,716,717,5,70,0,0,717,718,5,71,0,0,718,746,3, - 126,63,0,719,720,5,135,0,0,720,725,3,126,63,0,721,722,5,137,0,0,722,724, - 3,126,63,0,723,721,1,0,0,0,724,727,1,0,0,0,725,723,1,0,0,0,725,726,1,0, - 0,0,726,728,1,0,0,0,727,725,1,0,0,0,728,729,5,136,0,0,729,731,1,0,0,0, - 730,719,1,0,0,0,730,731,1,0,0,0,731,742,1,0,0,0,732,733,5,72,0,0,733,738, - 3,66,33,0,734,735,5,137,0,0,735,737,3,66,33,0,736,734,1,0,0,0,737,740, - 1,0,0,0,738,736,1,0,0,0,738,739,1,0,0,0,739,743,1,0,0,0,740,738,1,0,0, - 0,741,743,3,70,35,0,742,732,1,0,0,0,742,741,1,0,0,0,743,747,1,0,0,0,744, - 745,5,84,0,0,745,747,5,72,0,0,746,730,1,0,0,0,746,744,1,0,0,0,747,65,1, - 0,0,0,748,749,5,135,0,0,749,754,3,68,34,0,750,751,5,137,0,0,751,753,3, - 68,34,0,752,750,1,0,0,0,753,756,1,0,0,0,754,752,1,0,0,0,754,755,1,0,0, - 0,755,757,1,0,0,0,756,754,1,0,0,0,757,758,5,136,0,0,758,67,1,0,0,0,759, - 762,5,84,0,0,760,762,3,112,56,0,761,759,1,0,0,0,761,760,1,0,0,0,762,69, - 1,0,0,0,763,769,3,72,36,0,764,765,3,74,37,0,765,766,3,72,36,0,766,768, - 1,0,0,0,767,764,1,0,0,0,768,771,1,0,0,0,769,767,1,0,0,0,769,770,1,0,0, - 0,770,773,1,0,0,0,771,769,1,0,0,0,772,774,3,108,54,0,773,772,1,0,0,0,773, - 774,1,0,0,0,774,776,1,0,0,0,775,777,3,88,44,0,776,775,1,0,0,0,776,777, - 1,0,0,0,777,71,1,0,0,0,778,793,3,76,38,0,779,780,5,135,0,0,780,781,3,70, - 35,0,781,782,5,136,0,0,782,793,1,0,0,0,783,784,5,72,0,0,784,789,3,66,33, - 0,785,786,5,137,0,0,786,788,3,66,33,0,787,785,1,0,0,0,788,791,1,0,0,0, - 789,787,1,0,0,0,789,790,1,0,0,0,790,793,1,0,0,0,791,789,1,0,0,0,792,778, - 1,0,0,0,792,779,1,0,0,0,792,783,1,0,0,0,793,73,1,0,0,0,794,796,5,53,0, - 0,795,797,5,54,0,0,796,795,1,0,0,0,796,797,1,0,0,0,797,801,1,0,0,0,798, - 801,5,55,0,0,799,801,5,56,0,0,800,794,1,0,0,0,800,798,1,0,0,0,800,799, - 1,0,0,0,801,75,1,0,0,0,802,804,5,1,0,0,803,805,3,78,39,0,804,803,1,0,0, - 0,804,805,1,0,0,0,805,807,1,0,0,0,806,808,3,84,42,0,807,806,1,0,0,0,807, - 808,1,0,0,0,808,809,1,0,0,0,809,812,3,92,46,0,810,811,5,71,0,0,811,813, - 3,126,63,0,812,810,1,0,0,0,812,813,1,0,0,0,813,815,1,0,0,0,814,816,3,96, - 48,0,815,814,1,0,0,0,815,816,1,0,0,0,816,818,1,0,0,0,817,819,3,106,53, - 0,818,817,1,0,0,0,818,819,1,0,0,0,819,821,1,0,0,0,820,822,3,80,40,0,821, - 820,1,0,0,0,821,822,1,0,0,0,822,824,1,0,0,0,823,825,3,82,41,0,824,823, - 1,0,0,0,824,825,1,0,0,0,825,77,1,0,0,0,826,827,7,4,0,0,827,79,1,0,0,0, - 828,829,5,27,0,0,829,830,5,29,0,0,830,835,3,112,56,0,831,832,5,137,0,0, - 832,834,3,112,56,0,833,831,1,0,0,0,834,837,1,0,0,0,835,833,1,0,0,0,835, - 836,1,0,0,0,836,81,1,0,0,0,837,835,1,0,0,0,838,839,5,30,0,0,839,840,3, - 112,56,0,840,83,1,0,0,0,841,842,5,4,0,0,842,847,3,86,43,0,843,844,7,5, - 0,0,844,846,3,86,43,0,845,843,1,0,0,0,846,849,1,0,0,0,847,845,1,0,0,0, - 847,848,1,0,0,0,848,851,1,0,0,0,849,847,1,0,0,0,850,852,5,36,0,0,851,850, - 1,0,0,0,851,852,1,0,0,0,852,85,1,0,0,0,853,860,5,143,0,0,854,860,5,141, - 0,0,855,856,5,135,0,0,856,857,3,112,56,0,857,858,5,136,0,0,858,860,1,0, - 0,0,859,853,1,0,0,0,859,854,1,0,0,0,859,855,1,0,0,0,860,87,1,0,0,0,861, - 862,5,45,0,0,862,863,3,112,56,0,863,870,3,90,45,0,864,865,5,46,0,0,865, - 866,7,6,0,0,866,867,3,112,56,0,867,868,3,90,45,0,868,869,5,51,0,0,869, - 871,1,0,0,0,870,864,1,0,0,0,870,871,1,0,0,0,871,879,1,0,0,0,872,873,5, - 46,0,0,873,874,7,6,0,0,874,875,3,112,56,0,875,876,3,90,45,0,876,877,5, - 51,0,0,877,879,1,0,0,0,878,861,1,0,0,0,878,872,1,0,0,0,879,89,1,0,0,0, - 880,881,7,7,0,0,881,91,1,0,0,0,882,892,5,122,0,0,883,888,3,94,47,0,884, - 885,5,137,0,0,885,887,3,94,47,0,886,884,1,0,0,0,887,890,1,0,0,0,888,886, - 1,0,0,0,888,889,1,0,0,0,889,892,1,0,0,0,890,888,1,0,0,0,891,882,1,0,0, - 0,891,883,1,0,0,0,892,93,1,0,0,0,893,894,3,126,63,0,894,895,5,138,0,0, - 895,896,5,122,0,0,896,905,1,0,0,0,897,902,3,112,56,0,898,900,5,5,0,0,899, - 898,1,0,0,0,899,900,1,0,0,0,900,901,1,0,0,0,901,903,3,126,63,0,902,899, - 1,0,0,0,902,903,1,0,0,0,903,905,1,0,0,0,904,893,1,0,0,0,904,897,1,0,0, - 0,905,95,1,0,0,0,906,907,5,2,0,0,907,912,3,98,49,0,908,909,5,137,0,0,909, - 911,3,98,49,0,910,908,1,0,0,0,911,914,1,0,0,0,912,910,1,0,0,0,912,913, - 1,0,0,0,913,97,1,0,0,0,914,912,1,0,0,0,915,919,3,100,50,0,916,918,3,102, - 51,0,917,916,1,0,0,0,918,921,1,0,0,0,919,917,1,0,0,0,919,920,1,0,0,0,920, - 99,1,0,0,0,921,919,1,0,0,0,922,927,3,126,63,0,923,925,5,5,0,0,924,923, - 1,0,0,0,924,925,1,0,0,0,925,926,1,0,0,0,926,928,3,126,63,0,927,924,1,0, - 0,0,927,928,1,0,0,0,928,943,1,0,0,0,929,930,5,135,0,0,930,931,3,70,35, - 0,931,936,5,136,0,0,932,934,5,5,0,0,933,932,1,0,0,0,933,934,1,0,0,0,934, - 935,1,0,0,0,935,937,3,126,63,0,936,933,1,0,0,0,936,937,1,0,0,0,937,943, - 1,0,0,0,938,939,5,135,0,0,939,940,3,98,49,0,940,941,5,136,0,0,941,943, - 1,0,0,0,942,922,1,0,0,0,942,929,1,0,0,0,942,938,1,0,0,0,943,101,1,0,0, - 0,944,945,3,104,52,0,945,946,5,23,0,0,946,947,3,100,50,0,947,948,5,25, - 0,0,948,949,3,112,56,0,949,960,1,0,0,0,950,951,5,37,0,0,951,952,5,23,0, - 0,952,960,3,100,50,0,953,954,5,37,0,0,954,955,5,38,0,0,955,960,3,100,50, - 0,956,957,5,22,0,0,957,958,5,38,0,0,958,960,3,100,50,0,959,944,1,0,0,0, - 959,950,1,0,0,0,959,953,1,0,0,0,959,956,1,0,0,0,960,103,1,0,0,0,961,963, - 5,18,0,0,962,961,1,0,0,0,962,963,1,0,0,0,963,977,1,0,0,0,964,966,5,19, - 0,0,965,967,5,22,0,0,966,965,1,0,0,0,966,967,1,0,0,0,967,977,1,0,0,0,968, - 970,5,20,0,0,969,971,5,22,0,0,970,969,1,0,0,0,970,971,1,0,0,0,971,977, - 1,0,0,0,972,974,5,21,0,0,973,975,5,22,0,0,974,973,1,0,0,0,974,975,1,0, - 0,0,975,977,1,0,0,0,976,962,1,0,0,0,976,964,1,0,0,0,976,968,1,0,0,0,976, - 972,1,0,0,0,977,105,1,0,0,0,978,979,5,3,0,0,979,980,3,112,56,0,980,107, - 1,0,0,0,981,982,5,26,0,0,982,983,5,29,0,0,983,988,3,110,55,0,984,985,5, - 137,0,0,985,987,3,110,55,0,986,984,1,0,0,0,987,990,1,0,0,0,988,986,1,0, - 0,0,988,989,1,0,0,0,989,109,1,0,0,0,990,988,1,0,0,0,991,993,3,112,56,0, - 992,994,7,1,0,0,993,992,1,0,0,0,993,994,1,0,0,0,994,111,1,0,0,0,995,996, - 6,56,-1,0,996,997,7,5,0,0,997,1002,3,112,56,19,998,999,7,8,0,0,999,1002, - 3,112,56,7,1000,1002,3,114,57,0,1001,995,1,0,0,0,1001,998,1,0,0,0,1001, - 1000,1,0,0,0,1002,1087,1,0,0,0,1003,1004,10,20,0,0,1004,1005,5,125,0,0, - 1005,1086,3,112,56,21,1006,1007,10,18,0,0,1007,1008,7,9,0,0,1008,1086, - 3,112,56,19,1009,1010,10,17,0,0,1010,1011,5,124,0,0,1011,1086,3,112,56, - 18,1012,1013,10,16,0,0,1013,1014,5,17,0,0,1014,1086,3,112,56,17,1015,1016, - 10,15,0,0,1016,1017,7,5,0,0,1017,1086,3,112,56,16,1018,1019,10,14,0,0, - 1019,1020,5,128,0,0,1020,1086,3,112,56,15,1021,1022,10,13,0,0,1022,1023, - 7,10,0,0,1023,1086,3,112,56,14,1024,1026,10,12,0,0,1025,1027,5,8,0,0,1026, - 1025,1,0,0,0,1026,1027,1,0,0,0,1027,1028,1,0,0,0,1028,1029,5,52,0,0,1029, - 1030,3,112,56,0,1030,1031,5,6,0,0,1031,1032,3,112,56,13,1032,1086,1,0, - 0,0,1033,1035,10,11,0,0,1034,1036,5,8,0,0,1035,1034,1,0,0,0,1035,1036, - 1,0,0,0,1036,1037,1,0,0,0,1037,1038,5,16,0,0,1038,1086,3,112,56,12,1039, - 1040,10,6,0,0,1040,1041,7,11,0,0,1041,1086,3,112,56,7,1042,1043,10,5,0, - 0,1043,1044,7,12,0,0,1044,1086,3,112,56,6,1045,1046,10,4,0,0,1046,1047, - 7,13,0,0,1047,1086,3,112,56,5,1048,1049,10,3,0,0,1049,1050,5,10,0,0,1050, - 1086,3,112,56,4,1051,1052,10,2,0,0,1052,1053,5,11,0,0,1053,1086,3,112, - 56,3,1054,1056,10,10,0,0,1055,1057,5,8,0,0,1056,1055,1,0,0,0,1056,1057, - 1,0,0,0,1057,1058,1,0,0,0,1058,1059,5,24,0,0,1059,1060,5,135,0,0,1060, - 1061,3,70,35,0,1061,1062,5,136,0,0,1062,1086,1,0,0,0,1063,1065,10,9,0, - 0,1064,1066,5,8,0,0,1065,1064,1,0,0,0,1065,1066,1,0,0,0,1066,1067,1,0, - 0,0,1067,1068,5,24,0,0,1068,1069,5,135,0,0,1069,1074,3,112,56,0,1070,1071, - 5,137,0,0,1071,1073,3,112,56,0,1072,1070,1,0,0,0,1073,1076,1,0,0,0,1074, - 1072,1,0,0,0,1074,1075,1,0,0,0,1075,1077,1,0,0,0,1076,1074,1,0,0,0,1077, - 1078,5,136,0,0,1078,1086,1,0,0,0,1079,1080,10,8,0,0,1080,1082,5,28,0,0, - 1081,1083,5,8,0,0,1082,1081,1,0,0,0,1082,1083,1,0,0,0,1083,1084,1,0,0, - 0,1084,1086,5,107,0,0,1085,1003,1,0,0,0,1085,1006,1,0,0,0,1085,1009,1, - 0,0,0,1085,1012,1,0,0,0,1085,1015,1,0,0,0,1085,1018,1,0,0,0,1085,1021, - 1,0,0,0,1085,1024,1,0,0,0,1085,1033,1,0,0,0,1085,1039,1,0,0,0,1085,1042, - 1,0,0,0,1085,1045,1,0,0,0,1085,1048,1,0,0,0,1085,1051,1,0,0,0,1085,1054, - 1,0,0,0,1085,1063,1,0,0,0,1085,1079,1,0,0,0,1086,1089,1,0,0,0,1087,1085, - 1,0,0,0,1087,1088,1,0,0,0,1088,113,1,0,0,0,1089,1087,1,0,0,0,1090,1110, - 3,128,64,0,1091,1110,3,116,58,0,1092,1110,3,120,60,0,1093,1110,3,124,62, - 0,1094,1110,5,141,0,0,1095,1110,5,140,0,0,1096,1097,5,31,0,0,1097,1098, - 5,135,0,0,1098,1099,3,70,35,0,1099,1100,5,136,0,0,1100,1110,1,0,0,0,1101, - 1102,5,135,0,0,1102,1103,3,70,35,0,1103,1104,5,136,0,0,1104,1110,1,0,0, - 0,1105,1106,5,135,0,0,1106,1107,3,112,56,0,1107,1108,5,136,0,0,1108,1110, - 1,0,0,0,1109,1090,1,0,0,0,1109,1091,1,0,0,0,1109,1092,1,0,0,0,1109,1093, - 1,0,0,0,1109,1094,1,0,0,0,1109,1095,1,0,0,0,1109,1096,1,0,0,0,1109,1101, - 1,0,0,0,1109,1105,1,0,0,0,1110,115,1,0,0,0,1111,1113,5,41,0,0,1112,1114, - 3,112,56,0,1113,1112,1,0,0,0,1113,1114,1,0,0,0,1114,1116,1,0,0,0,1115, - 1117,3,118,59,0,1116,1115,1,0,0,0,1117,1118,1,0,0,0,1118,1116,1,0,0,0, - 1118,1119,1,0,0,0,1119,1122,1,0,0,0,1120,1121,5,43,0,0,1121,1123,3,112, - 56,0,1122,1120,1,0,0,0,1122,1123,1,0,0,0,1123,1124,1,0,0,0,1124,1125,5, - 44,0,0,1125,117,1,0,0,0,1126,1127,5,42,0,0,1127,1128,3,112,56,0,1128,1129, - 5,33,0,0,1129,1130,3,112,56,0,1130,119,1,0,0,0,1131,1132,3,122,61,0,1132, - 1145,5,135,0,0,1133,1146,5,122,0,0,1134,1136,5,35,0,0,1135,1134,1,0,0, - 0,1135,1136,1,0,0,0,1136,1137,1,0,0,0,1137,1142,3,112,56,0,1138,1139,5, - 137,0,0,1139,1141,3,112,56,0,1140,1138,1,0,0,0,1141,1144,1,0,0,0,1142, - 1140,1,0,0,0,1142,1143,1,0,0,0,1143,1146,1,0,0,0,1144,1142,1,0,0,0,1145, - 1133,1,0,0,0,1145,1135,1,0,0,0,1145,1146,1,0,0,0,1146,1147,1,0,0,0,1147, - 1149,5,136,0,0,1148,1150,3,148,74,0,1149,1148,1,0,0,0,1149,1150,1,0,0, - 0,1150,1152,1,0,0,0,1151,1153,3,146,73,0,1152,1151,1,0,0,0,1152,1153,1, - 0,0,0,1153,1162,1,0,0,0,1154,1156,3,150,75,0,1155,1154,1,0,0,0,1155,1156, - 1,0,0,0,1156,1158,1,0,0,0,1157,1159,3,152,76,0,1158,1157,1,0,0,0,1158, - 1159,1,0,0,0,1159,1160,1,0,0,0,1160,1161,5,39,0,0,1161,1163,3,134,67,0, - 1162,1155,1,0,0,0,1162,1163,1,0,0,0,1163,121,1,0,0,0,1164,1171,3,126,63, - 0,1165,1171,5,19,0,0,1166,1171,5,20,0,0,1167,1171,5,103,0,0,1168,1171, - 5,48,0,0,1169,1171,5,40,0,0,1170,1164,1,0,0,0,1170,1165,1,0,0,0,1170,1166, - 1,0,0,0,1170,1167,1,0,0,0,1170,1168,1,0,0,0,1170,1169,1,0,0,0,1171,123, - 1,0,0,0,1172,1173,3,126,63,0,1173,1174,5,138,0,0,1174,1176,1,0,0,0,1175, - 1172,1,0,0,0,1175,1176,1,0,0,0,1176,1177,1,0,0,0,1177,1178,3,126,63,0, - 1178,125,1,0,0,0,1179,1184,5,150,0,0,1180,1184,5,148,0,0,1181,1184,5,149, - 0,0,1182,1184,3,144,72,0,1183,1179,1,0,0,0,1183,1180,1,0,0,0,1183,1181, - 1,0,0,0,1183,1182,1,0,0,0,1184,127,1,0,0,0,1185,1195,5,143,0,0,1186,1195, - 5,144,0,0,1187,1195,5,142,0,0,1188,1195,5,145,0,0,1189,1195,5,146,0,0, - 1190,1195,5,147,0,0,1191,1195,5,105,0,0,1192,1195,5,106,0,0,1193,1195, - 5,107,0,0,1194,1185,1,0,0,0,1194,1186,1,0,0,0,1194,1187,1,0,0,0,1194,1188, - 1,0,0,0,1194,1189,1,0,0,0,1194,1190,1,0,0,0,1194,1191,1,0,0,0,1194,1192, - 1,0,0,0,1194,1193,1,0,0,0,1195,129,1,0,0,0,1196,1198,5,59,0,0,1197,1199, - 7,14,0,0,1198,1197,1,0,0,0,1198,1199,1,0,0,0,1199,1209,1,0,0,0,1200,1202, - 5,60,0,0,1201,1203,7,14,0,0,1202,1201,1,0,0,0,1202,1203,1,0,0,0,1203,1209, - 1,0,0,0,1204,1206,5,61,0,0,1205,1207,7,14,0,0,1206,1205,1,0,0,0,1206,1207, - 1,0,0,0,1207,1209,1,0,0,0,1208,1196,1,0,0,0,1208,1200,1,0,0,0,1208,1204, - 1,0,0,0,1209,131,1,0,0,0,1210,1211,3,112,56,0,1211,1212,5,0,0,1,1212,133, - 1,0,0,0,1213,1224,5,135,0,0,1214,1215,5,40,0,0,1215,1216,5,29,0,0,1216, - 1221,3,112,56,0,1217,1218,5,137,0,0,1218,1220,3,112,56,0,1219,1217,1,0, - 0,0,1220,1223,1,0,0,0,1221,1219,1,0,0,0,1221,1222,1,0,0,0,1222,1225,1, - 0,0,0,1223,1221,1,0,0,0,1224,1214,1,0,0,0,1224,1225,1,0,0,0,1225,1227, - 1,0,0,0,1226,1228,3,108,54,0,1227,1226,1,0,0,0,1227,1228,1,0,0,0,1228, - 1230,1,0,0,0,1229,1231,3,138,69,0,1230,1229,1,0,0,0,1230,1231,1,0,0,0, - 1231,1232,1,0,0,0,1232,1233,5,136,0,0,1233,135,1,0,0,0,1234,1235,7,15, - 0,0,1235,137,1,0,0,0,1236,1243,7,16,0,0,1237,1238,5,52,0,0,1238,1239,3, - 140,70,0,1239,1240,5,6,0,0,1240,1241,3,140,70,0,1241,1244,1,0,0,0,1242, - 1244,3,140,70,0,1243,1237,1,0,0,0,1243,1242,1,0,0,0,1244,1247,1,0,0,0, - 1245,1246,5,114,0,0,1246,1248,3,142,71,0,1247,1245,1,0,0,0,1247,1248,1, - 0,0,0,1248,139,1,0,0,0,1249,1250,5,110,0,0,1250,1257,7,17,0,0,1251,1252, - 5,113,0,0,1252,1257,5,50,0,0,1253,1254,3,112,56,0,1254,1255,7,17,0,0,1255, - 1257,1,0,0,0,1256,1249,1,0,0,0,1256,1251,1,0,0,0,1256,1253,1,0,0,0,1257, - 141,1,0,0,0,1258,1259,5,113,0,0,1259,1265,5,50,0,0,1260,1265,5,27,0,0, - 1261,1265,5,115,0,0,1262,1263,5,85,0,0,1263,1265,5,116,0,0,1264,1258,1, - 0,0,0,1264,1260,1,0,0,0,1264,1261,1,0,0,0,1264,1262,1,0,0,0,1265,143,1, - 0,0,0,1266,1267,7,18,0,0,1267,145,1,0,0,0,1268,1269,5,121,0,0,1269,1270, - 5,135,0,0,1270,1271,5,3,0,0,1271,1272,3,112,56,0,1272,1273,5,136,0,0,1273, - 147,1,0,0,0,1274,1275,5,117,0,0,1275,1276,5,27,0,0,1276,1277,5,135,0,0, - 1277,1278,3,108,54,0,1278,1279,5,136,0,0,1279,149,1,0,0,0,1280,1281,5, - 2,0,0,1281,1282,7,19,0,0,1282,151,1,0,0,0,1283,1284,7,20,0,0,1284,1285, - 5,120,0,0,1285,153,1,0,0,0,162,155,171,174,180,200,209,212,220,229,233, - 245,253,257,265,270,273,281,288,298,305,319,324,333,344,354,357,364,373, - 380,387,392,401,428,433,437,449,455,472,476,483,488,493,503,506,509,515, - 519,522,535,544,549,553,557,561,571,576,581,589,593,595,599,604,612,619, - 623,631,638,644,652,664,669,674,679,686,693,695,704,714,725,730,738,742, - 746,754,761,769,773,776,789,792,796,800,804,807,812,815,818,821,824,835, - 847,851,859,870,878,888,891,899,902,904,912,919,924,927,933,936,942,959, - 962,966,970,974,976,988,993,1001,1026,1035,1056,1065,1074,1082,1085,1087, - 1109,1113,1118,1122,1135,1142,1145,1149,1152,1155,1158,1162,1170,1175, - 1183,1194,1198,1202,1206,1208,1221,1224,1227,1230,1243,1247,1256,1264 + 56,1,56,1,56,1,56,1,56,1,56,1,56,3,56,1029,8,56,1,56,1,56,1,56,1,56,1, + 56,1,56,1,56,3,56,1038,8,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1, + 56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,3,56,1059,8,56,1, + 56,1,56,1,56,1,56,1,56,1,56,1,56,3,56,1068,8,56,1,56,1,56,1,56,1,56,1, + 56,5,56,1075,8,56,10,56,12,56,1078,9,56,1,56,1,56,1,56,1,56,1,56,3,56, + 1085,8,56,1,56,5,56,1088,8,56,10,56,12,56,1091,9,56,1,57,1,57,1,57,1,57, + 1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57, + 1,57,3,57,1112,8,57,1,58,1,58,3,58,1116,8,58,1,58,4,58,1119,8,58,11,58, + 12,58,1120,1,58,1,58,3,58,1125,8,58,1,58,1,58,1,59,1,59,1,59,1,59,1,59, + 1,60,1,60,1,60,1,60,3,60,1138,8,60,1,60,1,60,1,60,5,60,1143,8,60,10,60, + 12,60,1146,9,60,3,60,1148,8,60,1,60,1,60,3,60,1152,8,60,1,60,3,60,1155, + 8,60,1,60,3,60,1158,8,60,1,60,3,60,1161,8,60,1,60,1,60,3,60,1165,8,60, + 1,61,1,61,1,61,1,61,1,61,1,61,3,61,1173,8,61,1,62,1,62,1,62,3,62,1178, + 8,62,1,62,1,62,1,63,1,63,1,63,1,63,3,63,1186,8,63,1,64,1,64,1,64,1,64, + 1,64,1,64,1,64,1,64,1,64,3,64,1197,8,64,1,65,1,65,3,65,1201,8,65,1,65, + 1,65,3,65,1205,8,65,1,65,1,65,3,65,1209,8,65,3,65,1211,8,65,1,66,1,66, + 1,66,1,67,1,67,1,67,1,67,1,67,1,67,5,67,1222,8,67,10,67,12,67,1225,9,67, + 3,67,1227,8,67,1,67,3,67,1230,8,67,1,67,3,67,1233,8,67,1,67,1,67,1,68, + 1,68,1,69,1,69,1,69,1,69,1,69,1,69,1,69,3,69,1246,8,69,1,69,1,69,3,69, + 1250,8,69,1,70,1,70,1,70,1,70,1,70,1,70,1,70,3,70,1259,8,70,1,71,1,71, + 1,71,1,71,1,71,1,71,3,71,1267,8,71,1,72,1,72,1,73,1,73,1,73,1,73,1,73, + 1,73,1,74,1,74,1,74,1,74,1,74,1,74,1,75,1,75,1,75,1,76,1,76,1,76,1,76, + 0,1,112,77,0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42, + 44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90, + 92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128, + 130,132,134,136,138,140,142,144,146,148,150,152,0,21,1,0,101,102,1,0,103, + 104,1,0,93,94,1,0,135,136,2,0,34,35,54,54,1,0,126,127,1,0,47,48,1,0,49, + 50,2,0,8,8,15,15,1,0,122,123,1,0,129,134,2,0,6,6,12,12,2,0,7,7,13,13,2, + 0,9,9,14,14,1,0,62,63,2,0,87,87,89,89,2,0,49,49,108,109,1,0,111,112,1, + 0,108,121,2,0,48,48,118,118,2,0,96,96,119,119,1470,0,155,1,0,0,0,2,178, + 1,0,0,0,4,200,1,0,0,0,6,202,1,0,0,0,8,214,1,0,0,0,10,235,1,0,0,0,12,239, + 1,0,0,0,14,259,1,0,0,0,16,268,1,0,0,0,18,275,1,0,0,0,20,286,1,0,0,0,22, + 310,1,0,0,0,24,329,1,0,0,0,26,357,1,0,0,0,28,359,1,0,0,0,30,364,1,0,0, + 0,32,366,1,0,0,0,34,428,1,0,0,0,36,435,1,0,0,0,38,437,1,0,0,0,40,474,1, + 0,0,0,42,476,1,0,0,0,44,485,1,0,0,0,46,487,1,0,0,0,48,498,1,0,0,0,50,505, + 1,0,0,0,52,524,1,0,0,0,54,597,1,0,0,0,56,688,1,0,0,0,58,697,1,0,0,0,60, + 706,1,0,0,0,62,716,1,0,0,0,64,718,1,0,0,0,66,750,1,0,0,0,68,763,1,0,0, + 0,70,765,1,0,0,0,72,794,1,0,0,0,74,802,1,0,0,0,76,804,1,0,0,0,78,828,1, + 0,0,0,80,830,1,0,0,0,82,840,1,0,0,0,84,843,1,0,0,0,86,861,1,0,0,0,88,880, + 1,0,0,0,90,882,1,0,0,0,92,893,1,0,0,0,94,906,1,0,0,0,96,908,1,0,0,0,98, + 917,1,0,0,0,100,944,1,0,0,0,102,961,1,0,0,0,104,978,1,0,0,0,106,980,1, + 0,0,0,108,983,1,0,0,0,110,993,1,0,0,0,112,1003,1,0,0,0,114,1111,1,0,0, + 0,116,1113,1,0,0,0,118,1128,1,0,0,0,120,1133,1,0,0,0,122,1172,1,0,0,0, + 124,1177,1,0,0,0,126,1185,1,0,0,0,128,1196,1,0,0,0,130,1210,1,0,0,0,132, + 1212,1,0,0,0,134,1215,1,0,0,0,136,1236,1,0,0,0,138,1238,1,0,0,0,140,1258, + 1,0,0,0,142,1266,1,0,0,0,144,1268,1,0,0,0,146,1270,1,0,0,0,148,1276,1, + 0,0,0,150,1282,1,0,0,0,152,1285,1,0,0,0,154,156,3,18,9,0,155,154,1,0,0, + 0,155,156,1,0,0,0,156,171,1,0,0,0,157,172,3,2,1,0,158,172,3,20,10,0,159, + 172,3,38,19,0,160,172,3,22,11,0,161,172,3,24,12,0,162,172,3,32,16,0,163, + 172,3,40,20,0,164,172,3,64,32,0,165,172,3,8,4,0,166,172,3,12,6,0,167,172, + 3,130,65,0,168,172,3,6,3,0,169,172,3,14,7,0,170,172,3,70,35,0,171,157, + 1,0,0,0,171,158,1,0,0,0,171,159,1,0,0,0,171,160,1,0,0,0,171,161,1,0,0, + 0,171,162,1,0,0,0,171,163,1,0,0,0,171,164,1,0,0,0,171,165,1,0,0,0,171, + 166,1,0,0,0,171,167,1,0,0,0,171,168,1,0,0,0,171,169,1,0,0,0,171,170,1, + 0,0,0,172,174,1,0,0,0,173,175,5,139,0,0,174,173,1,0,0,0,174,175,1,0,0, + 0,175,176,1,0,0,0,176,177,5,0,0,1,177,1,1,0,0,0,178,180,5,32,0,0,179,181, + 5,8,0,0,180,179,1,0,0,0,180,181,1,0,0,0,181,182,1,0,0,0,182,183,5,31,0, + 0,183,184,5,135,0,0,184,185,3,70,35,0,185,186,5,136,0,0,186,187,5,33,0, + 0,187,188,3,4,2,0,188,3,1,0,0,0,189,201,3,20,10,0,190,201,3,38,19,0,191, + 201,3,22,11,0,192,201,3,24,12,0,193,201,3,32,16,0,194,201,3,40,20,0,195, + 201,3,64,32,0,196,201,3,8,4,0,197,201,3,12,6,0,198,201,3,6,3,0,199,201, + 3,70,35,0,200,189,1,0,0,0,200,190,1,0,0,0,200,191,1,0,0,0,200,192,1,0, + 0,0,200,193,1,0,0,0,200,194,1,0,0,0,200,195,1,0,0,0,200,196,1,0,0,0,200, + 197,1,0,0,0,200,198,1,0,0,0,200,199,1,0,0,0,201,5,1,0,0,0,202,203,7,0, + 0,0,203,212,3,126,63,0,204,209,3,112,56,0,205,206,5,137,0,0,206,208,3, + 112,56,0,207,205,1,0,0,0,208,211,1,0,0,0,209,207,1,0,0,0,209,210,1,0,0, + 0,210,213,1,0,0,0,211,209,1,0,0,0,212,204,1,0,0,0,212,213,1,0,0,0,213, + 7,1,0,0,0,214,215,5,79,0,0,215,220,3,98,49,0,216,217,5,137,0,0,217,219, + 3,98,49,0,218,216,1,0,0,0,219,222,1,0,0,0,220,218,1,0,0,0,220,221,1,0, + 0,0,221,223,1,0,0,0,222,220,1,0,0,0,223,224,5,83,0,0,224,229,3,10,5,0, + 225,226,5,137,0,0,226,228,3,10,5,0,227,225,1,0,0,0,228,231,1,0,0,0,229, + 227,1,0,0,0,229,230,1,0,0,0,230,233,1,0,0,0,231,229,1,0,0,0,232,234,3, + 106,53,0,233,232,1,0,0,0,233,234,1,0,0,0,234,9,1,0,0,0,235,236,3,124,62, + 0,236,237,5,129,0,0,237,238,3,112,56,0,238,11,1,0,0,0,239,245,5,78,0,0, + 240,241,3,126,63,0,241,242,5,138,0,0,242,243,5,122,0,0,243,246,1,0,0,0, + 244,246,5,122,0,0,245,240,1,0,0,0,245,244,1,0,0,0,245,246,1,0,0,0,246, + 247,1,0,0,0,247,248,5,2,0,0,248,253,3,98,49,0,249,250,5,137,0,0,250,252, + 3,98,49,0,251,249,1,0,0,0,252,255,1,0,0,0,253,251,1,0,0,0,253,254,1,0, + 0,0,254,257,1,0,0,0,255,253,1,0,0,0,256,258,3,106,53,0,257,256,1,0,0,0, + 257,258,1,0,0,0,258,13,1,0,0,0,259,260,5,1,0,0,260,265,3,16,8,0,261,262, + 5,137,0,0,262,264,3,16,8,0,263,261,1,0,0,0,264,267,1,0,0,0,265,263,1,0, + 0,0,265,266,1,0,0,0,266,15,1,0,0,0,267,265,1,0,0,0,268,273,5,140,0,0,269, + 271,5,5,0,0,270,269,1,0,0,0,270,271,1,0,0,0,271,272,1,0,0,0,272,274,3, + 126,63,0,273,270,1,0,0,0,273,274,1,0,0,0,274,17,1,0,0,0,275,276,5,100, + 0,0,276,281,3,28,14,0,277,278,5,137,0,0,278,280,3,28,14,0,279,277,1,0, + 0,0,280,283,1,0,0,0,281,279,1,0,0,0,281,282,1,0,0,0,282,284,1,0,0,0,283, + 281,1,0,0,0,284,285,5,139,0,0,285,19,1,0,0,0,286,288,5,57,0,0,287,289, + 5,91,0,0,288,287,1,0,0,0,288,289,1,0,0,0,289,290,1,0,0,0,290,291,5,58, + 0,0,291,292,3,126,63,0,292,293,5,135,0,0,293,298,3,46,23,0,294,295,5,137, + 0,0,295,297,3,46,23,0,296,294,1,0,0,0,297,300,1,0,0,0,298,296,1,0,0,0, + 298,299,1,0,0,0,299,305,1,0,0,0,300,298,1,0,0,0,301,302,5,137,0,0,302, + 304,3,56,28,0,303,301,1,0,0,0,304,307,1,0,0,0,305,303,1,0,0,0,305,306, + 1,0,0,0,306,308,1,0,0,0,307,305,1,0,0,0,308,309,5,136,0,0,309,21,1,0,0, + 0,310,311,5,57,0,0,311,312,5,98,0,0,312,324,3,126,63,0,313,314,5,135,0, + 0,314,319,3,126,63,0,315,316,5,137,0,0,316,318,3,126,63,0,317,315,1,0, + 0,0,318,321,1,0,0,0,319,317,1,0,0,0,319,320,1,0,0,0,320,322,1,0,0,0,321, + 319,1,0,0,0,322,323,5,136,0,0,323,325,1,0,0,0,324,313,1,0,0,0,324,325, + 1,0,0,0,325,326,1,0,0,0,326,327,5,5,0,0,327,328,3,70,35,0,328,23,1,0,0, + 0,329,330,5,57,0,0,330,331,5,99,0,0,331,333,3,126,63,0,332,334,3,26,13, + 0,333,332,1,0,0,0,333,334,1,0,0,0,334,335,1,0,0,0,335,336,5,5,0,0,336, + 337,3,36,18,0,337,25,1,0,0,0,338,339,5,135,0,0,339,344,3,28,14,0,340,341, + 5,137,0,0,341,343,3,28,14,0,342,340,1,0,0,0,343,346,1,0,0,0,344,342,1, + 0,0,0,344,345,1,0,0,0,345,347,1,0,0,0,346,344,1,0,0,0,347,348,5,136,0, + 0,348,358,1,0,0,0,349,354,3,28,14,0,350,351,5,137,0,0,351,353,3,28,14, + 0,352,350,1,0,0,0,353,356,1,0,0,0,354,352,1,0,0,0,354,355,1,0,0,0,355, + 358,1,0,0,0,356,354,1,0,0,0,357,338,1,0,0,0,357,349,1,0,0,0,358,27,1,0, + 0,0,359,360,3,30,15,0,360,361,3,50,25,0,361,29,1,0,0,0,362,365,3,126,63, + 0,363,365,5,141,0,0,364,362,1,0,0,0,364,363,1,0,0,0,365,31,1,0,0,0,366, + 367,5,64,0,0,367,368,5,58,0,0,368,369,3,126,63,0,369,370,3,34,17,0,370, + 33,1,0,0,0,371,373,5,67,0,0,372,374,5,69,0,0,373,372,1,0,0,0,373,374,1, + 0,0,0,374,375,1,0,0,0,375,429,3,46,23,0,376,377,5,67,0,0,377,429,3,56, + 28,0,378,380,5,64,0,0,379,381,5,69,0,0,380,379,1,0,0,0,380,381,1,0,0,0, + 381,382,1,0,0,0,382,383,3,126,63,0,383,387,3,50,25,0,384,386,3,54,27,0, + 385,384,1,0,0,0,386,389,1,0,0,0,387,385,1,0,0,0,387,388,1,0,0,0,388,429, + 1,0,0,0,389,387,1,0,0,0,390,392,5,64,0,0,391,393,5,69,0,0,392,391,1,0, + 0,0,392,393,1,0,0,0,393,394,1,0,0,0,394,395,3,126,63,0,395,396,5,83,0, + 0,396,397,5,84,0,0,397,398,3,112,56,0,398,429,1,0,0,0,399,401,5,64,0,0, + 400,402,5,69,0,0,401,400,1,0,0,0,401,402,1,0,0,0,402,403,1,0,0,0,403,404, + 3,126,63,0,404,405,5,68,0,0,405,406,5,84,0,0,406,429,1,0,0,0,407,408,5, + 68,0,0,408,409,5,69,0,0,409,429,3,126,63,0,410,411,5,68,0,0,411,412,5, + 75,0,0,412,429,3,126,63,0,413,414,5,65,0,0,414,415,5,66,0,0,415,429,3, + 126,63,0,416,417,5,65,0,0,417,418,5,69,0,0,418,419,3,126,63,0,419,420, + 5,66,0,0,420,421,3,126,63,0,421,429,1,0,0,0,422,423,5,65,0,0,423,424,5, + 90,0,0,424,425,3,126,63,0,425,426,5,66,0,0,426,427,3,126,63,0,427,429, + 1,0,0,0,428,371,1,0,0,0,428,376,1,0,0,0,428,378,1,0,0,0,428,390,1,0,0, + 0,428,399,1,0,0,0,428,407,1,0,0,0,428,410,1,0,0,0,428,413,1,0,0,0,428, + 416,1,0,0,0,428,422,1,0,0,0,429,35,1,0,0,0,430,436,3,70,35,0,431,436,3, + 64,32,0,432,436,3,8,4,0,433,436,3,12,6,0,434,436,3,20,10,0,435,430,1,0, + 0,0,435,431,1,0,0,0,435,432,1,0,0,0,435,433,1,0,0,0,435,434,1,0,0,0,436, + 37,1,0,0,0,437,439,5,57,0,0,438,440,5,86,0,0,439,438,1,0,0,0,439,440,1, + 0,0,0,440,441,1,0,0,0,441,442,5,90,0,0,442,443,3,126,63,0,443,444,5,25, + 0,0,444,445,3,126,63,0,445,446,5,135,0,0,446,451,3,42,21,0,447,448,5,137, + 0,0,448,450,3,42,21,0,449,447,1,0,0,0,450,453,1,0,0,0,451,449,1,0,0,0, + 451,452,1,0,0,0,452,454,1,0,0,0,453,451,1,0,0,0,454,457,5,136,0,0,455, + 456,5,92,0,0,456,458,3,44,22,0,457,455,1,0,0,0,457,458,1,0,0,0,458,39, + 1,0,0,0,459,460,5,68,0,0,460,461,5,58,0,0,461,475,3,126,63,0,462,463,5, + 68,0,0,463,464,5,90,0,0,464,465,3,126,63,0,465,466,5,25,0,0,466,467,3, + 126,63,0,467,475,1,0,0,0,468,469,5,68,0,0,469,470,5,99,0,0,470,475,3,126, + 63,0,471,472,5,68,0,0,472,473,5,98,0,0,473,475,3,126,63,0,474,459,1,0, + 0,0,474,462,1,0,0,0,474,468,1,0,0,0,474,471,1,0,0,0,475,41,1,0,0,0,476, + 478,3,126,63,0,477,479,7,1,0,0,478,477,1,0,0,0,478,479,1,0,0,0,479,43, + 1,0,0,0,480,486,5,73,0,0,481,482,5,95,0,0,482,486,5,107,0,0,483,484,5, + 96,0,0,484,486,5,107,0,0,485,480,1,0,0,0,485,481,1,0,0,0,485,483,1,0,0, + 0,486,45,1,0,0,0,487,488,3,126,63,0,488,490,3,50,25,0,489,491,3,48,24, + 0,490,489,1,0,0,0,490,491,1,0,0,0,491,495,1,0,0,0,492,494,3,54,27,0,493, + 492,1,0,0,0,494,497,1,0,0,0,495,493,1,0,0,0,495,496,1,0,0,0,496,47,1,0, + 0,0,497,495,1,0,0,0,498,499,5,5,0,0,499,500,5,135,0,0,500,501,3,112,56, + 0,501,502,5,136,0,0,502,49,1,0,0,0,503,506,3,126,63,0,504,506,5,88,0,0, + 505,503,1,0,0,0,505,504,1,0,0,0,506,508,1,0,0,0,507,509,3,126,63,0,508, + 507,1,0,0,0,508,509,1,0,0,0,509,511,1,0,0,0,510,512,3,126,63,0,511,510, + 1,0,0,0,511,512,1,0,0,0,512,521,1,0,0,0,513,514,5,135,0,0,514,517,3,52, + 26,0,515,516,5,137,0,0,516,518,3,52,26,0,517,515,1,0,0,0,517,518,1,0,0, + 0,518,519,1,0,0,0,519,520,5,136,0,0,520,522,1,0,0,0,521,513,1,0,0,0,521, + 522,1,0,0,0,522,51,1,0,0,0,523,525,5,127,0,0,524,523,1,0,0,0,524,525,1, + 0,0,0,525,526,1,0,0,0,526,527,5,143,0,0,527,53,1,0,0,0,528,529,5,8,0,0, + 529,598,5,107,0,0,530,598,5,107,0,0,531,532,5,84,0,0,532,598,3,112,56, + 0,533,534,5,92,0,0,534,598,7,2,0,0,535,536,5,75,0,0,536,538,3,126,63,0, + 537,535,1,0,0,0,537,538,1,0,0,0,538,539,1,0,0,0,539,540,5,97,0,0,540,541, + 5,135,0,0,541,542,3,58,29,0,542,543,5,136,0,0,543,598,1,0,0,0,544,545, + 5,75,0,0,545,547,3,126,63,0,546,544,1,0,0,0,546,547,1,0,0,0,547,548,1, + 0,0,0,548,549,5,73,0,0,549,551,5,74,0,0,550,552,3,136,68,0,551,550,1,0, + 0,0,551,552,1,0,0,0,552,598,1,0,0,0,553,554,5,75,0,0,554,556,3,126,63, + 0,555,553,1,0,0,0,555,556,1,0,0,0,556,557,1,0,0,0,557,559,5,86,0,0,558, + 560,3,136,68,0,559,558,1,0,0,0,559,560,1,0,0,0,560,598,1,0,0,0,561,562, + 5,75,0,0,562,564,3,126,63,0,563,561,1,0,0,0,563,564,1,0,0,0,564,565,1, + 0,0,0,565,566,5,77,0,0,566,578,3,126,63,0,567,568,5,135,0,0,568,573,3, + 126,63,0,569,570,5,137,0,0,570,572,3,126,63,0,571,569,1,0,0,0,572,575, + 1,0,0,0,573,571,1,0,0,0,573,574,1,0,0,0,574,576,1,0,0,0,575,573,1,0,0, + 0,576,577,5,136,0,0,577,579,1,0,0,0,578,567,1,0,0,0,578,579,1,0,0,0,579, + 583,1,0,0,0,580,582,3,60,30,0,581,580,1,0,0,0,582,585,1,0,0,0,583,581, + 1,0,0,0,583,584,1,0,0,0,584,598,1,0,0,0,585,583,1,0,0,0,586,595,5,88,0, + 0,587,588,5,135,0,0,588,591,3,52,26,0,589,590,5,137,0,0,590,592,3,52,26, + 0,591,589,1,0,0,0,591,592,1,0,0,0,592,593,1,0,0,0,593,594,5,136,0,0,594, + 596,1,0,0,0,595,587,1,0,0,0,595,596,1,0,0,0,596,598,1,0,0,0,597,528,1, + 0,0,0,597,530,1,0,0,0,597,531,1,0,0,0,597,533,1,0,0,0,597,537,1,0,0,0, + 597,546,1,0,0,0,597,555,1,0,0,0,597,563,1,0,0,0,597,586,1,0,0,0,598,55, + 1,0,0,0,599,600,5,75,0,0,600,602,3,126,63,0,601,599,1,0,0,0,601,602,1, + 0,0,0,602,603,1,0,0,0,603,604,5,73,0,0,604,606,5,74,0,0,605,607,3,136, + 68,0,606,605,1,0,0,0,606,607,1,0,0,0,607,608,1,0,0,0,608,609,5,135,0,0, + 609,614,3,126,63,0,610,611,5,137,0,0,611,613,3,126,63,0,612,610,1,0,0, + 0,613,616,1,0,0,0,614,612,1,0,0,0,614,615,1,0,0,0,615,617,1,0,0,0,616, + 614,1,0,0,0,617,618,5,136,0,0,618,689,1,0,0,0,619,620,5,75,0,0,620,622, + 3,126,63,0,621,619,1,0,0,0,621,622,1,0,0,0,622,623,1,0,0,0,623,625,5,86, + 0,0,624,626,3,136,68,0,625,624,1,0,0,0,625,626,1,0,0,0,626,627,1,0,0,0, + 627,628,5,135,0,0,628,633,3,126,63,0,629,630,5,137,0,0,630,632,3,126,63, + 0,631,629,1,0,0,0,632,635,1,0,0,0,633,631,1,0,0,0,633,634,1,0,0,0,634, + 636,1,0,0,0,635,633,1,0,0,0,636,637,5,136,0,0,637,689,1,0,0,0,638,639, + 5,75,0,0,639,641,3,126,63,0,640,638,1,0,0,0,640,641,1,0,0,0,641,642,1, + 0,0,0,642,643,5,76,0,0,643,646,5,74,0,0,644,645,5,85,0,0,645,647,5,90, + 0,0,646,644,1,0,0,0,646,647,1,0,0,0,647,648,1,0,0,0,648,649,5,135,0,0, + 649,654,3,126,63,0,650,651,5,137,0,0,651,653,3,126,63,0,652,650,1,0,0, + 0,653,656,1,0,0,0,654,652,1,0,0,0,654,655,1,0,0,0,655,657,1,0,0,0,656, + 654,1,0,0,0,657,658,5,136,0,0,658,659,5,77,0,0,659,671,3,126,63,0,660, + 661,5,135,0,0,661,666,3,126,63,0,662,663,5,137,0,0,663,665,3,126,63,0, + 664,662,1,0,0,0,665,668,1,0,0,0,666,664,1,0,0,0,666,667,1,0,0,0,667,669, + 1,0,0,0,668,666,1,0,0,0,669,670,5,136,0,0,670,672,1,0,0,0,671,660,1,0, + 0,0,671,672,1,0,0,0,672,676,1,0,0,0,673,675,3,60,30,0,674,673,1,0,0,0, + 675,678,1,0,0,0,676,674,1,0,0,0,676,677,1,0,0,0,677,689,1,0,0,0,678,676, + 1,0,0,0,679,680,5,75,0,0,680,682,3,126,63,0,681,679,1,0,0,0,681,682,1, + 0,0,0,682,683,1,0,0,0,683,684,5,97,0,0,684,685,5,135,0,0,685,686,3,58, + 29,0,686,687,5,136,0,0,687,689,1,0,0,0,688,601,1,0,0,0,688,621,1,0,0,0, + 688,640,1,0,0,0,688,681,1,0,0,0,689,57,1,0,0,0,690,696,8,3,0,0,691,692, + 5,135,0,0,692,693,3,58,29,0,693,694,5,136,0,0,694,696,1,0,0,0,695,690, + 1,0,0,0,695,691,1,0,0,0,696,699,1,0,0,0,697,695,1,0,0,0,697,698,1,0,0, + 0,698,59,1,0,0,0,699,697,1,0,0,0,700,701,5,25,0,0,701,702,5,79,0,0,702, + 707,3,62,31,0,703,704,5,25,0,0,704,705,5,78,0,0,705,707,3,62,31,0,706, + 700,1,0,0,0,706,703,1,0,0,0,707,61,1,0,0,0,708,717,5,80,0,0,709,710,5, + 85,0,0,710,717,5,82,0,0,711,717,5,81,0,0,712,713,5,83,0,0,713,717,5,107, + 0,0,714,715,5,83,0,0,715,717,5,84,0,0,716,708,1,0,0,0,716,709,1,0,0,0, + 716,711,1,0,0,0,716,712,1,0,0,0,716,714,1,0,0,0,717,63,1,0,0,0,718,719, + 5,70,0,0,719,720,5,71,0,0,720,748,3,126,63,0,721,722,5,135,0,0,722,727, + 3,126,63,0,723,724,5,137,0,0,724,726,3,126,63,0,725,723,1,0,0,0,726,729, + 1,0,0,0,727,725,1,0,0,0,727,728,1,0,0,0,728,730,1,0,0,0,729,727,1,0,0, + 0,730,731,5,136,0,0,731,733,1,0,0,0,732,721,1,0,0,0,732,733,1,0,0,0,733, + 744,1,0,0,0,734,735,5,72,0,0,735,740,3,66,33,0,736,737,5,137,0,0,737,739, + 3,66,33,0,738,736,1,0,0,0,739,742,1,0,0,0,740,738,1,0,0,0,740,741,1,0, + 0,0,741,745,1,0,0,0,742,740,1,0,0,0,743,745,3,70,35,0,744,734,1,0,0,0, + 744,743,1,0,0,0,745,749,1,0,0,0,746,747,5,84,0,0,747,749,5,72,0,0,748, + 732,1,0,0,0,748,746,1,0,0,0,749,65,1,0,0,0,750,751,5,135,0,0,751,756,3, + 68,34,0,752,753,5,137,0,0,753,755,3,68,34,0,754,752,1,0,0,0,755,758,1, + 0,0,0,756,754,1,0,0,0,756,757,1,0,0,0,757,759,1,0,0,0,758,756,1,0,0,0, + 759,760,5,136,0,0,760,67,1,0,0,0,761,764,5,84,0,0,762,764,3,112,56,0,763, + 761,1,0,0,0,763,762,1,0,0,0,764,69,1,0,0,0,765,771,3,72,36,0,766,767,3, + 74,37,0,767,768,3,72,36,0,768,770,1,0,0,0,769,766,1,0,0,0,770,773,1,0, + 0,0,771,769,1,0,0,0,771,772,1,0,0,0,772,775,1,0,0,0,773,771,1,0,0,0,774, + 776,3,108,54,0,775,774,1,0,0,0,775,776,1,0,0,0,776,778,1,0,0,0,777,779, + 3,88,44,0,778,777,1,0,0,0,778,779,1,0,0,0,779,71,1,0,0,0,780,795,3,76, + 38,0,781,782,5,135,0,0,782,783,3,70,35,0,783,784,5,136,0,0,784,795,1,0, + 0,0,785,786,5,72,0,0,786,791,3,66,33,0,787,788,5,137,0,0,788,790,3,66, + 33,0,789,787,1,0,0,0,790,793,1,0,0,0,791,789,1,0,0,0,791,792,1,0,0,0,792, + 795,1,0,0,0,793,791,1,0,0,0,794,780,1,0,0,0,794,781,1,0,0,0,794,785,1, + 0,0,0,795,73,1,0,0,0,796,798,5,53,0,0,797,799,5,54,0,0,798,797,1,0,0,0, + 798,799,1,0,0,0,799,803,1,0,0,0,800,803,5,55,0,0,801,803,5,56,0,0,802, + 796,1,0,0,0,802,800,1,0,0,0,802,801,1,0,0,0,803,75,1,0,0,0,804,806,5,1, + 0,0,805,807,3,78,39,0,806,805,1,0,0,0,806,807,1,0,0,0,807,809,1,0,0,0, + 808,810,3,84,42,0,809,808,1,0,0,0,809,810,1,0,0,0,810,811,1,0,0,0,811, + 814,3,92,46,0,812,813,5,71,0,0,813,815,3,126,63,0,814,812,1,0,0,0,814, + 815,1,0,0,0,815,817,1,0,0,0,816,818,3,96,48,0,817,816,1,0,0,0,817,818, + 1,0,0,0,818,820,1,0,0,0,819,821,3,106,53,0,820,819,1,0,0,0,820,821,1,0, + 0,0,821,823,1,0,0,0,822,824,3,80,40,0,823,822,1,0,0,0,823,824,1,0,0,0, + 824,826,1,0,0,0,825,827,3,82,41,0,826,825,1,0,0,0,826,827,1,0,0,0,827, + 77,1,0,0,0,828,829,7,4,0,0,829,79,1,0,0,0,830,831,5,27,0,0,831,832,5,29, + 0,0,832,837,3,112,56,0,833,834,5,137,0,0,834,836,3,112,56,0,835,833,1, + 0,0,0,836,839,1,0,0,0,837,835,1,0,0,0,837,838,1,0,0,0,838,81,1,0,0,0,839, + 837,1,0,0,0,840,841,5,30,0,0,841,842,3,112,56,0,842,83,1,0,0,0,843,844, + 5,4,0,0,844,849,3,86,43,0,845,846,7,5,0,0,846,848,3,86,43,0,847,845,1, + 0,0,0,848,851,1,0,0,0,849,847,1,0,0,0,849,850,1,0,0,0,850,853,1,0,0,0, + 851,849,1,0,0,0,852,854,5,36,0,0,853,852,1,0,0,0,853,854,1,0,0,0,854,85, + 1,0,0,0,855,862,5,143,0,0,856,862,5,141,0,0,857,858,5,135,0,0,858,859, + 3,112,56,0,859,860,5,136,0,0,860,862,1,0,0,0,861,855,1,0,0,0,861,856,1, + 0,0,0,861,857,1,0,0,0,862,87,1,0,0,0,863,864,5,45,0,0,864,865,3,112,56, + 0,865,872,3,90,45,0,866,867,5,46,0,0,867,868,7,6,0,0,868,869,3,112,56, + 0,869,870,3,90,45,0,870,871,5,51,0,0,871,873,1,0,0,0,872,866,1,0,0,0,872, + 873,1,0,0,0,873,881,1,0,0,0,874,875,5,46,0,0,875,876,7,6,0,0,876,877,3, + 112,56,0,877,878,3,90,45,0,878,879,5,51,0,0,879,881,1,0,0,0,880,863,1, + 0,0,0,880,874,1,0,0,0,881,89,1,0,0,0,882,883,7,7,0,0,883,91,1,0,0,0,884, + 894,5,122,0,0,885,890,3,94,47,0,886,887,5,137,0,0,887,889,3,94,47,0,888, + 886,1,0,0,0,889,892,1,0,0,0,890,888,1,0,0,0,890,891,1,0,0,0,891,894,1, + 0,0,0,892,890,1,0,0,0,893,884,1,0,0,0,893,885,1,0,0,0,894,93,1,0,0,0,895, + 896,3,126,63,0,896,897,5,138,0,0,897,898,5,122,0,0,898,907,1,0,0,0,899, + 904,3,112,56,0,900,902,5,5,0,0,901,900,1,0,0,0,901,902,1,0,0,0,902,903, + 1,0,0,0,903,905,3,126,63,0,904,901,1,0,0,0,904,905,1,0,0,0,905,907,1,0, + 0,0,906,895,1,0,0,0,906,899,1,0,0,0,907,95,1,0,0,0,908,909,5,2,0,0,909, + 914,3,98,49,0,910,911,5,137,0,0,911,913,3,98,49,0,912,910,1,0,0,0,913, + 916,1,0,0,0,914,912,1,0,0,0,914,915,1,0,0,0,915,97,1,0,0,0,916,914,1,0, + 0,0,917,921,3,100,50,0,918,920,3,102,51,0,919,918,1,0,0,0,920,923,1,0, + 0,0,921,919,1,0,0,0,921,922,1,0,0,0,922,99,1,0,0,0,923,921,1,0,0,0,924, + 929,3,126,63,0,925,927,5,5,0,0,926,925,1,0,0,0,926,927,1,0,0,0,927,928, + 1,0,0,0,928,930,3,126,63,0,929,926,1,0,0,0,929,930,1,0,0,0,930,945,1,0, + 0,0,931,932,5,135,0,0,932,933,3,70,35,0,933,938,5,136,0,0,934,936,5,5, + 0,0,935,934,1,0,0,0,935,936,1,0,0,0,936,937,1,0,0,0,937,939,3,126,63,0, + 938,935,1,0,0,0,938,939,1,0,0,0,939,945,1,0,0,0,940,941,5,135,0,0,941, + 942,3,98,49,0,942,943,5,136,0,0,943,945,1,0,0,0,944,924,1,0,0,0,944,931, + 1,0,0,0,944,940,1,0,0,0,945,101,1,0,0,0,946,947,3,104,52,0,947,948,5,23, + 0,0,948,949,3,100,50,0,949,950,5,25,0,0,950,951,3,112,56,0,951,962,1,0, + 0,0,952,953,5,37,0,0,953,954,5,23,0,0,954,962,3,100,50,0,955,956,5,37, + 0,0,956,957,5,38,0,0,957,962,3,100,50,0,958,959,5,22,0,0,959,960,5,38, + 0,0,960,962,3,100,50,0,961,946,1,0,0,0,961,952,1,0,0,0,961,955,1,0,0,0, + 961,958,1,0,0,0,962,103,1,0,0,0,963,965,5,18,0,0,964,963,1,0,0,0,964,965, + 1,0,0,0,965,979,1,0,0,0,966,968,5,19,0,0,967,969,5,22,0,0,968,967,1,0, + 0,0,968,969,1,0,0,0,969,979,1,0,0,0,970,972,5,20,0,0,971,973,5,22,0,0, + 972,971,1,0,0,0,972,973,1,0,0,0,973,979,1,0,0,0,974,976,5,21,0,0,975,977, + 5,22,0,0,976,975,1,0,0,0,976,977,1,0,0,0,977,979,1,0,0,0,978,964,1,0,0, + 0,978,966,1,0,0,0,978,970,1,0,0,0,978,974,1,0,0,0,979,105,1,0,0,0,980, + 981,5,3,0,0,981,982,3,112,56,0,982,107,1,0,0,0,983,984,5,26,0,0,984,985, + 5,29,0,0,985,990,3,110,55,0,986,987,5,137,0,0,987,989,3,110,55,0,988,986, + 1,0,0,0,989,992,1,0,0,0,990,988,1,0,0,0,990,991,1,0,0,0,991,109,1,0,0, + 0,992,990,1,0,0,0,993,995,3,112,56,0,994,996,7,1,0,0,995,994,1,0,0,0,995, + 996,1,0,0,0,996,111,1,0,0,0,997,998,6,56,-1,0,998,999,7,5,0,0,999,1004, + 3,112,56,19,1000,1001,7,8,0,0,1001,1004,3,112,56,7,1002,1004,3,114,57, + 0,1003,997,1,0,0,0,1003,1000,1,0,0,0,1003,1002,1,0,0,0,1004,1089,1,0,0, + 0,1005,1006,10,20,0,0,1006,1007,5,125,0,0,1007,1088,3,112,56,21,1008,1009, + 10,18,0,0,1009,1010,7,9,0,0,1010,1088,3,112,56,19,1011,1012,10,17,0,0, + 1012,1013,5,124,0,0,1013,1088,3,112,56,18,1014,1015,10,16,0,0,1015,1016, + 5,17,0,0,1016,1088,3,112,56,17,1017,1018,10,15,0,0,1018,1019,7,5,0,0,1019, + 1088,3,112,56,16,1020,1021,10,14,0,0,1021,1022,5,128,0,0,1022,1088,3,112, + 56,15,1023,1024,10,13,0,0,1024,1025,7,10,0,0,1025,1088,3,112,56,14,1026, + 1028,10,12,0,0,1027,1029,5,8,0,0,1028,1027,1,0,0,0,1028,1029,1,0,0,0,1029, + 1030,1,0,0,0,1030,1031,5,52,0,0,1031,1032,3,112,56,0,1032,1033,5,6,0,0, + 1033,1034,3,112,56,13,1034,1088,1,0,0,0,1035,1037,10,11,0,0,1036,1038, + 5,8,0,0,1037,1036,1,0,0,0,1037,1038,1,0,0,0,1038,1039,1,0,0,0,1039,1040, + 5,16,0,0,1040,1088,3,112,56,12,1041,1042,10,6,0,0,1042,1043,7,11,0,0,1043, + 1088,3,112,56,7,1044,1045,10,5,0,0,1045,1046,7,12,0,0,1046,1088,3,112, + 56,6,1047,1048,10,4,0,0,1048,1049,7,13,0,0,1049,1088,3,112,56,5,1050,1051, + 10,3,0,0,1051,1052,5,10,0,0,1052,1088,3,112,56,4,1053,1054,10,2,0,0,1054, + 1055,5,11,0,0,1055,1088,3,112,56,3,1056,1058,10,10,0,0,1057,1059,5,8,0, + 0,1058,1057,1,0,0,0,1058,1059,1,0,0,0,1059,1060,1,0,0,0,1060,1061,5,24, + 0,0,1061,1062,5,135,0,0,1062,1063,3,70,35,0,1063,1064,5,136,0,0,1064,1088, + 1,0,0,0,1065,1067,10,9,0,0,1066,1068,5,8,0,0,1067,1066,1,0,0,0,1067,1068, + 1,0,0,0,1068,1069,1,0,0,0,1069,1070,5,24,0,0,1070,1071,5,135,0,0,1071, + 1076,3,112,56,0,1072,1073,5,137,0,0,1073,1075,3,112,56,0,1074,1072,1,0, + 0,0,1075,1078,1,0,0,0,1076,1074,1,0,0,0,1076,1077,1,0,0,0,1077,1079,1, + 0,0,0,1078,1076,1,0,0,0,1079,1080,5,136,0,0,1080,1088,1,0,0,0,1081,1082, + 10,8,0,0,1082,1084,5,28,0,0,1083,1085,5,8,0,0,1084,1083,1,0,0,0,1084,1085, + 1,0,0,0,1085,1086,1,0,0,0,1086,1088,5,107,0,0,1087,1005,1,0,0,0,1087,1008, + 1,0,0,0,1087,1011,1,0,0,0,1087,1014,1,0,0,0,1087,1017,1,0,0,0,1087,1020, + 1,0,0,0,1087,1023,1,0,0,0,1087,1026,1,0,0,0,1087,1035,1,0,0,0,1087,1041, + 1,0,0,0,1087,1044,1,0,0,0,1087,1047,1,0,0,0,1087,1050,1,0,0,0,1087,1053, + 1,0,0,0,1087,1056,1,0,0,0,1087,1065,1,0,0,0,1087,1081,1,0,0,0,1088,1091, + 1,0,0,0,1089,1087,1,0,0,0,1089,1090,1,0,0,0,1090,113,1,0,0,0,1091,1089, + 1,0,0,0,1092,1112,3,128,64,0,1093,1112,3,116,58,0,1094,1112,3,120,60,0, + 1095,1112,3,124,62,0,1096,1112,5,141,0,0,1097,1112,5,140,0,0,1098,1099, + 5,31,0,0,1099,1100,5,135,0,0,1100,1101,3,70,35,0,1101,1102,5,136,0,0,1102, + 1112,1,0,0,0,1103,1104,5,135,0,0,1104,1105,3,70,35,0,1105,1106,5,136,0, + 0,1106,1112,1,0,0,0,1107,1108,5,135,0,0,1108,1109,3,112,56,0,1109,1110, + 5,136,0,0,1110,1112,1,0,0,0,1111,1092,1,0,0,0,1111,1093,1,0,0,0,1111,1094, + 1,0,0,0,1111,1095,1,0,0,0,1111,1096,1,0,0,0,1111,1097,1,0,0,0,1111,1098, + 1,0,0,0,1111,1103,1,0,0,0,1111,1107,1,0,0,0,1112,115,1,0,0,0,1113,1115, + 5,41,0,0,1114,1116,3,112,56,0,1115,1114,1,0,0,0,1115,1116,1,0,0,0,1116, + 1118,1,0,0,0,1117,1119,3,118,59,0,1118,1117,1,0,0,0,1119,1120,1,0,0,0, + 1120,1118,1,0,0,0,1120,1121,1,0,0,0,1121,1124,1,0,0,0,1122,1123,5,43,0, + 0,1123,1125,3,112,56,0,1124,1122,1,0,0,0,1124,1125,1,0,0,0,1125,1126,1, + 0,0,0,1126,1127,5,44,0,0,1127,117,1,0,0,0,1128,1129,5,42,0,0,1129,1130, + 3,112,56,0,1130,1131,5,33,0,0,1131,1132,3,112,56,0,1132,119,1,0,0,0,1133, + 1134,3,122,61,0,1134,1147,5,135,0,0,1135,1148,5,122,0,0,1136,1138,5,35, + 0,0,1137,1136,1,0,0,0,1137,1138,1,0,0,0,1138,1139,1,0,0,0,1139,1144,3, + 112,56,0,1140,1141,5,137,0,0,1141,1143,3,112,56,0,1142,1140,1,0,0,0,1143, + 1146,1,0,0,0,1144,1142,1,0,0,0,1144,1145,1,0,0,0,1145,1148,1,0,0,0,1146, + 1144,1,0,0,0,1147,1135,1,0,0,0,1147,1137,1,0,0,0,1147,1148,1,0,0,0,1148, + 1149,1,0,0,0,1149,1151,5,136,0,0,1150,1152,3,148,74,0,1151,1150,1,0,0, + 0,1151,1152,1,0,0,0,1152,1154,1,0,0,0,1153,1155,3,146,73,0,1154,1153,1, + 0,0,0,1154,1155,1,0,0,0,1155,1164,1,0,0,0,1156,1158,3,150,75,0,1157,1156, + 1,0,0,0,1157,1158,1,0,0,0,1158,1160,1,0,0,0,1159,1161,3,152,76,0,1160, + 1159,1,0,0,0,1160,1161,1,0,0,0,1161,1162,1,0,0,0,1162,1163,5,39,0,0,1163, + 1165,3,134,67,0,1164,1157,1,0,0,0,1164,1165,1,0,0,0,1165,121,1,0,0,0,1166, + 1173,3,126,63,0,1167,1173,5,19,0,0,1168,1173,5,20,0,0,1169,1173,5,103, + 0,0,1170,1173,5,48,0,0,1171,1173,5,40,0,0,1172,1166,1,0,0,0,1172,1167, + 1,0,0,0,1172,1168,1,0,0,0,1172,1169,1,0,0,0,1172,1170,1,0,0,0,1172,1171, + 1,0,0,0,1173,123,1,0,0,0,1174,1175,3,126,63,0,1175,1176,5,138,0,0,1176, + 1178,1,0,0,0,1177,1174,1,0,0,0,1177,1178,1,0,0,0,1178,1179,1,0,0,0,1179, + 1180,3,126,63,0,1180,125,1,0,0,0,1181,1186,5,150,0,0,1182,1186,5,148,0, + 0,1183,1186,5,149,0,0,1184,1186,3,144,72,0,1185,1181,1,0,0,0,1185,1182, + 1,0,0,0,1185,1183,1,0,0,0,1185,1184,1,0,0,0,1186,127,1,0,0,0,1187,1197, + 5,143,0,0,1188,1197,5,144,0,0,1189,1197,5,142,0,0,1190,1197,5,145,0,0, + 1191,1197,5,146,0,0,1192,1197,5,147,0,0,1193,1197,5,105,0,0,1194,1197, + 5,106,0,0,1195,1197,5,107,0,0,1196,1187,1,0,0,0,1196,1188,1,0,0,0,1196, + 1189,1,0,0,0,1196,1190,1,0,0,0,1196,1191,1,0,0,0,1196,1192,1,0,0,0,1196, + 1193,1,0,0,0,1196,1194,1,0,0,0,1196,1195,1,0,0,0,1197,129,1,0,0,0,1198, + 1200,5,59,0,0,1199,1201,7,14,0,0,1200,1199,1,0,0,0,1200,1201,1,0,0,0,1201, + 1211,1,0,0,0,1202,1204,5,60,0,0,1203,1205,7,14,0,0,1204,1203,1,0,0,0,1204, + 1205,1,0,0,0,1205,1211,1,0,0,0,1206,1208,5,61,0,0,1207,1209,7,14,0,0,1208, + 1207,1,0,0,0,1208,1209,1,0,0,0,1209,1211,1,0,0,0,1210,1198,1,0,0,0,1210, + 1202,1,0,0,0,1210,1206,1,0,0,0,1211,131,1,0,0,0,1212,1213,3,112,56,0,1213, + 1214,5,0,0,1,1214,133,1,0,0,0,1215,1226,5,135,0,0,1216,1217,5,40,0,0,1217, + 1218,5,29,0,0,1218,1223,3,112,56,0,1219,1220,5,137,0,0,1220,1222,3,112, + 56,0,1221,1219,1,0,0,0,1222,1225,1,0,0,0,1223,1221,1,0,0,0,1223,1224,1, + 0,0,0,1224,1227,1,0,0,0,1225,1223,1,0,0,0,1226,1216,1,0,0,0,1226,1227, + 1,0,0,0,1227,1229,1,0,0,0,1228,1230,3,108,54,0,1229,1228,1,0,0,0,1229, + 1230,1,0,0,0,1230,1232,1,0,0,0,1231,1233,3,138,69,0,1232,1231,1,0,0,0, + 1232,1233,1,0,0,0,1233,1234,1,0,0,0,1234,1235,5,136,0,0,1235,135,1,0,0, + 0,1236,1237,7,15,0,0,1237,137,1,0,0,0,1238,1245,7,16,0,0,1239,1240,5,52, + 0,0,1240,1241,3,140,70,0,1241,1242,5,6,0,0,1242,1243,3,140,70,0,1243,1246, + 1,0,0,0,1244,1246,3,140,70,0,1245,1239,1,0,0,0,1245,1244,1,0,0,0,1246, + 1249,1,0,0,0,1247,1248,5,114,0,0,1248,1250,3,142,71,0,1249,1247,1,0,0, + 0,1249,1250,1,0,0,0,1250,139,1,0,0,0,1251,1252,5,110,0,0,1252,1259,7,17, + 0,0,1253,1254,5,113,0,0,1254,1259,5,50,0,0,1255,1256,3,112,56,0,1256,1257, + 7,17,0,0,1257,1259,1,0,0,0,1258,1251,1,0,0,0,1258,1253,1,0,0,0,1258,1255, + 1,0,0,0,1259,141,1,0,0,0,1260,1261,5,113,0,0,1261,1267,5,50,0,0,1262,1267, + 5,27,0,0,1263,1267,5,115,0,0,1264,1265,5,85,0,0,1265,1267,5,116,0,0,1266, + 1260,1,0,0,0,1266,1262,1,0,0,0,1266,1263,1,0,0,0,1266,1264,1,0,0,0,1267, + 143,1,0,0,0,1268,1269,7,18,0,0,1269,145,1,0,0,0,1270,1271,5,121,0,0,1271, + 1272,5,135,0,0,1272,1273,5,3,0,0,1273,1274,3,112,56,0,1274,1275,5,136, + 0,0,1275,147,1,0,0,0,1276,1277,5,117,0,0,1277,1278,5,27,0,0,1278,1279, + 5,135,0,0,1279,1280,3,108,54,0,1280,1281,5,136,0,0,1281,149,1,0,0,0,1282, + 1283,5,2,0,0,1283,1284,7,19,0,0,1284,151,1,0,0,0,1285,1286,7,20,0,0,1286, + 1287,5,120,0,0,1287,153,1,0,0,0,162,155,171,174,180,200,209,212,220,229, + 233,245,253,257,265,270,273,281,288,298,305,319,324,333,344,354,357,364, + 373,380,387,392,401,428,435,439,451,457,474,478,485,490,495,505,508,511, + 517,521,524,537,546,551,555,559,563,573,578,583,591,595,597,601,606,614, + 621,625,633,640,646,654,666,671,676,681,688,695,697,706,716,727,732,740, + 744,748,756,763,771,775,778,791,794,798,802,806,809,814,817,820,823,826, + 837,849,853,861,872,880,890,893,901,904,906,914,921,926,929,935,938,944, + 961,964,968,972,976,978,990,995,1003,1028,1037,1058,1067,1076,1084,1087, + 1089,1111,1115,1120,1124,1137,1144,1147,1151,1154,1157,1160,1164,1172, + 1177,1185,1196,1200,1204,1208,1210,1223,1226,1229,1232,1245,1249,1258, + 1266 }; public static readonly ATN _ATN = diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlVisitor.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlVisitor.cs index 366aaa7d..558a71e7 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlVisitor.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlVisitor.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -// Generated from D:/toolkits/efcorejetlibred/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 by ANTLR 4.13.1 +// Generated from AccessSql.g4 by ANTLR 4.13.1 // Unreachable code detected #pragma warning disable 0162 diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index 4891482c..7025e869 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -401,7 +401,8 @@ private static SqlStatement BuildCreateView(CreateViewStatementContext ctx) private static SqlStatement BuildCreateProcedure(CreateProcedureStatementContext ctx) { var parameters = (ctx.procParamList()?.procParam() ?? []) - .Select(p => new ProcedureParameter(ParamName(p), TypeName(p.dataType()))) + .Select(p => new ProcedureParameter( + ParamName(p), TypeName(p.dataType()), Size(p.dataType()), Scale(p.dataType()))) .ToList(); // A procedure body is a SELECT (stored as a parameterized query, like a view) or an action query @@ -411,29 +412,59 @@ private static SqlStatement BuildCreateProcedure(CreateProcedureStatementContext if (body.createTableStatement() is { } ddl) { - RejectParametersOnAction(parameters); + // A data-definition query is stored as its verbatim text, with nothing decomposed — there is no + // row for a parameter to reach, so one declared here could never bind. + if (parameters.Count > 0) + throw new NotSupportedException( + "Parameters on a data-definition procedure are not supported: its SQL is stored verbatim."); return new CreateActionProcedureStatement( name, ProcedureActionKind.DataDefinition, OriginalText(ddl), null, null); } - if (body.insertStatement() is { } insert) - { - RejectParametersOnAction(parameters); - return BuildAppendProcedure(name, insert); - } + if (body.insertStatement() is { } insert) return BuildAppendProcedure(name, insert, parameters); + if (body.updateStatement() is { } update) return BuildUpdateProcedure(name, update, parameters); + if (body.deleteStatement() is { } delete) return BuildDeleteProcedure(name, delete, parameters); + + QueryExpressionContext query = body.queryExpression(); + // A SELECT with an INTO is a make-table query — an action query that stores its target on the action + // row — not a view of the SELECT. + if (MakeTableTarget(query) is { } target) + return new CreateActionProcedureStatement( + name, ProcedureActionKind.MakeTable, null, target, null, + BuildViewDefinition(query), null, parameters); - ViewDefinition definition = BuildViewDefinition(body.queryExpression()); - return new CreateProcedureStatement(name, parameters, definition, OriginalText(body.queryExpression())); + ViewDefinition definition = BuildViewDefinition(query); + return new CreateProcedureStatement(name, parameters, definition, OriginalText(query)); } - private static void RejectParametersOnAction(IReadOnlyList parameters) - { - if (parameters.Count > 0) - throw new NotSupportedException("Parameters on an action-query procedure are not stored yet."); - } + /// The table a SELECT … INTO t body writes into, or null for an ordinary SELECT. + private static string? MakeTableTarget(QueryExpressionContext ctx) => + ctx.setOperator().Length == 0 && ctx.queryTerm(0) is SelectTermContext term + && term.querySpecification().into is { } into + ? Identifier(into) + : null; - private static SqlStatement BuildAppendProcedure(string name, InsertStatementContext insert) + private static SqlStatement BuildAppendProcedure( + string name, InsertStatementContext insert, IReadOnlyList parameters) { var columns = insert._columns; + if (columns.Count == 0) + throw new NotSupportedException("An INSERT procedure body must list its target columns."); + + // The multiple-record form: the values come from a SELECT, which is stored as the query's own source + // — the same table / join / where rows a view stores — with each column row naming what it reads. + if (insert.source is { } source) + { + ViewDefinition body = BuildViewDefinition(source); + if (body.Columns.Count != columns.Count) + throw new SqlParseException( + $"INSERT lists {columns.Count} columns but its SELECT returns {body.Columns.Count}."); + + var sourced = columns + .Select((col, i) => new AppendColumn(Identifier(col), body.Columns[i].Expression)) + .ToList(); + return new CreateActionProcedureStatement( + name, ProcedureActionKind.Append, null, Identifier(insert.table), sourced, body, null, parameters); + } // A stored append query keeps its columns and values as text pairs, which has room for exactly one // row — so a multi-row table value constructor cannot be stored as a procedure even though it is @@ -451,8 +482,6 @@ private static SqlStatement BuildAppendProcedure(string name, InsertStatementCon "An INSERT procedure body cannot use DEFAULT as a value."); var values = rowValues.Select(v => v.expression()).ToArray(); - if (columns.Count == 0) - throw new NotSupportedException("An INSERT procedure body must list its target columns."); if (columns.Count != values.Length) throw new SqlParseException( $"INSERT lists {columns.Count} columns but {values.Length} values."); @@ -461,7 +490,47 @@ private static SqlStatement BuildAppendProcedure(string name, InsertStatementCon .Select((col, i) => new AppendColumn(Identifier(col), OriginalText(values[i]))) .ToList(); return new CreateActionProcedureStatement( - name, ProcedureActionKind.Append, null, Identifier(insert.table), appendColumns); + name, ProcedureActionKind.Append, null, Identifier(insert.table), appendColumns, + null, null, parameters); + } + + /// An UPDATE body: its sources and WHERE are stored exactly as a view's are, and each SET + /// assignment becomes a column row naming its target (qualified, over a join) and holding the new + /// value's verbatim text. + private static SqlStatement BuildUpdateProcedure( + string name, UpdateStatementContext update, IReadOnlyList parameters) + { + var assignments = update.assignment() + .Select(a => new AppendColumn(OriginalText(a.target), OriginalText(a.expression()))) + .ToList(); + return new CreateActionProcedureStatement( + name, ProcedureActionKind.Update, null, null, assignments, + ActionBody(update.tableSource(), update.whereClause()), null, parameters); + } + + /// A DELETE body: its sources and WHERE, plus the table.* target when the statement names + /// one — which Access stores verbatim, and omits entirely for a bare DELETE FROM. + private static SqlStatement BuildDeleteProcedure( + string name, DeleteStatementContext delete, IReadOnlyList parameters) + { + string? target = delete.target is { } t ? $"{Identifier(t)}.*" : null; + return new CreateActionProcedureStatement( + name, ProcedureActionKind.Delete, null, null, null, + ActionBody(delete.tableSource(), delete.whereClause()), target, parameters); + } + + /// The sources, joins and WHERE of an UPDATE or DELETE body, in the shape a view stores them — + /// they carry no output columns of their own. + private static ViewDefinition ActionBody(TableSourceContext[] sources, WhereClauseContext? where) + { + var tables = new List(); + var joins = new List(); + foreach (TableSourceContext ts in sources) CollectSources(ts, tables, joins); + + return new ViewDefinition( + Distinct: false, Columns: [], tables, joins, + where is null ? null : OriginalText(where.expression()), + GroupBy: [], OrderBy: [], Top: null); } /// A declared parameter's name, with any leading @ stripped — Access stores the bare @@ -496,6 +565,19 @@ private static string TypeName(DataTypeContext type) => string.Join(' ', // appear in its WHERE just as it can in a VALUES list. Source = ins.Source is null ? null : LowerParameters(ins.Source, names), }, + // An action query declares parameters exactly as a SELECT does, and Access stores UPDATE and DELETE + // ones — a stored `UPDATE t SET c = pValue WHERE k = pKey` reads back through here. + UpdateStatement upd => upd with + { + From = LowerFrom(upd.From, names)!, + Assignments = upd.Assignments.Select(a => a with { Value = LowerExpr(a.Value, names) }).ToList(), + Where = upd.Where is null ? null : LowerExpr(upd.Where, names), + }, + DeleteStatement del => del with + { + From = LowerFrom(del.From, names)!, + Where = del.Where is null ? null : LowerExpr(del.Where, names), + }, _ => s, }; @@ -582,10 +664,12 @@ private static ViewDefinition BuildViewDefinition(QueryExpressionContext ctx) : select.selectList().selectItem().Select(BuildViewColumn).ToList(); // Flatten the FROM into a flat list of source tables and joins, descending through any parenthesised - // join groups (Access stores them flat — one Attribute=5 per table, one Attribute=7 per join). + // join groups (Access stores them flat — one Attribute=5 per table, one Attribute=7 per join). A + // body with no FROM at all (`SELECT 1 AS n`) is a query Access stores and runs, and it stores it the + // same way minus the table rows — so it decomposes to no sources rather than being rejected. var tables = new List(); var joins = new List(); - foreach (TableSourceContext ts in select.fromClause().tableSource()) + foreach (TableSourceContext ts in select.fromClause()?.tableSource() ?? []) CollectSources(ts, tables, joins); string? where = select.whereClause() is { } w ? OriginalText(w.expression()) : null; diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md index 494b039b..2810c944 100644 --- a/src/LibRed/docs/format/system-catalog.md +++ b/src/LibRed/docs/format/system-catalog.md @@ -301,6 +301,14 @@ > (`0x08|0x01`) is what Access writes for its auto-generated form/report record-source queries**, the > `~sq_f…` / `~sq_r…` / `~sq_c…` objects, which it renders as `SELECT DISTINCTROW * FROM
`. + > **A query with no `0x05` rows at all has no FROM clause.** `SELECT 1 AS n` is a query Access stores (as + > a view or a procedure) and stores exactly as any other, minus the table rows: the type row, one `0x06` + > column row (`Name1` = the alias, `Expression` = the value) and the end row, with the ordinary view flags + > `0x10000000`. ACE will **open** such a query and return its row, but refuses to use it as a *source* — + > `SELECT n FROM [Q]` fails with "Query input must contain at least one table or query" — on its own files + > as much as on LibRed's. LibRed stores the same rows and is the more permissive of the two: its engine + > resolves one as a derived table like any other view. + > > **A query with no `0x06` rows at all is `SELECT *`.** The absence of output columns is the encoding, not a > sign of an unreadable query — every auto-generated record-source query takes this shape. **Nested / parenthesised joins are stored flat** — one `0x05` per base table and one `0x07` per join condition, no grouping — so Access re-derives @@ -370,21 +378,66 @@ > LibRed-created database. > **Action-query procedure bodies** (a CREATE PROCEDURE body that is not a SELECT) are stored with a - > different MSysObjects `Flags` and an `Attribute=0x01` row (verified vs ACE): - > - **Delete**: the `0x01` action row has `Flag 5`. - > - **Update**: the `0x01` action row has `Flag 4`. + > different MSysObjects `Flags` and an `Attribute=0x01` row (verified vs ACE). **Every kind keeps its + > sources, predicate and declared parameters exactly where a SELECT keeps them** — one `0x05` row per table, + > one `0x07` per join condition, `0x08` for the WHERE, `0x02` per declared parameter — and they differ only + > in the action row and in what the `0x06` column rows mean: > - **Data-definition** (CREATE TABLE / DROP TABLE): MSysObjects `Flags=0x10000060`; one `0x01` row with - > `Flag 7` and `Expression` = the **whole DDL statement** verbatim (ACE prepends a single space). + > `Flag 7` and `Expression` = the **whole DDL statement** verbatim (ACE prepends a single space). No other + > rows: the statement is not decomposed at all. > - **Append** (INSERT): MSysObjects `Flags=0x10000040`; a `0x01` row with `Flag 3` and `Name1` = the > target table, then one `0x06` column row per appended column — `Name2` = target column, `Expression` > = the value; `Flag 0x8000` marks an INSERT … **VALUES** append (an INSERT … **SELECT** instead uses > `Flag 0` on the `0x06` rows plus the usual `0x05` table / `0x08` where rows). + > - **Update**: the `0x01` action row has `Flag 4` and nothing else on it — the target is the FROM source. + > One `0x06` row per SET assignment: **`Name2` = the assigned column**, `Expression` = the new value. + > Over a join, `Name2` is **table-qualified** (`Orders.ShipCountry`) and the join is an ordinary `0x07` + > row, so an UPDATE over a join stores exactly what the same join in a SELECT stores. + > - **Delete**: the `0x01` action row has `Flag 5`. A `DELETE
.* FROM …` keeps that target as a + > single `0x06` row whose `Expression` is the verbatim `
.*` and which has no `Name2`; a + > `DELETE FROM …` (no named target) stores **no** `0x06` row at all, and ACE renders it back as + > `DELETE * FROM …`. + > - **Make-table** (`SELECT … INTO`): `Flag 2`, with the target table in `Name1` and, when the target is in + > another database file, its path in `Name2`. Everything else is stored as the SELECT it is. + > + > **The MSysObjects `Flags` low byte is DAO's own `QueryDef.Type`** — not the `0x01` row's `Flag`, which + > numbers the kinds differently. Measured across six kinds: `0x10000000` plus crosstab `0x10`, delete + > `0x20`, update `0x30`, append `0x40`, make-table `0x50`, data-definition `0x60` — exactly the DAO values + > in the table above (16/32/48/64/80/96). + > + > **A declared parameter's facets live in the `0x02` row's `LvExtra`**, and Access renders the PARAMETERS + > clause from them — a row without them reads back as `Text(255)`. Verified against ACE, declaration by + > declaration: + > + > | declared | `Flag` | `LvExtra` | + > |---|---|---| + > | `Text(50)` | 10 | `50` — the length | + > | `Decimal(18,4)` / `Numeric(18,4)` | 16 | `262162` = `(scale << 16) \| precision` | + > | `Numeric(10,2)` | 16 | `131082` = `(2 << 16) \| 10` | + > | `Binary(10)` | 9 | *nothing* — a sized binary records no facet | + > | `Long`, and every type with no declared size | its code | *nothing* | + > + > On every *other* row `LvExtra` holds nothing: for one statement ACE left it null with no parameter + > declared and wrote `0` and `226` on those same rows once one was, and Northwind's designer-authored query + > carries `936840680` throughout. Don't model it outside a parameter row. + > + > **The `0x02` `Flag` is the Jet on-disk type code**, the same code a column of that type carries — not + > DAO's type constants, which agree with it only up to `15` (GUID) and then diverge. Measured across every + > declarable type: `Bit 1`, `Byte 2`, `Short 3`, `Long 4`, `Currency 5`, `Single 6`, `Double 7`, + > `DateTime 8`, `Binary 9`, `Text 10`, `LongBinary 11`, `Memo 12`, `GUID 15`, `Decimal 16`, **`BigInt 19`**, + > **`DateTime2 20`**, and `0` for Access's untyped `Value` parameter. The last two are worth noting twice + > over: DAO numbers `dbBigInt` 16, which is Decimal's code here, and ACE accepts a `BigInt` or `DateTime2` + > **parameter** on an ACE 12 file, where a *column* of either type is refused — a parameter declares no + > storage, so nothing forces the format's hand. > - > (A plain view/SELECT query uses `Flags=0x10000000` and no `0x01` row.) LibRed writes CREATE TABLE and - > INSERT … VALUES bodies; INSERT … SELECT and UPDATE/DELETE are not written yet. **Read-back:** LibRed - > reconstructs a stored action query from these rows (DDL → the verbatim SQL; INSERT … VALUES → a rebuilt - > `INSERT INTO t (cols) VALUES (…)`) and executes it by name; kinds it can't run (INSERT … SELECT, etc.) - > read back with an "unsupported" reason and throw when executed. + > (A plain view/SELECT query uses `Flags=0x10000000` and no `0x01` row.) LibRed **writes** every kind it + > reads: CREATE TABLE verbatim, INSERT from VALUES or from a SELECT, UPDATE (joins included), DELETE (with + > or without a `table.*` target) and make-table, each with its declared parameters and their facets — row + > for row what ACE writes for the same statement, including the object flags, and ACE runs the result. **Read-back:** LibRed reconstructs and runs + > every kind whose statement its engine can execute — DDL (verbatim), INSERT from VALUES or from a SELECT, + > UPDATE (joins included), DELETE and make-table — rebuilt with a leading `PARAMETERS` clause when the query + > declares parameters, so `EXECUTE name arg, …` binds them. Crosstab, pass-through and UNION read back with + > an "unsupported" reason naming the kind, and throw when executed. - **MSysRelationships** defines foreign keys (one row per relationship column): `szRelationship` (name), `szObject` (child/referencing table), `szColumn` (child column), `szReferencedObject` diff --git a/test/LibRed.Ado.Tests/LibRedSchemaTests.cs b/test/LibRed.Ado.Tests/LibRedSchemaTests.cs index a860b6c5..1928bc0d 100644 --- a/test/LibRed.Ado.Tests/LibRedSchemaTests.cs +++ b/test/LibRed.Ado.Tests/LibRedSchemaTests.cs @@ -188,6 +188,9 @@ public void ProcedureParameters_describes_a_stored_querys_declared_parameter() Assert.Equal(1, parameter["PARAMETER_TYPE"]); // input Assert.Equal(130, parameter["DATA_TYPE"]); // DBTYPE_WSTR Assert.Equal("VarChar", parameter["TYPE_NAME"]); + // The declared length, read back from the parameter row's LvExtra; octets are two per character. + Assert.Equal(5L, parameter["CHARACTER_MAXIMUM_LENGTH"]); + Assert.Equal(10L, parameter["CHARACTER_OCTET_LENGTH"]); } [Fact] @@ -197,7 +200,8 @@ public void Procedures_carry_the_parameters_clause_ahead_of_their_statement() Schema("Procedures", null, null, "CustOrdersOrders", null).Rows.Cast()); string definition = (string)procedure["PROCEDURE_DEFINITION"]; - Assert.StartsWith("PARAMETERS [CustomerID] TEXT;", definition); + // Including the length it was declared with, which the parameter row keeps in its LvExtra. + Assert.StartsWith("PARAMETERS [CustomerID] TEXT(5);", definition); Assert.Contains("FROM [Orders]", definition); } diff --git a/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs b/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs index 73b8ce81..8dc91827 100644 --- a/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs +++ b/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs @@ -76,35 +76,54 @@ public void Access_runs_a_libred_written_make_table_and_append_procedure() finally { TemporaryDatabase.Delete(path); } } - // An INSERT ... SELECT stored query (written by ACE) is read back but classified unsupported: LibRed - // reconstructs no executable SQL for it, only a reason ("throw on the rest"). - [Fact] - public void Insert_select_stored_query_is_read_back_as_unsupported() + /// + /// An ACE-written query of each action kind, read back as the statement LibRed runs. Every kind keeps its + /// sources, predicate and declared parameters where a SELECT keeps them — one 0x05 row per table, + /// 0x07 per join, 0x08 for the WHERE — and differs only in what the 0x06 column rows + /// mean: a SET assignment names its target in Name2 (qualified, over a join), an append names the + /// target column there and holds the value in Expression, and a DELETE's single row holds the + /// verbatim table.* that Access writes when the query names one. + /// + [Theory] + // UPDATE (kind 4): one column row per assignment. + [InlineData(4, "UPDATE Customers SET ContactTitle = 'Changed' WHERE Country = 'UK'", + "UPDATE [Customers] SET [ContactTitle] = 'Changed' WHERE Country = 'UK'")] + [InlineData(4, "UPDATE Products SET UnitPrice = UnitPrice * 1.1, Discontinued = True WHERE CategoryID = 1", + "UPDATE [Products] SET [UnitPrice] = UnitPrice * 1.1, [Discontinued] = True WHERE CategoryID = 1")] + [InlineData(4, "UPDATE Orders INNER JOIN Customers ON Orders.CustomerID = Customers.CustomerID " + + "SET Orders.ShipCountry = Customers.Country WHERE Customers.Country = 'UK'", + "UPDATE [Orders] INNER JOIN [Customers] ON Orders.CustomerID = Customers.CustomerID " + + "SET [Orders].[ShipCountry] = Customers.Country WHERE Customers.Country = 'UK'")] + // DELETE (kind 5): Access writes `DELETE * FROM` when the query names no columns, `DELETE t.* FROM` when + // it does, and stores that `t.*` verbatim. + [InlineData(5, "DELETE FROM Shippers WHERE CompanyName = 'Does not exist'", + "DELETE * FROM [Shippers] WHERE CompanyName = 'Does not exist'")] + [InlineData(5, "DELETE Shippers.* FROM Shippers WHERE Phone IS NULL", + "DELETE Shippers.* FROM [Shippers] WHERE Phone IS NULL")] + // Append (kind 3), from a SELECT rather than from VALUES. + [InlineData(3, "INSERT INTO Shippers (CompanyName) SELECT ContactName FROM Customers WHERE Country = 'UK'", + "INSERT INTO [Shippers] ([CompanyName]) SELECT ContactName FROM [Customers] WHERE Country = 'UK'")] + // Make-table (kind 2): the target is on the action row rather than in the SQL. + [InlineData(2, "SELECT ShipperID, CompanyName INTO [ShipperCopy] FROM Shippers WHERE ShipperID > 1", + "SELECT ShipperID, CompanyName INTO [ShipperCopy] FROM [Shippers] WHERE ShipperID > 1")] + public void Ace_written_action_query_is_read_back_as_runnable_sql(short kind, string body, string expected) { - string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-sel-"); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-kinds-"); try { - using (var conn = OpenOleDb(path)) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = - "CREATE PROCEDURE CopyUkShippers AS " + - "INSERT INTO Shippers (CompanyName) SELECT ContactName FROM Customers WHERE Country = 'UK'"; - cmd.ExecuteNonQuery(); - } + using (var conn = OpenOleDb(path)) CreateProcedure(conn, "P", body); using var db = JetDatabase.Open(path); - StoredActionQuery q = db.Catalog.ActionQueries["CopyUkShippers"]; - Assert.Null(q.Sql); - Assert.Contains("INSERT", q.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); - Assert.Contains("SELECT", q.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); - Assert.Equal((short)3, ActionFlag(db, "CopyUkShippers")); + StoredActionQuery query = db.Catalog.ActionQueries["P"]; + Assert.Equal(kind, ActionFlag(db, "P")); + Assert.Null(query.UnsupportedReason); + Assert.Equal(expected, query.Sql); } finally { TemporaryDatabase.Delete(path); } } [Fact] - public void Ace_parameterized_update_retains_parameter_order_while_remaining_explicitly_unsupported() + public void Ace_parameterized_update_is_read_back_with_its_parameters_clause() { string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-params-"); try @@ -120,37 +139,37 @@ public void Ace_parameterized_update_retains_parameter_order_while_remaining_exp using var db = JetDatabase.Open(path); Assert.Equal(["pTitle", "pCountry"], db.Catalog.QueryParameters["UpdateByCountry"].Select(p => p.Name)); + StoredActionQuery query = db.Catalog.ActionQueries["UpdateByCountry"]; - Assert.Null(query.Sql); - Assert.Contains("UPDATE", query.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); Assert.Equal((short)4, ActionFlag(db, "UpdateByCountry")); + // An action query declares its parameters exactly as a SELECT does, and is rebuilt with the same + // leading clause — which is what makes the body's references to them parameters and not columns. + // The declared lengths come back too: they are stored in the parameter row's LvExtra. + Assert.Equal( + "PARAMETERS [pTitle] TEXT(50), [pCountry] TEXT(20); " + + "UPDATE [Customers] SET [ContactTitle] = pTitle WHERE Country = pCountry", + query.Sql); + Assert.Equal([50, 20], db.Catalog.QueryParameters["UpdateByCountry"].Select(p => p.Size)); } finally { TemporaryDatabase.Delete(path); } } [Fact] - public void Ace_update_and_delete_procedures_are_retained_with_their_exact_action_kinds() + public void A_kind_libred_cannot_run_still_reports_which_kind_it_is() { - string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-kinds-"); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-crosstab-"); try { using (var conn = OpenOleDb(path)) - { - CreateProcedure(conn, "UpdateUkTitles", - "UPDATE Customers SET ContactTitle = 'Changed' WHERE Country = 'UK'"); - CreateProcedure(conn, "DeleteNoShipper", - "DELETE FROM Shippers WHERE CompanyName = 'Does not exist'"); - } + CreateProcedure(conn, "ByCountry", + "TRANSFORM Count(*) SELECT Country FROM Customers GROUP BY Country PIVOT City"); using var db = JetDatabase.Open(path); - StoredActionQuery update = db.Catalog.ActionQueries["UpdateUkTitles"]; - StoredActionQuery delete = db.Catalog.ActionQueries["DeleteNoShipper"]; - Assert.Equal((short)4, ActionFlag(db, "UpdateUkTitles")); - Assert.Equal((short)5, ActionFlag(db, "DeleteNoShipper")); - Assert.Null(update.Sql); - Assert.Contains("UPDATE", update.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); - Assert.Null(delete.Sql); - Assert.Contains("DELETE", delete.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); + StoredActionQuery query = db.Catalog.ActionQueries["ByCountry"]; + Assert.Null(query.Sql); + // "Not supported" that doesn't say what it is leaves a caller no way to tell an unimplemented + // feature from an unreadable file. + Assert.Contains("Crosstab", query.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); } finally { TemporaryDatabase.Delete(path); } } diff --git a/test/LibRed.Core.AccessTests/StoredQueryKindAccessTests.cs b/test/LibRed.Core.AccessTests/StoredQueryKindAccessTests.cs index c31580b3..81484ca9 100644 --- a/test/LibRed.Core.AccessTests/StoredQueryKindAccessTests.cs +++ b/test/LibRed.Core.AccessTests/StoredQueryKindAccessTests.cs @@ -104,11 +104,10 @@ public void Top_percent_keeps_its_percent() } [Theory] - [InlineData("UpdQ", "UPDATE Shippers SET Shippers.Phone = '555-0100'", 4, "UPDATE")] - [InlineData("DelQ", "DELETE FROM Shippers WHERE Shippers.CompanyName = 'nope'", 5, "DELETE")] - [InlineData("UniQ", "SELECT CompanyName FROM Shippers UNION SELECT CompanyName FROM Customers", 9, "UNION")] - [InlineData("MakeQ", "SELECT Shippers.* INTO ShipCopy FROM Shippers", 2, "Make-table")] - public void Action_query_kinds_are_named_in_the_refusal(string name, string sql, int flag, string expected) + [InlineData("UpdQ", "UPDATE Shippers SET Shippers.Phone = '555-0100'", 4, "SET")] + [InlineData("DelQ", "DELETE FROM Shippers WHERE Shippers.CompanyName = 'nope'", 5, "FROM [Shippers]")] + [InlineData("MakeQ", "SELECT Shippers.* INTO ShipCopy FROM Shippers", 2, "INTO [ShipCopy]")] + public void Action_query_kinds_authored_through_dao_are_rebuilt(string name, string sql, int flag, string expected) { string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "qkind-action-"); try @@ -118,11 +117,31 @@ public void Action_query_kinds_are_named_in_the_refusal(string name, string sql, using var db = JetDatabase.Open(path); Assert.Equal((short)flag, OperationFlag(db, name)); + // Authored the way the Access UI authors them, rather than through ACE's CREATE PROCEDURE — the + // rows have to be read the same either way. + StoredActionQuery q = db.Catalog.ActionQueries[name]; + Assert.Null(q.UnsupportedReason); + Assert.Contains(expected, q.Sql!, StringComparison.OrdinalIgnoreCase); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void A_kind_libred_cannot_run_is_named_in_the_refusal() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "qkind-union-"); + try + { + if (!Author(path, ("UniQ", "SELECT CompanyName FROM Shippers UNION SELECT CompanyName FROM Customers"))) return; + + using var db = JetDatabase.Open(path); + Assert.Equal((short)9, OperationFlag(db, "UniQ")); + // Not executed — but the reason has to say WHICH kind. "Not supported" on its own gives a caller // no way to tell an unimplemented feature from a file LibRed failed to read. - StoredActionQuery q = db.Catalog.ActionQueries[name]; + StoredActionQuery q = db.Catalog.ActionQueries["UniQ"]; Assert.Null(q.Sql); - Assert.Contains(expected, q.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); + Assert.Contains("UNION", q.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); } finally { TemporaryDatabase.Delete(path); } } diff --git a/test/LibRed.Engine.AccessTests/StoredActionQueryExecutionAccessTests.cs b/test/LibRed.Engine.AccessTests/StoredActionQueryExecutionAccessTests.cs new file mode 100644 index 00000000..9f9af099 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/StoredActionQueryExecutionAccessTests.cs @@ -0,0 +1,165 @@ +using System.Data; +using System.Data.OleDb; +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// Running a stored action query BY NAME, cross-checked against ACE running the same one. That LibRed rebuilds +// the statement is one thing; what decides whether the reconstruction is right is whether executing the query +// leaves the database in the state Access leaves it in — a SET assignment read from the wrong row column, or +// a join dropped from the FROM, changes which rows are touched rather than failing outright. +[Collection(AceCollection.Name)] +public class StoredActionQueryExecutionAccessTests : TempDatabaseTest +{ + private static string Copy() => TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "action-exec-"); + + /// Has ACE store as a procedure, then runs it in ACE on one copy and in + /// LibRed on another, comparing what reports afterwards. + private static void AssertSameAsAce(string body, string verify) + { + string acePath = Copy(), libRedPath = Copy(); + try + { + object? aceResult; + foreach (string path in new[] { acePath, libRedPath }) + using (var connection = AceTestDatabase.Open(path)) + { + using var create = connection.CreateCommand(); + create.CommandText = $"CREATE PROCEDURE [P] AS {body}"; + create.ExecuteNonQuery(); + } + + using (var connection = AceTestDatabase.Open(acePath)) + { + using (var run = connection.CreateCommand()) + { + run.CommandText = "P"; + run.CommandType = CommandType.StoredProcedure; + run.ExecuteNonQuery(); + } + using var check = connection.CreateCommand(); + check.CommandText = verify; + aceResult = check.ExecuteScalar(); + } + + using var db = TemporaryDatabase.OpenTracked(libRedPath, readOnly: false); + var engine = new QueryEngine(db); + engine.ExecuteNonQuery("EXECUTE [P]"); + object? ourResult = engine.ExecuteQuery(verify).Rows.Single()[0]; + + Assert.Equal(Convert.ToString(aceResult), Convert.ToString(ourResult)); + } + finally + { + TemporaryDatabase.Delete(acePath); + TemporaryDatabase.Delete(libRedPath); + } + } + + [Fact] + public void Update_query_touches_the_rows_ace_touches() => + AssertSameAsAce( + "UPDATE Customers SET ContactTitle = 'Changed' WHERE Country = 'UK'", + "SELECT COUNT(*) FROM Customers WHERE ContactTitle = 'Changed'"); + + [Fact] + public void Update_query_over_a_join_reads_the_other_table() + { + // The join lives in rows of its own, so losing it would silently update every order instead of the + // seven UK ones. + AssertSameAsAce( + "UPDATE Orders INNER JOIN Customers ON Orders.CustomerID = Customers.CustomerID " + + "SET Orders.ShipCountry = 'Checked' WHERE Customers.Country = 'UK'", + "SELECT COUNT(*) FROM Orders WHERE ShipCountry = 'Checked'"); + } + + // Deleted from the child table: a customer or an order has dependent rows, and both engines refuse to + // orphan them — which would be a test of referential integrity rather than of the stored query. + [Fact] + public void Delete_query_removes_the_rows_ace_removes() => + AssertSameAsAce( + "DELETE FROM [Order Details] WHERE OrderID = 10248", + "SELECT COUNT(*) FROM [Order Details]"); + + [Fact] + public void Delete_query_written_with_a_table_star_target_removes_the_same_rows() => + AssertSameAsAce( + "DELETE [Order Details].* FROM [Order Details] WHERE OrderID = 10249", + "SELECT COUNT(*) FROM [Order Details]"); + + [Fact] + public void Append_query_from_a_select_inserts_the_rows_ace_inserts() => + AssertSameAsAce( + "INSERT INTO Shippers (CompanyName) SELECT ContactName FROM Customers WHERE Country = 'UK'", + "SELECT COUNT(*) FROM Shippers"); + + [Fact] + public void Make_table_query_writes_the_table_ace_writes() => + AssertSameAsAce( + "SELECT ShipperID, CompanyName INTO [ShipperCopy] FROM Shippers WHERE ShipperID > 1", + "SELECT COUNT(*) FROM ShipperCopy"); + + [Fact] + public void A_parameterized_action_query_runs_with_the_values_supplied_to_it() + { + string path = Copy(); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + using var create = connection.CreateCommand(); + create.CommandText = + "CREATE PROCEDURE [UpdateByCountry] (pTitle Text(50), pCountry Text(20)) AS " + + "UPDATE Customers SET ContactTitle = pTitle WHERE Country = pCountry"; + create.ExecuteNonQuery(); + } + + using var db = TemporaryDatabase.OpenTracked(path, readOnly: false); + var engine = new QueryEngine(db); + + // Positional arguments bind to the parameters in declaration order, as EXECUTE does everywhere. + // The title is one no Northwind customer already holds, so every row counted below was set here. + Assert.Equal(7, engine.ExecuteNonQuery("EXECUTE [UpdateByCountry] 'Set by parameter', 'UK'")); + Assert.Equal( + 7, + Convert.ToInt32(engine.ExecuteQuery( + "SELECT COUNT(*) FROM Customers WHERE ContactTitle = 'Set by parameter' AND Country = 'UK'") + .Rows.Single()[0])); + + // Nothing else was touched: the second parameter is the predicate, not a constant folded into it. + Assert.Equal( + 0, + Convert.ToInt32(engine.ExecuteQuery( + "SELECT COUNT(*) FROM Customers WHERE ContactTitle = 'Set by parameter' AND Country <> 'UK'") + .Rows.Single()[0])); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void A_kind_libred_cannot_run_is_refused_by_name() + { + string path = Copy(); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + using var create = connection.CreateCommand(); + create.CommandText = + "CREATE PROCEDURE [ByCountry] AS " + + "TRANSFORM Count(*) SELECT Country FROM Customers GROUP BY Country PIVOT City"; + create.ExecuteNonQuery(); + } + + using var db = TemporaryDatabase.OpenTracked(path, readOnly: false); + var engine = new QueryEngine(db); + + var error = Assert.Throws(() => engine.ExecuteNonQuery("EXECUTE [ByCountry]")); + Assert.Contains("Crosstab", error.Message, StringComparison.OrdinalIgnoreCase); + } + finally { TemporaryDatabase.Delete(path); } + } +} diff --git a/test/LibRed.Engine.AccessTests/StoredActionQueryWriteAccessTests.cs b/test/LibRed.Engine.AccessTests/StoredActionQueryWriteAccessTests.cs new file mode 100644 index 00000000..ce27cca5 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/StoredActionQueryWriteAccessTests.cs @@ -0,0 +1,252 @@ +using System.Data; +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// Writing a stored action query of each kind. The test is not that LibRed can write rows — it is that the +// rows are the ones ACE writes for the same statement, and that ACE then reads and runs the query. Storing a +// query the engine will not run is worse than refusing to store it: the file opens, the query is listed, and +// it fails only when someone tries to use it. +[Collection(AceCollection.Name)] +public class StoredActionQueryWriteAccessTests : TempDatabaseTest +{ + private static string Copy() => TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "action-write-"); + + [Theory] + [InlineData("UPDATE Customers SET ContactTitle = 'Owner' WHERE Country = 'UK'")] + [InlineData("UPDATE Products SET UnitPrice = UnitPrice * 1.1, Discontinued = True WHERE CategoryID = 1")] + [InlineData("UPDATE Orders INNER JOIN Customers ON Orders.CustomerID = Customers.CustomerID " + + "SET Orders.ShipCountry = Customers.Country WHERE Customers.Country = 'UK'")] + [InlineData("DELETE FROM Shippers WHERE ShipperID > 900")] + [InlineData("DELETE Shippers.* FROM Shippers WHERE ShipperID > 900")] + [InlineData("SELECT ShipperID, CompanyName INTO ShipperCopy FROM Shippers WHERE ShipperID > 1")] + [InlineData("INSERT INTO Shippers (CompanyName, Phone) SELECT CompanyName, Phone FROM Customers WHERE Country = 'UK'")] + public void A_libred_written_action_query_stores_the_rows_ace_stores(string body) + { + string ourPath = Copy(), acePath = Copy(); + try + { + using (var db = TemporaryDatabase.OpenTracked(ourPath, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery($"CREATE PROCEDURE [P] AS {body}"); + + using (var connection = AceTestDatabase.Open(acePath)) + { + using var create = connection.CreateCommand(); + create.CommandText = $"CREATE PROCEDURE [P] AS {body}"; + create.ExecuteNonQuery(); + } + + Assert.Equal(QueryRows(acePath, "P"), QueryRows(ourPath, "P")); + Assert.Equal(ObjectFlags(acePath, "P"), ObjectFlags(ourPath, "P")); + + // And the row set is one ACE acts on, not merely one it tolerates: it runs the query. + using (var connection = AceTestDatabase.Open(ourPath)) + { + using var run = connection.CreateCommand(); + run.CommandText = "P"; + run.CommandType = CommandType.StoredProcedure; + run.ExecuteNonQuery(); + } + } + finally + { + TemporaryDatabase.Delete(ourPath); + TemporaryDatabase.Delete(acePath); + } + } + + [Fact] + public void A_libred_written_action_query_declares_its_parameters_as_ace_does() + { + const string Body = + "CREATE PROCEDURE [ByCountry] (pTitle Text(50), pCountry Text(20)) AS " + + "UPDATE Customers SET ContactTitle = pTitle WHERE Country = pCountry"; + + string ourPath = Copy(), acePath = Copy(); + try + { + using (var db = TemporaryDatabase.OpenTracked(ourPath, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery(Body); + + using (var connection = AceTestDatabase.Open(acePath)) + { + using var create = connection.CreateCommand(); + create.CommandText = Body; + create.ExecuteNonQuery(); + } + + // The parameter rows carry the type code AND the declared length (in LvExtra, which is where + // Access reads it back from) — a bare Text would be a memo, and a missing length reads as 255. + Assert.Equal(QueryRows(acePath, "ByCountry"), QueryRows(ourPath, "ByCountry")); + + using var ace = AceTestDatabase.Open(ourPath); + using var run = ace.CreateCommand(); + run.CommandText = "ByCountry"; + run.CommandType = CommandType.StoredProcedure; + run.Parameters.Add(new OleDbParameter { OleDbType = OleDbType.VarWChar, Size = 50, Value = "Set by ACE" }); + run.Parameters.Add(new OleDbParameter { OleDbType = OleDbType.VarWChar, Size = 20, Value = "UK" }); + Assert.Equal(7, run.ExecuteNonQuery()); // Northwind has seven UK customers + } + finally + { + TemporaryDatabase.Delete(ourPath); + TemporaryDatabase.Delete(acePath); + } + } + + /// A declared parameter's facets ride in its row's LvExtra: a length for text, precision + /// and scale packed into one value for a decimal, and nothing for the types that record none — a sized + /// binary included, which is why this cannot just write whatever size was declared. + [Theory] + [InlineData("TEXT(50)")] + [InlineData("DECIMAL(18,4)")] + [InlineData("NUMERIC(10,2)")] + [InlineData("BINARY(10)")] + [InlineData("LONG")] + public void A_declared_parameters_facets_are_stored_as_ace_stores_them(string declared) + { + const string Body = "AS SELECT CompanyName FROM Shippers WHERE CompanyName = p"; + string ourPath = Copy(), acePath = Copy(); + try + { + using (var db = TemporaryDatabase.OpenTracked(ourPath, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery($"CREATE PROCEDURE [P] (p {declared}) {Body}"); + + using (var connection = AceTestDatabase.Open(acePath)) + { + using var create = connection.CreateCommand(); + create.CommandText = $"CREATE PROCEDURE [P] (p {declared}) {Body}"; + create.ExecuteNonQuery(); + } + + Assert.Equal(QueryRows(acePath, "P"), QueryRows(ourPath, "P")); + } + finally + { + TemporaryDatabase.Delete(ourPath); + TemporaryDatabase.Delete(acePath); + } + } + + /// A stored query whose body has no FROM at all. ACE stores and runs one, and stores it as any + /// other query minus the table rows — so LibRed's has to be the same rows, and ACE has to run it. + [Theory] + [InlineData("CREATE VIEW [Q] AS SELECT 1 AS n")] + [InlineData("CREATE PROCEDURE [Q] AS SELECT 1 AS n")] + public void A_from_less_body_is_stored_as_ace_stores_it(string sql) + { + string ourPath = Copy(), acePath = Copy(); + try + { + using (var db = TemporaryDatabase.OpenTracked(ourPath, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery(sql); + + using (var connection = AceTestDatabase.Open(acePath)) + { + using var create = connection.CreateCommand(); + create.CommandText = sql; + create.ExecuteNonQuery(); + } + + Assert.Equal(QueryRows(acePath, "Q"), QueryRows(ourPath, "Q")); + Assert.Equal(ObjectFlags(acePath, "Q"), ObjectFlags(ourPath, "Q")); + + // ACE opens the query LibRed wrote and returns its row. It will not use such a query as a + // SOURCE — `SELECT n FROM [Q]` fails with "Query input must contain at least one table or + // query" — but it fails that way on its own file too, so the two files behave identically; + // LibRed's engine is the more permissive of the two, not the odd one out. + using var ace = AceTestDatabase.Open(ourPath); + using var run = ace.CreateCommand(); + run.CommandText = "Q"; + run.CommandType = CommandType.StoredProcedure; + using var reader = run.ExecuteReader(); + Assert.True(reader.Read()); + Assert.Equal(1, Convert.ToInt32(reader.GetValue(0))); + } + finally + { + TemporaryDatabase.Delete(ourPath); + TemporaryDatabase.Delete(acePath); + } + } + + [Fact] + public void A_written_action_query_reads_back_as_the_statement_it_was_written_from() + { + string path = Copy(); + try + { + using var db = TemporaryDatabase.OpenTracked(path, readOnly: false); + var engine = new QueryEngine(db); + engine.ExecuteNonQuery( + "CREATE PROCEDURE [P] AS UPDATE Customers SET ContactTitle = 'Owner' WHERE Country = 'UK'"); + + // Round trip: what was written is read back as runnable SQL, and running it by name works. + StoredActionQuery stored = db.Catalog.ActionQueries["P"]; + Assert.Null(stored.UnsupportedReason); + Assert.Equal("UPDATE [Customers] SET [ContactTitle] = 'Owner' WHERE Country = 'UK'", stored.Sql); + Assert.Equal(7, engine.ExecuteNonQuery("EXECUTE [P]")); + } + finally { TemporaryDatabase.Delete(path); } + } + + /// + /// Every MSysQueries field of a stored query's rows, ordered so two files compare — a field + /// neither side sets cannot hide behind a narrower comparison. Two are left out: ObjectId, which + /// is a per-file identity, and LvExtra on any row that is not a declared parameter, where it holds + /// nothing. It carries the parameter's declared length on an 0x02 row and is uninitialised + /// elsewhere — measured: for the same statement ACE left it null with no parameters declared, and wrote 0 + /// and 226 on the very same rows once one was, while Northwind's designer-authored query has 936840680 + /// throughout. + /// + private static List QueryRows(string path, string queryName) + { + using var db = JetDatabase.Open(path); + TableDef queries = db.Catalog.FindTable("MSysQueries")!; + int objectIdIndex = Index(queries, "ObjectId"); + int attributeIndex = Index(queries, "Attribute"); + int id = QueryObjectId(db, queryName); + + var rows = db.OpenTable("MSysQueries").Rows() + .Where(row => Equals(row[objectIdIndex], id)) + .Select(row => string.Join(" | ", queries.Columns + .Where(c => c.Name != "ObjectId" + && (c.Name != "LvExtra" || Equals(row[attributeIndex], (byte)0x02))) + .Select(c => $"{c.Name}={Text(row[c.Index])}"))) + .ToList(); + rows.Sort(StringComparer.Ordinal); + return rows; + + static string Text(object? value) => value switch + { + null => "-", + byte[] bytes => Convert.ToHexString(bytes), + _ => value.ToString()!, + }; + } + + private static int ObjectFlags(string path, string queryName) + { + using var db = JetDatabase.Open(path); + TableDef objects = db.Catalog.FindTable("MSysObjects")!; + int flagsIndex = Index(objects, "Flags"); + int idIndex = Index(objects, "Id"); + int id = QueryObjectId(db, queryName); + return (int)db.OpenTable("MSysObjects").Rows().Single(row => Equals(row[idIndex], id))[flagsIndex]!; + } + + private static int QueryObjectId(JetDatabase db, string queryName) + { + TableDef objects = db.Catalog.FindTable("MSysObjects")!; + int idIndex = Index(objects, "Id"), nameIndex = Index(objects, "Name"); + return (int)db.OpenTable("MSysObjects").Rows() + .Single(row => string.Equals(row[nameIndex] as string, queryName, StringComparison.OrdinalIgnoreCase))[idIndex]!; + } + + private static int Index(TableDef table, string column) => table.FindColumn(column)!.Index; +} diff --git a/test/LibRed.Engine.Tests/CreateProcedureTests.cs b/test/LibRed.Engine.Tests/CreateProcedureTests.cs index 58ecc670..cf85949c 100644 --- a/test/LibRed.Engine.Tests/CreateProcedureTests.cs +++ b/test/LibRed.Engine.Tests/CreateProcedureTests.cs @@ -198,23 +198,83 @@ public void Action_queries_read_back_and_execute_through_libred() finally { TemporaryDatabase.Delete(path); } } - // Bodies we don't support: UPDATE/DELETE/DROP have no grammar; an INSERT without a column list can't be - // stored as an append query. Each is rejected. - [Theory] - [InlineData("CREATE PROCEDURE `DelCust` AS DELETE FROM Customers")] - [InlineData("CREATE PROCEDURE `UpdCust` AS UPDATE Customers SET City = 'X'")] - [InlineData("CREATE PROCEDURE `AddNoCols` AS INSERT INTO Shippers VALUES ('ZZ Co')")] - public void Unsupported_procedure_body_is_rejected(string sql) + // An INSERT with no column list can't be stored as an append query: the rows pair each value with the + // column it goes in, so there is nowhere to put a positional value list. + [Fact] + public void Unsupported_procedure_body_is_rejected() { string path = Fresh(); try { using var db = JetDatabase.Open(path, readOnly: false); - if (sql.Contains("AddNoCols", StringComparison.Ordinal)) - Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery(sql)); - else - Assert.Throws(() => - new QueryEngine(db).ExecuteNonQuery(sql)); + Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery( + "CREATE PROCEDURE `AddNoCols` AS INSERT INTO Shippers VALUES ('ZZ Co')")); + } + finally { TemporaryDatabase.Delete(path); } + } + + // Each remaining action kind, written and then read back from the file as the statement it was written + // from, and run by name. (That the stored rows are the ones ACE writes, and that ACE runs them, is + // measured in StoredActionQueryWriteAccessTests — this is the half that needs no engine but LibRed's.) + [Theory] + [InlineData("UPDATE Customers SET City = 'X' WHERE Country = 'UK'", + "UPDATE [Customers] SET [City] = 'X' WHERE Country = 'UK'", 7)] + [InlineData("UPDATE Orders INNER JOIN Customers ON Orders.CustomerID = Customers.CustomerID " + + "SET Orders.ShipCity = 'X' WHERE Customers.Country = 'UK'", + "UPDATE [Orders] INNER JOIN [Customers] ON Orders.CustomerID = Customers.CustomerID " + + "SET [Orders].[ShipCity] = 'X' WHERE Customers.Country = 'UK'", 56)] + [InlineData("DELETE FROM Shippers WHERE ShipperID > 900", + "DELETE * FROM [Shippers] WHERE ShipperID > 900", 0)] + [InlineData("DELETE Shippers.* FROM Shippers WHERE ShipperID > 900", + "DELETE Shippers.* FROM [Shippers] WHERE ShipperID > 900", 0)] + [InlineData("SELECT ShipperID, CompanyName INTO ShipperCopy FROM Shippers WHERE ShipperID > 1", + "SELECT ShipperID, CompanyName INTO [ShipperCopy] FROM [Shippers] WHERE ShipperID > 1", 2)] + [InlineData("INSERT INTO Shippers (CompanyName) SELECT ContactName FROM Customers WHERE Country = 'UK'", + "INSERT INTO [Shippers] ([CompanyName]) SELECT ContactName FROM [Customers] WHERE Country = 'UK'", 7)] + public void Action_query_body_round_trips_through_the_file(string body, string expected, int affected) + { + string path = Fresh(); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery($"CREATE PROCEDURE [P] AS {body}"); + + using (var db = JetDatabase.Open(path, readOnly: false)) // fresh open: read from the file + { + Assert.Equal(expected, db.Catalog.ActionQueries["P"].Sql); + Assert.Equal(affected, new QueryEngine(db).ExecuteNonQuery("EXECUTE [P]")); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void A_written_action_querys_parameters_bind_when_it_is_executed() + { + string path = Fresh(); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery( + "CREATE PROCEDURE [ByCountry] (pCity Text(50), pCountry Text(20)) AS " + + "UPDATE Customers SET City = pCity WHERE Country = pCountry"); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var engine = new QueryEngine(db); + // Read back with the PARAMETERS clause that makes the body's references parameters, declared + // lengths and all — they ride in the parameter row's LvExtra. + Assert.Equal( + "PARAMETERS [pCity] TEXT(50), [pCountry] TEXT(20); " + + "UPDATE [Customers] SET [City] = pCity WHERE Country = pCountry", + db.Catalog.ActionQueries["ByCountry"].Sql); + + Assert.Equal(7, engine.ExecuteNonQuery("EXECUTE [ByCountry] 'Ankh-Morpork', 'UK'")); + Assert.Equal( + 7, + engine.ExecuteQuery("SELECT COUNT(*) FROM Customers WHERE City = 'Ankh-Morpork'") + .Rows.Single()[0]); + } } finally { TemporaryDatabase.Delete(path); } } diff --git a/test/LibRed.Engine.Tests/CreateViewTests.cs b/test/LibRed.Engine.Tests/CreateViewTests.cs index 44201808..5726adb5 100644 --- a/test/LibRed.Engine.Tests/CreateViewTests.cs +++ b/test/LibRed.Engine.Tests/CreateViewTests.cs @@ -26,6 +26,29 @@ public void Create_view_executes() finally { TemporaryDatabase.Delete(path); } } + // A body with no FROM at all. Access stores and runs one — the rows are an ordinary query's minus the + // table rows — so this must store, read back and run rather than fail. It used to throw a bare + // NullReferenceException out of the decomposer. + [Theory] + [InlineData("CREATE VIEW `Const` AS SELECT 1 AS `n`")] + [InlineData("CREATE PROCEDURE `Const` AS SELECT 1 AS `n`")] + public void A_from_less_body_is_stored_and_queryable(string sql) + { + string path = Fresh(); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery(sql); + + using (var db = JetDatabase.Open(path)) // fresh open: read from the file + { + Assert.Equal("SELECT 1 AS [n]", db.Catalog.Views["Const"]); + Assert.Equal(1, new QueryEngine(db).ExecuteQuery("SELECT `n` FROM `Const`").Rows.Single()[0]); + } + } + finally { TemporaryDatabase.Delete(path); } + } + // A view is read back from the file (its MSysQueries rows), reconstructed to SQL, and resolved as a // derived table when queried through LibRed's own engine. [Fact] From 0513ffd96bd7ca90b16bc1c0fa73f950c4c6fbb6 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 21 Sep 2026 00:42:08 +0800 Subject: [PATCH 04/14] Record that an over-long text declaration is refused The spec gave text's 1..255 limit but not what happens when DDL asks for more, and the bullet next to it is about an over-long value, which is a different refusal with a different message. Measured: VARCHAR(255) creates the column, VARCHAR(256) and anything above it fails the whole CREATE TABLE with "Size of field is too long" - not promoted to a memo, not clamped. LibRed refuses the same declarations at the same threshold and opens its message with ACE's wording, which is already covered by tests on both sides. Co-Authored-By: Claude Opus 5 --- src/LibRed/docs/format/data-types.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/LibRed/docs/format/data-types.md b/src/LibRed/docs/format/data-types.md index b9a33335..6595deed 100644 --- a/src/LibRed/docs/format/data-types.md +++ b/src/LibRed/docs/format/data-types.md @@ -227,6 +227,11 @@ Points verified against ACE that aren't obvious from that page: `CHARACTER_MAXIMUM_LENGTH`), **not** 1. - **Bare `TEXT` → Memo** (long text); `TEXT(n)` → `varchar(n)` (a Jet quirk, ACE-verified). - Sized Text/Binary dimensions must be positive: Text is `1..255` characters and Binary is `1..510` bytes. +- **A declaration wider than that is refused at DDL time — it is not promoted to Memo and not clamped.** + Verified: `VARCHAR(255)` creates a 255-character column, `VARCHAR(256)` and everything above it + (`VARCHAR(1000)`, `TEXT(1000)`, `CHAR(1000)`, `VARCHAR(65535)`) fail the whole `CREATE TABLE` with + ACE's *"Size of field 'c' is too long."* LibRed refuses the same declarations at the same threshold and + opens its message with ACE's wording, so a caller matching on it behaves the same against either engine. - **`CHAR(n)` / `BINARY(n)` are FIXED-length columns; `TEXT(n)` / `VARBINARY(n)` are variable** — ACE's own DDL produces both forms, so the fixed form is not a LibRed-only construct. - **An over-long value is refused on both forms, with one message**: *"The field is too small to accept the From a393dfa4bf3dc070b93c37c577a94ea79efae9e7 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 21 Sep 2026 00:42:17 +0800 Subject: [PATCH 05/14] Take SQL Server's second trim argument and STRING_AGG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-argument LTRIM/RTRIM, as SQL Server 2022 has them: the second argument is a SET of characters rather than a substring, so every leading (or trailing) character found in it is removed and the stripping stops at the first that is not. An empty set strips nothing and either argument Null gives Null. The one-argument form is untouched - still Access's, stripping the space and U+3000 alone - and Trim still takes only the one, since Access's own second argument does not exist. STRING_AGG turned out to be a second spelling of the LISTAGG already here, so it shares the implementation, DISTINCT, FILTER (WHERE …) and the window form. What differs is what each insists on: LISTAGG needs its WITHIN GROUP and lets the separator go, where STRING_AGG needs the separator and lets the order go - and without a WITHIN GROUP the values list in the order the rows arrive, which over a window is window order. The separator is written as a string, because it is the one value the whole group shares. The ACE arity test records LTrim/RTrim as taking one argument or two, the way it already records Log: ACE rejects the second, deliberately. Co-Authored-By: Claude Opus 5 --- .../Execution/ExpressionEvaluator.cs | 25 ++++++++++-- .../LibRed.Engine/Execution/QueryExecutor.cs | 34 ++++++++++------- .../Execution/WindowFunctions.cs | 7 +++- .../LibRed.Engine/Planning/QueryPlanner.cs | 2 +- src/LibRed/LibRed.Sql/Ast/Expressions.cs | 14 +++++++ src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 28 ++++++++++++-- src/LibRed/README.md | 6 ++- src/LibRed/docs/functions.md | 8 ++++ .../FunctionArityAccessTests.cs | 5 ++- test/LibRed.Engine.Tests/ListAggTests.cs | 36 ++++++++++++++++++ .../LibRed.Engine.Tests/TrimFunctionsTests.cs | 38 ++++++++++++++++++- 11 files changed, 176 insertions(+), 27 deletions(-) diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index 7091c49c..78ada7d8 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -251,8 +251,8 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) "LCASE" => Convert1(f, v => ConcatText(v).ToLowerInvariant()), "UCASE" => Convert1(f, v => ConcatText(v).ToUpperInvariant()), "TRIM" => Convert1(f, v => ConcatText(v).Trim(TrimmedSpaces)), - "LTRIM" => Convert1(f, v => ConcatText(v).TrimStart(TrimmedSpaces)), - "RTRIM" => Convert1(f, v => ConcatText(v).TrimEnd(TrimmedSpaces)), + "LTRIM" => Trim(f, static (s, chars) => s.TrimStart(chars)), + "RTRIM" => Trim(f, static (s, chars) => s.TrimEnd(chars)), "LEFT" => StringInt(f, static (s, n) => n <= 0 ? "" : n >= s.Length ? s : s[..n]), "RIGHT" => StringInt(f, static (s, n) => n <= 0 ? "" : n >= s.Length ? s : s[^n..]), "MID" => Mid(f), @@ -413,7 +413,7 @@ internal static void ValidateArity(string name, int count) or "TAN" or "ATN" or "FLOOR" or "CEILING" or "CEIL" or "SIGN" or "SQRT" or "LN" or "LOG10" or "ASIN" or "ACOS" or "ATAN" or "SINH" or "COSH" or "TANH" or "DEGREES" or "RADIANS" - or "LEN" or "LCASE" or "UCASE" or "TRIM" or "LTRIM" or "RTRIM" or "SPACE" + or "LEN" or "LCASE" or "UCASE" or "TRIM" or "SPACE" or "STRREVERSE" or "STR" or "VAL" or "CHR" or "ASC" or "HEX" or "OCT" or "DATEVALUE" or "TIMEVALUE" or "YEAR" or "MONTH" or "DAY" or "HOUR" or "MINUTE" or "SECOND" or "ISDATE" or "ISNULL" or "ISNUMERIC" or "ISERROR" or "TYPENAME" or "VARTYPE" @@ -445,6 +445,8 @@ internal static void ValidateArity(string name, int count) "RGB" => (3, 3), "ROUND" => (1, 2), "LOG" => (1, 2), + // Access takes one argument; the second, a set of characters to strip, is SQL Server 2022's. + "LTRIM" or "RTRIM" => (1, 2), "POWER" or "ATAN2" => (2, 2), "PI" => (0, 0), "RND" => (0, 1), @@ -1416,6 +1418,23 @@ static object RoundWritten(T original, int places, Func written, Fun } } + /// + /// LTrim / RTrim. With one argument they are Access's, stripping . + /// With two they are SQL Server 2022's: every leading (or trailing) character that appears anywhere in the + /// second argument is removed, so the second argument is a SET of characters and not a substring — a + /// LibRed extension, since ACE takes only the one argument. Either argument Null gives Null, and an empty + /// set of characters strips nothing. + /// + private object? Trim(FunctionCall f, Func trim) + { + object? value = Evaluate(f.Arguments[0]); + if (value is null) return null; + if (f.Arguments.Count == 1) return trim(ConcatText(value), TrimmedSpaces); + + object? characters = Evaluate(f.Arguments[1]); + return characters is null ? null : trim(ConcatText(value), ConcatText(characters).ToCharArray()); + } + /// Applies a conversion to a single argument, propagating NULL. private object? Convert1(FunctionCall f, Func convert) { diff --git a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs index 0e3f4a59..88dfa79b 100644 --- a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs @@ -1069,7 +1069,7 @@ private static bool IsNumeric(Type type) /// and . internal static Type? AggregateResultType(string name, Type? argument) => RunningAggregate.Canonical(name) switch { - "LISTAGG" => typeof(string), + "LISTAGG" or "STRING_AGG" => typeof(string), "PERCENTILE_CONT" or "PERCENTILE_DISC" => Percentile.ResultType(name, argument), "COUNT" or "REGR_COUNT" => typeof(int), var pair when RunningAggregate.IsPair(pair) => typeof(double), @@ -2109,23 +2109,29 @@ private static int CountRows(IEnumerable rows) if (name == "LAST") return group.Count == 0 ? null : Eval(columns, group[^1], outer).Evaluate(arg!); + // A list aggregate, ordered or not: STRING_AGG may go without its WITHIN GROUP, in which case the + // values list in the order the rows arrive (no keys, no directions — the sort is stable). + if (FunctionCall.IsListAggregate(name)) + { + if (group.Count == 0) + return null; + IReadOnlyList order = call.WithinGroup ?? []; + IReadOnlyList keys = call.WithinGroupKeys; + return ListAgg.Of( + group.Select(r => + { + ExpressionEvaluator e = Eval(columns, r, outer); + return (e.Evaluate(call.Arguments[0]), keys.Select(k => e.Evaluate(k)).ToArray()); + }), + call.Arguments.Count - keys.Count == 2 ? (string)((LiteralExpression)call.Arguments[1]).Value! : "", + order, + call.Distinct); + } + if (call.WithinGroup is { } directions) { if (group.Count == 0) return null; - if (name == "LISTAGG") - { - IReadOnlyList keys = call.WithinGroupKeys; - return ListAgg.Of( - group.Select(r => - { - ExpressionEvaluator e = Eval(columns, r, outer); - return (e.Evaluate(call.Arguments[0]), keys.Select(k => e.Evaluate(k)).ToArray()); - }), - call.Arguments.Count - keys.Count == 2 ? (string)((LiteralExpression)call.Arguments[1]).Value! : "", - directions, - call.Distinct); - } // The fraction is the group's, so any row gives it; the standard makes it a constant. return Percentile.Of(name, group.Select(r => Eval(columns, r, outer).Evaluate(call.Arguments[1])), diff --git a/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs b/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs index c44bdcaf..5ef8f82c 100644 --- a/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs +++ b/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs @@ -101,6 +101,10 @@ internal static class WindowFunctions ["PERCENTILE_DISC"] = PercentileOf("PERCENTILE_DISC"), ["LISTAGG"] = new(2, int.MaxValue, static _ => typeof(string), ListAggOf, WindowOptions.Frame | WindowOptions.Distinct | WindowOptions.Filter), + // SQL Server's spelling of the same aggregate, whose WITHIN GROUP is optional — so over a window it + // can arrive with just the value and the separator. + ["STRING_AGG"] = new(2, int.MaxValue, static _ => typeof(string), ListAggOf, + WindowOptions.Frame | WindowOptions.Distinct | WindowOptions.Filter), // Access's own First and Last, over the frame rather than the group: the same rows as FIRST_VALUE and // LAST_VALUE, as the grouped forms take the group's first and last row. @@ -228,7 +232,8 @@ public void Add(int position) /// private static void ListAggOf(WindowPartition p, object?[] o) { - IReadOnlyList directions = p.Call.WithinGroup!; + // STRING_AGG may have no WITHIN GROUP at all, and then lists in window order with no keys of its own. + IReadOnlyList directions = p.Call.WithinGroup ?? []; int keys = directions.Count; string separator = p.ArgumentCount - keys == 2 && o.Length > 0 ? (string)p.Argument(0, 1)! : ""; FrameRows previous = default; diff --git a/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs b/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs index b2722c43..e86beeaa 100644 --- a/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs +++ b/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs @@ -222,7 +222,7 @@ private static PlanNode PushSort(PlanNode node, IReadOnlyList keys) /// and LISTAGG. internal static bool IsAggregate(string name) => name.ToUpperInvariant() is var upper - && (upper is "FIRST" or "LAST" or "PERCENTILE_CONT" or "PERCENTILE_DISC" or "LISTAGG" + && (upper is "FIRST" or "LAST" or "PERCENTILE_CONT" or "PERCENTILE_DISC" or "LISTAGG" or "STRING_AGG" || Execution.RunningAggregate.Supports(upper)); internal static bool HasAggregate(Expression e) => e switch diff --git a/src/LibRed/LibRed.Sql/Ast/Expressions.cs b/src/LibRed/LibRed.Sql/Ast/Expressions.cs index f763cb25..5319d81b 100644 --- a/src/LibRed/LibRed.Sql/Ast/Expressions.cs +++ b/src/LibRed/LibRed.Sql/Ast/Expressions.cs @@ -63,6 +63,20 @@ public static bool IsOrderedSetAggregate(string name) => || name.Equals("PERCENTILE_DISC", StringComparison.OrdinalIgnoreCase) || name.Equals("LISTAGG", StringComparison.OrdinalIgnoreCase); + /// Whether is a list aggregate — the standard's LISTAGG or SQL + /// Server's STRING_AGG, which compute the same thing and differ only in what they require: LISTAGG + /// needs its WITHIN GROUP and lets the separator go, STRING_AGG needs the separator and lets the order + /// go. + public static bool IsListAggregate(string name) => + name.Equals("LISTAGG", StringComparison.OrdinalIgnoreCase) + || name.Equals("STRING_AGG", StringComparison.OrdinalIgnoreCase); + + /// Whether accepts WITHIN GROUP — the ordered-set aggregates, which + /// require it, and STRING_AGG, for which it is optional (unordered, the values list in the order + /// the rows arrive). + public static bool AcceptsWithinGroup(string name) => + IsOrderedSetAggregate(name) || name.Equals("STRING_AGG", StringComparison.OrdinalIgnoreCase); + /// The WITHIN GROUP keys: the last arguments, one per direction. public IReadOnlyList WithinGroupKeys => WithinGroup is null ? [] : Arguments.Skip(Arguments.Count - WithinGroup.Count).ToList(); diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index 7025e869..498c3ebe 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -1120,20 +1120,29 @@ private static Expression BuildFunctionCall(FunctionCallContext ctx) /// private static IReadOnlyList? BuildWithinGroup(FunctionCallContext ctx, string name, List args) { - bool orderedSet = FunctionCall.IsOrderedSetAggregate(name); + bool stringAgg = name.Equals("STRING_AGG", StringComparison.OrdinalIgnoreCase); if (ctx.withinGroup() is not { } within) { - return orderedSet + // STRING_AGG's order is optional — without it the values list as the rows arrive — but its + // separator is not, which is the other way round from LISTAGG. + if (stringAgg) + { + ValidateStringAgg(ctx, args); + return null; + } + return FunctionCall.IsOrderedSetAggregate(name) ? throw new SqlParseException($"{name} needs WITHIN GROUP (ORDER BY …).") : null; } - if (!orderedSet) + if (!FunctionCall.AcceptsWithinGroup(name)) throw new SqlParseException($"{name} takes no WITHIN GROUP."); if (ctx.star is not null) throw new SqlParseException($"{name} takes no *."); var keys = within.orderByClause().orderByItem().Select(BuildOrderByItem).ToList(); - if (name.Equals("LISTAGG", StringComparison.OrdinalIgnoreCase)) + if (stringAgg) + ValidateStringAgg(ctx, args); + else if (name.Equals("LISTAGG", StringComparison.OrdinalIgnoreCase)) { if (args.Count is not (1 or 2) || args is [_, not LiteralExpression { Value: string }]) throw new SqlParseException("LISTAGG takes a value and, optionally, a separator written as a string."); @@ -1145,6 +1154,17 @@ private static Expression BuildFunctionCall(FunctionCallContext ctx) return keys.Select(k => k.Direction).ToList(); } + /// SQL Server's STRING_AGG(expression, separator): both arguments, no *, and the + /// separator written as a string, which is what makes it the one value the whole group shares. + private static void ValidateStringAgg(FunctionCallContext ctx, List args) + { + if (ctx.star is not null) + throw new SqlParseException("STRING_AGG takes no *."); + if (args.Count != 2 || args[1] is not LiteralExpression { Value: string }) + throw new SqlParseException( + "STRING_AGG takes a value and a separator written as a string."); + } + private static WindowSpec BuildWindowSpec(WindowSpecificationContext ctx) => new(ctx._partition.Select(BuildExpression).ToList(), ctx.orderByClause() is { } o ? o.orderByItem().Select(BuildOrderByItem).ToList() : [], diff --git a/src/LibRed/README.md b/src/LibRed/README.md index e90e2642..a8452ca4 100644 --- a/src/LibRed/README.md +++ b/src/LibRed/README.md @@ -203,7 +203,8 @@ Treat the number as of its date — an EF Core version bump moves it. - **Ordered-set aggregates** — `PERCENTILE_CONT(p)` and `PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY x [DESC])`, grouped or over a window. `PERCENTILE_CONT` interpolates between numbers or dates; `PERCENTILE_DISC` returns one of the values, so it also takes text. `LISTAGG([DISTINCT] x [, 'separator']) WITHIN GROUP (ORDER BY …)` - joins the values as text. + joins the values as text, and SQL Server's `STRING_AGG(x, 'separator')` is the same aggregate with its + `WITHIN GROUP` optional and its separator required. - **`FILTER (WHERE …)`** on any aggregate, grouped or windowed: `COUNT(*) FILTER (WHERE Amount > 100)`. - `FULL [OUTER] JOIN` — ACE offers only inner/left/right, and its query designer cannot express a full one. - **`OFFSET … ROWS FETCH NEXT … ROWS ONLY`** paging, where the count may be any expression, not just a @@ -211,6 +212,9 @@ Treat the number as of its date — an EF Core version bump moves it. - **Standard scalar syntax** ACE lacks: `CASE`, `COALESCE`, `NULLIF`, `GREATEST`/`LEAST` (NULL arguments ignored, as SQL Server and PostgreSQL treat them — extended mode translates `Math.Max`/`Math.Min` to them), and the `VALUES` table value constructor standing in for a query. + - **Arguments ACE's own functions do not take**: `LOG(x, base)`, and SQL Server 2022's + `LTRIM(x, characters)` / `RTRIM(x, characters)`, where the second argument is a set of characters to + strip rather than a substring. - **Set operations in a subquery predicate** — `IN (… UNION …)`, `EXISTS (… EXCEPT …)`, and a scalar subquery over a set operation. - **`ORDER BY` bound to the query expression**, so it applies to a whole set operation rather than to its diff --git a/src/LibRed/docs/functions.md b/src/LibRed/docs/functions.md index 1c5fc64a..f2e03867 100644 --- a/src/LibRed/docs/functions.md +++ b/src/LibRed/docs/functions.md @@ -86,6 +86,10 @@ procedure call, and a result past a Double an overflow. `StrReverse` `StrComp` `StrConv` `Str` `Val` `Chr` `Asc` `Hex` `Oct` - `Trim`/`LTrim`/`RTrim` strip the space and the ideographic space U+3000 — nothing else. +- **`LTrim(x, characters)` / `RTrim(x, characters)`** (a LibRed extension, SQL Server 2022's): the second + argument is a **set** of characters, not a substring — every leading (or trailing) character that appears + anywhere in it is removed, stopping at the first that does not. An empty set strips nothing; either argument + Null gives Null. ACE takes only the one argument, and `Trim` takes only the one here too. - `Asc` and `Chr` work in the system ANSI code page (`Chr` takes 0–255). - A value that is not text is read as `CStr` writes it. @@ -257,6 +261,10 @@ Server counts a `bit`. A Long. - **`LISTAGG([DISTINCT] x [, 'separator']) WITHIN GROUP (ORDER BY k [DESC], …)`** — the non-Null values as text (each written as `&` writes it) in that order, joined by the separator: a string literal, as the standard has it, and none when left out. Null when there are no values. +- **`STRING_AGG([DISTINCT] x, 'separator') [WITHIN GROUP (ORDER BY k [DESC], …)]`** — SQL Server's spelling of + the same aggregate, and it computes the same thing. The two differ only in what each insists on: `LISTAGG` + needs the `WITHIN GROUP` and lets the separator go, `STRING_AGG` needs the separator and lets the order go. + Without a `WITHIN GROUP` the values list in the order the rows arrive — over a window, in window order. - **`FILTER (WHERE condition)`** — every aggregate, Access's included, takes it after the call (and after `WITHIN GROUP`): only the rows the condition is true for go in, so `COUNT(*) FILTER (WHERE x > 1)` counts those rows, and a group none of whose rows pass has what an empty group has. diff --git a/test/LibRed.Engine.AccessTests/FunctionArityAccessTests.cs b/test/LibRed.Engine.AccessTests/FunctionArityAccessTests.cs index d8818fcf..75ffb965 100644 --- a/test/LibRed.Engine.AccessTests/FunctionArityAccessTests.cs +++ b/test/LibRed.Engine.AccessTests/FunctionArityAccessTests.cs @@ -18,11 +18,14 @@ private sealed record Arity(string Name, int Min, int? Max, params string[] Argu [ .. Unary("CBool", "CByte", "CInt", "CLng", "CSng", "CDbl", "CCur", "CStr", "CDate", "CVar", "Abs", "Sgn", "Int", "Fix", "Sqr", "Exp", "Sin", "Cos", "Tan", "Atn", - "Len", "LCase", "UCase", "Trim", "LTrim", "RTrim", "Space", "StrReverse", "Str", "Val", + "Len", "LCase", "UCase", "Trim", "Space", "StrReverse", "Str", "Val", "Chr", "Asc", "Hex", "Oct"), new("Round", 1, 2, "1", "0", "0"), new("Rnd", 0, 1, "1", "1"), new("Timer", 0, 0, "1"), // ACE's Log takes one argument; LibRed's also takes the standard's base as a second (docs/functions.md). new("Log", 1, 2, "1", "1", "1"), + // Likewise LTrim/RTrim: one argument in ACE, and a second — SQL Server 2022's set of characters to + // strip — in LibRed. + new("LTrim", 1, 2, "'abc'", "'a'", "0"), new("RTrim", 1, 2, "'abc'", "'c'", "0"), new("Left", 2, 2, "'abc'", "1", "0"), new("Right", 2, 2, "'abc'", "1", "0"), new("Mid", 2, 3, "'abc'", "1", "1", "0"), new("InStr", 2, 4, "1", "'abc'", "'b'", "0", "0"), diff --git a/test/LibRed.Engine.Tests/ListAggTests.cs b/test/LibRed.Engine.Tests/ListAggTests.cs index 81d24195..f13f3369 100644 --- a/test/LibRed.Engine.Tests/ListAggTests.cs +++ b/test/LibRed.Engine.Tests/ListAggTests.cs @@ -61,4 +61,40 @@ public void Over_a_window_each_frame_is_listed(string expression, string expecte [InlineData("LISTAGG(*) WITHIN GROUP (ORDER BY Id)")] public void The_syntax_is_the_standards(string aggregate) => Assert.Throws(() => Rows($"SELECT {aggregate} FROM S")); + + // SQL Server's STRING_AGG(expression, separator) computes what LISTAGG computes, and the two differ only + // in what each insists on: LISTAGG needs its WITHIN GROUP and lets the separator go, STRING_AGG needs the + // separator and lets the order go. + [Theory] + [InlineData("STRING_AGG(Rep, ', ') WITHIN GROUP (ORDER BY Rep)", "E:eve N:ann, ann, bob S:cat, dan, dan")] + [InlineData("STRING_AGG(DISTINCT Rep, ',') WITHIN GROUP (ORDER BY Rep)", "E:eve N:ann,bob S:cat,dan")] + [InlineData("STRING_AGG(Rep, ',') FILTER (WHERE Amount > 60)", "E:eve N:ann,bob S:cat")] + public void String_agg_lists_each_group(string aggregate, string expected) => + Assert.Equal(expected, Rows($"SELECT Region, {aggregate} FROM S GROUP BY Region ORDER BY Region")); + + [Fact] + public void String_agg_without_within_group_lists_in_the_order_the_rows_arrive() => + Assert.Equal("ann,ann,bob,cat,dan,dan,eve", Rows("SELECT STRING_AGG(Rep, ',') FROM S")); + + [Fact] + public void String_agg_leaves_nulls_out_and_lists_nothing_as_null() + { + Assert.Equal("ann,bob", Rows( + "SELECT STRING_AGG(IIF(Amount > 60 AND Region = 'N', Rep, NULL), ',') FROM S")); + Assert.Equal("", Rows("SELECT STRING_AGG(Rep, ',') FROM S WHERE Id > 100")); + } + + [Fact] + public void String_agg_over_a_window_lists_each_frame() => + Assert.Equal( + "1:ann 2:ann,ann 3:ann,ann,bob 4:cat 5:cat,dan 6:cat,dan,dan 7:eve", + Rows("SELECT Id, STRING_AGG(Rep, ',') OVER (PARTITION BY Region ORDER BY Id) FROM S ORDER BY Id")); + + [Theory] + [InlineData("STRING_AGG(Rep)")] // the separator is not optional + [InlineData("STRING_AGG(Rep, Region)")] // and must be written as a string + [InlineData("STRING_AGG(Rep, ',', 'x') WITHIN GROUP (ORDER BY Id)")] + [InlineData("STRING_AGG(*)")] + public void String_agg_takes_a_value_and_a_written_separator(string aggregate) => + Assert.Throws(() => Rows($"SELECT {aggregate} FROM S")); } diff --git a/test/LibRed.Engine.Tests/TrimFunctionsTests.cs b/test/LibRed.Engine.Tests/TrimFunctionsTests.cs index da210b6a..4edf451b 100644 --- a/test/LibRed.Engine.Tests/TrimFunctionsTests.cs +++ b/test/LibRed.Engine.Tests/TrimFunctionsTests.cs @@ -4,8 +4,10 @@ namespace LibRed.Engine.Tests; -// Trim/LTrim/RTrim are single-argument and remove ONLY spaces — the space and the ideographic space U+3000, in any -// mixture; not tabs or other whitespace, and no trim-char parameter — verified vs ACE. NULL-propagating. +// With one argument Trim/LTrim/RTrim remove ONLY spaces — the space and the ideographic space U+3000, in any +// mixture; not tabs or other whitespace — verified vs ACE. NULL-propagating. LTrim and RTrim also take SQL +// Server 2022's second argument, a set of characters to strip; that one is a LibRed extension, since ACE takes +// no such parameter. public class TrimFunctionsTests : TempDatabaseTest { private static QueryEngine Fresh() @@ -54,4 +56,36 @@ public void Other_unicode_spaces_are_kept(int codePoint) [InlineData("RTrim(Null)")] public void Trim_propagates_null(string expr) => Assert.Null(Eval(expr)); + + // The second argument is a SET of characters, not a substring: every leading (or trailing) character that + // appears anywhere in it is removed, and the stripping stops at the first character that does not. + [Theory] + [InlineData("LTrim('xxhixx', 'x')", "hixx")] + [InlineData("RTrim('xxhixx', 'x')", "xxhi")] + [InlineData("LTrim('xyxhiyx', 'xy')", "hiyx")] + [InlineData("RTrim('xyhixyx', 'xy')", "xyhi")] + [InlineData("LTrim('.,.hi', '.,')", "hi")] + [InlineData("LTrim('xyz', 'zyx')", "")] // every character stripped + [InlineData("LTrim('hi', 'x')", "hi")] // nothing to strip + [InlineData("LTrim('hi', '')", "hi")] // an empty set strips nothing + [InlineData("LTrim(' hi ', ' ')", "hi ")] // spaces, said explicitly + [InlineData("RTrim(Chr(9) & 'hi' & Chr(9), Chr(9))", "\thi")] // a tab, which the one-argument form keeps + public void Two_argument_trim_strips_the_characters_given(string expr, string expected) + => Assert.Equal(expected, Convert.ToString(Eval(expr))); + + [Theory] + [InlineData("LTrim(Null, 'x')")] + [InlineData("LTrim('xhi', Null)")] + [InlineData("RTrim(Null, 'x')")] + [InlineData("RTrim('hix', Null)")] + public void Two_argument_trim_propagates_null(string expr) + => Assert.Null(Eval(expr)); + + // Access has no third argument, and Trim itself takes only the one. + [Theory] + [InlineData("LTrim('hi', 'x', 'y')")] + [InlineData("RTrim('hi', 'x', 'y')")] + [InlineData("Trim('hi', 'x')")] + public void A_third_argument_is_rejected(string expr) + => Assert.Throws(() => Eval(expr)); } From b9f821946583b8d130b9e81cef03908964480bc5 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 21 Sep 2026 01:28:55 +0800 Subject: [PATCH 06/14] Name a column's type as the rest of the surface names it GetDataTypeName answered with the CLR type's name, so every text column read as "String" whether it was a Char, a VarChar or a LongText - while the same reader's GetColumnSchema and GetSchemaTable had the provider's name for it all along. It now gives that name, which is also the one the DataTypes collection and Columns.TYPE_NAME use, so one type has one name across the whole surface and the fixed/variable distinction survives. ACE answers this with the OLE DB spelling instead (DBTYPE_WVARCHAR where this says VarChar), but its own schema rowsets use these short names, so matching them is what keeps the provider consistent with itself. A result with nothing described behind it - a system-variable select - has no stored column to name and still falls back to the CLR name. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedDataReader.cs | 11 ++++- .../LibRedDataReaderMetadataTests.cs | 41 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/LibRed/LibRed.Ado/LibRedDataReader.cs b/src/LibRed/LibRed.Ado/LibRedDataReader.cs index 3c4bbab1..74cce129 100644 --- a/src/LibRed/LibRed.Ado/LibRedDataReader.cs +++ b/src/LibRed/LibRed.Ado/LibRedDataReader.cs @@ -129,7 +129,16 @@ public override Type GetFieldType(int ordinal) : ordinal < _current.Length && _current[ordinal] is { } value ? value.GetType() : typeof(object); } - public override string GetDataTypeName(int ordinal) => GetFieldType(ordinal).Name; + /// + /// The provider's name for the column's type — VarChar, Char, Long, Currency — + /// the same name , and the DataTypes + /// metadata collection give it, so one type has one name across the whole surface. (ACE's OLE DB provider + /// answers this with the OLE DB spelling, DBTYPE_WVARCHAR; its own schema rowsets use these names, + /// and matching them is what keeps this provider self-consistent.) A result with nothing described behind + /// it — a system-variable select, say — falls back to the CLR type's name. + /// + public override string GetDataTypeName(int ordinal) => + _result.Columns[ordinal].TypeName is { Length: > 0 } name ? name : GetFieldType(ordinal).Name; /// The result's columns as s: each column's type, and for one read straight /// from a table the stored column behind it — its table, its own name, and whether it is a key, unique, diff --git a/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs b/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs index 02eef3cf..77eab1fa 100644 --- a/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs +++ b/test/LibRed.Ado.Tests/LibRedDataReaderMetadataTests.cs @@ -22,8 +22,45 @@ public void Empty_result_preserves_declared_column_types() Assert.False(reader.HasRows); Assert.Equal(typeof(int), reader.GetFieldType(0)); Assert.Equal(typeof(string), reader.GetFieldType(1)); + // The provider's name for the type, not the CLR type's: one type, one name across GetDataTypeName, + // the column schema and the DataTypes collection. ProductName is Text(40), a variable-length column. + Assert.Equal("Long", reader.GetDataTypeName(0)); + Assert.Equal("VarChar", reader.GetDataTypeName(1)); + } + + [Fact] + public void GetDataTypeName_is_the_name_the_column_schema_gives() + { + using var connection = new LibRedConnection($"Data Source={Northwind}"); + connection.Open(); + using var command = connection.CreateCommand(); + // A fixed-width text column, a variable one, a number, a date, a currency and a computed column — + // the fixed and variable text are the pair that used to read alike. + command.CommandText = + "SELECT Orders.CustomerID, Customers.CompanyName, Orders.OrderID, Orders.OrderDate, " + + "Orders.Freight, Orders.Freight * 2 AS Doubled FROM Orders INNER JOIN Customers " + + "ON Orders.CustomerID = Customers.CustomerID"; + + using DbDataReader reader = command.ExecuteReader(); + Assert.Equal( + ["Char", "VarChar", "Long", "DateTime", "Currency", "Currency"], + Enumerable.Range(0, reader.FieldCount).Select(reader.GetDataTypeName)); + Assert.Equal( + reader.GetColumnSchema().Select(c => c.DataTypeName), + Enumerable.Range(0, reader.FieldCount).Select(reader.GetDataTypeName)); + } + + [Fact] + public void A_result_with_nothing_described_behind_it_falls_back_to_the_clr_name() + { + // @@IDENTITY is session state rather than a column, so there is no stored type to name. + using var connection = new LibRedConnection($"Data Source={Northwind}"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT @@ROWCOUNT AS `Rows`"; + + using var reader = command.ExecuteReader(); Assert.Equal(nameof(Int32), reader.GetDataTypeName(0)); - Assert.Equal(nameof(String), reader.GetDataTypeName(1)); } [Fact] @@ -49,7 +86,7 @@ public void First_null_result_preserves_the_declared_column_type() using var reader = command.ExecuteReader(); Assert.True(reader.HasRows); Assert.Equal(typeof(string), reader.GetFieldType(0)); - Assert.Equal(nameof(String), reader.GetDataTypeName(0)); + Assert.Equal("VarChar", reader.GetDataTypeName(0)); Assert.True(reader.Read()); Assert.True(reader.IsDBNull(0)); Assert.True(reader.Read()); From d38b0c6356de443a2ea5a86388ee2b2fee922cfd Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 21 Sep 2026 23:18:30 +0800 Subject: [PATCH 07/14] Hold the shipping code to the analyzers' standards src/Directory.Build.props turns on AnalysisMode=Recommended and EnforceCodeStyleInBuild. TreatWarningsAsErrors already applied, so every diagnostic is a build error. The test projects import the repo-root props and stay on the SDK's default rule set. Nearly everything is fixed rather than suppressed: culture arguments on locale-sensitive conversions and comparisons, Any() -> Count, First() -> indexer, sealing, GC.SuppressFinalize, ObjectDisposedException.ThrowIf, missing tags, override parameter names matched to their base, dead members removed. A #pragma, each with a written reason, only where the rule's own fix would change behaviour or fights a fixed external contract: IDE0028, whose collection expression silently drops a comparer (measured); CA1859 where it is wrong about what a method returns; CA1711/CA1069 on the ADO/ADOX/DAO type-library enums, which are transcribed name-for-name; CA1051, CA1010, CA1848 and CA2254 on EF Core and ADO.NET house shapes. Three behaviour changes: eight `throw new Exception(...)` in EFCore.Jet.Data become InvalidOperationException; the .mdb extension test in both database creators is now case-insensitive, where .MDB never matched before; and JetDualTable's two public static fields become properties. src/Shared/SharedSource.props declares the shared sources and the implicit usings they need, imported by the five projects that compile them. Those projects' lists had drifted, so a directive redundant in one was load-bearing in another, and IDE0005 is evaluated per project - formatting one project would delete a using the rest still needed. RowEncoder keeps its JetFormatBase parameter, unread until Jet 3 needs it. CLAUDE.md and AGENTS.md said src/LibRed/Directory.Build.props bypasses src/Directory.Build.props. It imports it, which is how the LibRed projects pick the analyzers up; corrected, and both now describe the analysis settings and the src/Shared formatting trap. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 16 +- CLAUDE.md | 16 +- src/Directory.Build.props | 4 + .../Internal/JetAnnotationCodeGenerator.cs | 2 +- ...JetCSharpRuntimeAnnotationCodeGenerator.cs | 2 +- ...ctingValueGenerationStrategiesEventData.cs | 6 +- .../Internal/JetLoggingDefinitions.cs | 4 + .../EFCore.Jet.Common.csproj | 7 +- .../Extensions/JetDbFunctionsExtensions.cs | 2 +- .../Extensions/JetModelExtensions.cs | 2 +- .../Infrastructure/JetDualTable.cs | 9 +- .../Internal/JetLoggerExtensions.cs | 8 +- .../Internal/JetModelValidator.cs | 2 +- .../Conventions/JetIndexConvention.cs | 12 +- .../Conventions/JetOnDeleteConvention.cs | 4 +- .../Conventions/JetSharedTableConvention.cs | 2 +- .../JetValueGenerationStrategyConvention.cs | 2 +- .../JetValueGeneratorConvention.cs | 2 +- .../Internal/JetAnnotationProvider.cs | 4 +- .../Metadata/JetValueGenerationStrategy.cs | 2 +- .../Internal/JetMigrationDatabaseLock.cs | 6 +- .../Migrations/JetMigrationsSqlGenerator.cs | 10 +- .../Operations/JetCreateDatabaseOperation.cs | 2 +- .../Operations/JetDropDatabaseOperation.cs | 2 +- ...etAggregateMethodCallTranslatorProvider.cs | 2 +- .../Internal/JetByteArrayMethodTranslator.cs | 46 +- .../JetDateDiffFunctionsTranslator.cs | 4 +- .../Internal/JetDateOnlyMemberTranslator.cs | 4 +- .../Internal/JetDateOnlyMethodTranslator.cs | 4 +- .../Internal/JetDateTimeMemberTranslator.cs | 4 + .../Internal/JetDateTimeMethodTranslator.cs | 8 +- .../Internal/JetIsDateFunctionTranslator.cs | 2 +- .../Internal/JetMemberTranslatorProvider.cs | 2 +- .../Internal/JetParseTranslator.cs | 2 +- .../JetStatisticsAggregateMethodTranslator.cs | 2 +- .../Internal/JetStringMethodTranslator.cs | 2 +- .../Internal/JetTimeOnlyMemberTranslator.cs | 2 +- .../Internal/JetTimeOnlyMethodTranslator.cs | 2 +- .../Internal/JetTimeSpanMemberTranslator.cs | 2 +- .../JetCompatibilityExpressionVisitor.cs | 8 +- .../JetCompiledQueryCacheKeyGenerator.cs | 2 +- .../Internal/JetDateTimeRangeConverter.cs | 8 +- .../Internal/JetLiftOrderByPostprocessor.cs | 25 +- .../JetLocateScalarSubqueryVisitor.cs | 18 +- ...terJoinProjectionGuardExpressionVisitor.cs | 46 +- .../Internal/JetQueryCompilationContext.cs | 2 +- .../JetQueryCompilationContextFactory.cs | 4 +- .../JetQueryTranslationPreprocessor.cs | 4 +- .../JetQueryTranslationPreprocessorFactory.cs | 2 +- ...yableMethodTranslatingExpressionVisitor.cs | 26 +- ...thodTranslatingExpressionVisitorFactory.cs | 2 +- .../Internal/JetSkipTakePostprocessor.cs | 18 +- .../Internal/JetSqlNullabilityProcessor.cs | 2 +- .../JetSqlTranslatingExpressionVisitor.cs | 15 +- ...tSqlTranslatingExpressionVisitorFactory.cs | 2 +- .../Query/Internal/JetZeroLimitConverter.cs | 9 +- ...rchConditionConvertingExpressionVisitor.cs | 37 +- .../Query/JetSqlExpressionFactory.cs | 2 +- .../Sql/Internal/IJetExpressionVisitor.cs | 2 +- .../Sql/Internal/JetQuerySqlGenerator.cs | 32 +- .../Internal/JetQuerySqlGeneratorFactory.cs | 2 +- .../Internal/JetSqlGenerationHelper.cs | 10 +- .../Update/Internal/IJetUpdateSqlGenerator.cs | 2 +- .../Internal/JetModificationCommandBatch.cs | 8 +- .../JetModificationCommandBatchFactory.cs | 2 +- .../Update/Internal/JetUpdateSqlGenerator.cs | 10 +- .../JetSequentialGuidValueGenerator.cs | 2 +- src/EFCore.Jet.Data/AdoxDatabaseCreator.cs | 22 +- src/EFCore.Jet.Data/AdoxSchema.cs | 16 +- src/EFCore.Jet.Data/ComObject.cs | 26 +- .../ConnectionPooling/ConnectionSet.cs | 4 +- .../ConnectionSetCollection.cs | 13 +- src/EFCore.Jet.Data/DaoDatabaseCreator.cs | 35 +- src/EFCore.Jet.Data/DaoSchema.cs | 15 +- src/EFCore.Jet.Data/DbColumnFlagEnum.cs | 5 +- .../DbConnectionStringBuilderExtensions.cs | 26 +- src/EFCore.Jet.Data/EFCore.Jet.Data.csproj | 2 + src/EFCore.Jet.Data/IJetDatabaseCreator.cs | 7 +- src/EFCore.Jet.Data/InnerConnectionFactory.cs | 8 +- src/EFCore.Jet.Data/JetCommand.cs | 41 +- src/EFCore.Jet.Data/JetConfiguration.cs | 12 +- src/EFCore.Jet.Data/JetConnection.cs | 60 ++- .../JetConnectionStringBuilder.cs | 4 + src/EFCore.Jet.Data/JetDataReader.cs | 18 +- src/EFCore.Jet.Data/JetFactory.cs | 38 +- src/EFCore.Jet.Data/JetParameterHelper.cs | 29 +- .../JetStoreSchemaDefinition/Column.cs | 10 +- .../ColumnCollection.cs | 2 +- .../JetInformationSchema.cs | 12 +- .../JetSchemaOperationsHandling.cs | 10 +- .../JetStoreDatabaseHandling.cs | 37 +- .../JetStoreSchemaDefinition/SchemaTables.cs | 20 +- src/EFCore.Jet.Data/JetSyntaxHelper.cs | 6 +- src/EFCore.Jet.Data/JetTransaction.cs | 29 +- src/EFCore.Jet.Data/LogHelper.cs | 5 +- src/EFCore.Jet.Data/Messages.cs | 7 +- src/EFCore.Jet.Data/PreciseSchema.cs | 12 +- src/EFCore.Jet.Data/SchemaProvider.cs | 2 +- src/EFCore.Jet.Data/TypesExtensions.cs | 1 - src/EFCore.Jet.Odbc/EFCore.Jet.Odbc.csproj | 6 +- ...etOdbcDbContextOptionsBuilderExtensions.cs | 9 +- src/EFCore.Jet.OleDb/EFCore.Jet.OleDb.csproj | 6 +- .../JetDbContextOptionsBuilderExtensions.cs | 9 +- src/EFCore.Jet/EFCore.Jet.csproj | 7 +- .../Extensions/JetDatabaseFacadeExtensions.cs | 2 +- .../JetServiceCollectionExtensions.cs | 4 +- .../Infrastructure/Internal/IJetOptions.cs | 2 +- src/EFCore.Jet/Internal/JetOptions.cs | 3 +- .../Internal/JetHistoryRepository.cs | 2 +- .../Internal/JetMigrationCommandExecutor.cs | 11 +- .../Properties/InternalsVisibleTo.cs | 2 +- .../Internal/JetParameterBasedSqlProcessor.cs | 8 +- .../JetParameterBasedSqlProcessorFactory.cs | 2 +- .../JetQueryTranslationPostprocessor.cs | 8 +- ...JetQueryTranslationPostprocessorFactory.cs | 2 +- .../Scaffolding/Internal/JetCodeGenerator.cs | 2 +- .../Internal/JetDataReaderExtension.cs | 2 +- .../Internal/JetDatabaseModelFactory.cs | 40 +- .../Internal/IJetRelationalConnection.cs | 2 +- .../Storage/Internal/JetBoolTypeMapping.cs | 2 +- .../Internal/JetByteArrayTypeMapping.cs | 4 +- .../Storage/Internal/JetByteTypeMapping.cs | 2 +- .../Storage/Internal/JetDatabaseCreator.cs | 6 +- .../Internal/JetDateOnlyTypeMapping.cs | 2 +- .../Internal/JetDateTimeOffsetTypeMapping.cs | 6 +- .../Internal/JetDateTimeTypeMapping.cs | 1 - .../Storage/Internal/JetDecimalTypeMapping.cs | 2 +- .../Storage/Internal/JetDoubleTypeMapping.cs | 4 +- .../Storage/Internal/JetExecutionStrategy.cs | 2 +- .../Internal/JetExecutionStrategyFactory.cs | 2 +- .../Storage/Internal/JetFloatTypeMapping.cs | 4 +- .../Storage/Internal/JetIntTypeMapping.cs | 2 +- .../Storage/Internal/JetJsonTypeMapping.cs | 2 +- .../Internal/JetOdbcGuidTypeMapping.cs | 2 - .../Storage/Internal/JetShortTypeMapping.cs | 2 +- .../Storage/Internal/JetStringTypeMapping.cs | 6 +- .../Internal/JetTimeOnlyTypeMapping.cs | 2 - .../Storage/Internal/JetTransactionFactory.cs | 2 +- .../Internal/JetTransientExceptionDetector.cs | 2 +- .../Storage/Internal/JetTypeMappingSource.cs | 21 +- .../Utilities/EnumerableExtensions.cs | 2 +- src/LibRed/Directory.Build.props | 5 +- src/LibRed/LibRed.Ado/LibRedCommand.cs | 6 +- src/LibRed/LibRed.Ado/LibRedConnection.cs | 11 +- .../LibRedConnectionStringBuilder.cs | 6 +- src/LibRed/LibRed.Ado/LibRedDataReader.cs | 18 +- src/LibRed/LibRed.Ado/LibRedDbColumn.cs | 4 +- src/LibRed/LibRed.Ado/LibRedException.cs | 2 +- src/LibRed/LibRed.Ado/LibRedFactory.cs | 2 +- src/LibRed/LibRed.Ado/LibRedParameter.cs | 2 +- .../LibRed.Ado/LibRedParameterCollection.cs | 6 +- src/LibRed/LibRed.Ado/LibRedSchema.cs | 25 +- src/LibRed/LibRed.Ado/LibRedTransaction.cs | 10 +- src/LibRed/LibRed.Core/Catalog/Collation.cs | 2 +- src/LibRed/LibRed.Core/Catalog/ColumnDef.cs | 2 +- src/LibRed/LibRed.Core/Catalog/ForeignKey.cs | 6 +- src/LibRed/LibRed.Core/Catalog/IndexDef.cs | 2 +- src/LibRed/LibRed.Core/Catalog/JetCatalog.cs | 115 ++--- src/LibRed/LibRed.Core/Catalog/JetDataType.cs | 5 + src/LibRed/LibRed.Core/Catalog/JetName.cs | 2 +- .../LibRed.Core/Catalog/LogicalIndexDef.cs | 2 +- .../LibRed.Core/Catalog/PropertyBlob.cs | 13 +- .../LibRed.Core/Catalog/RecordLayout.cs | 2 +- .../LibRed.Core/Catalog/RelationshipSpec.cs | 2 +- src/LibRed/LibRed.Core/Catalog/TableDef.cs | 2 +- src/LibRed/LibRed.Core/Catalog/TableType.cs | 2 +- src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs | 9 +- src/LibRed/LibRed.Core/Catalog/ViewSpec.cs | 2 +- .../ConstraintViolationException.cs | 2 +- .../LibRed.Core/Crypto/AccessEncryption.cs | 4 +- .../LibRed.Core/Crypto/AgileEncryption.cs | 16 +- .../LibRed.Core/Crypto/DatabaseEncryption.cs | 4 +- src/LibRed/LibRed.Core/Crypto/IPageCodec.cs | 2 +- .../LibRed.Core/Crypto/JetLegacyEncryption.cs | 2 +- .../Crypto/OfficeStandardEncryption.cs | 11 +- .../LibRed.Core/Formats/CatalogFormat.cs | 2 +- .../LibRed.Core/Formats/IndexBlockFormat.cs | 2 +- src/LibRed/LibRed.Core/Formats/IndexFlags.cs | 2 +- .../LibRed.Core/Formats/IndexKeyFlags.cs | 2 +- src/LibRed/LibRed.Core/Formats/Jet12Format.cs | 2 +- src/LibRed/LibRed.Core/Formats/Jet14Format.cs | 2 +- src/LibRed/LibRed.Core/Formats/Jet16Format.cs | 2 +- src/LibRed/LibRed.Core/Formats/Jet17Format.cs | 2 +- src/LibRed/LibRed.Core/Formats/Jet3Format.cs | 2 +- src/LibRed/LibRed.Core/Formats/Jet4Format.cs | 2 +- .../LibRed.Core/Formats/JetFormatBase.cs | 2 +- src/LibRed/LibRed.Core/Formats/JetVersion.cs | 7 +- .../LibRed.Core/Formats/LongValueFormat.cs | 2 +- .../LibRed.Core/Formats/RelationshipFlags.cs | 2 +- src/LibRed/LibRed.Core/Formats/RowPointer.cs | 2 +- .../LibRed.Core/Formats/StoredQueryFormat.cs | 2 +- src/LibRed/LibRed.Core/IO/ILockManager.cs | 2 +- .../LibRed.Core/IO/MonitorLockManager.cs | 4 +- src/LibRed/LibRed.Core/IO/PageBuffer.cs | 2 +- src/LibRed/LibRed.Core/IO/PageCache.cs | 25 +- src/LibRed/LibRed.Core/IO/PageChannel.cs | 9 +- src/LibRed/LibRed.Core/IO/Transaction.cs | 2 +- src/LibRed/LibRed.Core/JetDatabase.cs | 2 +- src/LibRed/LibRed.Core/Pages/DataPage.cs | 2 +- .../Pages/DatabaseDefinitionPage.cs | 4 +- src/LibRed/LibRed.Core/Pages/Page.cs | 2 +- src/LibRed/LibRed.Core/Pages/PageType.cs | 2 +- .../LibRed.Core/Pages/TableDefinitionPage.cs | 4 +- .../LibRed.Core/Pages/TdefChainReader.cs | 2 +- .../SchemaObjectExistsException.cs | 2 +- .../Storage/Calculated/CalculatedEvaluator.cs | 81 ++-- .../Calculated/CalculatedExpression.cs | 51 +-- .../LibRed.Core/Storage/CalculatedValue.cs | 16 +- .../LibRed.Core/Storage/DatabaseCreator.cs | 24 +- src/LibRed/LibRed.Core/Storage/IndexCursor.cs | 2 +- .../LibRed.Core/Storage/IndexKeyDecoder.cs | 3 +- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 8 +- .../LibRed.Core/Storage/IndexPageReader.cs | 4 +- src/LibRed/LibRed.Core/Storage/IndexWriter.cs | 18 +- .../Storage/JetIndexKeyChecksum.cs | 2 +- .../LibRed.Core/Storage/JetKanaSection.cs | 2 +- .../LibRed.Core/Storage/JetLocaleTailoring.cs | 16 +- .../LibRed.Core/Storage/JetTextCollation.cs | 87 +++- .../Storage/JetTextCollationTableV0.cs | 6 +- .../LibRed.Core/Storage/JetTextCollationV1.cs | 5 +- .../Storage/JetTextCollationV1Overrides.cs | 2 +- .../LibRed.Core/Storage/JetTextComparer.cs | 2 +- .../LibRed.Core/Storage/LongValueReader.cs | 2 +- .../LibRed.Core/Storage/LongValueWriter.cs | 4 +- .../LibRed.Core/Storage/PageAllocator.cs | 8 +- src/LibRed/LibRed.Core/Storage/RowDecoder.cs | 3 +- src/LibRed/LibRed.Core/Storage/RowEncoder.cs | 15 +- src/LibRed/LibRed.Core/Storage/RowId.cs | 2 +- src/LibRed/LibRed.Core/Storage/RowInserter.cs | 22 +- src/LibRed/LibRed.Core/Storage/RowLayout.cs | 8 +- .../Storage/RowRelocationReader.cs | 4 +- src/LibRed/LibRed.Core/Storage/Table.cs | 2 +- .../LibRed.Core/Storage/TableCreator.cs | 45 +- src/LibRed/LibRed.Core/Storage/TableCursor.cs | 2 +- .../LibRed.Core/Storage/Types/JetTypeCodec.cs | 45 +- src/LibRed/LibRed.Core/Storage/UsageMap.cs | 6 +- .../LibRed.Core/Storage/UsageMapWriter.cs | 4 +- src/LibRed/LibRed.Core/Storage/ViewCreator.cs | 4 +- .../Internal/LibRedDesignTimeServices.cs | 2 +- ...LibRedDbContextOptionsBuilderExtensions.cs | 5 +- .../LibRedServiceCollectionExtensions.cs | 5 +- .../Infrastructure/Internal/ILibRedOptions.cs | 2 +- .../LibRedDbContextOptionsBuilder.cs | 3 - .../Infrastructure/LibRedSqlMode.cs | 4 +- .../LibRed.EFCore/Internal/LibRedOptions.cs | 3 +- src/LibRed/LibRed.EFCore/LibRed.EFCore.csproj | 8 +- .../LibRed.EFCore/LibRedDesignTimeMarker.cs | 2 +- .../LibRedRetryingExecutionStrategy.cs | 3 +- .../Conventions/LibRedConventionSetBuilder.cs | 2 +- .../Internal/LibRedHistoryRepository.cs | 4 +- .../LibRedMigrationsSqlGenerator.cs | 10 +- .../LibRed.EFCore/Properties/AssemblyInfo.cs | 4 +- .../Query/Internal/LibRedMathTranslator.cs | 2 +- .../LibRedMethodCallTranslatorProvider.cs | 2 +- .../LibRedParameterBasedSqlProcessor.cs | 7 +- ...LibRedParameterBasedSqlProcessorFactory.cs | 2 +- .../LibRedQueryTranslationPostprocessor.cs | 7 +- ...RedQueryTranslationPostprocessorFactory.cs | 2 +- .../LibRedSqlTranslatingExpressionVisitor.cs | 15 +- ...dSqlTranslatingExpressionVisitorFactory.cs | 2 +- .../Sql/Internal/LibRedQuerySqlGenerator.cs | 11 +- .../LibRedQuerySqlGeneratorFactory.cs | 4 +- .../Internal/LibRedCodeGenerator.cs | 2 +- .../Internal/LibRedDatabaseModelFactory.cs | 12 +- .../Internal/ILibRedRelationalConnection.cs | 4 +- .../Storage/Internal/LibRedBoolTypeMapping.cs | 4 +- .../Internal/LibRedByteArrayTypeMapping.cs | 4 +- .../Storage/Internal/LibRedByteTypeMapping.cs | 2 +- .../Internal/LibRedCurrencyTypeMapping.cs | 2 +- .../Storage/Internal/LibRedDatabaseCreator.cs | 4 +- .../Internal/LibRedDateOnlyTypeMapping.cs | 4 +- .../LibRedDateTimeOffsetTypeMapping.cs | 2 +- .../Internal/LibRedDateTimeTypeMapping.cs | 2 +- .../Internal/LibRedDecimalTypeMapping.cs | 4 +- .../Internal/LibRedDoubleTypeMapping.cs | 4 +- .../Internal/LibRedExecutionStrategy.cs | 4 +- .../LibRedExecutionStrategyFactory.cs | 6 +- .../Internal/LibRedFloatTypeMapping.cs | 4 +- .../Storage/Internal/LibRedGuidTypeMapping.cs | 2 +- .../Storage/Internal/LibRedIntTypeMapping.cs | 2 +- .../Storage/Internal/LibRedJsonTypeMapping.cs | 2 +- .../Storage/Internal/LibRedLongTypeMapping.cs | 2 +- .../Internal/LibRedRelationalConnection.cs | 5 +- .../Internal/LibRedRelationalTransaction.cs | 7 +- .../Internal/LibRedShortTypeMapping.cs | 2 +- .../Internal/LibRedStringTypeMapping.cs | 8 +- .../Internal/LibRedTimeOnlyTypeMapping.cs | 2 +- .../Internal/LibRedTimeSpanTypeMapping.cs | 2 +- .../Internal/LibRedTransactionFactory.cs | 7 +- .../LibRedTransientExceptionDetector.cs | 2 +- .../Internal/LibRedTypeMappingSource.cs | 19 +- .../Internal/LibRedUpdateSqlGenerator.cs | 12 +- .../Execution/AccessTypeMapper.cs | 4 +- .../Execution/DecorrelationGate.cs | 2 +- .../LibRed.Engine/Execution/EvalScope.cs | 5 +- .../LibRed.Engine/Execution/ExistsSemiJoin.cs | 5 +- .../Execution/ExpressionEvaluator.Format.cs | 10 +- .../Execution/ExpressionEvaluator.cs | 89 ++-- .../LibRed.Engine/Execution/HoistedInSet.cs | 2 +- .../Execution/JoinPredicateHoisting.cs | 2 +- .../LibRed.Engine/Execution/LikeMatcher.cs | 2 +- src/LibRed/LibRed.Engine/Execution/ListAgg.cs | 2 +- .../LibRed.Engine/Execution/ParameterBag.cs | 6 +- .../LibRed.Engine/Execution/Percentile.cs | 7 +- .../LibRed.Engine/Execution/QueryExecutor.cs | 415 ++++++++++-------- .../LibRed.Engine/Execution/ResultSet.cs | 14 +- .../Execution/RunningAggregate.cs | 6 +- .../Execution/ScalarAggregateSemiJoin.cs | 2 +- .../Execution/StatementExecutor.cs | 12 +- .../Execution/SubqueryCorrelation.cs | 2 +- .../Execution/SubqueryHoisting.cs | 2 +- .../LibRed.Engine/Execution/VbaDateText.cs | 34 +- .../Execution/WindowFunctions.cs | 6 +- .../Execution/WindowPartition.cs | 10 +- src/LibRed/LibRed.Engine/Plan/PlanNode.cs | 2 +- src/LibRed/LibRed.Engine/Plan/PlanNodes.cs | 6 +- .../LibRed.Engine/Planning/IndexSelection.cs | 14 +- .../LibRed.Engine/Planning/QueryPlanner.cs | 17 +- .../LibRed.Engine/Planning/ViewExpander.cs | 62 +-- src/LibRed/LibRed.Engine/QueryEngine.cs | 12 +- .../Schema/CatalogSchemaProvider.cs | 2 +- .../LibRed.Engine/Schema/InformationSchema.cs | 6 +- .../LibRed.Engine/Schema/JetClrTypeMap.cs | 2 +- .../LibRed.Engine/Schema/JetStoreType.cs | 2 +- .../LibRed.Engine/Schema/SchemaRowsets.cs | 2 +- src/LibRed/LibRed.Engine/SessionState.cs | 2 +- src/LibRed/LibRed.Sql/Ast/Expressions.cs | 3 + src/LibRed/LibRed.Sql/Ast/Statements.cs | 11 +- .../LibRed.Sql/Parsing/AntlrSqlParser.cs | 2 +- src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 52 +-- src/LibRed/LibRed.Sql/SqlKeywords.cs | 2 +- src/Shared/Check.cs | 8 +- src/Shared/CodeAnnotations.cs | 6 +- src/Shared/Data/JetDecimalConverter.cs | 8 +- src/Shared/EnumerableMthods.cs | 6 +- src/Shared/ExpressionVisitorExtensions.cs | 7 +- src/Shared/MemberInfoExtensions.cs | 4 +- src/Shared/PropertyInfoExtensions.cs | 5 +- src/Shared/SharedSource.props | 23 + src/Shared/SharedTypeExtensions.cs | 8 +- src/Shared/StringBuilderExtensions.cs | 3 +- .../Suites/PipelineBenchmarks.cs | 5 +- 342 files changed, 1783 insertions(+), 1560 deletions(-) create mode 100644 src/Shared/SharedSource.props diff --git a/AGENTS.md b/AGENTS.md index e3d52900..c78072b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,14 @@ dotnet build EFCore.Jet.sln Assemblies are **strong-name signed** using `Key.snk`. `TreatWarningsAsErrors=True` is set globally — fix all warnings. +`src/Directory.Build.props` also turns on `AnalysisMode=Recommended` and `EnforceCodeStyleInBuild`, so **in `src` +every CA/IDE diagnostic is a build error** (the test projects import the repo-root props and stay on the SDK +default). Fix them rather than suppress them; where a rule's own fix would change behaviour, a `#pragma` carries a +written reason. Two traps: `dotnet format whitespace` handles IDE0055 and `dotnet format style` the other IDE rules, +but **never run either against a single project when the change can touch `src/Shared`** — IDE0005 is evaluated per +project, and formatting one will delete a using the other four consumers still need. The implicit usings those +shared files rely on are declared once in `src/Shared/SharedSource.props`. + ### Local EFCore Repository (optional) To develop against a local EF Core build instead of NuGet packages, copy `Development.props.sample` to `Development.props` and set `LocalEFCoreRepository` to your EF Core checkout. That local build must be compiled with `AssemblyVersion=11.0.0.0` to avoid binding conflicts. @@ -267,9 +275,11 @@ The Jet provider (`src/EFCore.Jet*`) has **no** equivalent — it does no ACE ve `JetTypeMappingSource` keeps `{"bigint", …}` commented out, so `long` maps to `decimal(20,0)` regardless of the installed engine. That asymmetry is deliberate for now; don't "fix" one side by assuming the other behaves the same. -**Build configuration:** `src/LibRed/Directory.Build.props` bypasses `src/Directory.Build.props` (it imports the -repo-root props directly) to set its own build options — not packable, no documentation file, its own `NoWarn`, -`ImplicitUsings`/`Nullable` on. The Windows stamp is no longer a reason: each driver-bound project applies +**Build configuration:** `src/LibRed/Directory.Build.props` **imports** `src/Directory.Build.props` and adds to it: +`ImplicitUsings`/`Nullable` on, `GenerateDocumentationFile` on (IDE0005 cannot run without it), its own `NoWarn`, +and a LibRed-specific package readme in place of the Jet one the repo root packs. Because it imports rather than +bypasses, the LibRed projects inherit the same `AnalysisMode`/`EnforceCodeStyleInBuild` as the rest of `src`. The +Windows stamp is no longer a reason for any of it: each driver-bound project applies `[SupportedOSPlatform("windows")]` itself. Strong-naming is preserved. **SQL pipeline** (always run end-to-end, even for trivial queries, so new features add diff --git a/CLAUDE.md b/CLAUDE.md index bebf1608..e7c6234a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,14 @@ dotnet build EFCore.Jet.sln Assemblies are **strong-name signed** using `Key.snk`. `TreatWarningsAsErrors=True` is set globally — fix all warnings. +`src/Directory.Build.props` also turns on `AnalysisMode=Recommended` and `EnforceCodeStyleInBuild`, so **in `src` +every CA/IDE diagnostic is a build error** (the test projects import the repo-root props and stay on the SDK +default). Fix them rather than suppress them; where a rule's own fix would change behaviour, a `#pragma` carries a +written reason. Two traps: `dotnet format whitespace` handles IDE0055 and `dotnet format style` the other IDE rules, +but **never run either against a single project when the change can touch `src/Shared`** — IDE0005 is evaluated per +project, and formatting one will delete a using the other four consumers still need. The implicit usings those +shared files rely on are declared once in `src/Shared/SharedSource.props`. + ### Local EFCore Repository (optional) To develop against a local EF Core build instead of NuGet packages, copy `Development.props.sample` to `Development.props` and set `LocalEFCoreRepository` to your EF Core checkout. That local build must be compiled with `AssemblyVersion=11.0.0.0` to avoid binding conflicts. @@ -282,9 +290,11 @@ The Jet provider (`src/EFCore.Jet*`) has **no** equivalent — it does no ACE ve `JetTypeMappingSource` keeps `{"bigint", …}` commented out, so `long` maps to `decimal(20,0)` regardless of the installed engine. That asymmetry is deliberate for now; don't "fix" one side by assuming the other behaves the same. -**Build configuration:** `src/LibRed/Directory.Build.props` bypasses `src/Directory.Build.props` (it imports the -repo-root props directly) to set its own build options — not packable, no documentation file, its own `NoWarn`, -`ImplicitUsings`/`Nullable` on. The Windows stamp is no longer a reason: each driver-bound project applies +**Build configuration:** `src/LibRed/Directory.Build.props` **imports** `src/Directory.Build.props` and adds to it: +`ImplicitUsings`/`Nullable` on, `GenerateDocumentationFile` on (IDE0005 cannot run without it), its own `NoWarn`, +and a LibRed-specific package readme in place of the Jet one the repo root packs. Because it imports rather than +bypasses, the LibRed projects inherit the same `AnalysisMode`/`EnforceCodeStyleInBuild` as the rest of `src`. The +Windows stamp is no longer a reason for any of it: each driver-bound project applies `[SupportedOSPlatform("windows")]` itself. Strong-naming is preserved. **SQL pipeline** (always run end-to-end, even for trivial queries, so new features add diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c1670327..0277ac6e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,6 +8,10 @@ $(NoWarn);CS1574;CS1587;CS1591;EF1001;NU1903 true + + Recommended + true diff --git a/src/EFCore.Jet.Common/Design/Internal/JetAnnotationCodeGenerator.cs b/src/EFCore.Jet.Common/Design/Internal/JetAnnotationCodeGenerator.cs index dd04a027..92835966 100644 --- a/src/EFCore.Jet.Common/Design/Internal/JetAnnotationCodeGenerator.cs +++ b/src/EFCore.Jet.Common/Design/Internal/JetAnnotationCodeGenerator.cs @@ -35,7 +35,7 @@ protected override bool IsHandledByConvention(IModel model, IAnnotation annotati // CHECK: Jet default schema handling. if (annotation.Name == RelationalAnnotationNames.DefaultSchema) { - return string.Equals(null, (string?)annotation.Value); + return string.Equals(null, (string?)annotation.Value, StringComparison.Ordinal); } return annotation.Name == JetAnnotationNames.ValueGenerationStrategy diff --git a/src/EFCore.Jet.Common/Design/Internal/JetCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.Jet.Common/Design/Internal/JetCSharpRuntimeAnnotationCodeGenerator.cs index becfb83c..9906e710 100644 --- a/src/EFCore.Jet.Common/Design/Internal/JetCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.Jet.Common/Design/Internal/JetCSharpRuntimeAnnotationCodeGenerator.cs @@ -152,4 +152,4 @@ public override void Generate(IRelationalPropertyOverrides overrides, CSharpRunt base.Generate(overrides, parameters); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Diagnostics/ConflictingValueGenerationStrategiesEventData.cs b/src/EFCore.Jet.Common/Diagnostics/ConflictingValueGenerationStrategiesEventData.cs index 7ccfb1f0..36eae609 100644 --- a/src/EFCore.Jet.Common/Diagnostics/ConflictingValueGenerationStrategiesEventData.cs +++ b/src/EFCore.Jet.Common/Diagnostics/ConflictingValueGenerationStrategiesEventData.cs @@ -38,6 +38,10 @@ public class ConflictingValueGenerationStrategiesEventData( /// /// The property. /// + // CA1716 objects to "Property" as a VB keyword; it is the name EF Core's own PropertyEventData uses, + // and this type is consumed through that same diagnostics surface. +#pragma warning disable CA1716 public virtual IProperty Property { get; } = property; +#pragma warning restore CA1716 } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Diagnostics/Internal/JetLoggingDefinitions.cs b/src/EFCore.Jet.Common/Diagnostics/Internal/JetLoggingDefinitions.cs index 7a398c82..2f6bfe32 100644 --- a/src/EFCore.Jet.Common/Diagnostics/Internal/JetLoggingDefinitions.cs +++ b/src/EFCore.Jet.Common/Diagnostics/Internal/JetLoggingDefinitions.cs @@ -8,6 +8,9 @@ namespace EntityFrameworkCore.Jet.Diagnostics.Internal /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// + // CA1051 wants properties, but a LoggingDefinitions class is a cache of lazily-built event definitions that + // the logger extensions assign into by field; EF Core's own SqlServerLoggingDefinitions is written the same way. +#pragma warning disable CA1051 public class JetLoggingDefinitions : RelationalLoggingDefinitions { /// @@ -162,4 +165,5 @@ public class JetLoggingDefinitions : RelationalLoggingDefinitions /// public EventDefinitionBase? LogConflictingValueGenerationStrategies; } +#pragma warning restore CA1051 } \ No newline at end of file diff --git a/src/EFCore.Jet.Common/EFCore.Jet.Common.csproj b/src/EFCore.Jet.Common/EFCore.Jet.Common.csproj index 0d4502bd..1721699f 100644 --- a/src/EFCore.Jet.Common/EFCore.Jet.Common.csproj +++ b/src/EFCore.Jet.Common/EFCore.Jet.Common.csproj @@ -16,9 +16,7 @@ - - - + @@ -37,9 +35,6 @@ - - - diff --git a/src/EFCore.Jet.Common/Extensions/JetDbFunctionsExtensions.cs b/src/EFCore.Jet.Common/Extensions/JetDbFunctionsExtensions.cs index 575bd40b..4f4fd3c3 100644 --- a/src/EFCore.Jet.Common/Extensions/JetDbFunctionsExtensions.cs +++ b/src/EFCore.Jet.Common/Extensions/JetDbFunctionsExtensions.cs @@ -778,4 +778,4 @@ public static int ByteArrayLength( #endregion Population variance } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Extensions/JetModelExtensions.cs b/src/EFCore.Jet.Common/Extensions/JetModelExtensions.cs index e5987650..012c74d7 100644 --- a/src/EFCore.Jet.Common/Extensions/JetModelExtensions.cs +++ b/src/EFCore.Jet.Common/Extensions/JetModelExtensions.cs @@ -74,7 +74,7 @@ public static void SetJetIdentityIncrement(this IMutableModel model, int? increm /// Indicates whether the configuration was specified using a data annotation. public static int? SetJetIdentityIncrement( this IConventionModel model, int? increment, bool fromDataAnnotation = false) - => (int?) model.SetOrRemoveAnnotation( + => (int?)model.SetOrRemoveAnnotation( JetAnnotationNames.IdentityIncrement, increment, fromDataAnnotation)?.Value; diff --git a/src/EFCore.Jet.Common/Infrastructure/JetDualTable.cs b/src/EFCore.Jet.Common/Infrastructure/JetDualTable.cs index f0bb832f..3a6d4832 100644 --- a/src/EFCore.Jet.Common/Infrastructure/JetDualTable.cs +++ b/src/EFCore.Jet.Common/Infrastructure/JetDualTable.cs @@ -32,13 +32,16 @@ public static class JetDualTable /// /// The DUAL table or query /// - public static string CustomName = ""; + public static string CustomName { get; set; } = ""; //MSysRelationships //MSysAccessStorage //#Dual //(SELECT COUNT(*) FROM MSysAccessStorage) - public static string DetectedName = "#Dual"; + /// + /// The DUAL table or query the scaffolding factory last detected in the database. + /// + public static string DetectedName { get; set; } = "#Dual"; /// /// The name to generate into SQL: the user's when one has been set, @@ -49,4 +52,4 @@ public static string Name ? DetectedName : CustomName; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Internal/JetLoggerExtensions.cs b/src/EFCore.Jet.Common/Internal/JetLoggerExtensions.cs index 5bf42e8f..fd0c1348 100644 --- a/src/EFCore.Jet.Common/Internal/JetLoggerExtensions.cs +++ b/src/EFCore.Jet.Common/Internal/JetLoggerExtensions.cs @@ -1,5 +1,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Every method here follows EF Core's own logger-extension shape: the message template comes from an +// EventDefinition rather than being a literal, and the call is already guarded by ShouldLog. That is what +// CA2254 (varying template), CA1848 (LoggerMessage delegates) and CA1873 (params array) each object to, and +// EF Core's own generated extensions are written the same way. +#pragma warning disable CA1848, CA1873, CA2254 + namespace EntityFrameworkCore.Jet.Internal { /// @@ -486,4 +492,4 @@ public static void ReflexiveConstraintIgnored( // No DiagnosticsSource events because these are purely design-time messages } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Internal/JetModelValidator.cs b/src/EFCore.Jet.Common/Internal/JetModelValidator.cs index 5a86fa13..a33dc21c 100644 --- a/src/EFCore.Jet.Common/Internal/JetModelValidator.cs +++ b/src/EFCore.Jet.Common/Internal/JetModelValidator.cs @@ -1,8 +1,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Text; using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Metadata.Internal; +using System.Text; namespace EntityFrameworkCore.Jet.Internal { diff --git a/src/EFCore.Jet.Common/Metadata/Conventions/JetIndexConvention.cs b/src/EFCore.Jet.Common/Metadata/Conventions/JetIndexConvention.cs index af6a2731..ee8ba626 100644 --- a/src/EFCore.Jet.Common/Metadata/Conventions/JetIndexConvention.cs +++ b/src/EFCore.Jet.Common/Metadata/Conventions/JetIndexConvention.cs @@ -33,7 +33,11 @@ public class JetIndexConvention( IPropertyNullabilityChangedConvention, IPropertyAnnotationChangedConvention { + // Unread: Jet's index filter is the fixed "IGNORE NULL", so nothing here quotes an identifier. The + // constructor parameter stays because it is the signature EF Core's convention-set builder constructs. +#pragma warning disable IDE0052 private readonly ISqlGenerationHelper _sqlGenerationHelper = sqlGenerationHelper; +#pragma warning restore IDE0052 /// /// Dependencies for this service. @@ -144,7 +148,7 @@ public virtual void ProcessPropertyAnnotationChanged( } } - private void SetIndexFilter(IConventionIndexBuilder indexBuilder, bool columnNameChanged = false) + private static void SetIndexFilter(IConventionIndexBuilder indexBuilder, bool columnNameChanged = false) { var index = indexBuilder.Metadata; if (index.IsUnique @@ -166,11 +170,11 @@ private void SetIndexFilter(IConventionIndexBuilder indexBuilder, bool columnNam } } - private string CreateIndexFilter(List nullableColumns) + private static string CreateIndexFilter(List nullableColumns) { var builder = new StringBuilder(); - if (nullableColumns.Any()) + if (nullableColumns.Count > 0) { builder.Append("IGNORE NULL"); } @@ -214,4 +218,4 @@ private string CreateIndexFilter(List nullableColumns) return nullableColumns; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Metadata/Conventions/JetOnDeleteConvention.cs b/src/EFCore.Jet.Common/Metadata/Conventions/JetOnDeleteConvention.cs index 5ccef3fd..8073c465 100644 --- a/src/EFCore.Jet.Common/Metadata/Conventions/JetOnDeleteConvention.cs +++ b/src/EFCore.Jet.Common/Metadata/Conventions/JetOnDeleteConvention.cs @@ -43,7 +43,7 @@ protected override DeleteBehavior GetTargetDeleteBehavior(IConventionForeignKey return ProcessSkipNavigations(foreignKey.GetReferencingSkipNavigations()) ?? deleteBehavior; } - private DeleteBehavior? ProcessSkipNavigations(IEnumerable skipNavigations) + private static DeleteBehavior? ProcessSkipNavigations(IEnumerable skipNavigations) { var skipNavigation = skipNavigations .FirstOrDefault( @@ -121,4 +121,4 @@ public virtual void ProcessEntityTypeAnnotationChanged( } } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Metadata/Conventions/JetSharedTableConvention.cs b/src/EFCore.Jet.Common/Metadata/Conventions/JetSharedTableConvention.cs index eddf17e1..c4556026 100644 --- a/src/EFCore.Jet.Common/Metadata/Conventions/JetSharedTableConvention.cs +++ b/src/EFCore.Jet.Common/Metadata/Conventions/JetSharedTableConvention.cs @@ -22,4 +22,4 @@ public class JetSharedTableConvention( /// protected override bool IndexesUniqueAcrossTables => false; -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGenerationStrategyConvention.cs b/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGenerationStrategyConvention.cs index 228ffdac..25e5076b 100644 --- a/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGenerationStrategyConvention.cs +++ b/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGenerationStrategyConvention.cs @@ -85,7 +85,7 @@ public virtual void ProcessModelFinalizing( static bool IsStrategyNoneNeeded(IReadOnlyProperty property, StoreObjectIdentifier storeObject) { if (property.ValueGenerated == ValueGenerated.OnAdd - && property.TryGetDefaultValue(storeObject, out _) == false + && !property.TryGetDefaultValue(storeObject, out _) && property.GetDefaultValueSql(storeObject) == null && property.GetComputedColumnSql(storeObject) == null && property.DeclaringType.Model.GetValueGenerationStrategy() == JetValueGenerationStrategy.IdentityColumn) diff --git a/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGeneratorConvention.cs b/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGeneratorConvention.cs index 6c3556d3..6c03f088 100644 --- a/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGeneratorConvention.cs +++ b/src/EFCore.Jet.Common/Metadata/Conventions/JetValueGeneratorConvention.cs @@ -84,7 +84,7 @@ public override void ProcessPropertyAnnotationChanged( /// The property. /// The identifier of the store object. /// The store value generation strategy to set for the given property. - public new static ValueGenerated? GetValueGenerated(IReadOnlyProperty property, in StoreObjectIdentifier storeObject) + public static new ValueGenerated? GetValueGenerated(IReadOnlyProperty property, in StoreObjectIdentifier storeObject) => RelationalValueGenerationConvention.GetValueGenerated(property, storeObject) ?? (property.GetValueGenerationStrategy(storeObject) != JetValueGenerationStrategy.None ? ValueGenerated.OnAdd diff --git a/src/EFCore.Jet.Common/Metadata/Internal/JetAnnotationProvider.cs b/src/EFCore.Jet.Common/Metadata/Internal/JetAnnotationProvider.cs index c8f00dce..b22cc57f 100644 --- a/src/EFCore.Jet.Common/Metadata/Internal/JetAnnotationProvider.cs +++ b/src/EFCore.Jet.Common/Metadata/Internal/JetAnnotationProvider.cs @@ -1,5 +1,5 @@ -using System.Globalization; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using System.Globalization; namespace EntityFrameworkCore.Jet.Metadata.Internal { @@ -96,7 +96,7 @@ public override IEnumerable For(IColumn column, bool designTime) else { if (column is JsonColumn) yield break; - property = column.PropertyMappings.First().Property; + property = column.PropertyMappings[0].Property; if (property.DeclaringType is IEntityType entityType) { // Only return auto increment for integer single column primary key diff --git a/src/EFCore.Jet.Common/Metadata/JetValueGenerationStrategy.cs b/src/EFCore.Jet.Common/Metadata/JetValueGenerationStrategy.cs index 7aa63553..b46bd80c 100644 --- a/src/EFCore.Jet.Common/Metadata/JetValueGenerationStrategy.cs +++ b/src/EFCore.Jet.Common/Metadata/JetValueGenerationStrategy.cs @@ -8,4 +8,4 @@ public enum JetValueGenerationStrategy IdentityColumn, Sequence } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Migrations/Internal/JetMigrationDatabaseLock.cs b/src/EFCore.Jet.Common/Migrations/Internal/JetMigrationDatabaseLock.cs index 18b260db..195a60f2 100644 --- a/src/EFCore.Jet.Common/Migrations/Internal/JetMigrationDatabaseLock.cs +++ b/src/EFCore.Jet.Common/Migrations/Internal/JetMigrationDatabaseLock.cs @@ -40,6 +40,8 @@ public void Dispose() { if (!e.Message.Contains("cannot find the input table")) throw; } + + GC.SuppressFinalize(this); } /// @@ -58,5 +60,7 @@ public async ValueTask DisposeAsync() { if (!e.Message.Contains("cannot find the input table")) throw; } + + GC.SuppressFinalize(this); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Migrations/JetMigrationsSqlGenerator.cs b/src/EFCore.Jet.Common/Migrations/JetMigrationsSqlGenerator.cs index a52a8ede..c4347e9e 100644 --- a/src/EFCore.Jet.Common/Migrations/JetMigrationsSqlGenerator.cs +++ b/src/EFCore.Jet.Common/Migrations/JetMigrationsSqlGenerator.cs @@ -4,10 +4,8 @@ using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Metadata.Internal; using EntityFrameworkCore.Jet.Migrations.Operations; -using EntityFrameworkCore.Jet.Storage.Internal; using EntityFrameworkCore.Jet.Update.Internal; using EntityFrameworkCore.Jet.Utilities; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using System.Text; // ReSharper disable once CheckNamespace @@ -830,7 +828,7 @@ protected override void ColumnDefinition( var columnType = GetColumnType(schema, table, name, operation, model); //int has no size - ignore - if (columnType != null && columnType.StartsWith("int(")) + if (columnType != null && columnType.StartsWith("int(", StringComparison.Ordinal)) { columnType = "int"; } @@ -846,7 +844,7 @@ protected override void ColumnDefinition( protected override string GetColumnType( string? schema, - string table, + string tableName, string name, ColumnOperation operation, IModel? model) @@ -867,7 +865,7 @@ protected override string GetColumnType( storeType = "integer"; } - storeType ??= base.GetColumnType(schema, table, name, operation, model); + storeType ??= base.GetColumnType(schema, tableName, name, operation, model); if (string.Equals(storeType, "counter", StringComparison.OrdinalIgnoreCase) && operation[JetAnnotationNames.Identity] is string identity && @@ -887,7 +885,7 @@ operation[JetAnnotationNames.Identity] is string identity && return storeType; } - var fullTableName = schema != null ? $"{schema}.{table}" : table; + var fullTableName = schema != null ? $"{schema}.{tableName}" : tableName; throw new InvalidOperationException( RelationalStrings.UnsupportedTypeForColumn(fullTableName, name, operation.ClrType?.Name ?? "unknown")); } diff --git a/src/EFCore.Jet.Common/Migrations/Operations/JetCreateDatabaseOperation.cs b/src/EFCore.Jet.Common/Migrations/Operations/JetCreateDatabaseOperation.cs index f573489b..95e748ac 100644 --- a/src/EFCore.Jet.Common/Migrations/Operations/JetCreateDatabaseOperation.cs +++ b/src/EFCore.Jet.Common/Migrations/Operations/JetCreateDatabaseOperation.cs @@ -13,4 +13,4 @@ public class JetCreateDatabaseOperation : MigrationOperation public virtual string Name { get; set; } = null!; public virtual string? Password { get; set; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Migrations/Operations/JetDropDatabaseOperation.cs b/src/EFCore.Jet.Common/Migrations/Operations/JetDropDatabaseOperation.cs index 6a73407a..63fe2e5c 100644 --- a/src/EFCore.Jet.Common/Migrations/Operations/JetDropDatabaseOperation.cs +++ b/src/EFCore.Jet.Common/Migrations/Operations/JetDropDatabaseOperation.cs @@ -12,4 +12,4 @@ public class JetDropDatabaseOperation : MigrationOperation /// public virtual string Name { get; set; } = null!; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetAggregateMethodCallTranslatorProvider.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetAggregateMethodCallTranslatorProvider.cs index 52ed0b3c..7321abd0 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetAggregateMethodCallTranslatorProvider.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetAggregateMethodCallTranslatorProvider.cs @@ -25,4 +25,4 @@ public JetAggregateMethodCallTranslatorProvider(RelationalAggregateMethodCallTra new JetStatisticsAggregateMethodTranslator(sqlExpressionFactory, typeMappingSource) ]); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs index aa43e466..f748afb1 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs @@ -76,23 +76,23 @@ public class JetByteArrayMethodTranslator(ISqlExpressionFactory sqlExpressionFac switch (method.Name) { case nameof(Enumerable.Contains) when arguments is [var source, var item] && source.Type == typeof(byte[]): - { - var sourceTypeMapping = source.TypeMapping; - - var value = item is SqlConstantExpression constantValue - ? _sqlExpressionFactory.Constant(new[] { (byte)constantValue.Value! }, sourceTypeMapping) - : _sqlExpressionFactory.Function( - "CHR", - [item], - nullable: true, - argumentsPropagateNullability: [true], - typeof(string)); + { + var sourceTypeMapping = source.TypeMapping; + + var value = item is SqlConstantExpression constantValue + ? _sqlExpressionFactory.Constant(new[] { (byte)constantValue.Value! }, sourceTypeMapping) + : _sqlExpressionFactory.Function( + "CHR", + [item], + nullable: true, + argumentsPropagateNullability: [true], + typeof(string)); - return _sqlExpressionFactory.GreaterThan( - _sqlExpressionFactory.Function( - "INSTR", - [ - _sqlExpressionFactory.Constant(1), + return _sqlExpressionFactory.GreaterThan( + _sqlExpressionFactory.Function( + "INSTR", + [ + _sqlExpressionFactory.Constant(1), _sqlExpressionFactory.Function( "STRCONV", [source, _sqlExpressionFactory.Constant(64)], @@ -101,12 +101,12 @@ public class JetByteArrayMethodTranslator(ISqlExpressionFactory sqlExpressionFac typeof(string)), value, _sqlExpressionFactory.Constant(0) - ], - nullable: true, - argumentsPropagateNullability: [false, true, true, false], - typeof(int)), - _sqlExpressionFactory.Constant(0)); - } + ], + nullable: true, + argumentsPropagateNullability: [false, true, true, false], + typeof(int)), + _sqlExpressionFactory.Constant(0)); + } // First without a predicate case nameof(Enumerable.First) when arguments is [var source] && source.Type == typeof(byte[]): @@ -146,4 +146,4 @@ public class JetByteArrayMethodTranslator(ISqlExpressionFactory sqlExpressionFac private static string? GetProviderType(SqlExpression expression) => expression.TypeMapping?.StoreType; -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateDiffFunctionsTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateDiffFunctionsTranslator.cs index 19adbea1..d15284a7 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateDiffFunctionsTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateDiffFunctionsTranslator.cs @@ -207,11 +207,11 @@ private readonly Dictionary _methodInfoDateDiffMapping "DATEDIFF", [sqlExpressionFactory.Constant(datePart), startDate, endDate], false, - [false, false, false], + [false, false, false], typeof(int)); } return null; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMemberTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMemberTranslator.cs index ce3a84cd..8d275a6a 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMemberTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMemberTranslator.cs @@ -52,7 +52,7 @@ public class JetDateOnlyMemberTranslator(ISqlExpressionFactory sqlExpressionFact ], nullable: true, argumentsPropagateNullability: [false, true, true], - returnType),sqlExpressionFactory.Constant(new DateOnly(100,1,1).DayNumber)), + returnType), sqlExpressionFactory.Constant(new DateOnly(100, 1, 1).DayNumber)), _ => null }; @@ -65,4 +65,4 @@ SqlExpression DatePart(string datePart) argumentsPropagateNullability: [false, true], returnType); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMethodTranslator.cs index a5755c9d..2c9ed22f 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateOnlyMethodTranslator.cs @@ -98,7 +98,7 @@ private static readonly MethodInfo ToDateTimeMethodInfo instance.TypeMapping); } } - + if (method.DeclaringType == typeof(DateOnly) && method.Name == nameof(DateOnly.FromDateTime) @@ -156,4 +156,4 @@ private SqlExpression MapDatePartExpression(string datepart, SqlExpression argum argumentsPropagateNullability: [true, true], typeof(int)); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs index 0eb7a2a5..7a829629 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs @@ -28,7 +28,11 @@ private static readonly Dictionary DatePartMapping }; private readonly JetSqlExpressionFactory _sqlExpressionFactory = (JetSqlExpressionFactory)sqlExpressionFactory; + // Unread: every member this translator handles maps to a DatePart call whose type the factory settles. + // The dependency stays because it is the signature the member-translator provider constructs this with. +#pragma warning disable IDE0052 private readonly IRelationalTypeMappingSource _typeMappingSource = typeMappingSource; +#pragma warning restore IDE0052 /// /// This API supports the Entity Framework Core infrastructure and is not intended to be used diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs index 4ae71188..546fccc6 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs @@ -42,8 +42,8 @@ public class JetDateTimeMethodTranslator(ISqlExpressionFactory sqlExpressionFact { var amountToAdd = arguments[0]; - if (!datePart.Equals("yyyy") - && !datePart.Equals("m") + if (!datePart.Equals("yyyy", StringComparison.Ordinal) + && !datePart.Equals("m", StringComparison.Ordinal) && amountToAdd is SqlConstantExpression constantExpression && constantExpression.Value is double doubleValue && (doubleValue >= int.MaxValue @@ -97,7 +97,7 @@ public class JetDateTimeMethodTranslator(ISqlExpressionFactory sqlExpressionFact return null; } - private List ExtractColumnExpressions(SqlBinaryExpression binaryexp) + private static List ExtractColumnExpressions(SqlBinaryExpression binaryexp) { List result = []; if (binaryexp.Left is SqlBinaryExpression left) @@ -120,7 +120,7 @@ private List ExtractColumnExpressions(SqlBinaryExpression bina return result; } - private List ExtractColumnExpressions(SqlUnaryExpression unaryexp) + private static List ExtractColumnExpressions(SqlUnaryExpression unaryexp) { List result = []; if (unaryexp.Operand is SqlBinaryExpression left) diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetIsDateFunctionTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetIsDateFunctionTranslator.cs index 1f7b6eb2..774fb1bf 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetIsDateFunctionTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetIsDateFunctionTranslator.cs @@ -23,4 +23,4 @@ public class JetIsDateFunctionTranslator(ISqlExpressionFactory sqlExpressionFact : null; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetMemberTranslatorProvider.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetMemberTranslatorProvider.cs index a2b2d40e..e042e93d 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetMemberTranslatorProvider.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetMemberTranslatorProvider.cs @@ -27,4 +27,4 @@ public JetMemberTranslatorProvider(RelationalMemberTranslatorProviderDependencie ]); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetParseTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetParseTranslator.cs index 41d0ae43..bdbb3758 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetParseTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetParseTranslator.cs @@ -45,4 +45,4 @@ private static readonly MethodInfo[] SupportedMethods arguments[0], method.ReturnType) : null; -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStatisticsAggregateMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStatisticsAggregateMethodTranslator.cs index cda17556..f04ee67b 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStatisticsAggregateMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStatisticsAggregateMethodTranslator.cs @@ -57,4 +57,4 @@ public class JetStatisticsAggregateMethodTranslator( return _sqlExpressionFactory.Function(functionName, [sqlExpression], nullable: true, argumentsPropagateNullability: [false], typeof(double), _doubleTypeMapping); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStringMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStringMethodTranslator.cs index 883500da..d30da7a4 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStringMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetStringMethodTranslator.cs @@ -350,4 +350,4 @@ [new CaseWhenClause(_sqlExpressionFactory.IsNotNull(instance), _sqlExpressionFac return _sqlExpressionFactory.Subtract(charIndexExpression, offsetExpression); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs index e10c4964..fbf580f0 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs @@ -52,4 +52,4 @@ public class JetTimeOnlyMemberTranslator(ISqlExpressionFactory sqlExpressionFact return null; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMethodTranslator.cs index 72747d21..cdb97f31 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeOnlyMethodTranslator.cs @@ -106,4 +106,4 @@ public class JetTimeOnlyMethodTranslator(ISqlExpressionFactory sqlExpressionFact return null; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs index d8d5432a..cff7a2cc 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs @@ -52,4 +52,4 @@ public class JetTimeSpanMemberTranslator(ISqlExpressionFactory sqlExpressionFact return null; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetCompatibilityExpressionVisitor.cs b/src/EFCore.Jet.Common/Query/Internal/JetCompatibilityExpressionVisitor.cs index 1fa063ba..c7064cd2 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetCompatibilityExpressionVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetCompatibilityExpressionVisitor.cs @@ -7,8 +7,8 @@ namespace EntityFrameworkCore.Jet.Query.Internal; public class JetCompatibilityExpressionVisitor : ExpressionVisitor { - protected override Expression VisitExtension(Expression extensionExpression) - => extensionExpression switch + protected override Expression VisitExtension(Expression node) + => node switch { RowNumberExpression rowNumberExpression => VisitRowNumber(rowNumberExpression), CrossApplyExpression crossApplyExpression => VisitCrossApply(crossApplyExpression), @@ -21,7 +21,7 @@ protected override Expression VisitExtension(Expression extensionExpression) SelectExpression selectExpression => VisitSelect(selectExpression), ShapedQueryExpression shapedQueryExpression => shapedQueryExpression.Update(Visit(shapedQueryExpression.QueryExpression), Visit(shapedQueryExpression.ShaperExpression)), CrossJoinExpression crossJoinExpression => VisitCrossJoin(crossJoinExpression), - _ => base.VisitExtension(extensionExpression) + _ => base.VisitExtension(node) }; protected virtual Expression VisitRowNumber(RowNumberExpression rowNumberExpression) @@ -92,7 +92,7 @@ protected virtual Expression VisitCrossJoin(CrossJoinExpression crossJoinExpress protected virtual Expression TranslationFailed(Expression expression) => throw new InvalidOperationException("Unsupported Jet expression: " + expression.Print()); - private bool ContainsUnsupportCol(SqlBinaryExpression binaryexp) + private static bool ContainsUnsupportCol(SqlBinaryExpression binaryexp) { bool containsunsupported = false; if (binaryexp.Left is SqlBinaryExpression left) diff --git a/src/EFCore.Jet.Common/Query/Internal/JetCompiledQueryCacheKeyGenerator.cs b/src/EFCore.Jet.Common/Query/Internal/JetCompiledQueryCacheKeyGenerator.cs index 520c1deb..cad189e6 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetCompiledQueryCacheKeyGenerator.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetCompiledQueryCacheKeyGenerator.cs @@ -55,4 +55,4 @@ public override int GetHashCode() => _relationalCompiledQueryCacheKey.GetHashCode(); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetDateTimeRangeConverter.cs b/src/EFCore.Jet.Common/Query/Internal/JetDateTimeRangeConverter.cs index 37274a24..d6f0c052 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetDateTimeRangeConverter.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetDateTimeRangeConverter.cs @@ -48,9 +48,9 @@ public virtual Expression Process(Expression queryExpression, ParametersCacheDec } /// - protected override Expression VisitExtension(Expression extensionExpression) + protected override Expression VisitExtension(Expression node) { - if (extensionExpression is SqlBinaryExpression + if (node is SqlBinaryExpression { OperatorType: ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual @@ -68,7 +68,7 @@ or ExpressionType.LessThanOrEqual } } - return base.VisitExtension(extensionExpression); + return base.VisitExtension(node); } /// Swaps an operand that is the CLR minimum DateTime for Jet's minimum. @@ -91,4 +91,4 @@ private SqlExpression Replace(SqlExpression operand) _ => operand, }; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetLiftOrderByPostprocessor.cs b/src/EFCore.Jet.Common/Query/Internal/JetLiftOrderByPostprocessor.cs index a4ba4ab2..669a88b3 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetLiftOrderByPostprocessor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetLiftOrderByPostprocessor.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; namespace EntityFrameworkCore.Jet.Query.Internal; @@ -13,7 +12,7 @@ namespace EntityFrameworkCore.Jet.Query.Internal; public class JetLiftOrderByPostprocessor(IRelationalTypeMappingSource typeMappingSource, ISqlExpressionFactory sqlExpressionFactory, SqlAliasManager sqlAliasManager) - : ExpressionVisitor + : ExpressionVisitor { /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -32,10 +31,10 @@ public virtual Expression Process(Expression expression) /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - [return: NotNullIfNotNull(nameof(expression))] - public override Expression? Visit(Expression? expression) + [return: NotNullIfNotNull(nameof(node))] + public override Expression? Visit(Expression? node) { - switch (expression) + switch (node) { case ShapedQueryExpression shapedQueryExpression: return shapedQueryExpression.Update( @@ -50,7 +49,7 @@ public virtual Expression Process(Expression expression) relationalSplitCollectionShaperExpression.ChildIdentifier, (SelectExpression)newSelect, newInner); return relationalSplitCollectionShaperExpression; case UpdateExpression or DeleteExpression: - return expression; + return node; case SelectExpression selectExpression: { Dictionary columnsToRewrite = []; @@ -90,9 +89,9 @@ public virtual Expression Process(Expression expression) } } - if (columnsToRewrite.Count == 0 || columnsToRewrite.All(p => p.Value.rewrite == false)) + if (columnsToRewrite.Count == 0 || columnsToRewrite.All(p => !p.Value.rewrite)) { - return base.Visit(expression); + return base.Visit(node); } // A lift IS happening: ClearOrdering below wipes every ordering and we re-append only what's @@ -163,12 +162,12 @@ public virtual Expression Process(Expression expression) return result; } case RelationalGroupByShaperExpression relationalGroupByShaperExpression: - { - return base.VisitExtension(relationalGroupByShaperExpression); - } + { + return base.VisitExtension(relationalGroupByShaperExpression); + } } - return base.Visit(expression); + return base.Visit(node); } private SelectExpression AddAliasManager(SelectExpression selectExpression) @@ -195,4 +194,4 @@ private SelectExpression AddAliasManager(SelectExpression selectExpression) projMap?.SetValue(newselect, projMap.GetValue(selectExpression)); return newselect; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetLocateScalarSubqueryVisitor.cs b/src/EFCore.Jet.Common/Query/Internal/JetLocateScalarSubqueryVisitor.cs index 4772c555..772bd779 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetLocateScalarSubqueryVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetLocateScalarSubqueryVisitor.cs @@ -14,8 +14,8 @@ public class JetLocateScalarSubqueryVisitor : ExpressionVisitor // Every one of those nodes is an Expression with NodeType Extension, so the dispatch is reproduced here and // the per-node methods below are unchanged. Anything unrecognised falls through to the base, which visits // the node's children — the same default the removed base class applied. - protected override Expression VisitExtension(Expression extensionExpression) - => extensionExpression switch + protected override Expression VisitExtension(Expression node) + => node switch { AtTimeZoneExpression e => VisitAtTimeZone(e), CaseExpression e => VisitCase(e), @@ -53,12 +53,9 @@ protected override Expression VisitExtension(Expression extensionExpression) UnionExpression e => VisitUnion(e), UpdateExpression e => VisitUpdate(e), ValuesExpression e => VisitValues(e), - _ => base.VisitExtension(extensionExpression), + _ => base.VisitExtension(node), }; - private readonly IRelationalTypeMappingSource _typeMappingSource; - private readonly ISqlExpressionFactory _sqlExpressionFactory; - /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -69,7 +66,10 @@ public JetLocateScalarSubqueryVisitor( IRelationalTypeMappingSource typeMappingSource, ISqlExpressionFactory sqlExpressionFactory) { - (_typeMappingSource, _sqlExpressionFactory) = (typeMappingSource, sqlExpressionFactory); + // Neither dependency is needed to locate a scalar subquery; both stay on the signature because that is + // what the postprocessor constructs this with. + _ = typeMappingSource; + _ = sqlExpressionFactory; } protected virtual Expression VisitAtTimeZone(AtTimeZoneExpression atTimeZoneExpression) @@ -120,7 +120,7 @@ protected virtual Expression VisitCrossJoin(CrossJoinExpression crossJoinExpress protected virtual Expression VisitDelete(DeleteExpression deleteExpression) { - return deleteExpression.Update(deleteExpression.Table,(SelectExpression)Visit(deleteExpression.SelectExpression)); + return deleteExpression.Update(deleteExpression.Table, (SelectExpression)Visit(deleteExpression.SelectExpression)); } protected virtual Expression VisitDistinct(DistinctExpression distinctExpression) @@ -460,4 +460,4 @@ protected virtual Expression VisitValues(ValuesExpression valuesExpression) throw new UnreachableException(); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetOuterJoinProjectionGuardExpressionVisitor.cs b/src/EFCore.Jet.Common/Query/Internal/JetOuterJoinProjectionGuardExpressionVisitor.cs index 16208d5e..ec0bb573 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetOuterJoinProjectionGuardExpressionVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetOuterJoinProjectionGuardExpressionVisitor.cs @@ -1,7 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Diagnostics.CodeAnalysis; -using System.Reflection; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; namespace EntityFrameworkCore.Jet.Query.Internal @@ -73,12 +71,16 @@ public sealed class JetOuterJoinProjectionGuardExpressionVisitor(ISqlExpressionF ?? throw new InvalidOperationException("Could not find SelectExpression._projection."); /// Subqueries seen as the target of a LEFT JOIN, by reference. + // IDE0028's only fix here is a collection expression, which would drop the comparer and match the + // subqueries structurally instead of by reference. +#pragma warning disable IDE0028 private readonly HashSet _leftJoinTargets = new(ReferenceEqualityComparer.Instance); +#pragma warning restore IDE0028 - [return: NotNullIfNotNull(nameof(expression))] - public override Expression? Visit(Expression? expression) + [return: NotNullIfNotNull(nameof(node))] + public override Expression? Visit(Expression? node) { - switch (expression) + switch (node) { // ShapedQueryExpression and the split-collection shaper forbid generic child visiting. case ShapedQueryExpression shapedQueryExpression: @@ -94,30 +96,30 @@ public sealed class JetOuterJoinProjectionGuardExpressionVisitor(ISqlExpressionF Visit(splitCollectionShaper.InnerShaper)); case UpdateExpression or DeleteExpression: - return expression; + return node; case SelectExpression selectExpression: - { - // Record this select's LEFT JOIN targets before descending, so we recognise one when - // VisitChildren reaches it. - foreach (var table in selectExpression.Tables) { - if (table is LeftJoinExpression { Table: SelectExpression target }) + // Record this select's LEFT JOIN targets before descending, so we recognise one when + // VisitChildren reaches it. + foreach (var table in selectExpression.Tables) { - _leftJoinTargets.Add(target); + if (table is LeftJoinExpression { Table: SelectExpression target }) + { + _leftJoinTargets.Add(target); + } } - } - if (_leftJoinTargets.Contains(selectExpression)) - { - Guard(selectExpression); - } + if (_leftJoinTargets.Contains(selectExpression)) + { + Guard(selectExpression); + } - return base.VisitExtension(selectExpression); - } + return base.VisitExtension(selectExpression); + } default: - return base.Visit(expression); + return base.Visit(node); } } @@ -125,7 +127,7 @@ private void Guard(SelectExpression selectExpression) { // Anchor on something non-NULL for any row that actually matched: the grouping key when the subquery // groups (referencing anything else there would not be legal), otherwise any projected column. - var anchor = selectExpression.GroupBy.FirstOrDefault() + var anchor = (selectExpression.GroupBy is [var key, ..] ? key : null) ?? selectExpression.Projection.Select(p => p.Expression).OfType().FirstOrDefault(); if (anchor is null) @@ -176,4 +178,4 @@ private void Guard(SelectExpression selectExpression) private static bool IsMarker(SqlExpression expression) => expression is SqlFragmentExpression; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContext.cs b/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContext.cs index e0f3edb9..1b529ccf 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContext.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContext.cs @@ -47,4 +47,4 @@ public JetQueryCompilationContext( /// public override bool SupportsPrecompiledQuery => true; -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContextFactory.cs b/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContextFactory.cs index 98f9fcb9..f4a3bcc4 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContextFactory.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetQueryCompilationContextFactory.cs @@ -1,5 +1,3 @@ -using EntityFrameworkCore.Jet.Storage.Internal; - namespace EntityFrameworkCore.Jet.Query.Internal; /// @@ -47,4 +45,4 @@ public virtual QueryCompilationContext Create(bool async) public virtual QueryCompilationContext CreatePrecompiled(bool async) => new JetQueryCompilationContext( Dependencies, RelationalDependencies, async, precompiling: true); -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessor.cs b/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessor.cs index 38440cda..1a51293e 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessor.cs @@ -49,7 +49,7 @@ private static bool IsLongCountMethod(MethodInfo method) => method == QueryableMethods.LongCountWithoutPredicate || method == QueryableMethods.LongCountWithPredicate; - private static Expression MakeAnyCall(MethodCallExpression longCountCall) + private static MethodCallExpression MakeAnyCall(MethodCallExpression longCountCall) { var elementType = longCountCall.Method.GetGenericArguments()[0]; var anyMethod = longCountCall.Arguments.Count == 2 @@ -58,4 +58,4 @@ private static Expression MakeAnyCall(MethodCallExpression longCountCall) return Expression.Call(anyMethod, longCountCall.Arguments); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessorFactory.cs b/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessorFactory.cs index b6b09760..ba4c289d 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessorFactory.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetQueryTranslationPreprocessorFactory.cs @@ -12,4 +12,4 @@ public virtual QueryTranslationPreprocessor Create(QueryCompilationContext query dependencies, relationalDependencies, (RelationalQueryCompilationContext)queryCompilationContext); -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitor.cs index 08e0baed..e959d37c 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitor.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using EntityFrameworkCore.Jet.Internal; -using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -using System.Diagnostics.CodeAnalysis; namespace EntityFrameworkCore.Jet.Query.Internal; @@ -16,7 +14,7 @@ namespace EntityFrameworkCore.Jet.Query.Internal; /// public class JetQueryableMethodTranslatingExpressionVisitor : RelationalQueryableMethodTranslatingExpressionVisitor { - protected readonly RelationalQueryCompilationContext queryCompilationContext; + private readonly RelationalQueryCompilationContext _queryCompilationContext; private readonly bool _subquery; @@ -32,7 +30,7 @@ public JetQueryableMethodTranslatingExpressionVisitor( RelationalQueryCompilationContext queryCompilationContext) : base(dependencies, relationalDependencies, queryCompilationContext) { - this.queryCompilationContext = queryCompilationContext; + _queryCompilationContext = queryCompilationContext; _subquery = false; } @@ -46,7 +44,7 @@ protected JetQueryableMethodTranslatingExpressionVisitor( JetQueryableMethodTranslatingExpressionVisitor parentVisitor) : base(parentVisitor) { - this.queryCompilationContext = parentVisitor.queryCompilationContext; + _queryCompilationContext = parentVisitor._queryCompilationContext; _subquery = true; } @@ -64,7 +62,7 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis { //Jet can't handle a left join following a cross join //so we push the cross join into a subquery wrapped by parentheses and then do the left join on that - if (outer.QueryExpression is SelectExpression selectExpression && selectExpression.Tables.Last() is CrossJoinExpression) + if (outer.QueryExpression is SelectExpression selectExpression && selectExpression.Tables[^1] is CrossJoinExpression) { selectExpression.PushdownIntoSubquery(); } @@ -120,7 +118,7 @@ protected override bool IsValidSelectExpressionForExecuteDelete( /// protected override bool IsValidSelectExpressionForExecuteUpdate( SelectExpression selectExpression, - TableExpressionBase table, + TableExpressionBase targetTable, [NotNullWhen(true)] out TableExpression? tableExpression) { if (selectExpression is @@ -133,12 +131,12 @@ protected override bool IsValidSelectExpressionForExecuteUpdate( Limit: null }) { - if (selectExpression.Tables.Count > 1 && table is JoinExpressionBase joinExpressionBase) + if (selectExpression.Tables.Count > 1 && targetTable is JoinExpressionBase joinExpressionBase) { - table = joinExpressionBase.Table; + targetTable = joinExpressionBase.Table; } - if (table is TableExpression te) + if (targetTable is TableExpression te) { tableExpression = te; return true; @@ -163,7 +161,7 @@ protected override bool IsValidSelectExpressionForExecuteUpdate( if (!IsOrdered(selectExpression)) { - queryCompilationContext.Logger.RowLimitingOperationWithoutOrderByWarning(); + _queryCompilationContext.Logger.RowLimitingOperationWithoutOrderByWarning(); } selectExpression.ApplyOffset(translation); @@ -193,7 +191,7 @@ protected override bool IsValidSelectExpressionForExecuteUpdate( if (selectExpression.Predicate == null && selectExpression.Orderings.Count == 0) { - queryCompilationContext.Logger.FirstWithoutOrderByAndFilterWarning(); + _queryCompilationContext.Logger.FirstWithoutOrderByAndFilterWarning(); } JetApplyLimit(selectExpression, TranslateExpression(Expression.Constant(1))!); @@ -271,7 +269,7 @@ protected override bool IsValidSelectExpressionForExecuteUpdate( if (!IsOrdered(selectExpression)) { - queryCompilationContext.Logger.RowLimitingOperationWithoutOrderByWarning(); + _queryCompilationContext.Logger.RowLimitingOperationWithoutOrderByWarning(); } JetApplyLimit(selectExpression, translation); @@ -279,7 +277,7 @@ protected override bool IsValidSelectExpressionForExecuteUpdate( return source; } - private void JetApplyLimit(SelectExpression selectExpression, SqlExpression limit) + private static void JetApplyLimit(SelectExpression selectExpression, SqlExpression limit) { var oldLimit = selectExpression.Limit; diff --git a/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitorFactory.cs b/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitorFactory.cs index 864553f0..bcb3c901 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitorFactory.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetQueryableMethodTranslatingExpressionVisitorFactory.cs @@ -38,4 +38,4 @@ public class JetQueryableMethodTranslatingExpressionVisitorFactory( /// public virtual QueryableMethodTranslatingExpressionVisitor Create(QueryCompilationContext queryCompilationContext) => new JetQueryableMethodTranslatingExpressionVisitor(Dependencies, RelationalDependencies, (RelationalQueryCompilationContext)queryCompilationContext); -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetSkipTakePostprocessor.cs b/src/EFCore.Jet.Common/Query/Internal/JetSkipTakePostprocessor.cs index 0bd35382..8a3999f0 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetSkipTakePostprocessor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetSkipTakePostprocessor.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using EntityFrameworkCore.Jet.Internal; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; @@ -12,8 +11,6 @@ namespace EntityFrameworkCore.Jet.Query.Internal; /// public class JetSkipTakePostprocessor : ExpressionVisitor { - private readonly IRelationalTypeMappingSource _typeMappingSource; - private readonly ISqlExpressionFactory _sqlExpressionFactory; private Stack parent = new(); private readonly QuerySplittingBehavior? _splittingBehavior; /// @@ -27,7 +24,10 @@ public JetSkipTakePostprocessor( ISqlExpressionFactory sqlExpressionFactory, QuerySplittingBehavior? splittingBehavior) { - (_typeMappingSource, _sqlExpressionFactory) = (typeMappingSource, sqlExpressionFactory); + // Neither dependency is needed by the rewrite; both stay on the signature because that is what the + // postprocessor constructs this with. + _ = typeMappingSource; + _ = sqlExpressionFactory; _splittingBehavior = splittingBehavior; } @@ -48,10 +48,10 @@ public virtual Expression Process(Expression expression) /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - [return: NotNullIfNotNull(nameof(expression))] - public override Expression? Visit(Expression? expression) + [return: NotNullIfNotNull(nameof(node))] + public override Expression? Visit(Expression? node) { - switch (expression) + switch (node) { case ShapedQueryExpression shapedQueryExpression: return shapedQueryExpression.UpdateQueryExpression(Visit(shapedQueryExpression.QueryExpression)); @@ -100,7 +100,7 @@ public virtual Expression Process(Expression expression) return newselectExpression; } default: - return base.Visit(expression); + return base.Visit(node); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetSqlNullabilityProcessor.cs b/src/EFCore.Jet.Common/Query/Internal/JetSqlNullabilityProcessor.cs index 1a51a03d..010ed10f 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetSqlNullabilityProcessor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetSqlNullabilityProcessor.cs @@ -19,4 +19,4 @@ public class JetSqlNullabilityProcessor( RelationalParameterBasedSqlProcessorDependencies dependencies, RelationalParameterBasedSqlProcessorParameters parameters) : SqlNullabilityProcessor(dependencies, parameters) { -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitor.cs b/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitor.cs index 29895067..8a32e9f0 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitor.cs @@ -1,8 +1,7 @@ -using System.Diagnostics.CodeAnalysis; -using System.Globalization; +using EntityFrameworkCore.Jet.Internal; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using System.Globalization; using System.Text; -using EntityFrameworkCore.Jet.Internal; using ExpressionExtensions = Microsoft.EntityFrameworkCore.Query.ExpressionExtensions; namespace EntityFrameworkCore.Jet.Query.Internal; @@ -189,12 +188,12 @@ protected override Expression VisitUnary(UnaryExpression unaryExpression) protected override Expression VisitExtension(Expression extensionExpression) { - var result = base.VisitExtension(extensionExpression); + var result = base.VisitExtension(extensionExpression); if (extensionExpression is ShapedQueryExpression shapedQueryExpression) { var shaperExpression = shapedQueryExpression.ShaperExpression; if (shapedQueryExpression.ResultCardinality == ResultCardinality.SingleOrDefault - && !shaperExpression.Type.IsNullableType() && result is SqlFunctionExpression { Name: "COALESCE"} sqlFunctionExpression) + && !shaperExpression.Type.IsNullableType() && result is SqlFunctionExpression { Name: "COALESCE" } sqlFunctionExpression) { if (sqlFunctionExpression.Arguments?[1] is SqlConstantExpression { Value: DateTime { Ticks: 0 } }) { @@ -299,7 +298,7 @@ bool TryTranslateStartsEndsWithContains( // IsLikeWildChar) and send a simple LIKE translation = patternConstant.Value switch { - null => _sqlExpressionFactory.Like(translatedInstance, _sqlExpressionFactory.Constant(null,typeof(string), stringTypeMapping)), + null => _sqlExpressionFactory.Like(translatedInstance, _sqlExpressionFactory.Constant(null, typeof(string), stringTypeMapping)), // In .NET, all strings start with/end with/contain the empty string, but SQL LIKE return false for empty patterns. // Return % which always matches instead. @@ -535,7 +534,7 @@ private Expression TranslateByteArrayElementAccess(Expression array, Expression "ASCB", [ Dependencies.SqlExpressionFactory.Function( "MIDB", - [ + [ sqlArray, Dependencies.SqlExpressionFactory.Add( Dependencies.SqlExpressionFactory.ApplyDefaultTypeMapping(sqlIndex), @@ -614,4 +613,4 @@ private IReadOnlyList ApplyMinMaxResultTypeMapping( private static string? GetProviderType(SqlExpression expression) => expression.TypeMapping?.StoreType; -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitorFactory.cs b/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitorFactory.cs index 50c6d159..97acc18f 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitorFactory.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetSqlTranslatingExpressionVisitorFactory.cs @@ -34,4 +34,4 @@ public virtual RelationalSqlTranslatingExpressionVisitor Create( Dependencies, queryCompilationContext, queryableMethodTranslatingExpressionVisitor); -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/JetZeroLimitConverter.cs b/src/EFCore.Jet.Common/Query/Internal/JetZeroLimitConverter.cs index 6b1f61fe..6a8e8f6a 100644 --- a/src/EFCore.Jet.Common/Query/Internal/JetZeroLimitConverter.cs +++ b/src/EFCore.Jet.Common/Query/Internal/JetZeroLimitConverter.cs @@ -1,4 +1,3 @@ -using EntityFrameworkCore.Jet.Storage.Internal; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; namespace EntityFrameworkCore.Jet.Query.Internal; @@ -52,9 +51,9 @@ public virtual Expression Process(Expression queryExpression, ParametersCacheDec /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - protected override Expression VisitExtension(Expression extensionExpression) + protected override Expression VisitExtension(Expression node) { - if (extensionExpression is SelectExpression selectExpression) + if (node is SelectExpression selectExpression) { if (IsZero(selectExpression.Limit)) { @@ -85,6 +84,6 @@ bool IsZero(SqlExpression? sqlExpression) } } - return base.VisitExtension(extensionExpression); + return base.VisitExtension(node); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Internal/SearchConditionConvertingExpressionVisitor.cs b/src/EFCore.Jet.Common/Query/Internal/SearchConditionConvertingExpressionVisitor.cs index 82e0174a..d746097f 100644 --- a/src/EFCore.Jet.Common/Query/Internal/SearchConditionConvertingExpressionVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Internal/SearchConditionConvertingExpressionVisitor.cs @@ -2,7 +2,6 @@ using EntityFrameworkCore.Jet.Utilities; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -#nullable enable namespace EntityFrameworkCore.Jet.Query.Internal; @@ -13,8 +12,8 @@ public class SearchConditionConvertingExpressionVisitor(ISqlExpressionFactory sq // Every one of those nodes is an Expression with NodeType Extension, so the dispatch is reproduced here and // the per-node methods below are unchanged. Anything unrecognised falls through to the base, which visits // the node's children — the same default the removed base class applied. - protected override Expression VisitExtension(Expression extensionExpression) - => extensionExpression switch + protected override Expression VisitExtension(Expression node) + => node switch { AtTimeZoneExpression e => VisitAtTimeZone(e), CaseExpression e => VisitCase(e), @@ -52,7 +51,7 @@ protected override Expression VisitExtension(Expression extensionExpression) UnionExpression e => VisitUnion(e), UpdateExpression e => VisitUpdate(e), ValuesExpression e => VisitValues(e), - _ => base.VisitExtension(extensionExpression), + _ => base.VisitExtension(node), }; private bool _isSearchCondition; @@ -368,23 +367,23 @@ protected virtual Expression VisitSqlUnary(SqlUnaryExpression sqlUnaryExpression { case ExpressionType.Not when sqlUnaryExpression.Type == typeof(bool): - { - // when possible, avoid converting to/from predicate form - if (!_isSearchCondition && sqlUnaryExpression.Operand is not (ExistsExpression or InExpression or LikeExpression)) { - var negatedOperand = (SqlExpression)Visit(sqlUnaryExpression.Operand); - return sqlExpressionFactory.MakeBinary( - ExpressionType.ExclusiveOr, - negatedOperand, - sqlExpressionFactory.Constant(true, negatedOperand.TypeMapping), - negatedOperand.TypeMapping - )!; - } + // when possible, avoid converting to/from predicate form + if (!_isSearchCondition && sqlUnaryExpression.Operand is not (ExistsExpression or InExpression or LikeExpression)) + { + var negatedOperand = (SqlExpression)Visit(sqlUnaryExpression.Operand); + return sqlExpressionFactory.MakeBinary( + ExpressionType.ExclusiveOr, + negatedOperand, + sqlExpressionFactory.Constant(true, negatedOperand.TypeMapping), + negatedOperand.TypeMapping + )!; + } - _isSearchCondition = true; - resultCondition = true; - break; - } + _isSearchCondition = true; + resultCondition = true; + break; + } case ExpressionType.Not: _isSearchCondition = false; diff --git a/src/EFCore.Jet.Common/Query/JetSqlExpressionFactory.cs b/src/EFCore.Jet.Common/Query/JetSqlExpressionFactory.cs index 89d7b86c..6165e254 100644 --- a/src/EFCore.Jet.Common/Query/JetSqlExpressionFactory.cs +++ b/src/EFCore.Jet.Common/Query/JetSqlExpressionFactory.cs @@ -37,7 +37,7 @@ public override SqlExpression Convert( ExpressionType.Coalesce, sqlExpression, Constant( - null,typeof(string), + null, typeof(string), RelationalTypeMapping.NullMapping), typeMapping); diff --git a/src/EFCore.Jet.Common/Query/Sql/Internal/IJetExpressionVisitor.cs b/src/EFCore.Jet.Common/Query/Sql/Internal/IJetExpressionVisitor.cs index e957a765..bb8acf92 100644 --- a/src/EFCore.Jet.Common/Query/Sql/Internal/IJetExpressionVisitor.cs +++ b/src/EFCore.Jet.Common/Query/Sql/Internal/IJetExpressionVisitor.cs @@ -6,4 +6,4 @@ public interface IJetExpressionVisitor { // Expression VisitRowNumber(RowNumberExpression rowNumberExpression); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGenerator.cs b/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGenerator.cs index b12e2ed2..632620de 100644 --- a/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGenerator.cs +++ b/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGenerator.cs @@ -89,7 +89,7 @@ protected override Expression VisitSelect(SelectExpression selectExpression) GenerateTop(selectExpression); - if (selectExpression.Projection.Any()) + if (selectExpression.Projection.Count > 0) { GenerateList(selectExpression.Projection, e => Visit(e)); } @@ -208,7 +208,7 @@ protected override Expression VisitSelect(SelectExpression selectExpression) private void VisitJetTables(IReadOnlyList tables, bool addFromSql, out List colexp) { colexp = []; - if (!tables.Any()) + if (tables.Count == 0) { GeneratePseudoFromClause(); return; @@ -301,7 +301,7 @@ private void VisitJetTablesGrouped(IReadOnlyList tables, Li case CrossApplyExpression or OuterApplyExpression: throw new UnreachableException(); case CrossJoinExpression cj: - groups.Add((cj.Table, [], new HashSet { cj.Table.Alias! })); + groups.Add((cj.Table, [], [cj.Table.Alias!])); break; case PredicateJoinExpressionBase join: var predicateAliases = ExtractTableAliases(join.JoinPredicate); @@ -319,7 +319,7 @@ private void VisitJetTablesGrouped(IReadOnlyList tables, Li } break; default: - groups.Add((table, [], new HashSet { table.Alias! })); + groups.Add((table, [], [table.Alias!])); break; } } @@ -388,7 +388,7 @@ private void EmitJoinInGroup(PredicateJoinExpressionBase join, TableExpressionBa } } - private HashSet ExtractTableAliases(SqlExpression expression) + private static HashSet ExtractTableAliases(SqlExpression expression) { var result = new HashSet(); CollectTableAliases(expression, result); @@ -412,7 +412,7 @@ private static void CollectTableAliases(SqlExpression expression, HashSet ExtractColumnExpressions(SqlBinaryExpression binaryexp) + private static List ExtractColumnExpressions(SqlBinaryExpression binaryexp) { List result = []; switch (binaryexp.Left) @@ -437,7 +437,7 @@ private List ExtractColumnExpressions(SqlBinaryExpression bina return result; } - private List ExtractColumnExpressions(SqlUnaryExpression unaryexp) + private static List ExtractColumnExpressions(SqlUnaryExpression unaryexp) { List result = []; switch (unaryexp.Operand) @@ -508,7 +508,7 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp throw new UnreachableException(); } - private bool IsNonComposedSetOperation(SelectExpression selectExpression) + private static bool IsNonComposedSetOperation(SelectExpression selectExpression) => selectExpression.Offset == null && selectExpression.Limit == null && selectExpression is { IsDistinct: false, Predicate: null, Having: null, Orderings.Count: 0, GroupBy.Count: 0, Tables: [SetOperationBase setOperation] } @@ -826,7 +826,7 @@ protected override Expression VisitSqlUnary(SqlUnaryExpression sqlUnaryExpressio } - private Expression VisitJetConvertExpression(SqlUnaryExpression convertExpression) + private SqlUnaryExpression VisitJetConvertExpression(SqlUnaryExpression convertExpression) { var typeMapping = convertExpression.TypeMapping ?? throw new InvalidOperationException( RelationalStrings.UnsupportedType(convertExpression.Type.ShortDisplayName())); @@ -986,11 +986,11 @@ static void CollectEqualityColumns(SqlExpression expression, HashSet columnNames) + private RowValueExpression VisitRowValuePrivate(RowValueExpression rowValueExpression, IReadOnlyList columnNames) { var values = rowValueExpression.Values; var count = values.Count; @@ -1436,4 +1436,4 @@ protected override void CheckComposableSqlTrimmed(ReadOnlySpan sql) } } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGeneratorFactory.cs b/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGeneratorFactory.cs index 5883225c..e830a70c 100644 --- a/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGeneratorFactory.cs +++ b/src/EFCore.Jet.Common/Query/Sql/Internal/JetQuerySqlGeneratorFactory.cs @@ -18,4 +18,4 @@ public class JetQuerySqlGeneratorFactory( public virtual QuerySqlGenerator Create() => new JetQuerySqlGenerator(_dependencies, _typeMappingSource); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Storage/Internal/JetSqlGenerationHelper.cs b/src/EFCore.Jet.Common/Storage/Internal/JetSqlGenerationHelper.cs index 630e82ee..1f051015 100644 --- a/src/EFCore.Jet.Common/Storage/Internal/JetSqlGenerationHelper.cs +++ b/src/EFCore.Jet.Common/Storage/Internal/JetSqlGenerationHelper.cs @@ -1,7 +1,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Text; using EntityFrameworkCore.Jet.Utilities; +using System.Globalization; +using System.Text; namespace EntityFrameworkCore.Jet.Storage.Internal { @@ -43,7 +44,7 @@ public override void EscapeIdentifier(StringBuilder builder, string identifier) identifier = identifier .Replace(".", "#") .Replace("`", "``"); - + builder.Append(identifier); } @@ -86,7 +87,8 @@ public static string TruncateIdentifier(string identifier) if (identifier.Length <= 64) return identifier; - return identifier[..56] + identifier.ToLowerInvariant().GetHashCode().ToString("X8"); + return identifier[..56] + + identifier.ToLowerInvariant().GetHashCode().ToString("X8", CultureInfo.InvariantCulture); } public override string GenerateCreateSavepointStatement(string name) @@ -107,4 +109,4 @@ public override string GenerateReleaseSavepointStatement(string name) public override string StartTransactionStatement => "BEGIN TRANSACTION" + StatementTerminator; public override string CommitTransactionStatement => "COMMIT TRANSACTION" + StatementTerminator; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Update/Internal/IJetUpdateSqlGenerator.cs b/src/EFCore.Jet.Common/Update/Internal/IJetUpdateSqlGenerator.cs index 53a6d072..e51e73dc 100644 --- a/src/EFCore.Jet.Common/Update/Internal/IJetUpdateSqlGenerator.cs +++ b/src/EFCore.Jet.Common/Update/Internal/IJetUpdateSqlGenerator.cs @@ -43,4 +43,4 @@ ResultSetMapping AppendBulkInsertOperation( int commandPosition) => AppendBulkInsertOperation(commandStringBuilder, modificationCommands, commandPosition, out _); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatch.cs b/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatch.cs index 12c1a41e..ac974827 100644 --- a/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatch.cs +++ b/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatch.cs @@ -15,10 +15,12 @@ namespace EntityFrameworkCore.Jet.Update.Internal /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public class JetModificationCommandBatch( - ModificationCommandBatchFactoryDependencies dependencies) : AffectedCountModificationCommandBatch(dependencies, 1) + ModificationCommandBatchFactoryDependencies dependencies) + : AffectedCountModificationCommandBatch(dependencies, MaxRowCount) { + // Jet executes one statement at a time: a batch is a single command, never a multi-row one. private const int MaxRowCount = 1; - private const int MaxParameterCount = 2100 - 2; + private readonly List _pendingBulkInsertCommands = []; /// @@ -176,4 +178,4 @@ public override async Task ExecuteAsync( await base.ExecuteAsync(connection, cancellationToken).ConfigureAwait(false); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatchFactory.cs b/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatchFactory.cs index 3c02db71..bc0ab3a4 100644 --- a/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatchFactory.cs +++ b/src/EFCore.Jet.Common/Update/Internal/JetModificationCommandBatchFactory.cs @@ -47,4 +47,4 @@ public JetModificationCommandBatchFactory( public virtual ModificationCommandBatch Create() => new JetModificationCommandBatch(_dependencies); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/Update/Internal/JetUpdateSqlGenerator.cs b/src/EFCore.Jet.Common/Update/Internal/JetUpdateSqlGenerator.cs index 4eacf1ff..80902cce 100644 --- a/src/EFCore.Jet.Common/Update/Internal/JetUpdateSqlGenerator.cs +++ b/src/EFCore.Jet.Common/Update/Internal/JetUpdateSqlGenerator.cs @@ -1,10 +1,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using EntityFrameworkCore.Jet.Metadata; +using EntityFrameworkCore.Jet.Utilities; using System.Data; using System.Globalization; using System.Text; -using EntityFrameworkCore.Jet.Metadata; -using EntityFrameworkCore.Jet.Utilities; namespace EntityFrameworkCore.Jet.Update.Internal { @@ -225,7 +225,7 @@ public override ResultSetMapping AppendStoredProcedureCall( } Check.DebugAssert( - storedProcedure.Parameters.Any() || storedProcedure.ResultColumns.Any(), + storedProcedure.Parameters.Count > 0 || storedProcedure.ResultColumns.Any(), "Stored procedure call with neither parameters nor result columns"); commandStringBuilder.Append("EXEC "); @@ -246,7 +246,7 @@ public override ResultSetMapping AppendStoredProcedureCall( SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, storedProcedure.Name, storedProcedure.Schema); - if (storedProcedure.Parameters.Any()) + if (storedProcedure.Parameters.Count > 0) { commandStringBuilder.Append(' '); @@ -296,4 +296,4 @@ public override ResultSetMapping AppendStoredProcedureCall( return resultSetMapping; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Common/ValueGeneration/JetSequentialGuidValueGenerator.cs b/src/EFCore.Jet.Common/ValueGeneration/JetSequentialGuidValueGenerator.cs index 2cff99ce..a68fbcf2 100644 --- a/src/EFCore.Jet.Common/ValueGeneration/JetSequentialGuidValueGenerator.cs +++ b/src/EFCore.Jet.Common/ValueGeneration/JetSequentialGuidValueGenerator.cs @@ -58,4 +58,4 @@ public override Guid Next(EntityEntry entry) /// public override bool GeneratesTemporaryValues => false; -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/AdoxDatabaseCreator.cs b/src/EFCore.Jet.Data/AdoxDatabaseCreator.cs index ac2ba6ad..8c9eff79 100644 --- a/src/EFCore.Jet.Data/AdoxDatabaseCreator.cs +++ b/src/EFCore.Jet.Data/AdoxDatabaseCreator.cs @@ -1,5 +1,9 @@ -using System; using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; +using System; + +// The enums nested below are ADO type-library enums transcribed name-for-name and value-for-value, so a +// caller can match them against the library's own documentation. CA1711 objects to their "Enum" suffix. +#pragma warning disable CA1711 namespace EntityFrameworkCore.Jet.Data { @@ -17,11 +21,11 @@ public override void CreateDatabase( { throw new ArgumentOutOfRangeException(nameof(databasePassword)); } - + var filePath = JetStoreDatabaseHandling.ExpandFileName(JetStoreDatabaseHandling.ExtractFileNameFromConnectionString(fileNameOrConnectionString)); - + if (version == DatabaseVersion.NewestSupported && - string.Equals(System.IO.Path.GetExtension(filePath), ".mdb")) + string.Equals(System.IO.Path.GetExtension(filePath), ".mdb", StringComparison.OrdinalIgnoreCase)) { version = DatabaseVersion.Version40; } @@ -29,17 +33,17 @@ public override void CreateDatabase( try { using dynamic catalog = new ComObject("ADOX.Catalog"); - + // ADOX is an ADO eXtension and ADO is build on top of OLE DB. var connectionString = GetConnectionString(filePath, version, collatingOrder, databasePassword); using var connection = catalog.Create(connectionString); } catch (Exception e) { - throw new Exception($"Cannot create database \"{filePath}\" using DAO.", e); + throw new InvalidOperationException($"Cannot create database \"{filePath}\" using ADOX.", e); } } - + private static string GetConnectionString(string filePath, DatabaseVersion version, CollatingOrder collatingOrder, string? databasePassword) { var connectionString = JetConnection.GetConnectionString(filePath, DataAccessProviderType.OleDb); @@ -72,7 +76,7 @@ private static string GetConnectionString(string filePath, DatabaseVersion versi return connectionString; } - + [Flags] protected enum CommandTypeEnum { @@ -84,7 +88,7 @@ protected enum CommandTypeEnum adCmdFile = 0x00000100, adCmdTableDirect = 0x00000200, } - + [Flags] protected enum ExecuteOptionEnum { diff --git a/src/EFCore.Jet.Data/AdoxSchema.cs b/src/EFCore.Jet.Data/AdoxSchema.cs index 8d567965..8aa59148 100644 --- a/src/EFCore.Jet.Data/AdoxSchema.cs +++ b/src/EFCore.Jet.Data/AdoxSchema.cs @@ -1,10 +1,15 @@ +using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System; using System.Collections.Generic; using System.Data; using System.Data.Common; -using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System.Diagnostics; +// The enums nested below are ADO/ADOX type-library enums (RuleEnum, DataTypeEnum, …) transcribed +// name-for-name and value-for-value, so a caller can match them against the library's own documentation. +// CA1711 objects to the "Enum" suffix those names carry. +#pragma warning disable CA1711 + namespace EntityFrameworkCore.Jet.Data { public class AdoxSchema : SchemaProvider @@ -599,7 +604,7 @@ public override void EnsureDualTable() } catch (Exception e) { - throw new Exception($"Cannot create dual table '{JetConnection.DefaultDualTableName}' using ADOX.", e); + throw new InvalidOperationException($"Cannot create dual table '{JetConnection.DefaultDualTableName}' using ADOX.", e); } } } @@ -620,7 +625,7 @@ public override void RenameTable(string oldTableName, string newTableName) catch (Exception e) { // TODO: Try interating over the collections instead of using Item["Name"]. - throw new Exception($"Cannot rename table '{oldTableName}' to '{newTableName}'.", e); + throw new InvalidOperationException($"Cannot rename table '{oldTableName}' to '{newTableName}'.", e); } } @@ -644,7 +649,7 @@ public override void RenameColumn(string tableName, string oldColumnName, string catch (Exception e) { // TODO: Try interating over the collections instead of using Item["Name"]. - throw new Exception($"Cannot rename column '{oldColumnName}' to '{newColumnName}' of table '{tableName}'.", e); + throw new InvalidOperationException($"Cannot rename column '{oldColumnName}' to '{newColumnName}' of table '{tableName}'.", e); } } @@ -720,7 +725,7 @@ private static string GetDataTypeString(DataTypeEnum dataType, bool isIdentity = case DataTypeEnum.adPropVariant: case DataTypeEnum.adVarNumeric: default: - throw new ArgumentOutOfRangeException(nameof(dataType), $"Could not map a data type of '{Enum.GetName(typeof(DataTypeEnum), dataType)}'."); + throw new ArgumentOutOfRangeException(nameof(dataType), $"Could not map a data type of '{Enum.GetName(dataType)}'."); } } @@ -783,6 +788,7 @@ public override void Dispose() { _connection.Dispose(); _catalog.Dispose(); + GC.SuppressFinalize(this); } protected enum RuleEnum diff --git a/src/EFCore.Jet.Data/ComObject.cs b/src/EFCore.Jet.Data/ComObject.cs index 0768e2e8..5cd986b4 100644 --- a/src/EFCore.Jet.Data/ComObject.cs +++ b/src/EFCore.Jet.Data/ComObject.cs @@ -1,5 +1,6 @@ using System; using System.Dynamic; +using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; @@ -7,11 +8,14 @@ namespace EntityFrameworkCore.Jet.Data { // A small wrapper around COM interop to make it more easy to use. // See https://github.com/dotnet/runtime/issues/12587#issuecomment-534611966 - internal class ComObject : DynamicObject, IDisposable + internal sealed class ComObject : DynamicObject, IDisposable { private object _instance; #if DEBUG + // Read only in a debugger, to tell two wrappers of the same COM object apart. +#pragma warning disable IDE0052 private readonly Guid _trackingId = Guid.NewGuid(); +#pragma warning restore IDE0052 #endif public static ComObject CreateFirstFrom(params string[] progids) @@ -28,10 +32,10 @@ public static ComObject CreateFirstFrom(params string[] progids) } } } - + throw new TypeLoadException("Could not create an instance using any of the supplied ProgIDs."); } - + public static ComObject CreateFirstFrom(params Guid[] clsids) { foreach (var clsid in clsids) @@ -46,7 +50,7 @@ public static ComObject CreateFirstFrom(params Guid[] clsids) } } } - + throw new TypeLoadException("Could not create an instance using any of the supplied CLSIDs."); } @@ -56,7 +60,7 @@ public ComObject(object instance) { throw new ArgumentException("The object is already a ComObject.", nameof(instance)); } - + _instance = instance; } @@ -86,7 +90,8 @@ public override bool TryGetMember(GetMemberBinder binder, out object? result) BindingFlags.GetProperty, Type.DefaultBinder, _instance, - [] + [], + CultureInfo.InvariantCulture )); return true; } @@ -103,7 +108,8 @@ public override bool TrySetMember(SetMemberBinder binder, object? value) value is ComObject comObject ? comObject._instance : value - ] + ], + CultureInfo.InvariantCulture ); return true; } @@ -117,7 +123,8 @@ public override bool TryInvokeMember(InvokeMemberBinder binder, object?[]? args, BindingFlags.InvokeMethod, Type.DefaultBinder, _instance, - args + args, + CultureInfo.InvariantCulture )); return true; } @@ -132,7 +139,8 @@ public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out ob BindingFlags.GetProperty, Type.DefaultBinder, _instance, - indexes + indexes, + CultureInfo.InvariantCulture )); return true; } diff --git a/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSet.cs b/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSet.cs index 8ab5a54b..db948470 100644 --- a/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSet.cs +++ b/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSet.cs @@ -3,7 +3,7 @@ namespace EntityFrameworkCore.Jet.Data.ConnectionPooling { - class ConnectionSet(string connectionString) : IDisposable + sealed class ConnectionSet(string connectionString) : IDisposable { public string ConnectionString { get; } = connectionString; @@ -12,7 +12,7 @@ class ConnectionSet(string connectionString) : IDisposable public void AddConnection(DbConnection connection) { - lock(_connections) + lock (_connections) { if (ConnectionCount == _connections.Length) { diff --git a/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSetCollection.cs b/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSetCollection.cs index c708f11c..a359d8a8 100644 --- a/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSetCollection.cs +++ b/src/EFCore.Jet.Data/ConnectionPooling/ConnectionSetCollection.cs @@ -1,13 +1,8 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace EntityFrameworkCore.Jet.Data.ConnectionPooling { - class ConnectionSetCollection : KeyedCollection + sealed class ConnectionSetCollection : KeyedCollection { protected override string GetKeyForItem(ConnectionSet item) { @@ -15,7 +10,7 @@ protected override string GetKeyForItem(ConnectionSet item) } // TryGetValue has been added in .NET Core 2.0. - #pragma warning disable 109 +#pragma warning disable 109 public new bool TryGetValue(string key, out ConnectionSet? connectionSet) { try @@ -29,6 +24,6 @@ protected override string GetKeyForItem(ConnectionSet item) return false; } } - #pragma warning restore 109 +#pragma warning restore 109 } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/DaoDatabaseCreator.cs b/src/EFCore.Jet.Data/DaoDatabaseCreator.cs index 1f4ebb57..a3626a55 100644 --- a/src/EFCore.Jet.Data/DaoDatabaseCreator.cs +++ b/src/EFCore.Jet.Data/DaoDatabaseCreator.cs @@ -1,7 +1,12 @@ -using System; using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; +using System; using System.Linq; +// The enums nested below are DAO type-library enums transcribed name-for-name and value-for-value, so a +// caller can match them against the library's own documentation. CA1711 objects to the "Enum" suffix those +// names carry, and CA1069 to the members DAO gives the same value (dbSortPDXNor/dbSortNorwdan and friends). +#pragma warning disable CA1711, CA1069 + namespace EntityFrameworkCore.Jet.Data { public class DaoDatabaseCreator @@ -19,9 +24,9 @@ public override void CreateDatabase( } var filePath = JetStoreDatabaseHandling.ExpandFileName(JetStoreDatabaseHandling.ExtractFileNameFromConnectionString(fileNameOrConnectionString)); - + if (version == DatabaseVersion.NewestSupported && - string.Equals(System.IO.Path.GetExtension(filePath), ".mdb")) + string.Equals(System.IO.Path.GetExtension(filePath), ".mdb", StringComparison.OrdinalIgnoreCase)) { version = DatabaseVersion.Version40; } @@ -32,16 +37,16 @@ public override void CreateDatabase( var databaseType = version switch { - DatabaseVersion.Version10 => (int) DatabaseTypeEnum.dbVersion10, - DatabaseVersion.Version11 => (int) DatabaseTypeEnum.dbVersion11, - DatabaseVersion.Version20 => (int) DatabaseTypeEnum.dbVersion20, - DatabaseVersion.Version30 => (int) DatabaseTypeEnum.dbVersion30, - DatabaseVersion.Version40 => (int) DatabaseTypeEnum.dbVersion40, - DatabaseVersion.Version120 => (int) DatabaseTypeEnum.dbVersion120, + DatabaseVersion.Version10 => (int)DatabaseTypeEnum.dbVersion10, + DatabaseVersion.Version11 => (int)DatabaseTypeEnum.dbVersion11, + DatabaseVersion.Version20 => (int)DatabaseTypeEnum.dbVersion20, + DatabaseVersion.Version30 => (int)DatabaseTypeEnum.dbVersion30, + DatabaseVersion.Version40 => (int)DatabaseTypeEnum.dbVersion40, + DatabaseVersion.Version120 => (int)DatabaseTypeEnum.dbVersion120, _ => 0, }; - var daoCollatingOrder = (CollatingOrderEnum) collatingOrder; + var daoCollatingOrder = (CollatingOrderEnum)collatingOrder; var collatingOrderString = daoCollatingOrder switch { CollatingOrderEnum.dbSortArabic => ";LANGID=0x0401;CP=1256;COUNTRY=0", @@ -80,10 +85,12 @@ public override void CreateDatabase( } catch (Exception e) { - throw new Exception($"Cannot create database \"{filePath}\" using DAO.", e); + throw new InvalidOperationException($"Cannot create database \"{filePath}\" using DAO.", e); } } - + + private static readonly int[] Dao36Only = [36]; + private static dynamic CreateDbEngine() { var progids = Enumerable.Range(12, 6) @@ -92,7 +99,7 @@ private static dynamic CreateDbEngine() .Concat( Environment.Is64BitProcess ? [] - : new[] {36}) // DAO 3.6 is only available as an x86 library + : Dao36Only) // DAO 3.6 is only available as an x86 library .Select(n => "DAO.DBEngine." + n) .ToArray(); return ComObject.CreateFirstFrom(progids); @@ -143,7 +150,7 @@ protected enum DatabaseTypeEnum dbVersion40 = 0x00000040, dbVersion120 = 0x00000080 } - + [Flags] protected enum TableDefAttributeEnum { diff --git a/src/EFCore.Jet.Data/DaoSchema.cs b/src/EFCore.Jet.Data/DaoSchema.cs index cdedc6c7..ac1b9f53 100644 --- a/src/EFCore.Jet.Data/DaoSchema.cs +++ b/src/EFCore.Jet.Data/DaoSchema.cs @@ -1,10 +1,16 @@ +using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System; using System.Collections.Generic; using System.Data; -using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System.Diagnostics; +using System.Globalization; using System.Linq; +// The enums nested below are DAO type-library enums transcribed name-for-name and value-for-value, so a +// caller can match them against the library's own documentation. CA1711 objects to the "Enum" suffix those +// names carry, and CA1069 to the members DAO gives the same value (dbFixedField/dbDescending). +#pragma warning disable CA1711, CA1069 + namespace EntityFrameworkCore.Jet.Data { public class DaoSchema : SchemaProvider @@ -29,7 +35,7 @@ public DaoSchema(JetConnection connection, bool readOnly) Enumerable.Range(12, 6) .Reverse() .Concat([36]) - .Select(n => "DAO.DBEngine." + (n * 10).ToString()) + .Select(n => "DAO.DBEngine." + (n * 10).ToString(CultureInfo.InvariantCulture)) .ToArray()); try @@ -525,7 +531,7 @@ public override void RenameTable(string oldTableName, string newTableName) catch (Exception e) { // TODO: Try interating over the collections instead of using Item["Name"]. - throw new Exception($"Cannot rename table '{oldTableName}' to '{newTableName}'.", e); + throw new InvalidOperationException($"Cannot rename table '{oldTableName}' to '{newTableName}'.", e); } } @@ -549,7 +555,7 @@ public override void RenameColumn(string tableName, string oldColumnName, string catch (Exception e) { // TODO: Try interating over the collections instead of using Item["Name"]. - throw new Exception($"Cannot rename column '{oldColumnName}' to '{newColumnName}' of table '{tableName}'.", e); + throw new InvalidOperationException($"Cannot rename column '{oldColumnName}' to '{newColumnName}' of table '{tableName}'.", e); } } @@ -714,6 +720,7 @@ public override void Dispose() _database.Dispose(); _workspace.Dispose(); _dbEngine.Dispose(); + GC.SuppressFinalize(this); } [Flags] diff --git a/src/EFCore.Jet.Data/DbColumnFlagEnum.cs b/src/EFCore.Jet.Data/DbColumnFlagEnum.cs index 91ee99fd..1c35cb80 100644 --- a/src/EFCore.Jet.Data/DbColumnFlagEnum.cs +++ b/src/EFCore.Jet.Data/DbColumnFlagEnum.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace EntityFrameworkCore.Jet.Data { @@ -21,4 +18,4 @@ enum DbColumnFlag IsRowVer = 512, CachedDeferred = 4096 } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/DbConnectionStringBuilderExtensions.cs b/src/EFCore.Jet.Data/DbConnectionStringBuilderExtensions.cs index 35b8e0e8..8c5d72de 100644 --- a/src/EFCore.Jet.Data/DbConnectionStringBuilderExtensions.cs +++ b/src/EFCore.Jet.Data/DbConnectionStringBuilderExtensions.cs @@ -47,7 +47,7 @@ public static void SetProvider(this DbConnectionStringBuilder builder, string va else throw new InvalidOperationException("This extension method only supports OdbcConnectionStringBuilder and OleDbConnectionStringBuilder."); } - + public static string? GetDataSource(this DbConnectionStringBuilder builder, DataAccessProviderType providerType = DataAccessProviderType.Unconfigured) { if (providerType == DataAccessProviderType.OleDb || @@ -88,7 +88,7 @@ public static void SetDataSource(this DbConnectionStringBuilder builder, string else throw new InvalidOperationException("This extension method only supports OdbcConnectionStringBuilder and OleDbConnectionStringBuilder."); } - + public static string? GetUserId(this DbConnectionStringBuilder builder, DataAccessProviderType providerType = DataAccessProviderType.Unconfigured) { if (IsOleDb(builder)) @@ -128,7 +128,7 @@ public static void SetUserId(this DbConnectionStringBuilder builder, string valu else throw new InvalidOperationException("This extension method only supports OdbcConnectionStringBuilder and OleDbConnectionStringBuilder."); } - + public static string? GetPassword(this DbConnectionStringBuilder builder, DataAccessProviderType providerType = DataAccessProviderType.Unconfigured) { if (providerType == DataAccessProviderType.OleDb || @@ -149,16 +149,16 @@ public static void SetUserId(this DbConnectionStringBuilder builder, string valu { return !string.IsNullOrEmpty(builder.GetSystemDatabase(providerType)) ? builder.TryGetValue("PWD", out var value) - ? (string) value + ? (string)value : null : null; } return builder.TryGetValue("Password", out var oleDbValue) - ? (string) oleDbValue + ? (string)oleDbValue : !string.IsNullOrEmpty(builder.GetSystemDatabase(providerType)) ? builder.TryGetValue("PWD", out var odbcValue) - ? (string) odbcValue + ? (string)odbcValue : null : null; } @@ -178,7 +178,7 @@ public static void SetPassword(this DbConnectionStringBuilder builder, string va else throw new InvalidOperationException("This extension method only supports OdbcConnectionStringBuilder and OleDbConnectionStringBuilder."); } - + public static string? GetSystemDatabase(this DbConnectionStringBuilder builder, DataAccessProviderType providerType = DataAccessProviderType.Unconfigured) { if (providerType == DataAccessProviderType.OleDb || @@ -240,16 +240,16 @@ public static void SetSystemDatabase(this DbConnectionStringBuilder builder, str { return string.IsNullOrEmpty(builder.GetSystemDatabase(providerType)) ? builder.TryGetValue("PWD", out var value) - ? (string) value + ? (string)value : null : null; } return builder.TryGetValue("Jet OLEDB:Database Password", out var oleDbValue) - ? (string) oleDbValue + ? (string)oleDbValue : string.IsNullOrEmpty(builder.GetSystemDatabase(providerType)) ? builder.TryGetValue("PWD", out var odbcValue) - ? (string) odbcValue + ? (string)odbcValue : null : null; } @@ -269,13 +269,13 @@ public static void SetDatabasePassword(this DbConnectionStringBuilder builder, s else throw new InvalidOperationException("This extension method only supports OdbcConnectionStringBuilder and OleDbConnectionStringBuilder."); } - + private static bool IsOdbc(DbConnectionStringBuilder builder) { return builder .GetType() .GetTypesInHierarchy() - .Any(t => string.Equals(t.FullName, "System.Data.Odbc.OdbcConnectionStringBuilder")); + .Any(t => string.Equals(t.FullName, "System.Data.Odbc.OdbcConnectionStringBuilder", StringComparison.Ordinal)); } private static bool IsOleDb(DbConnectionStringBuilder builder) @@ -283,7 +283,7 @@ private static bool IsOleDb(DbConnectionStringBuilder builder) return builder .GetType() .GetTypesInHierarchy() - .Any(t => string.Equals(t.FullName, "System.Data.OleDb.OleDbConnectionStringBuilder")); + .Any(t => string.Equals(t.FullName, "System.Data.OleDb.OleDbConnectionStringBuilder", StringComparison.Ordinal)); } } } \ No newline at end of file diff --git a/src/EFCore.Jet.Data/EFCore.Jet.Data.csproj b/src/EFCore.Jet.Data/EFCore.Jet.Data.csproj index 0370df0b..51659157 100644 --- a/src/EFCore.Jet.Data/EFCore.Jet.Data.csproj +++ b/src/EFCore.Jet.Data/EFCore.Jet.Data.csproj @@ -8,6 +8,8 @@ EntityFrameworkCore.Jet.Data $(PackageTags);System.Data.Jet annotations + + en diff --git a/src/EFCore.Jet.Data/IJetDatabaseCreator.cs b/src/EFCore.Jet.Data/IJetDatabaseCreator.cs index 8c8dffbc..fefd971f 100644 --- a/src/EFCore.Jet.Data/IJetDatabaseCreator.cs +++ b/src/EFCore.Jet.Data/IJetDatabaseCreator.cs @@ -1,3 +1,8 @@ +// The constants below are DAO's own CollatingOrderEnum, transcribed value-for-value. Several of its names +// share a value (PDXNor/Norwdan, PDXIntl/General, SwedFin/PDXSwe), which CA1069 reads as duplicates; keeping +// DAO's spelling is what lets a caller match these against the type library and Access's documentation. +#pragma warning disable CA1069 + namespace EntityFrameworkCore.Jet.Data { public enum CollatingOrder @@ -40,7 +45,7 @@ public enum DatabaseVersion Version40 = 40, Version120 = 120 } - + public interface IJetDatabaseCreator { void CreateDatabase( diff --git a/src/EFCore.Jet.Data/InnerConnectionFactory.cs b/src/EFCore.Jet.Data/InnerConnectionFactory.cs index 393ed05b..d5357a0c 100644 --- a/src/EFCore.Jet.Data/InnerConnectionFactory.cs +++ b/src/EFCore.Jet.Data/InnerConnectionFactory.cs @@ -1,11 +1,11 @@ +using EntityFrameworkCore.Jet.Data.ConnectionPooling; using System; using System.Data; using System.Data.Common; -using EntityFrameworkCore.Jet.Data.ConnectionPooling; namespace EntityFrameworkCore.Jet.Data { - class InnerConnectionFactory : IDisposable + sealed class InnerConnectionFactory : IDisposable { public static readonly InnerConnectionFactory Instance = new(); @@ -24,7 +24,7 @@ public DbConnection OpenConnection(string? connectionString, DbProviderFactory d var connection = dataAccessProviderFactory.CreateConnection(); connection.ConnectionString = connectionString; connection.Open(); - + return connection; } @@ -37,7 +37,7 @@ public DbConnection OpenConnection(string? connectionString, DbProviderFactory d var connection = dataAccessProviderFactory.CreateConnection(); connection.ConnectionString = connectionString; connection.Open(); - + return connection; } diff --git a/src/EFCore.Jet.Data/JetCommand.cs b/src/EFCore.Jet.Data/JetCommand.cs index 426140aa..1e5ebe04 100644 --- a/src/EFCore.Jet.Data/JetCommand.cs +++ b/src/EFCore.Jet.Data/JetCommand.cs @@ -1,9 +1,10 @@ +using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System; using System.Collections.Generic; using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; -using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; +using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -283,8 +284,7 @@ protected virtual int ExecuteNonQueryCore() if (InnerCommand.CommandType != CommandType.Text) return InnerCommand.ExecuteNonQuery(); - if (_selectRowCountRegularExpression.Match(InnerCommand.CommandText) - .Success) + if (_selectRowCountRegularExpression.IsMatch(InnerCommand.CommandText)) { return _connection.RowCount; } @@ -293,7 +293,7 @@ protected virtual int ExecuteNonQueryCore() if (!CheckExists(InnerCommand.CommandText, out var newCommandText)) return 0; - bool isexistssql = newCommandText != InnerCommand.CommandText && (InnerCommand.CommandText.StartsWith("IF EXISTS",StringComparison.OrdinalIgnoreCase) || InnerCommand.CommandText.StartsWith("IF NOT EXISTS", StringComparison.OrdinalIgnoreCase)); + bool isexistssql = newCommandText != InnerCommand.CommandText && (InnerCommand.CommandText.StartsWith("IF EXISTS", StringComparison.OrdinalIgnoreCase) || InnerCommand.CommandText.StartsWith("IF NOT EXISTS", StringComparison.OrdinalIgnoreCase)); InnerCommand.CommandText = newCommandText; @@ -396,7 +396,7 @@ protected virtual IList SplitCommands() { //Remove any tag lines from the sql created by ef core. Jet doesn't like comments/tags in the SQL var lines = CommandText.Split([Environment.NewLine], StringSplitOptions.None); - var filteredLines = lines.Where(line => !line.TrimStart().StartsWith("--")); + var filteredLines = lines.Where(line => !line.TrimStart().StartsWith("--", StringComparison.Ordinal)); CommandText = string.Join(Environment.NewLine, filteredLines).TrimStart(); // At this point, all parameters have already been expanded. @@ -473,10 +473,9 @@ protected virtual IList SplitCommands() return commands; } - private DbDataReader TryGetDataReaderForSelectRowCount(string commandText) + private DataTableReader? TryGetDataReaderForSelectRowCount(string commandText) { - if (_selectRowCountRegularExpression.Match(commandText) - .Success) + if (_selectRowCountRegularExpression.IsMatch(commandText)) { var dataTable = new DataTable("Rowcount"); dataTable.Columns.Add("ROWCOUNT", typeof(int)); @@ -553,7 +552,7 @@ protected virtual string FixupIdentity(string commandText) command.CommandText = $"SELECT {placeholder}"; command.Parameters.Clear(); - var identityValue = Convert.ToInt32(command.ExecuteScalar()); + var identityValue = Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture); LogHelper.ShowInfo($"{placeholder} = {identityValue}"); @@ -600,10 +599,10 @@ private void InlineTopParameters() { var first = match.Groups["first"]; var sec = match.Groups["sec"]; - var sp = Convert.ToInt32(ExtractParameter(commandText, sec.Index, parameters).Value); - var fp = Convert.ToInt32(ExtractParameter(commandText, first.Index, parameters).Value); + var sp = Convert.ToInt32(ExtractParameter(commandText, sec.Index, parameters).Value, CultureInfo.InvariantCulture); + var fp = Convert.ToInt32(ExtractParameter(commandText, first.Index, parameters).Value, CultureInfo.InvariantCulture); var total = fp + sp; - return total.ToString(); + return total.ToString(CultureInfo.InvariantCulture); }, 1)) != lastCommandText) { @@ -614,8 +613,8 @@ private void InlineTopParameters() lastCommandText, match => Convert.ToInt32( ExtractParameter(commandText, match.Index, parameters) - .Value) - .ToString(), + .Value, CultureInfo.InvariantCulture) + .ToString(CultureInfo.InvariantCulture), 1)) != lastCommandText) { lastCommandText = commandText; @@ -630,7 +629,8 @@ private void InlineTopParameters() { var first = match.Groups["first"]; var sec = match.Groups["sec"]; - return (Convert.ToInt32(first.Value) + Convert.ToInt32(sec.Value)).ToString(); + return (Convert.ToInt32(first.Value, CultureInfo.InvariantCulture) + + Convert.ToInt32(sec.Value, CultureInfo.InvariantCulture)).ToString(CultureInfo.InvariantCulture); }, 1)) != lastCommandText) { @@ -651,7 +651,8 @@ private void ModifyOuterSelectTopValueForOuterSelectSkipEmulationViaDataReader() { InnerCommand.CommandText = _outerSelectTopValueRegularExpression.Replace( InnerCommand.CommandText, - match => (int.Parse(match.Value) + _outerSelectSkipEmulationViaDataReaderSkipCount).ToString()); + match => (int.Parse(match.Value, CultureInfo.InvariantCulture) + + _outerSelectSkipEmulationViaDataReaderSkipCount).ToString(CultureInfo.InvariantCulture)); } } @@ -681,21 +682,21 @@ private void PrepareOuterSelectSkipEmulationViaDataReader() } var parameter = ExtractParameter(InnerCommand.CommandText, match.Index, parameters); - _outerSelectSkipEmulationViaDataReaderSkipCount = Convert.ToInt32(parameter.Value); + _outerSelectSkipEmulationViaDataReaderSkipCount = Convert.ToInt32(parameter.Value, CultureInfo.InvariantCulture); InnerCommand.Parameters.Clear(); InnerCommand.Parameters.AddRange(parameters.ToArray()); } else { - _outerSelectSkipEmulationViaDataReaderSkipCount = int.Parse(skipValueOrParameter.Value); + _outerSelectSkipEmulationViaDataReaderSkipCount = int.Parse(skipValueOrParameter.Value, CultureInfo.InvariantCulture); } InnerCommand.CommandText = InnerCommand.CommandText.Remove(match.Index, match.Length); } protected virtual bool IsParameter(string fragment) - => fragment.Equals("?") || + => fragment.Equals("?", StringComparison.Ordinal) || fragment.Length >= 2 && fragment[0] == '@' && fragment[1] != '@'; protected virtual DbParameter ExtractParameter(string commandText, int count, List parameters) @@ -724,7 +725,7 @@ protected virtual void ExpandParameters() var placeholders = GetParameterPlaceholders(InnerCommand.CommandText, indices); - if (placeholders.All(t => t.Name.StartsWith("@"))) + if (placeholders.All(t => t.Name.StartsWith('@'))) { MatchParametersAndPlaceholders(placeholders); diff --git a/src/EFCore.Jet.Data/JetConfiguration.cs b/src/EFCore.Jet.Data/JetConfiguration.cs index 5c4a4cc5..ca150427 100644 --- a/src/EFCore.Jet.Data/JetConfiguration.cs +++ b/src/EFCore.Jet.Data/JetConfiguration.cs @@ -32,16 +32,16 @@ public static object IntegerNullValue _integerNullValue = value; } } - - public static DataAccessProviderType DefaultDataAccessProviderType { get; set; } = DataAccessProviderType.Odbc; - + + public static DataAccessProviderType DefaultDataAccessProviderType { get; set; } = DataAccessProviderType.Odbc; + /// /// Gets or sets a value indicating whether show SQL statements. /// /// /// true to show SQL statements; otherwise, false. /// - public static bool ShowSqlStatements { get; set; } = false; + public static bool ShowSqlStatements { get; set; } /// /// Gets or sets a value indicating whether the connection pooling should be used @@ -49,7 +49,7 @@ public static object IntegerNullValue /// /// true to use the connection pooling; otherwise, false. /// - public static bool UseConnectionPooling { get; set; } = false; + public static bool UseConnectionPooling { get; set; } /// /// Gets or sets a value indicating whether to return a default value for the column @@ -58,6 +58,6 @@ public static object IntegerNullValue /// /// true to return a default value; otherwise, false. /// - public static bool UseDefaultValueOnDBNullConversionError { get; set; } = false; + public static bool UseDefaultValueOnDBNullConversionError { get; set; } } } \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetConnection.cs b/src/EFCore.Jet.Data/JetConnection.cs index ddcc0eeb..2a8736a6 100644 --- a/src/EFCore.Jet.Data/JetConnection.cs +++ b/src/EFCore.Jet.Data/JetConnection.cs @@ -1,15 +1,13 @@ +using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; +using Microsoft.Win32; using System; using System.Collections.Generic; -using System.ComponentModel.Design; using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; -using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using System.IO; using System.Linq; -using System.Runtime.CompilerServices; using System.Text.RegularExpressions; -using Microsoft.Win32; namespace EntityFrameworkCore.Jet.Data { @@ -85,7 +83,7 @@ public JetConnection(string? fileNameOrConnectionString, DbProviderFactory? data public bool IgnoreMsys => _ignoreMSys; /// - /// Gets the for this . + /// Gets the for this . /// protected override DbProviderFactory DbProviderFactory => JetFactory; @@ -207,10 +205,10 @@ public override int ConnectionTimeout => 0; /// - /// Creates and returns a object associated with the current connection. + /// Creates and returns a object associated with the current connection. /// /// - /// A object. + /// A object. /// protected override DbCommand CreateDbCommand() { @@ -232,7 +230,7 @@ public override string DataSource => JetStoreDatabaseHandling.ExtractFileNameFromConnectionString(_connectionString); /// - /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// Releases the unmanaged resources used by the and optionally releases the managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected override void Dispose(bool disposing) @@ -248,7 +246,7 @@ protected override void Dispose(bool disposing) /// /// Enlists in the specified transaction. /// - /// A reference to an existing in which to enlist. + /// A reference to an existing in which to enlist. public override void EnlistTransaction(System.Transactions.Transaction? transaction) { if (InnerConnection == null) @@ -257,11 +255,11 @@ public override void EnlistTransaction(System.Transactions.Transaction? transact } /// - /// Returns schema information for the data source of this using the specified string for the schema name. + /// Returns schema information for the data source of this using the specified string for the schema name. /// /// Specifies the name of the schema to return. /// - /// A that contains schema information. + /// A that contains schema information. /// /// /// @@ -276,10 +274,10 @@ public override DataTable GetSchema(string collectionName) } /// - /// Returns schema information for the data source of this . + /// Returns schema information for the data source of this . /// /// - /// A that contains schema information. + /// A that contains schema information. /// /// /// @@ -294,12 +292,12 @@ public override DataTable GetSchema() } /// - /// Returns schema information for the data source of this using the specified string for the schema name and the specified string array for the restriction values. + /// Returns schema information for the data source of this using the specified string for the schema name and the specified string array for the restriction values. /// /// Specifies the name of the schema to return. /// Specifies a set of restriction values for the requested schema. /// - /// A that contains schema information. + /// A that contains schema information. /// /// /// @@ -314,7 +312,7 @@ public override DataTable GetSchema(string collectionName, string?[] restriction } /// - /// Opens a database connection with the settings specified by the . + /// Opens a database connection with the settings specified by the . /// public override void Open() { @@ -373,7 +371,7 @@ public override void Open() { var provider = GetMostRecentCompatibleProviders(dataAccessProviderType) .FirstOrDefault() - .Key ?? throw new InvalidOperationException($"Unable to find any compatible {Enum.GetName(typeof(DataAccessProviderType), dataAccessProviderType)} provider for the connection string: {fileNameOrConnectionString}"); + .Key ?? throw new InvalidOperationException($"Unable to find any compatible {Enum.GetName(dataAccessProviderType)} provider for the connection string: {fileNameOrConnectionString}"); connectionStringBuilder.SetProvider(provider, dataAccessProviderType); } @@ -578,7 +576,7 @@ public static string GetConnectionString(string fileNameOrConnectionString, Data internal static string GetConnectionString(string fileNameOrConnectionString, DataAccessProviderType dataAccessProviderType, DbProviderFactory dataAccessProviderFactory) { - var provider = GetMostRecentCompatibleProviders(dataAccessProviderType).FirstOrDefault().Key ?? throw new InvalidOperationException($"Unable to find any compatible {Enum.GetName(typeof(DataAccessProviderType), dataAccessProviderType)} provider for the connection string: {fileNameOrConnectionString}"); + var provider = GetMostRecentCompatibleProviders(dataAccessProviderType).FirstOrDefault().Key ?? throw new InvalidOperationException($"Unable to find any compatible {Enum.GetName(dataAccessProviderType)} provider for the connection string: {fileNameOrConnectionString}"); return IsConnectionString(fileNameOrConnectionString) ? ExpandDatabaseFilePath(fileNameOrConnectionString, dataAccessProviderFactory) : GetConnectionString( @@ -715,20 +713,20 @@ public static DataAccessProviderType GetDataAccessProviderType(string? connectio case true when isOleDb: throw new InvalidOperationException("The connection string appears to be for ODBC and OLE DB. Only one distinct style is supported at a time."); case false when !isOleDb: - { - isOleDb = Regex.IsMatch(connectionString, @"^(?:.*;)?\s*Data Source\s*=", RegexOptions.IgnoreCase); - isOdbc = Regex.IsMatch(connectionString, @"^(?:.*;)?\s*DBQ\s*=", RegexOptions.IgnoreCase); - - switch (isOdbc) { - case true when isOleDb: - throw new InvalidOperationException("The connection string appears to be for ODBC and OLE DB. Only one distinct style is supported at a time."); - case false when !isOleDb: - throw new ArgumentException("The connection string appears to be neither ODBC nor OLE DB compliant.", nameof(connectionString)); - } + isOleDb = Regex.IsMatch(connectionString, @"^(?:.*;)?\s*Data Source\s*=", RegexOptions.IgnoreCase); + isOdbc = Regex.IsMatch(connectionString, @"^(?:.*;)?\s*DBQ\s*=", RegexOptions.IgnoreCase); - break; - } + switch (isOdbc) + { + case true when isOleDb: + throw new InvalidOperationException("The connection string appears to be for ODBC and OLE DB. Only one distinct style is supported at a time."); + case false when !isOleDb: + throw new ArgumentException("The connection string appears to be neither ODBC nor OLE DB compliant.", nameof(connectionString)); + } + + break; + } } return isOleDb @@ -876,4 +874,4 @@ public static bool IsFileName(string? fileNameOrConnectionString) [GeneratedRegex(@"^(?:.*;)?\s*Provider\s*=\s*\w+", RegexOptions.IgnoreCase, "en-AU")] private static partial Regex IsOleDbRegex(); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetConnectionStringBuilder.cs b/src/EFCore.Jet.Data/JetConnectionStringBuilder.cs index 32ed956c..326d466f 100644 --- a/src/EFCore.Jet.Data/JetConnectionStringBuilder.cs +++ b/src/EFCore.Jet.Data/JetConnectionStringBuilder.cs @@ -3,7 +3,11 @@ namespace EntityFrameworkCore.Jet.Data { + // CA1010 asks for ICollection; the non-generic ICollection comes from DbConnectionStringBuilder, which + // every ADO.NET provider inherits as-is. +#pragma warning disable CA1010 public class JetConnectionStringBuilder : DbConnectionStringBuilder +#pragma warning restore CA1010 { private readonly DbConnectionStringBuilder _innerBuilder; diff --git a/src/EFCore.Jet.Data/JetDataReader.cs b/src/EFCore.Jet.Data/JetDataReader.cs index 0a09de0f..0f09d57e 100644 --- a/src/EFCore.Jet.Data/JetDataReader.cs +++ b/src/EFCore.Jet.Data/JetDataReader.cs @@ -8,7 +8,7 @@ namespace EntityFrameworkCore.Jet.Data { - internal class JetDataReader : DbDataReader + internal sealed class JetDataReader : DbDataReader { #if DEBUG private static int _activeObjectsCount; @@ -212,7 +212,7 @@ public TimeOnly GetTimeOnly(int ordinal) return (TimeOnly)value; } - public virtual TimeSpan GetTimeSpan(int ordinal) + public TimeSpan GetTimeSpan(int ordinal) { var value = _wrappedDataReader.GetValue(ordinal); @@ -227,7 +227,7 @@ public virtual TimeSpan GetTimeSpan(int ordinal) return (TimeSpan)value; } - public virtual DateTimeOffset GetDateTimeOffset(int ordinal) + public DateTimeOffset GetDateTimeOffset(int ordinal) { var value = _wrappedDataReader.GetValue(ordinal); if (value is String stringValue) @@ -270,7 +270,7 @@ public override decimal GetDecimal(int ordinal) try { - return Convert.ToDecimal(value); + return Convert.ToDecimal(value, CultureInfo.InvariantCulture); } catch { @@ -289,7 +289,7 @@ public override double GetDouble(int ordinal) try { - return Convert.ToDouble(value); + return Convert.ToDouble(value, CultureInfo.InvariantCulture); } catch { @@ -314,7 +314,7 @@ public override float GetFloat(int ordinal) try { - return Convert.ToSingle(value); + return Convert.ToSingle(value, CultureInfo.InvariantCulture); } catch { @@ -360,7 +360,7 @@ public override short GetInt16(int ordinal) try { - return Convert.ToInt16(value); + return Convert.ToInt16(value, CultureInfo.InvariantCulture); } catch { @@ -401,7 +401,7 @@ public override int GetInt32(int ordinal) //The text e.g. "Chai" should not be attempted to convert to bytes and then to an int but should throw an exception try { - return Convert.ToInt32(value); + return Convert.ToInt32(value, CultureInfo.InvariantCulture); } catch { @@ -429,7 +429,7 @@ public override long GetInt64(int ordinal) try { - return Convert.ToInt64(value); + return Convert.ToInt64(value, CultureInfo.InvariantCulture); } catch { diff --git a/src/EFCore.Jet.Data/JetFactory.cs b/src/EFCore.Jet.Data/JetFactory.cs index e6ff14c6..c2beb7c5 100644 --- a/src/EFCore.Jet.Data/JetFactory.cs +++ b/src/EFCore.Jet.Data/JetFactory.cs @@ -31,16 +31,16 @@ internal JetFactory(JetConnection connection, DbProviderFactory innerFactory) } /// - /// Specifies whether the specific supports the class. + /// Specifies whether the specific supports the class. /// public override bool CanCreateDataSourceEnumerator => false; /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbCommand CreateCommand() => InnerFactory == null @@ -48,10 +48,10 @@ public override DbCommand CreateCommand() : new JetCommand(Connection); /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbCommandBuilder CreateCommandBuilder() { @@ -66,10 +66,10 @@ public override DbCommandBuilder CreateCommandBuilder() } /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbConnection CreateConnection() => InnerFactory == null @@ -77,10 +77,10 @@ public override DbConnection CreateConnection() : new JetConnection(InnerFactory); /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbConnectionStringBuilder CreateConnectionStringBuilder() => InnerFactory == null @@ -88,10 +88,10 @@ public override DbConnectionStringBuilder CreateConnectionStringBuilder() : new JetConnectionStringBuilder(InnerFactory); /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbDataAdapter? CreateDataAdapter() => InnerFactory == null @@ -99,19 +99,19 @@ public override DbConnectionStringBuilder CreateConnectionStringBuilder() : InnerFactory.CreateDataAdapter(); /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbDataSourceEnumerator? CreateDataSourceEnumerator() => null; /// - /// Returns a new instance of the provider's class that implements the class. + /// Returns a new instance of the provider's class that implements the class. /// /// - /// A new instance of . + /// A new instance of . /// public override DbParameter? CreateParameter() => InnerFactory == null @@ -133,8 +133,8 @@ public static DbProviderFactory GetDataAccessProviderFactory(DataAccessProviderT { throw new TypeLoadException($"The referenced version '{version}' of 'System.Data.OleDb' is lower than the minimum required version {MinimumRequiredOleDbVersion}."); } - - return (DbProviderFactory) type + + return (DbProviderFactory)type .GetField("Instance", BindingFlags.Static | BindingFlags.Public) .GetValue(null); } @@ -156,8 +156,8 @@ public static DbProviderFactory GetDataAccessProviderFactory(DataAccessProviderT { throw new TypeLoadException($"The referenced version '{version}' of 'System.Data.Odbc' is lower than the minimum required version {MinimumRequiredOdbcVersion}."); } - - return (DbProviderFactory) type + + return (DbProviderFactory)type .GetField("Instance", BindingFlags.Static | BindingFlags.Public) .GetValue(null); } diff --git a/src/EFCore.Jet.Data/JetParameterHelper.cs b/src/EFCore.Jet.Data/JetParameterHelper.cs index 0f5c65e3..5b172c30 100644 --- a/src/EFCore.Jet.Data/JetParameterHelper.cs +++ b/src/EFCore.Jet.Data/JetParameterHelper.cs @@ -1,6 +1,7 @@ using System; using System.Data; using System.Data.Common; +using System.Globalization; namespace EntityFrameworkCore.Jet.Data { public static class JetParameterHelper @@ -10,23 +11,24 @@ internal static string GetParameterValueToDisplay(DbParameter parameter) if (parameter.Value == DBNull.Value || parameter.Value == null) return "null"; else if (IsString(parameter)) - return String.Format("'{0}'", parameter.Value); + return String.Format(CultureInfo.InvariantCulture, "'{0}'", parameter.Value); else if (IsDateTime(parameter)) { if (parameter.Value is TimeSpan) - return String.Format("#{0:c}#", parameter.Value); + return String.Format(CultureInfo.InvariantCulture, "#{0:c}#", parameter.Value); else - return String.Format("#{0:yyyy-MM-ddTHH:mm:ssZ}#", parameter.Value); - + return String.Format(CultureInfo.InvariantCulture, "#{0:yyyy-MM-ddTHH:mm:ssZ}#", parameter.Value); + } else if (IsTimeSpan(parameter)) - return String.Format("#{0:c}#", parameter.Value); + return String.Format(CultureInfo.InvariantCulture, "#{0:c}#", parameter.Value); else if (IsGuid(parameter)) - return String.Format("{{{0}}}", parameter.Value); + return String.Format(CultureInfo.InvariantCulture, "{{{0}}}", parameter.Value); else if (parameter.Value is Enum) - return String.Format("{0}({1})", Convert.ToInt32(parameter.Value), parameter.Value); + return String.Format(CultureInfo.InvariantCulture, "{0}({1})", + Convert.ToInt32(parameter.Value, CultureInfo.InvariantCulture), parameter.Value); else - return String.Format("{0}", parameter.Value); + return String.Format(CultureInfo.InvariantCulture, "{0}", parameter.Value); } @@ -39,17 +41,6 @@ private static bool IsGuid(DbParameter parameter) }; } - private static bool IsNumeric(DbParameter parameter) - { - return parameter.DbType switch - { - DbType.Byte or DbType.Int16 or DbType.Int32 or DbType.Int64 or DbType.Object or DbType.SByte - or DbType.UInt16 or DbType.UInt32 or DbType.UInt64 => true, - _ => false - }; - } - - internal static bool IsTimeSpan(DbParameter parameter) { if (!(parameter is DbParameter)) diff --git a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/Column.cs b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/Column.cs index bdcf5599..bf6ca3bd 100644 --- a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/Column.cs +++ b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/Column.cs @@ -1,8 +1,6 @@ -using System; - namespace EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition { - class Column + sealed class Column { public string Name { get; set; } public string Type { get; set; } @@ -11,7 +9,9 @@ class Column public override string ToString() { - return string.Format("{0} {2}({3}) {1}", Name, Nullable ? "Null" : "NotNull", Type, MaxLength); + return string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "{0} {2}({3}) {1}", Name, Nullable ? "Null" : "NotNull", Type, MaxLength); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/ColumnCollection.cs b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/ColumnCollection.cs index 42396e43..69e03f13 100644 --- a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/ColumnCollection.cs +++ b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/ColumnCollection.cs @@ -2,7 +2,7 @@ namespace EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition { - class ColumnCollection : List + sealed class ColumnCollection : List { } } \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetInformationSchema.cs b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetInformationSchema.cs index 62fe40f8..b0ca28e0 100644 --- a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetInformationSchema.cs +++ b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetInformationSchema.cs @@ -21,8 +21,8 @@ public static bool TryGetDataReaderFromInformationSchemaCommand(JetCommand comma dataReader = GetDbDataReaderFromSimpleStatement(command); return dataReader != null; } - - private static DbDataReader? GetDbDataReaderFromSimpleStatement(JetCommand command) + + private static DataTableReader? GetDbDataReaderFromSimpleStatement(JetCommand command) { // Command text format is // SELECT * FROM `INFORMATION_SCHEMA.` [WHERE ] [ORDER BY ] @@ -35,7 +35,7 @@ public static bool TryGetDataReaderFromInformationSchemaCommand(JetCommand comma // relation_columns // check_constraints - var jetConnection = (JetConnection) command.Connection; + var jetConnection = (JetConnection)command.Connection; var innerCommand = command.InnerCommand; var commandText = innerCommand.CommandText; var innerConnection = command.InnerCommand.Connection; @@ -51,7 +51,7 @@ public static bool TryGetDataReaderFromInformationSchemaCommand(JetCommand comma var conditions = match.Groups["conditions"].Value; var orderColumns = match.Groups["orderColumns"].Value; - Func? schemaMethod = dbObject.ToLower() switch + Func? schemaMethod = dbObject.ToLowerInvariant() switch { "tables" => GetTables, "columns" => GetColumns, @@ -105,7 +105,7 @@ public static bool TryGetDataReaderFromInformationSchemaCommand(JetCommand comma foreach (var row in selectedRows) selectedDataTable.ImportRow(row); - + return selectedDataTable.CreateDataReader(); } @@ -144,7 +144,7 @@ private static DataTable GetRelationColumns(JetConnection connection) using var schemaProvider = SchemaProvider.CreateInstance(connection.SchemaProviderType, connection); return schemaProvider.GetRelationColumns(); } - + private static DataTable GetCheckConstraints(JetConnection connection) { using var schemaProvider = SchemaProvider.CreateInstance(connection.SchemaProviderType, connection); diff --git a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetSchemaOperationsHandling.cs b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetSchemaOperationsHandling.cs index 57bb76a8..c6a7a04c 100644 --- a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetSchemaOperationsHandling.cs +++ b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetSchemaOperationsHandling.cs @@ -22,7 +22,7 @@ public static bool TryDatabaseOperation(JetConnection connection, string command { var oldTableName = match.Groups["OldTableName"].Value; var newTableName = match.Groups["NewTableName"].Value; - + RenameTable(connection, oldTableName, newTableName); return true; @@ -34,9 +34,9 @@ public static bool TryDatabaseOperation(JetConnection connection, string command var tableName = match.Groups["TableName"].Value; var oldColumnName = match.Groups["OldColumnName"].Value; var newColumnName = match.Groups["NewColumnName"].Value; - + RenameColumn(connection, tableName, oldColumnName, newColumnName); - + return true; } @@ -45,7 +45,7 @@ public static bool TryDatabaseOperation(JetConnection connection, string command private static string GetIdentifierPattern(string key) => $@"(?:`(?<{key}>.*?)`|\[(?<{key}>.*?)\]|(?<{key}>\S*))"; - + private static void RenameTable(JetConnection connection, string oldTableName, string newTableName) { using var schemaProvider = SchemaProvider.CreateInstance(connection.SchemaProviderType, connection, false); @@ -58,4 +58,4 @@ private static void RenameColumn(JetConnection connection, string tableName, str schemaProvider.RenameColumn(tableName, oldColumnName, newColumnName); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetStoreDatabaseHandling.cs b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetStoreDatabaseHandling.cs index 0e63024d..ba134f96 100644 --- a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetStoreDatabaseHandling.cs +++ b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/JetStoreDatabaseHandling.cs @@ -1,8 +1,6 @@ using System; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.CompilerServices; using System.Text.RegularExpressions; namespace EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition @@ -92,7 +90,7 @@ public static bool ProcessDatabaseOperation(JetCommand command) // // CREATE DATABASE: // - + match = _regExParseCreateDatabaseCommand.Match(commandText); if (match.Success) { @@ -101,8 +99,8 @@ public static bool ProcessDatabaseOperation(JetCommand command) if (string.IsNullOrWhiteSpace(fileName)) throw new InvalidOperationException("CREATE DATABASE statement is missing database file name."); - - JetConnection.CreateDatabase(fileName, databasePassword: databasePassword, dataAccessProviderType:JetConnection.GetDataAccessProviderType(((JetConnection)command.Connection)?.DataAccessProviderFactory)); + + JetConnection.CreateDatabase(fileName, databasePassword: databasePassword, dataAccessProviderType: JetConnection.GetDataAccessProviderType(((JetConnection)command.Connection)?.DataAccessProviderFactory)); return true; } @@ -110,22 +108,22 @@ public static bool ProcessDatabaseOperation(JetCommand command) if (match.Success) { var fileName = match.Groups["filename"].Value; - + if (string.IsNullOrWhiteSpace(fileName)) throw new InvalidOperationException("CREATE DATABASE statement is missing database file name or connection string."); - + JetConnection.CreateDatabase(fileName, dataAccessProviderType: JetConnection.GetDataAccessProviderType(((JetConnection)command.Connection)?.DataAccessProviderFactory)); return true; } - + match = _regExParseObsoleteCreateDatabaseCommandFromConnection.Match(commandText); if (match.Success) { var connectionString = ExtractFileNameFromConnectionString(match.Groups["connectionString"].Value); - + if (string.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException("CREATE DATABASE statement is missing database file name or connection string."); - + JetConnection.CreateDatabase(connectionString); return true; } @@ -133,7 +131,7 @@ public static bool ProcessDatabaseOperation(JetCommand command) // // DROP DATABASE: // - + match = _regExParseDropDatabaseCommand.Match(commandText); if (match.Success) { @@ -169,8 +167,8 @@ public static bool ProcessDatabaseOperation(JetCommand command) JetConnection.DropDatabase(connectionString); return true; } - - throw new Exception($"\"{commandText}\" is not a valid database command."); + + throw new InvalidOperationException($"\"{commandText}\" is not a valid database command."); } public static string ExtractFileNameFromConnectionString(string? connectionString) @@ -204,17 +202,17 @@ public static bool IsFileName(string? fileName) !fileName.ToCharArray() .Intersect(Path.GetInvalidPathChars()) .Any(); - + public static bool HasProvider(string connectionString) => _regExHasProvider.IsMatch(connectionString); - + public static void DeleteFile(string fileName) { JetConnection.ClearAllPools(); fileName = ExpandFileName(fileName); ValidateDatabaseFileExtension(fileName); - + var directoryPath = Path.GetDirectoryName(fileName) ?? string.Empty; var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); var extension = Path.GetExtension(fileName); @@ -232,7 +230,7 @@ public static void DeleteFile(string fileName) { File.Delete(Path.Combine(directoryPath, fileNameWithoutExtension + ".laccdb")); } - + if (string.IsNullOrEmpty(extension) || string.Equals(extension, ".mdb", StringComparison.OrdinalIgnoreCase) || !string.Equals(extension, ".accdb", StringComparison.OrdinalIgnoreCase)) @@ -257,8 +255,7 @@ private static string UnescapeSingleQuotes(string value) public static string ExpandFileName(string? fileName) { - if (fileName == null) - throw new ArgumentNullException(nameof(fileName)); + ArgumentNullException.ThrowIfNull(fileName); if (fileName.StartsWith("|DataDirectory|", StringComparison.OrdinalIgnoreCase)) { @@ -285,4 +282,4 @@ public static string EnsureFileExtension(string fileName) return fileName; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/SchemaTables.cs b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/SchemaTables.cs index 54138b65..399fb88d 100644 --- a/src/EFCore.Jet.Data/JetStoreSchemaDefinition/SchemaTables.cs +++ b/src/EFCore.Jet.Data/JetStoreSchemaDefinition/SchemaTables.cs @@ -7,7 +7,7 @@ internal static class SchemaTables public static DataTable GetTablesDataTable() { var dataTable = new DataTable("INFORMATION_SCHEMA.TABLES"); - + dataTable.Columns.AddRange( [ new DataColumn("TABLE_NAME", typeof(string)), @@ -15,7 +15,7 @@ public static DataTable GetTablesDataTable() new DataColumn("VALIDATION_RULE", typeof(string)), new DataColumn("VALIDATION_TEXT", typeof(string)) ]); - + return dataTable; } @@ -40,7 +40,7 @@ public static DataTable GetColumnsDataTable() new DataColumn("IDENTITY_INCREMENT", typeof(int)) // TODO: Add ALLOW_ZERO_LENGTH_STRING ]); - + return dataTable; } @@ -56,7 +56,7 @@ public static DataTable GetIndexesDataTable() new DataColumn("IS_NULLABLE", typeof(bool)), new DataColumn("IGNORES_NULLS", typeof(bool)) ]); - + return dataTable; } @@ -72,7 +72,7 @@ public static DataTable GetIndexColumnsDataTable() new DataColumn("COLUMN_NAME", typeof(string)), new DataColumn("IS_DESCENDING", typeof(bool)) ]); - + return dataTable; } @@ -91,10 +91,10 @@ public static DataTable GetRelationsDataTable() new DataColumn("IS_ENFORCED", typeof(bool)), new DataColumn("IS_INHERITED", typeof(bool)) ]); - + return dataTable; } - + public static DataTable GetRelationColumnsDataTable() { var dataTable = new DataTable("INFORMATION_SCHEMA.RELATION_COLUMNS"); @@ -106,10 +106,10 @@ public static DataTable GetRelationColumnsDataTable() new DataColumn("PRINCIPAL_COLUMN_NAME", typeof(string)), new DataColumn("ORDINAL_POSITION", typeof(int)) ]); - + return dataTable; } - + public static DataTable GetCheckConstraintsDataTable() { var dataTable = new DataTable("INFORMATION_SCHEMA.CHECK_CONSTRAINTS"); @@ -120,7 +120,7 @@ public static DataTable GetCheckConstraintsDataTable() new DataColumn("CONSTRAINT_NAME", typeof(string)), new DataColumn("CHECK_CLAUSE", typeof(string)) ]); - + return dataTable; } } diff --git a/src/EFCore.Jet.Data/JetSyntaxHelper.cs b/src/EFCore.Jet.Data/JetSyntaxHelper.cs index 883ae99b..ddadfc4f 100644 --- a/src/EFCore.Jet.Data/JetSyntaxHelper.cs +++ b/src/EFCore.Jet.Data/JetSyntaxHelper.cs @@ -52,7 +52,7 @@ static string ByteArrayToBinaryString(byte[] binaryArray) StringBuilder sb = new(binaryArray.Length * 2); foreach (byte b in binaryArray) - sb.Append(b.ToString("X2")); + sb.Append(b.ToString("X2", CultureInfo.InvariantCulture)); return sb.ToString(); } @@ -101,7 +101,7 @@ internal static string EscapeLikeText(string text, out bool usedEscapeChar) c == '*' || c == '?' || c == '[' || c == '#' ) { - sb.AppendFormat("[{0}]", c); + sb.AppendFormat(CultureInfo.InvariantCulture, "[{0}]", c); usedEscapeChar = true; } else @@ -112,4 +112,4 @@ internal static string EscapeLikeText(string text, out bool usedEscapeChar) } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/JetTransaction.cs b/src/EFCore.Jet.Data/JetTransaction.cs index 81d87765..9c1cf400 100644 --- a/src/EFCore.Jet.Data/JetTransaction.cs +++ b/src/EFCore.Jet.Data/JetTransaction.cs @@ -6,12 +6,12 @@ namespace EntityFrameworkCore.Jet.Data { - internal class JetTransaction : DbTransaction + internal sealed class JetTransaction : DbTransaction { private JetConnection _connection; private bool _disposed; - internal virtual DbTransaction WrappedTransaction { get; } + internal DbTransaction WrappedTransaction { get; } public JetTransaction(JetConnection connection, IsolationLevel isolationLevel) { @@ -22,9 +22,8 @@ public JetTransaction(JetConnection connection, IsolationLevel isolationLevel) public override void Commit() { - if (_disposed) - throw new ObjectDisposedException(nameof(JetTransaction)); - + ObjectDisposedException.ThrowIf(_disposed, this); + LogHelper.ShowCommandHeader("--- Commit"); WrappedTransaction.Commit(); @@ -39,9 +38,8 @@ public override IsolationLevel IsolationLevel public override void Rollback() { - if (_disposed) - throw new ObjectDisposedException(nameof(JetTransaction)); - + ObjectDisposedException.ThrowIf(_disposed, this); + LogHelper.ShowCommandHeader("^^^ Rollback"); WrappedTransaction.Rollback(); @@ -78,25 +76,22 @@ protected override void Dispose(bool disposing) public override Task CommitAsync(CancellationToken cancellationToken = new()) { - if (_disposed) - throw new ObjectDisposedException(nameof(JetTransaction)); - + ObjectDisposedException.ThrowIf(_disposed, this); + return base.CommitAsync(cancellationToken); } public override ValueTask DisposeAsync() { - if (_disposed) - throw new ObjectDisposedException(nameof(JetTransaction)); - + ObjectDisposedException.ThrowIf(_disposed, this); + return base.DisposeAsync(); } public override Task RollbackAsync(CancellationToken cancellationToken = new()) { - if (_disposed) - throw new ObjectDisposedException(nameof(JetTransaction)); - + ObjectDisposedException.ThrowIf(_disposed, this); + return base.RollbackAsync(cancellationToken); } } diff --git a/src/EFCore.Jet.Data/LogHelper.cs b/src/EFCore.Jet.Data/LogHelper.cs index fcf34213..4f1ec331 100644 --- a/src/EFCore.Jet.Data/LogHelper.cs +++ b/src/EFCore.Jet.Data/LogHelper.cs @@ -1,9 +1,8 @@ +using EntityFrameworkCore.Jet.Data; using System; -using System.Data; using System.Data.Common; -using EntityFrameworkCore.Jet.Data; -static internal class LogHelper +internal static class LogHelper { internal static void ShowInfo(string info) diff --git a/src/EFCore.Jet.Data/Messages.cs b/src/EFCore.Jet.Data/Messages.cs index 8a41d694..df8c25c7 100644 --- a/src/EFCore.Jet.Data/Messages.cs +++ b/src/EFCore.Jet.Data/Messages.cs @@ -1,4 +1,3 @@ -using System; using System.Data; namespace EntityFrameworkCore.Jet.Data @@ -24,12 +23,12 @@ public static string CannotCallMethodInThisConnectionState(string methodName, Co { return $"Cannot call method \"{methodName}\" in this connection state. Current connection state is {state}"; } - + public static string CannotCallJetProviderFactoryMethodOnSingletonInstance(string methodName) => $"""The method "{methodName}" can not be called on a singleton instance of JetProviderFactory. This method can only be called on a JetProviderFactory object retrieved from the JetConnnection.JetProviderFactory property."""; - + public static string MethodUnsupportedByJet(string methodName) { return $"\"{methodName}\" is not supported by Jet"; @@ -45,4 +44,4 @@ public static string UnsupportedParallelTransactions() return "JetConnection does not support parallel transactions"; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet.Data/PreciseSchema.cs b/src/EFCore.Jet.Data/PreciseSchema.cs index 8275e8db..fe981226 100644 --- a/src/EFCore.Jet.Data/PreciseSchema.cs +++ b/src/EFCore.Jet.Data/PreciseSchema.cs @@ -19,14 +19,14 @@ public override DataTable GetColumns() // DAO lacks seed and increment values for auto increment columns and needs a costly workaround to get the // numeric scale of decimal columns. // ADOX lacks ordinal position information and has very unreliable nullable information. - + var dataTable = _adoxSchema.Value.GetColumns(); var ordinalPositionsAndNullables = _daoSchema.Value.GetOrdinalPositionsAndNullables(); foreach (DataRow row in dataTable.Rows) { - var tableName = (string) row["TABLE_NAME"]; - var columnName = (string) row["COLUMN_NAME"]; + var tableName = (string)row["TABLE_NAME"]; + var columnName = (string)row["COLUMN_NAME"]; if (ordinalPositionsAndNullables.TryGetValue((tableName, columnName), out var ordinalPositionAndNullable)) { @@ -34,7 +34,7 @@ public override DataTable GetColumns() row["IS_NULLABLE"] = ordinalPositionAndNullable.Nullable; } } - + dataTable.AcceptChanges(); return dataTable; } @@ -73,7 +73,7 @@ public override DataTable GetRelationColumns() public override DataTable GetCheckConstraints() => _adoxSchema.Value.GetCheckConstraints(); // DAO does not support CHECK CONSTRAINTs, but ADOX does - + public override void RenameTable(string oldTableName, string newTableName) => _adoxSchema.Value.RenameTable(oldTableName, newTableName); // either ADOX or DAO is fine @@ -91,6 +91,8 @@ public override void Dispose() { _adoxSchema.Value.Dispose(); } + + GC.SuppressFinalize(this); } } } \ No newline at end of file diff --git a/src/EFCore.Jet.Data/SchemaProvider.cs b/src/EFCore.Jet.Data/SchemaProvider.cs index f07dd6dd..8f8d74e5 100644 --- a/src/EFCore.Jet.Data/SchemaProvider.cs +++ b/src/EFCore.Jet.Data/SchemaProvider.cs @@ -22,7 +22,7 @@ public static SchemaProvider CreateInstance(SchemaProviderType type, JetConnecti }; public abstract void Dispose(); - + public abstract DataTable GetTables(); public abstract DataTable GetColumns(); public abstract DataTable GetIndexes(); diff --git a/src/EFCore.Jet.Data/TypesExtensions.cs b/src/EFCore.Jet.Data/TypesExtensions.cs index f2acc24d..b3ffd306 100644 --- a/src/EFCore.Jet.Data/TypesExtensions.cs +++ b/src/EFCore.Jet.Data/TypesExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Reflection; namespace EntityFrameworkCore.Jet.Data { diff --git a/src/EFCore.Jet.Odbc/EFCore.Jet.Odbc.csproj b/src/EFCore.Jet.Odbc/EFCore.Jet.Odbc.csproj index 89c68236..be3890af 100644 --- a/src/EFCore.Jet.Odbc/EFCore.Jet.Odbc.csproj +++ b/src/EFCore.Jet.Odbc/EFCore.Jet.Odbc.csproj @@ -10,9 +10,7 @@ enable - - - + @@ -33,8 +31,6 @@ - - diff --git a/src/EFCore.Jet.Odbc/Extensions/JetOdbcDbContextOptionsBuilderExtensions.cs b/src/EFCore.Jet.Odbc/Extensions/JetOdbcDbContextOptionsBuilderExtensions.cs index 6aebbdb9..404568a5 100644 --- a/src/EFCore.Jet.Odbc/Extensions/JetOdbcDbContextOptionsBuilderExtensions.cs +++ b/src/EFCore.Jet.Odbc/Extensions/JetOdbcDbContextOptionsBuilderExtensions.cs @@ -1,6 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using EntityFrameworkCore.Jet.Data; using System.Data.Odbc; using EntityFrameworkCore.Jet.Infrastructure; @@ -15,7 +14,7 @@ namespace Microsoft.EntityFrameworkCore public static class JetOdbcDbContextOptionsBuilderExtensions { #region Connection String - + /// /// Configures the context to connect to a Microsoft Jet database using ODBC. /// @@ -48,9 +47,9 @@ public static DbContextOptionsBuilder UseJetOdbc( => optionsBuilder.UseJet(fileNameOrConnectionString, DataAccessProviderType.Odbc, jetOptionsAction); #endregion - + #region Connection - + /// /// Configures the context to connect to a Microsoft Jet database using ODBC. /// @@ -96,7 +95,7 @@ public static DbContextOptionsBuilder UseJetOdbc( return optionsBuilder.UseJet(connection, jetOptionsAction); } - + #endregion } } \ No newline at end of file diff --git a/src/EFCore.Jet.OleDb/EFCore.Jet.OleDb.csproj b/src/EFCore.Jet.OleDb/EFCore.Jet.OleDb.csproj index 567947c4..6bbbde29 100644 --- a/src/EFCore.Jet.OleDb/EFCore.Jet.OleDb.csproj +++ b/src/EFCore.Jet.OleDb/EFCore.Jet.OleDb.csproj @@ -10,9 +10,7 @@ enable - - - + @@ -33,8 +31,6 @@ - - diff --git a/src/EFCore.Jet.OleDb/Extensions/JetDbContextOptionsBuilderExtensions.cs b/src/EFCore.Jet.OleDb/Extensions/JetDbContextOptionsBuilderExtensions.cs index 9f55c410..f07485c1 100644 --- a/src/EFCore.Jet.OleDb/Extensions/JetDbContextOptionsBuilderExtensions.cs +++ b/src/EFCore.Jet.OleDb/Extensions/JetDbContextOptionsBuilderExtensions.cs @@ -1,6 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using EntityFrameworkCore.Jet.Data; using System.Data.OleDb; using EntityFrameworkCore.Jet.Infrastructure; @@ -15,7 +14,7 @@ namespace Microsoft.EntityFrameworkCore public static class JetOleDbDbContextOptionsBuilderExtensions { #region Connection String - + /// /// Configures the context to connect to a Microsoft Jet database using OLE DB. /// @@ -48,9 +47,9 @@ public static DbContextOptionsBuilder UseJetOleDb( => optionsBuilder.UseJet(fileNameOrConnectionString, DataAccessProviderType.OleDb, jetOptionsAction); #endregion - + #region Connection - + /// /// Configures the context to connect to a Microsoft Jet database using OLE DB. /// @@ -96,7 +95,7 @@ public static DbContextOptionsBuilder UseJetOleDb( return optionsBuilder.UseJet(connection, jetOptionsAction); } - + #endregion } } \ No newline at end of file diff --git a/src/EFCore.Jet/EFCore.Jet.csproj b/src/EFCore.Jet/EFCore.Jet.csproj index de983385..e432d4ab 100644 --- a/src/EFCore.Jet/EFCore.Jet.csproj +++ b/src/EFCore.Jet/EFCore.Jet.csproj @@ -17,9 +17,7 @@ - - - + @@ -75,9 +73,6 @@ - - - diff --git a/src/EFCore.Jet/Extensions/JetDatabaseFacadeExtensions.cs b/src/EFCore.Jet/Extensions/JetDatabaseFacadeExtensions.cs index 29bf56c7..ced4dd04 100644 --- a/src/EFCore.Jet/Extensions/JetDatabaseFacadeExtensions.cs +++ b/src/EFCore.Jet/Extensions/JetDatabaseFacadeExtensions.cs @@ -26,4 +26,4 @@ public static class JetDatabaseFacadeExtensions public static bool IsJet(this DatabaseFacade database) => database.ProviderName == typeof(JetOptionsExtension).Assembly.GetName().Name; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Extensions/JetServiceCollectionExtensions.cs b/src/EFCore.Jet/Extensions/JetServiceCollectionExtensions.cs index 34909ea3..bd0a1f4f 100644 --- a/src/EFCore.Jet/Extensions/JetServiceCollectionExtensions.cs +++ b/src/EFCore.Jet/Extensions/JetServiceCollectionExtensions.cs @@ -11,8 +11,8 @@ using EntityFrameworkCore.Jet.Query.Sql.Internal; using EntityFrameworkCore.Jet.Storage.Internal; using EntityFrameworkCore.Jet.Update.Internal; -using EntityFrameworkCore.Jet.ValueGeneration.Internal; using EntityFrameworkCore.Jet.Utilities; +using EntityFrameworkCore.Jet.ValueGeneration.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection @@ -69,4 +69,4 @@ public static IServiceCollection AddEntityFrameworkJet(this IServiceCollection s return serviceCollection; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Infrastructure/Internal/IJetOptions.cs b/src/EFCore.Jet/Infrastructure/Internal/IJetOptions.cs index ecba6e80..a4268b8c 100644 --- a/src/EFCore.Jet/Infrastructure/Internal/IJetOptions.cs +++ b/src/EFCore.Jet/Infrastructure/Internal/IJetOptions.cs @@ -14,4 +14,4 @@ public interface IJetOptions : ISingletonOptions bool UseOuterSelectSkipEmulationViaDataReader { get; } bool UseShortTextForSystemString { get; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Internal/JetOptions.cs b/src/EFCore.Jet/Internal/JetOptions.cs index 2774a966..feb14c31 100644 --- a/src/EFCore.Jet/Internal/JetOptions.cs +++ b/src/EFCore.Jet/Internal/JetOptions.cs @@ -1,6 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Linq; using EntityFrameworkCore.Jet.Data; using EntityFrameworkCore.Jet.Infrastructure.Internal; @@ -155,4 +154,4 @@ private static DataAccessProviderType GetDataAccessProviderTypeFromOptions(JetOp /// public virtual string? ConnectionString { get; private set; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs b/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs index 196a01ca..d1dc0e47 100644 --- a/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs +++ b/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs @@ -1,7 +1,7 @@ -using System.Text; using EntityFrameworkCore.Jet.Infrastructure; using EntityFrameworkCore.Jet.Internal; using EntityFrameworkCore.Jet.Utilities; +using System.Text; namespace EntityFrameworkCore.Jet.Migrations.Internal { diff --git a/src/EFCore.Jet/Migrations/Internal/JetMigrationCommandExecutor.cs b/src/EFCore.Jet/Migrations/Internal/JetMigrationCommandExecutor.cs index 984338e7..fa8268d3 100644 --- a/src/EFCore.Jet/Migrations/Internal/JetMigrationCommandExecutor.cs +++ b/src/EFCore.Jet/Migrations/Internal/JetMigrationCommandExecutor.cs @@ -1,4 +1,3 @@ -using EntityFrameworkCore.Jet.Data; using EntityFrameworkCore.Jet.Data.JetStoreSchemaDefinition; using Microsoft.EntityFrameworkCore.Migrations.Internal; @@ -53,7 +52,7 @@ public override async Task ExecuteNonQueryAsync( return -1; } - List<(IReadOnlyList,bool)> CreateMigrationBatches(IReadOnlyList migrationCommands) + static List<(IReadOnlyList, bool)> CreateMigrationBatches(IReadOnlyList migrationCommands) { //create new batch if JetSchemaOperationsHandling.IsDatabaseOperation is true otherwise had to current batch var migrationBatches = new List<(IReadOnlyList, bool)>(); @@ -64,10 +63,10 @@ public override async Task ExecuteNonQueryAsync( { if (currentBatch.Count != 0) { - migrationBatches.Add((currentBatch,false)); + migrationBatches.Add((currentBatch, false)); currentBatch = []; } - migrationBatches.Add(([migrationCommand],true)); + migrationBatches.Add(([migrationCommand], true)); } else { @@ -76,9 +75,9 @@ public override async Task ExecuteNonQueryAsync( } if (currentBatch.Count != 0) { - migrationBatches.Add((currentBatch,false)); + migrationBatches.Add((currentBatch, false)); } return migrationBatches; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Properties/InternalsVisibleTo.cs b/src/EFCore.Jet/Properties/InternalsVisibleTo.cs index 82472137..045b4a3d 100644 --- a/src/EFCore.Jet/Properties/InternalsVisibleTo.cs +++ b/src/EFCore.Jet/Properties/InternalsVisibleTo.cs @@ -2,4 +2,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("EntityFrameworkCore.Jet.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("EntityFrameworkCore.Jet.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ No newline at end of file diff --git a/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessor.cs b/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessor.cs index e33971e5..4f99e5a3 100644 --- a/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessor.cs +++ b/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessor.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using EntityFrameworkCore.Jet.Utilities; - namespace EntityFrameworkCore.Jet.Query.Internal; /// @@ -56,9 +54,9 @@ public override Expression Process(Expression queryExpression, ParametersCacheDe } /// - protected override Expression ProcessSqlNullability(Expression selectExpression, ParametersCacheDecorator Decorator) + protected override Expression ProcessSqlNullability(Expression queryExpression, ParametersCacheDecorator decorator) { return new JetSqlNullabilityProcessor(Dependencies, Parameters).Process( - selectExpression, Decorator); + queryExpression, decorator); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessorFactory.cs b/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessorFactory.cs index 8fa94194..6a3a8b9c 100644 --- a/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessorFactory.cs +++ b/src/EFCore.Jet/Query/Internal/JetParameterBasedSqlProcessorFactory.cs @@ -31,4 +31,4 @@ public class JetParameterBasedSqlProcessorFactory(RelationalParameterBasedSqlPro /// public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) => new JetParameterBasedSqlProcessor(Dependencies, parameters); -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessor.cs b/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessor.cs index 0b2a1873..226abf09 100644 --- a/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessor.cs +++ b/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessor.cs @@ -1,12 +1,7 @@ -using System.Diagnostics.CodeAnalysis; -using EntityFrameworkCore.Jet.Internal; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; - namespace EntityFrameworkCore.Jet.Query.Internal { public class JetQueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor { - private readonly IRelationalTypeMappingSource _relationalTypeMappingSource; private readonly JetLiftOrderByPostprocessor _liftOrderByPostprocessor; private readonly JetSkipTakePostprocessor _skipTakePostprocessor; @@ -17,7 +12,6 @@ public JetQueryTranslationPostprocessor( IRelationalTypeMappingSource relationalTypeMappingSource) : base(dependencies, relationalDependencies, queryCompilationContext) { - _relationalTypeMappingSource = relationalTypeMappingSource; _liftOrderByPostprocessor = new JetLiftOrderByPostprocessor(relationalTypeMappingSource, relationalDependencies.SqlExpressionFactory, queryCompilationContext.SqlAliasManager); _skipTakePostprocessor = new JetSkipTakePostprocessor(relationalTypeMappingSource, relationalDependencies.SqlExpressionFactory, ((RelationalQueryCompilationContext)QueryCompilationContext).QuerySplittingBehavior); @@ -34,4 +28,4 @@ public override Expression Process(Expression query) return query; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessorFactory.cs b/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessorFactory.cs index 6a7b5d8d..55ab8565 100644 --- a/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessorFactory.cs +++ b/src/EFCore.Jet/Query/Internal/JetQueryTranslationPostprocessorFactory.cs @@ -26,4 +26,4 @@ public virtual QueryTranslationPostprocessor Create(QueryCompilationContext quer (RelationalQueryCompilationContext)queryCompilationContext, relationalTypeMappingSource); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Scaffolding/Internal/JetCodeGenerator.cs b/src/EFCore.Jet/Scaffolding/Internal/JetCodeGenerator.cs index 81df76fc..9eb51f1e 100644 --- a/src/EFCore.Jet/Scaffolding/Internal/JetCodeGenerator.cs +++ b/src/EFCore.Jet/Scaffolding/Internal/JetCodeGenerator.cs @@ -35,4 +35,4 @@ public override MethodCallCodeFragment GenerateUseProvider( ? [connectionString] : [connectionString, new NestedClosureCodeFragment("x", providerOptions)]); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Scaffolding/Internal/JetDataReaderExtension.cs b/src/EFCore.Jet/Scaffolding/Internal/JetDataReaderExtension.cs index debe8f5d..9a626825 100644 --- a/src/EFCore.Jet/Scaffolding/Internal/JetDataReaderExtension.cs +++ b/src/EFCore.Jet/Scaffolding/Internal/JetDataReaderExtension.cs @@ -20,4 +20,4 @@ public static class JetDataReaderExtension : reader.GetFieldValue(idx); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Scaffolding/Internal/JetDatabaseModelFactory.cs b/src/EFCore.Jet/Scaffolding/Internal/JetDatabaseModelFactory.cs index 8c4770ca..12d69bbc 100644 --- a/src/EFCore.Jet/Scaffolding/Internal/JetDatabaseModelFactory.cs +++ b/src/EFCore.Jet/Scaffolding/Internal/JetDatabaseModelFactory.cs @@ -1,14 +1,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Data; using EntityFrameworkCore.Jet.Data; -using System.Globalization; -using System.Text.RegularExpressions; using EntityFrameworkCore.Jet.Infrastructure; using EntityFrameworkCore.Jet.Internal; using EntityFrameworkCore.Jet.Metadata.Internal; -using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using EntityFrameworkCore.Jet.Utilities; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using System.Data; +using System.Globalization; +using System.Text.RegularExpressions; namespace EntityFrameworkCore.Jet.Scaffolding.Internal { @@ -18,18 +18,6 @@ namespace EntityFrameworkCore.Jet.Scaffolding.Internal /// public class JetDatabaseModelFactory : DatabaseModelFactory { - private static string ObjectKey(string name) - => "`" + name + "`"; - - private static string TableKey(DatabaseTable table) - => TableKey(table.Name); - - private static string TableKey(String tableName) - => ObjectKey(tableName); - - private static string ColumnKey(DatabaseTable table, string columnName) - => TableKey(table) + "." + ObjectKey(columnName); - private static readonly List _tablePatterns = [ @"(?<=^`).*(?=`$)", @@ -124,15 +112,15 @@ public override DatabaseModel Create( var tableNames = databaseModel.Tables.Select(t => t.Name).ToList(); if (tableNames.Contains("MSysAccessStorage") || _msysNames.Contains("MSysAccessStorage")) { - JetDualTable.DetectedName ="MSysAccessStorage"; + JetDualTable.DetectedName = "MSysAccessStorage"; } else if (tableNames.Contains("MSysRelationships") || _msysNames.Contains("MSysRelationships")) { - JetDualTable.DetectedName ="MSysRelationships"; + JetDualTable.DetectedName = "MSysRelationships"; } else if (tableNames.Contains("#Dual")) { - JetDualTable.DetectedName ="#Dual"; + JetDualTable.DetectedName = "#Dual"; } return databaseModel; @@ -160,7 +148,7 @@ private static string Parse(string tableName) return tableName; } - private IReadOnlyList GetTables( + private List GetTables( DbConnection connection, DatabaseModel databaseModel, Func? filter) @@ -217,7 +205,7 @@ private void GetColumns(DbConnection connection, IReadOnlyList ta while (reader.Read()) { var tableName = reader.GetValueOrDefault("TABLE_NAME"); - var table = tables.FirstOrDefault(t => string.Equals(t.Name, tableName)) ?? + var table = tables.FirstOrDefault(t => string.Equals(t.Name, tableName, StringComparison.Ordinal)) ?? tables.FirstOrDefault(t => string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)); if (table != null) { @@ -316,7 +304,7 @@ private void GetColumns(DbConnection connection, IReadOnlyList ta { try { - return Convert.ChangeType(defaultValueSql, type); + return Convert.ChangeType(defaultValueSql, type, CultureInfo.InvariantCulture); } catch { @@ -389,7 +377,7 @@ private void GetColumns(DbConnection connection, IReadOnlyList ta return null; } - private string GetStoreType(string dataTypeName, int precision, int scale, int maxLength) + private static string GetStoreType(string dataTypeName, int precision, int scale, int maxLength) { if (precision > 0 && (string.Equals(dataTypeName, "decimal", StringComparison.OrdinalIgnoreCase) || @@ -437,7 +425,7 @@ private void GetIndexes(DbConnection connection, IReadOnlyList ta var nullable = indexRow.GetValueOrDefault("IS_NULLABLE"); var ignoresNulls = indexRow.GetValueOrDefault("IGNORES_NULLS"); - var table = tables.FirstOrDefault(t => string.Equals(t.Name, tableName)) ?? + var table = tables.FirstOrDefault(t => string.Equals(t.Name, tableName, StringComparison.Ordinal)) ?? tables.FirstOrDefault(t => string.Equals(t.Name, tableName, StringComparison.OrdinalIgnoreCase)); if (table != null) { @@ -580,7 +568,7 @@ private void GetRelations(DbConnection connection, IReadOnlyList var enforced = relationRow.GetValueOrDefault("IS_ENFORCED", true); var inherited = relationRow.GetValueOrDefault("IS_INHERITED", true); - var referencingTable = tables.FirstOrDefault(t => string.Equals(t.Name, referencingTableName)) ?? + var referencingTable = tables.FirstOrDefault(t => string.Equals(t.Name, referencingTableName, StringComparison.Ordinal)) ?? tables.FirstOrDefault(t => string.Equals(t.Name, referencingTableName, StringComparison.OrdinalIgnoreCase)); if (referencingTable != null) { @@ -593,7 +581,7 @@ private void GetRelations(DbConnection connection, IReadOnlyList principalTableName!, onDelete!); - var principalTable = tables.FirstOrDefault(t => string.Equals(t.Name, principalTableName)) ?? + var principalTable = tables.FirstOrDefault(t => string.Equals(t.Name, principalTableName, StringComparison.Ordinal)) ?? tables.FirstOrDefault(t => string.Equals(t.Name, principalTableName, StringComparison.OrdinalIgnoreCase)); if (principalTable == null) { diff --git a/src/EFCore.Jet/Storage/Internal/IJetRelationalConnection.cs b/src/EFCore.Jet/Storage/Internal/IJetRelationalConnection.cs index 42a3a896..ec9a596e 100644 --- a/src/EFCore.Jet/Storage/Internal/IJetRelationalConnection.cs +++ b/src/EFCore.Jet/Storage/Internal/IJetRelationalConnection.cs @@ -10,4 +10,4 @@ public interface IJetRelationalConnection : IRelationalConnection { IJetRelationalConnection CreateEmptyConnection(); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetBoolTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetBoolTypeMapping.cs index dfcfa2f9..cc9319d2 100644 --- a/src/EFCore.Jet/Storage/Internal/JetBoolTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetBoolTypeMapping.cs @@ -4,7 +4,7 @@ namespace EntityFrameworkCore.Jet.Storage.Internal { public class JetBoolTypeMapping : BoolTypeMapping { - public static new JetBoolTypeMapping Default { get; } = new("smallint"); + public static new JetBoolTypeMapping Default { get; } = new("smallint"); public JetBoolTypeMapping( string storeType, diff --git a/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs index d297b76f..7c47a859 100644 --- a/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs @@ -1,8 +1,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.EntityFrameworkCore.Storage.Json; using System.Globalization; using System.Text; -using Microsoft.EntityFrameworkCore.Storage.Json; namespace EntityFrameworkCore.Jet.Storage.Internal { @@ -108,4 +108,4 @@ protected override string ProcessStoreType(RelationalTypeMappingParameters param return base.ProcessStoreType(parameters, storeType.Replace("(max)", ""), storeTypeNameBase); } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetByteTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetByteTypeMapping.cs index 571370d6..11751bfe 100644 --- a/src/EFCore.Jet/Storage/Internal/JetByteTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetByteTypeMapping.cs @@ -57,4 +57,4 @@ protected override string GenerateNonNullSqlLiteral(object value) { return $"CBYTE({base.GenerateNonNullSqlLiteral(value)})"; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetDatabaseCreator.cs b/src/EFCore.Jet/Storage/Internal/JetDatabaseCreator.cs index 5527cd3c..50c0074d 100644 --- a/src/EFCore.Jet/Storage/Internal/JetDatabaseCreator.cs +++ b/src/EFCore.Jet/Storage/Internal/JetDatabaseCreator.cs @@ -85,13 +85,13 @@ private IReadOnlyList CreateCreateOperations() // Alternative: // var dataSource = _relationalConnection.DbConnection.DataSource; - var connection = (JetConnection) relationalConnection.DbConnection; + var connection = (JetConnection)relationalConnection.DbConnection; var fileNameOrConnectionString = connection.ConnectionString; var connectionString = JetConnection.GetConnectionString(fileNameOrConnectionString, connection.DataAccessProviderFactory); var csb = (connection.JetFactory?.CreateConnectionStringBuilder()) ?? throw new InvalidOperationException("Failed to create connection string builder."); csb.ConnectionString = connectionString; - + var dataSource = csb.GetDataSource(); var databasePassword = csb.GetDatabasePassword(); @@ -150,6 +150,6 @@ private static void ClearAllPools() // Clear connection pool for the database connection since after the 'create database' call, a previously // invalid connection may now be valid. private void ClearPool() - => JetConnection.ClearPool((JetConnection) relationalConnection.DbConnection); + => JetConnection.ClearPool((JetConnection)relationalConnection.DbConnection); } } \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetDateOnlyTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetDateOnlyTypeMapping.cs index 5a82d699..8ec8c92c 100644 --- a/src/EFCore.Jet/Storage/Internal/JetDateOnlyTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetDateOnlyTypeMapping.cs @@ -68,7 +68,7 @@ protected virtual DateTime ConvertToDateTimeCompatibleValue(object value) private static DateTime CheckDateTimeValue(DateTime dateTime) { - if (dateTime != default && dateTime < new DateTime(100,1,1)) + if (dateTime != default && dateTime < new DateTime(100, 1, 1)) { throw new InvalidOperationException($"The {nameof(DateTime)} value '{dateTime}' is smaller than the minimum supported value of '{new DateTime(100, 1, 1)}'."); } diff --git a/src/EFCore.Jet/Storage/Internal/JetDateTimeOffsetTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetDateTimeOffsetTypeMapping.cs index 5149a1d5..c4e3639f 100644 --- a/src/EFCore.Jet/Storage/Internal/JetDateTimeOffsetTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetDateTimeOffsetTypeMapping.cs @@ -1,8 +1,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using EntityFrameworkCore.Jet.Infrastructure.Internal; -using Microsoft.Extensions.Options; using System.Globalization; +using System.Text; namespace EntityFrameworkCore.Jet.Storage.Internal { @@ -10,6 +9,7 @@ public class JetDateTimeOffsetTypeMapping : DateTimeOffsetTypeMapping { private const string DateTimeOffsetFormatConst = @"'{0:yyyy-MM-ddTHH:mm:ss.fffffffzzz}'"; private const string DateTimeFormatConst = @"'{0:yyyy-MM-dd HH:mm:ss}'"; + private static readonly CompositeFormat DateTimeFormat = CompositeFormat.Parse(DateTimeFormatConst); public static new JetDateTimeOffsetTypeMapping Default { get; } = new JetDateTimeOffsetTypeMapping("datetime"); public JetDateTimeOffsetTypeMapping( @@ -46,7 +46,7 @@ protected override string GenerateNonNullSqlLiteral(object value) { if (value is not DateTimeOffset offset) return base.GenerateNonNullSqlLiteral(value); var dateTime = offset.Ticks == 0 ? DateTime.FromOADate(0) : offset.UtcDateTime; - return $"CDATE({string.Format(CultureInfo.InvariantCulture, DateTimeFormatConst, dateTime)})"; + return $"CDATE({string.Format(CultureInfo.InvariantCulture, DateTimeFormat, dateTime)})"; } } } \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetDateTimeTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetDateTimeTypeMapping.cs index b711bec3..72c5162c 100644 --- a/src/EFCore.Jet/Storage/Internal/JetDateTimeTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetDateTimeTypeMapping.cs @@ -3,7 +3,6 @@ using System.Data; using System.Globalization; using System.Text; -using EntityFrameworkCore.Jet.Infrastructure.Internal; namespace EntityFrameworkCore.Jet.Storage.Internal { diff --git a/src/EFCore.Jet/Storage/Internal/JetDecimalTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetDecimalTypeMapping.cs index 16840736..894ed26e 100644 --- a/src/EFCore.Jet/Storage/Internal/JetDecimalTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetDecimalTypeMapping.cs @@ -1,5 +1,5 @@ -using System.Data; using Microsoft.EntityFrameworkCore.Storage.Json; +using System.Data; namespace EntityFrameworkCore.Jet.Storage.Internal { diff --git a/src/EFCore.Jet/Storage/Internal/JetDoubleTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetDoubleTypeMapping.cs index 3b0b1243..981231fb 100644 --- a/src/EFCore.Jet/Storage/Internal/JetDoubleTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetDoubleTypeMapping.cs @@ -38,7 +38,7 @@ protected override string GenerateNonNullSqlLiteral(object value) // double.MinValue renders as -1.7976931348623157E+308, which ACE accepts. // ("R" was unreliable on .NET Framework — hence the old G17 advice — but has been // shortest-round-trippable since .NET Core 3.0.) - var doubleValue = Convert.ToDouble(value); + var doubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); var literal = doubleValue.ToString("R", CultureInfo.InvariantCulture); return !literal.Contains('E') @@ -50,4 +50,4 @@ protected override string GenerateNonNullSqlLiteral(object value) : literal; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetExecutionStrategy.cs b/src/EFCore.Jet/Storage/Internal/JetExecutionStrategy.cs index 78afb0f3..57ff4f26 100644 --- a/src/EFCore.Jet/Storage/Internal/JetExecutionStrategy.cs +++ b/src/EFCore.Jet/Storage/Internal/JetExecutionStrategy.cs @@ -71,4 +71,4 @@ public virtual async Task ExecuteAsync( } } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetExecutionStrategyFactory.cs b/src/EFCore.Jet/Storage/Internal/JetExecutionStrategyFactory.cs index 84f6925c..86202501 100644 --- a/src/EFCore.Jet/Storage/Internal/JetExecutionStrategyFactory.cs +++ b/src/EFCore.Jet/Storage/Internal/JetExecutionStrategyFactory.cs @@ -35,4 +35,4 @@ public class JetExecutionStrategyFactory( protected override IExecutionStrategy CreateDefaultStrategy(ExecutionStrategyDependencies dependencies) => new JetExecutionStrategy(dependencies); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetFloatTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetFloatTypeMapping.cs index 28d98204..f727a45d 100644 --- a/src/EFCore.Jet/Storage/Internal/JetFloatTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetFloatTypeMapping.cs @@ -42,6 +42,6 @@ protected override string GenerateNonNullSqlLiteral(object value) // G15, G7 rounds the extremes *down*, so it never yields an out-of-range literal.) // - "R" is the shortest string that parses back exactly: 0.1f/85.55f stay clean, and // float.MaxValue renders as 3.4028235E+38. - => Convert.ToSingle(value).ToString("R", CultureInfo.InvariantCulture); + => Convert.ToSingle(value, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture); } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetIntTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetIntTypeMapping.cs index 5e61d6e9..7fc5627e 100644 --- a/src/EFCore.Jet/Storage/Internal/JetIntTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetIntTypeMapping.cs @@ -8,7 +8,7 @@ public JetIntTypeMapping(string storeType) : base(storeType, System.Data.DbType.Int32) { } - + protected JetIntTypeMapping(RelationalTypeMappingParameters parameters) : base(parameters) { diff --git a/src/EFCore.Jet/Storage/Internal/JetJsonTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetJsonTypeMapping.cs index 2b054369..ff7ac2fb 100644 --- a/src/EFCore.Jet/Storage/Internal/JetJsonTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetJsonTypeMapping.cs @@ -115,4 +115,4 @@ protected override string GenerateNonNullSqlLiteral(object value) /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new JetJsonTypeMapping(parameters); -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetOdbcGuidTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetOdbcGuidTypeMapping.cs index 73867155..b3986da0 100644 --- a/src/EFCore.Jet/Storage/Internal/JetOdbcGuidTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetOdbcGuidTypeMapping.cs @@ -1,5 +1,3 @@ -using System.Data; - namespace EntityFrameworkCore.Jet.Storage.Internal { public class JetOdbcGuidTypeMapping : JetGuidTypeMapping diff --git a/src/EFCore.Jet/Storage/Internal/JetShortTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetShortTypeMapping.cs index 974af4c5..0237a2d4 100644 --- a/src/EFCore.Jet/Storage/Internal/JetShortTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetShortTypeMapping.cs @@ -56,4 +56,4 @@ protected override string GenerateNonNullSqlLiteral(object value) { return $"CINT({base.GenerateNonNullSqlLiteral(value)})"; } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetStringTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetStringTypeMapping.cs index 8e0be35f..bb8162fb 100644 --- a/src/EFCore.Jet/Storage/Internal/JetStringTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetStringTypeMapping.cs @@ -1,7 +1,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Data; using Microsoft.EntityFrameworkCore.Storage.Json; +using System.Data; namespace EntityFrameworkCore.Jet.Storage.Internal { @@ -24,7 +24,7 @@ public class JetStringTypeMapping : StringTypeMapping /// public JetStringTypeMapping( string? storeType = null, - bool unicode = true , + bool unicode = true, int? size = null, bool fixedLength = false, StoreTypePostfix? storeTypePostfix = null, @@ -32,7 +32,7 @@ public JetStringTypeMapping( bool useKeyComparison = false) : this( new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(typeof(string), comparer: useKeyComparison ? CaseInsensitiveValueComparer : null, keyComparer:useKeyComparison ? CaseInsensitiveValueComparer : null, jsonValueReaderWriter: JsonStringReaderWriter.Instance), + new CoreTypeMappingParameters(typeof(string), comparer: useKeyComparison ? CaseInsensitiveValueComparer : null, keyComparer: useKeyComparison ? CaseInsensitiveValueComparer : null, jsonValueReaderWriter: JsonStringReaderWriter.Instance), storeType ?? GetStoreName(fixedLength), storeTypePostfix ?? StoreTypePostfix.Size, (fixedLength diff --git a/src/EFCore.Jet/Storage/Internal/JetTimeOnlyTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetTimeOnlyTypeMapping.cs index 2bbd61af..a5b4a0c4 100644 --- a/src/EFCore.Jet/Storage/Internal/JetTimeOnlyTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetTimeOnlyTypeMapping.cs @@ -1,7 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using EntityFrameworkCore.Jet.Infrastructure.Internal; - namespace EntityFrameworkCore.Jet.Storage.Internal { public class JetTimeOnlyTypeMapping : TimeOnlyTypeMapping diff --git a/src/EFCore.Jet/Storage/Internal/JetTransactionFactory.cs b/src/EFCore.Jet/Storage/Internal/JetTransactionFactory.cs index 3cb68131..26c16173 100644 --- a/src/EFCore.Jet/Storage/Internal/JetTransactionFactory.cs +++ b/src/EFCore.Jet/Storage/Internal/JetTransactionFactory.cs @@ -18,6 +18,6 @@ public virtual RelationalTransaction Create( Guid transactionId, IDiagnosticsLogger logger, bool transactionOwned) - => new JetTransaction(connection, transaction, transactionId, logger, transactionOwned,Dependencies.SqlGenerationHelper); + => new JetTransaction(connection, transaction, transactionId, logger, transactionOwned, Dependencies.SqlGenerationHelper); } } \ No newline at end of file diff --git a/src/EFCore.Jet/Storage/Internal/JetTransientExceptionDetector.cs b/src/EFCore.Jet/Storage/Internal/JetTransientExceptionDetector.cs index 81c2517b..84b7a971 100644 --- a/src/EFCore.Jet/Storage/Internal/JetTransientExceptionDetector.cs +++ b/src/EFCore.Jet/Storage/Internal/JetTransientExceptionDetector.cs @@ -14,7 +14,7 @@ public static bool ShouldRetryOn(Exception ex) DataAccessProviderType dataAccessProviderType; var exceptionFullName = ex.GetType().FullName; - + if (exceptionFullName == "System.Data.OleDb.OleDbException") dataAccessProviderType = DataAccessProviderType.OleDb; else if (exceptionFullName == "System.Data.Odbc.OdbcException") diff --git a/src/EFCore.Jet/Storage/Internal/JetTypeMappingSource.cs b/src/EFCore.Jet/Storage/Internal/JetTypeMappingSource.cs index dc82dc71..667ce8f2 100644 --- a/src/EFCore.Jet/Storage/Internal/JetTypeMappingSource.cs +++ b/src/EFCore.Jet/Storage/Internal/JetTypeMappingSource.cs @@ -1,11 +1,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using EntityFrameworkCore.Jet.Data; -using System.Collections; -using System.Data; -using System.Text.Json; using EntityFrameworkCore.Jet.Infrastructure.Internal; using EntityFrameworkCore.Jet.Internal; +using System.Collections; +using System.Data; namespace EntityFrameworkCore.Jet.Storage.Internal { @@ -17,7 +16,6 @@ public class JetTypeMappingSource : RelationalTypeMappingSource { private readonly JetByteArrayTypeMapping _fixedLengthBinary = new("binary"); private readonly JetByteArrayTypeMapping _variableLengthBinary = new("varbinary"); - private readonly JetByteArrayTypeMapping _variableLengthMaxBinary = new("varbinary", storeTypePostfix: StoreTypePostfix.None); private readonly JetByteArrayTypeMapping _unboundedBinary = new("longbinary", storeTypePostfix: StoreTypePostfix.None); private readonly JetBoolTypeMapping _bit = new("bit"); // JET bits are not nullable @@ -37,7 +35,7 @@ public class JetTypeMappingSource : RelationalTypeMappingSource private readonly JetDecimalTypeMapping _decimal = new("decimal(18,2)", DbType.Decimal, precision: 18, scale: 2, StoreTypePostfix.PrecisionAndScale); private readonly JetDecimalTypeMapping _decimal18_0 = new("decimal", DbType.Decimal, precision: 18, scale: 0); - private readonly JetDecimalTypeMapping _currency = new("currency", DbType.Currency, storeTypePostfix:StoreTypePostfix.None); + private readonly JetDecimalTypeMapping _currency = new("currency", DbType.Currency, storeTypePostfix: StoreTypePostfix.None); private readonly JetDateTimeTypeMapping _datetime; private readonly JetDateTimeTypeMapping _dateasdatetime; @@ -48,7 +46,6 @@ public class JetTypeMappingSource : RelationalTypeMappingSource private readonly JetStringTypeMapping _fixedLengthUnicodeString = new("char", unicode: true); private readonly JetStringTypeMapping _variableLengthUnicodeString = new("varchar", unicode: true); - private readonly JetStringTypeMapping _variableLengthMaxUnicodeString = new("varchar", unicode: true, size: 255, storeTypePostfix: StoreTypePostfix.Size); private readonly JetStringTypeMapping _unboundedUnicodeString = new("longchar", unicode: true, storeTypePostfix: StoreTypePostfix.None); private readonly JetGuidTypeMapping _guid; private readonly JetByteArrayTypeMapping _rowversion = new("varbinary", size: 8, @@ -92,7 +89,7 @@ public JetTypeMappingSource( _dateonly = JetDateOnlyTypeMapping.Default; _timeonly = JetTimeOnlyTypeMapping.Default; _timespan = JetTimeSpanTypeMapping.Default; - + _storeTypeMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -204,10 +201,10 @@ public JetTypeMappingSource( // This is because we don't try to make a new type from this string and any max length value // specified in the model, which means use of these strings is almost certainly an error, and // if it is not an error, then using, for example, varbinary(1) will work instead. - _disallowedMappings - = new HashSet(StringComparer.OrdinalIgnoreCase) - { - }; + // IDE0028's only fix here is a collection expression, which would drop the comparer. +#pragma warning disable IDE0028 + _disallowedMappings = new HashSet(StringComparer.OrdinalIgnoreCase); +#pragma warning restore IDE0028 _options = options; } @@ -439,4 +436,4 @@ protected override void ValidateMapping(CoreTypeMapping? mapping, IProperty? pro return parsedName; } } -} +} \ No newline at end of file diff --git a/src/EFCore.Jet/Utilities/EnumerableExtensions.cs b/src/EFCore.Jet/Utilities/EnumerableExtensions.cs index f418faf4..51d8424e 100644 --- a/src/EFCore.Jet/Utilities/EnumerableExtensions.cs +++ b/src/EFCore.Jet/Utilities/EnumerableExtensions.cs @@ -27,4 +27,4 @@ public int GetHashCode(T obj) => 0; // force Equals } } -} +} \ No newline at end of file diff --git a/src/LibRed/Directory.Build.props b/src/LibRed/Directory.Build.props index 24a641d2..8e63c583 100644 --- a/src/LibRed/Directory.Build.props +++ b/src/LibRed/Directory.Build.props @@ -12,7 +12,10 @@ enable enable - false + + true $(NoWarn);CS1591 diff --git a/src/LibRed/LibRed.Ado/LibRedCommand.cs b/src/LibRed/LibRed.Ado/LibRedCommand.cs index 5b0e9517..77281202 100644 --- a/src/LibRed/LibRed.Ado/LibRedCommand.cs +++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs @@ -7,7 +7,7 @@ namespace LibRed.Data; /// ADO.NET command that runs SQL through the LibRed engine. public sealed class LibRedCommand : DbCommand { - private readonly LibRedParameterCollection _parameters = new(); + private readonly LibRedParameterCollection _parameters = []; private string _commandText = string.Empty; @@ -220,7 +220,7 @@ private void ValidateTransaction() /// Snapshots the command's parameters as a name→value map for the engine, clipping each to its /// declared size (see ) and translating to /// a SQL null. - private IReadOnlyDictionary BuildParameters() + private Dictionary BuildParameters() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (LibRedParameter parameter in _parameters.Cast()) @@ -265,4 +265,4 @@ private void ValidateTransaction() private static TimeSpan Milliseconds(TimeSpan t) => TimeSpan.FromTicks(t.Ticks - t.Ticks % TimeSpan.TicksPerMillisecond); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs index 15d31cf1..603c408d 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnection.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnection.cs @@ -1,7 +1,7 @@ +using LibRed.Engine; using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; -using LibRed.Engine; namespace LibRed.Data; @@ -79,7 +79,7 @@ internal void ReconcileSqlTransactionControl() internal LibRed.IO.Savepoint CreateSavepoint() => (_database ?? throw new InvalidOperationException("The connection is not open.")).CreateSavepoint(); - /// Rolls the active transaction back to a savepoint (called by ). + /// Rolls the active transaction back to a savepoint (called by ). internal void RollbackToSavepoint(LibRed.IO.Savepoint savepoint) => _database?.RollbackToSavepoint(savepoint); /// Releases a savepoint in the active transaction (called by ). @@ -125,6 +125,7 @@ public static string GetConnectionString(string fileNameOrConnectionString) /// Produces an .accdb that LibRed reads and writes fully; the remaining Access-compatibility /// system tables are still being filled in. /// + /// Names the file to create, in the same form a connection uses. /// The database's default text collating order, written to page 0 and inherited /// by every column created in it. Defaults to General-Legacy (the order the engine writes); pass /// for the "General" order Access 2010+ offers. @@ -313,7 +314,9 @@ private static string ParseDataSource(string connectionString) } private static string? TryGetString(DbConnectionStringBuilder builder, string key) => - builder.TryGetValue(key, out object? value) ? Convert.ToString(value) : null; + builder.TryGetValue(key, out object? value) + ? Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) + : null; /// /// Resolves to a full path and defaults to a ".accdb" extension - matches EFCore.Jet.Data's @@ -329,4 +332,4 @@ private static string ExpandPath(string path) var fullPath = Path.GetFullPath(path); return string.IsNullOrEmpty(Path.GetExtension(fullPath)) ? fullPath + ".accdb" : fullPath; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedConnectionStringBuilder.cs b/src/LibRed/LibRed.Ado/LibRedConnectionStringBuilder.cs index c4649bda..ee20cd8d 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnectionStringBuilder.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnectionStringBuilder.cs @@ -9,7 +9,11 @@ namespace LibRed.Data; /// DataAccessProviderType/ODBC/OLE DB and throw for any other builder type), this reads /// and writes the "Data Source" key directly - no provider-type gate to fall into. /// +// CA1010 asks for ICollection; the non-generic ICollection comes from DbConnectionStringBuilder, which +// every ADO.NET provider inherits as-is. +#pragma warning disable CA1010 public class LibRedConnectionStringBuilder : DbConnectionStringBuilder +#pragma warning restore CA1010 { /// /// Reads "Data Source", "DataSource", or "DBQ" (whichever is present - matches @@ -25,4 +29,4 @@ public string? DataSource : null; set => this["Data Source"] = value; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedDataReader.cs b/src/LibRed/LibRed.Ado/LibRedDataReader.cs index 74cce129..c5a4a07f 100644 --- a/src/LibRed/LibRed.Ado/LibRedDataReader.cs +++ b/src/LibRed/LibRed.Ado/LibRedDataReader.cs @@ -1,13 +1,18 @@ +using LibRed.Engine.Execution; using System.Collections; using System.Collections.ObjectModel; using System.Data; using System.Data.Common; -using LibRed.Engine.Execution; +using System.Globalization; namespace LibRed.Data; /// Forward-only reader projecting an engine as ADO.NET rows. +// CA1010 asks for IEnumerable; the non-generic IEnumerable comes from DbDataReader, which every ADO.NET +// provider inherits as-is, and a generic enumerator over a forward-only reader has no meaning. +#pragma warning disable CA1010 public sealed class LibRedDataReader : DbDataReader, IDbColumnSchemaGenerator +#pragma warning restore CA1010 { private readonly ResultSet _result; private readonly IEnumerator _rows; @@ -19,6 +24,7 @@ public sealed class LibRedDataReader : DbDataReader, IDbColumnSchemaGenerator private bool _hadRows; private bool _closed; + /// The rows and column metadata the engine produced. /// Rows affected for a DML command; -1 for a query (ADO convention). /// /// The behavior the command was executed with. Two of its flags reach the reader: SingleRow caps the @@ -78,7 +84,11 @@ public override int GetOrdinal(string name) for (int i = 0; i < _result.ColumnNames.Count; i++) if (string.Equals(_result.ColumnNames[i], name, StringComparison.OrdinalIgnoreCase)) return i; + // CA2201 objects to IndexOutOfRangeException, but it is what DbDataReader.GetOrdinal is documented to + // throw for a name that is not a column, and callers (EF among them) catch exactly that. +#pragma warning disable CA2201 throw new IndexOutOfRangeException(name); +#pragma warning restore CA2201 } public override object GetValue(int ordinal) => _current[ordinal] ?? DBNull.Value; @@ -193,7 +203,7 @@ public override DataTable GetSchemaTable() public override bool GetBoolean(int ordinal) { var value = GetValue(ordinal); - if (value is short) return Convert.ToBoolean(value); + if (value is short) return Convert.ToBoolean(value, CultureInfo.InvariantCulture); return (bool)value; } public override byte GetByte(int ordinal) => (byte)GetValue(ordinal); @@ -216,7 +226,7 @@ public override long GetInt64(int ordinal) try { - return Convert.ToInt64(result); + return Convert.ToInt64(result, CultureInfo.InvariantCulture); } catch (Exception) { @@ -281,4 +291,4 @@ protected override void Dispose(bool disposing) if (disposing) Close(); base.Dispose(disposing); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedDbColumn.cs b/src/LibRed/LibRed.Ado/LibRedDbColumn.cs index d24be241..ff5c4784 100644 --- a/src/LibRed/LibRed.Ado/LibRedDbColumn.cs +++ b/src/LibRed/LibRed.Ado/LibRedDbColumn.cs @@ -1,5 +1,5 @@ -using System.Data.Common; using LibRed.Engine.Execution; +using System.Data.Common; namespace LibRed.Data; @@ -46,4 +46,4 @@ internal LibRedDbColumn(ResultColumn column, int ordinal) public override object? this[string property] => property == nameof(ProviderType) ? ProviderType : base[property]; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedException.cs b/src/LibRed/LibRed.Ado/LibRedException.cs index c12d4f3b..1efecfe4 100644 --- a/src/LibRed/LibRed.Ado/LibRedException.cs +++ b/src/LibRed/LibRed.Ado/LibRedException.cs @@ -42,4 +42,4 @@ public LibRedException(string message, int number, Exception? innerException) /// branch. /// public const int ObjectAlreadyExists = 2714; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedFactory.cs b/src/LibRed/LibRed.Ado/LibRedFactory.cs index 5a8cfa7b..3d610be9 100644 --- a/src/LibRed/LibRed.Ado/LibRedFactory.cs +++ b/src/LibRed/LibRed.Ado/LibRedFactory.cs @@ -24,4 +24,4 @@ private LibRedFactory() { } /// rather than sharing that generic extension surface. /// public override DbConnectionStringBuilder CreateConnectionStringBuilder() => new LibRedConnectionStringBuilder(); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedParameter.cs b/src/LibRed/LibRed.Ado/LibRedParameter.cs index ec702127..80ce2b5d 100644 --- a/src/LibRed/LibRed.Ado/LibRedParameter.cs +++ b/src/LibRed/LibRed.Ado/LibRedParameter.cs @@ -125,4 +125,4 @@ private static byte DecimalPrecision(decimal d) char => DbType.StringFixedLength, _ => DbType.Object, }; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedParameterCollection.cs b/src/LibRed/LibRed.Ado/LibRedParameterCollection.cs index e1786075..09aa1328 100644 --- a/src/LibRed/LibRed.Ado/LibRedParameterCollection.cs +++ b/src/LibRed/LibRed.Ado/LibRedParameterCollection.cs @@ -4,7 +4,11 @@ namespace LibRed.Data; /// Parameter collection backed by a simple list. +// CA1010 asks for IList; the non-generic IList comes from DbParameterCollection, whose members every +// ADO.NET provider overrides as-is — SqlParameterCollection included. +#pragma warning disable CA1010 public sealed class LibRedParameterCollection : DbParameterCollection +#pragma warning restore CA1010 { private readonly List _items = []; @@ -52,4 +56,4 @@ public override int IndexOf(string parameterName) => protected override void SetParameter(string parameterName, DbParameter value) => _items[IndexOf(parameterName)] = (LibRedParameter)value; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedSchema.cs b/src/LibRed/LibRed.Ado/LibRedSchema.cs index e4892c4a..d18b8e1d 100644 --- a/src/LibRed/LibRed.Ado/LibRedSchema.cs +++ b/src/LibRed/LibRed.Ado/LibRedSchema.cs @@ -1,7 +1,7 @@ -using System.Data; -using System.Data.Common; using LibRed.Engine.Schema; using LibRed.Sql; +using System.Data; +using System.Data.Common; namespace LibRed.Data; @@ -28,10 +28,21 @@ internal static class LibRedSchema /// catalog, schema, table and column; a table one fewer. private static readonly Dictionary IdentifierParts = new(StringComparer.OrdinalIgnoreCase) { - ["Tables"] = 3, ["Columns"] = 4, ["Indexes"] = 4, ["Views"] = 3, ["Procedures"] = 3, - ["ForeignKeys"] = 3, ["PrimaryKeys"] = 3, ["TableConstraints"] = 3, ["KeyColumnUsage"] = 4, - ["ConstraintColumnUsage"] = 4, ["ReferentialConstraints"] = 3, ["CheckConstraints"] = 3, - ["Statistics"] = 3, ["ProcedureParameters"] = 4, ["ViewColumns"] = 4, + ["Tables"] = 3, + ["Columns"] = 4, + ["Indexes"] = 4, + ["Views"] = 3, + ["Procedures"] = 3, + ["ForeignKeys"] = 3, + ["PrimaryKeys"] = 3, + ["TableConstraints"] = 3, + ["KeyColumnUsage"] = 4, + ["ConstraintColumnUsage"] = 4, + ["ReferentialConstraints"] = 3, + ["CheckConstraints"] = 3, + ["Statistics"] = 3, + ["ProcedureParameters"] = 4, + ["ViewColumns"] = 4, }; /// Each collection's restrictions, in ACE's order — note that Indexes takes the table name last, @@ -296,4 +307,4 @@ private static DataTable Empty(string name, params (string Name, Type Type)[] co table.Columns.Add(column, type); return table; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Ado/LibRedTransaction.cs b/src/LibRed/LibRed.Ado/LibRedTransaction.cs index ce4cb8d6..318db9e7 100644 --- a/src/LibRed/LibRed.Ado/LibRedTransaction.cs +++ b/src/LibRed/LibRed.Ado/LibRedTransaction.cs @@ -1,13 +1,13 @@ +using LibRed.IO; using System.Data; using System.Data.Common; -using LibRed.IO; namespace LibRed.Data; /// /// A database transaction over LibRed's deferred-write page overlay. Writes are buffered in the overlay /// rather than going to disk, so is what makes them visible at all, and -/// simply discards the overlay — there is nothing on disk to restore. An uncommitted +/// simply discards the overlay — there is nothing on disk to restore. An uncommitted /// transaction that is disposed rolls back, which is what gives EF Core's shared-database tests their /// per-test isolation. /// @@ -18,7 +18,11 @@ public sealed class LibRedTransaction : DbTransaction // Named savepoints opened in this transaction (EF names them for nested SaveChanges). Maps the name to the // engine's savepoint handle. + // The comparer is explicit because savepoint names are matched case-sensitively, which IDE0028's + // collection expression would leave to the default. +#pragma warning disable IDE0028 private readonly Dictionary _savepoints = new(StringComparer.Ordinal); +#pragma warning restore IDE0028 internal LibRedTransaction(LibRedConnection connection, IsolationLevel isolationLevel, int openedAtDepth) { @@ -124,4 +128,4 @@ protected override void Dispose(bool disposing) _connection = null; base.Dispose(disposing); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs index c9e714cd..fd3894dc 100644 --- a/src/LibRed/LibRed.Core/Catalog/Collation.cs +++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs @@ -550,4 +550,4 @@ public bool IsIndexKeyEncodable return Storage.JetLocaleTailoring.For(this) is not null; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/ColumnDef.cs b/src/LibRed/LibRed.Core/Catalog/ColumnDef.cs index dd7684ff..b9f041a8 100644 --- a/src/LibRed/LibRed.Core/Catalog/ColumnDef.cs +++ b/src/LibRed/LibRed.Core/Catalog/ColumnDef.cs @@ -126,4 +126,4 @@ public sealed class ColumnDef /// Undocumented flag bits (byte 0x0F) to force-set — the system-catalog column marker (0x10) and /// security-identifier marker (0x20) Access sets on MSys* columns. 0 for ordinary columns. public byte SystemFlags { get; init; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/ForeignKey.cs b/src/LibRed/LibRed.Core/Catalog/ForeignKey.cs index 07b14b77..36577423 100644 --- a/src/LibRed/LibRed.Core/Catalog/ForeignKey.cs +++ b/src/LibRed/LibRed.Core/Catalog/ForeignKey.cs @@ -17,6 +17,8 @@ namespace LibRed.Catalog; /// Whether deleting the parent sets the child's FK columns to NULL (Jet's /// ON DELETE SET NULL, grbit 0x2000; mutually exclusive with cascade delete, and Jet has no /// ON UPDATE SET NULL). +/// ON UPDATE SET NULL — pathway only; not read back yet (its storage is +/// unverified: the ACE OLE DB provider rejects the DDL, so the grbit/info-block bytes could not be probed). public sealed record ForeignKey( string Name, string Table, @@ -27,6 +29,4 @@ public sealed record ForeignKey( bool CascadeDelete, bool IsInherited = false, bool DeleteSetNull = false, - /// ON UPDATE SET NULL — pathway only; not read back yet (its storage is unverified — the ACE - /// OLE DB provider rejects the DDL, so the grbit/info-block bytes couldn't be probed). - bool UpdateSetNull = false); + bool UpdateSetNull = false); \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/IndexDef.cs b/src/LibRed/LibRed.Core/Catalog/IndexDef.cs index 078df5bb..089ae9f8 100644 --- a/src/LibRed/LibRed.Core/Catalog/IndexDef.cs +++ b/src/LibRed/LibRed.Core/Catalog/IndexDef.cs @@ -36,4 +36,4 @@ public sealed record IndexDef /// block. Used to locate the stats block when maintaining the unique-entry count on insert. /// public int RealIndexOrdinal { get; init; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs index fbe125d4..213d8099 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs @@ -2,6 +2,7 @@ using LibRed.IO; using LibRed.Pages; using LibRed.Storage; +using System.Globalization; namespace LibRed.Catalog; @@ -198,9 +199,9 @@ private List LoadRelationships() private void EnsureStoredQueries() { if (_views is not null) return; - _views = new Dictionary(StringComparer.OrdinalIgnoreCase); - _actionQueries = new Dictionary(StringComparer.OrdinalIgnoreCase); - _queryParameters = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _views = [with(StringComparer.OrdinalIgnoreCase)]; + _actionQueries = [with(StringComparer.OrdinalIgnoreCase)]; + _queryParameters = [with(StringComparer.OrdinalIgnoreCase)]; TableDef? mqDef = FindTable("MSysQueries"); TableDef? objDef = FindTable("MSysObjects"); @@ -286,63 +287,63 @@ private static StoredActionQuery ReconstructAction( switch (kind) { case StoredQueryFormat.ActionAppend: - { - string target = Quote(action[n1] as string ?? ""); - // A literal-value column (Flag 0x8000) is an INSERT … VALUES; a Flag-0 one reads its value - // from the query's own FROM source, which makes it an INSERT … SELECT. Both name the target - // column in Name2 and hold the value's text in Expression. - if (columns.Count == 0) - return new StoredActionQuery(null, "An append query with no columns is not executed by LibRed."); - - string targetColumns = string.Join(", ", columns.Select(r => Quote(r[n2] as string ?? ""))); - string values = string.Join(", ", columns.Select(r => r[expr] as string ?? "NULL")); - - if (columns.All(r => r[flag] is short cf && cf == StoredQueryFormat.AppendValueFlag)) - return source is null - ? new StoredActionQuery($"{declared}INSERT INTO {target} ({targetColumns}) VALUES ({values})", null) - : new StoredActionQuery(null, "An append query cannot take both literal values and a source."); - - return source is { } appendSource - ? new StoredActionQuery( - $"{declared}INSERT INTO {target} ({targetColumns}) SELECT {values} FROM {appendSource.From}{Where()}", null) - : new StoredActionQuery(null, "An append query with no values and no source is not executed by LibRed."); - } + { + string target = Quote(action[n1] as string ?? ""); + // A literal-value column (Flag 0x8000) is an INSERT … VALUES; a Flag-0 one reads its value + // from the query's own FROM source, which makes it an INSERT … SELECT. Both name the target + // column in Name2 and hold the value's text in Expression. + if (columns.Count == 0) + return new StoredActionQuery(null, "An append query with no columns is not executed by LibRed."); + + string targetColumns = string.Join(", ", columns.Select(r => Quote(r[n2] as string ?? ""))); + string values = string.Join(", ", columns.Select(r => r[expr] as string ?? "NULL")); + + if (columns.All(r => r[flag] is short cf && cf == StoredQueryFormat.AppendValueFlag)) + return source is null + ? new StoredActionQuery($"{declared}INSERT INTO {target} ({targetColumns}) VALUES ({values})", null) + : new StoredActionQuery(null, "An append query cannot take both literal values and a source."); + + return source is { } appendSource + ? new StoredActionQuery( + $"{declared}INSERT INTO {target} ({targetColumns}) SELECT {values} FROM {appendSource.From}{Where()}", null) + : new StoredActionQuery(null, "An append query with no values and no source is not executed by LibRed."); + } case StoredQueryFormat.ActionUpdate when source is { } updateSource: - { - // One column row per assignment: Name2 is the target column — qualified when the update runs - // over a join — and Expression is the new value. - if (columns.Count == 0) - return new StoredActionQuery(null, "An update query with no assignments is not executed by LibRed."); - string assignments = string.Join(", ", - columns.Select(r => $"{Qualified(r[n2] as string ?? "")} = {r[expr] as string ?? "NULL"}")); - return new StoredActionQuery($"{declared}UPDATE {updateSource.From} SET {assignments}{Where()}", null); - } + { + // One column row per assignment: Name2 is the target column — qualified when the update runs + // over a join — and Expression is the new value. + if (columns.Count == 0) + return new StoredActionQuery(null, "An update query with no assignments is not executed by LibRed."); + string assignments = string.Join(", ", + columns.Select(r => $"{Qualified(r[n2] as string ?? "")} = {r[expr] as string ?? "NULL"}")); + return new StoredActionQuery($"{declared}UPDATE {updateSource.From} SET {assignments}{Where()}", null); + } case StoredQueryFormat.ActionDelete when source is { } deleteSource: - { - // Access writes `DELETE
.* FROM …` when the query names the table's columns and - // `DELETE * FROM …` when it doesn't; the column row holds that `
.*` verbatim. - string what = columns.Count > 0 ? columns[0][expr] as string ?? "*" : "*"; - return new StoredActionQuery($"{declared}DELETE {what} FROM {deleteSource.From}{Where()}", null); - } + { + // Access writes `DELETE
.* FROM …` when the query names the table's columns and + // `DELETE * FROM …` when it doesn't; the column row holds that `
.*` verbatim. + string what = columns.Count > 0 ? columns[0][expr] as string ?? "*" : "*"; + return new StoredActionQuery($"{declared}DELETE {what} FROM {deleteSource.From}{Where()}", null); + } case StoredQueryFormat.ActionMakeTable when source is { } intoSource: - { - // The target is on the action row; a target in ANOTHER database file (Name2) is a shape - // LibRed has no statement for. - if (action[n2] is string external && external.Length > 0) - return new StoredActionQuery(null, $"A make-table query writing into '{external}' is not executed by LibRed."); - - string selected = columns.Count == 0 - ? "*" - : string.Join(", ", columns.Select(r => - (r[n1] as string) is { } alias ? $"{r[expr] as string} AS {Quote(alias)}" : r[expr] as string ?? "")); - var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); - string grouping = groupBy.Count > 0 ? $" GROUP BY {string.Join(", ", groupBy)}" : ""; - return new StoredActionQuery( - $"{declared}SELECT {selected} INTO {Quote(action[n1] as string ?? "")} FROM {intoSource.From}{Where()}{grouping}", null); - } + { + // The target is on the action row; a target in ANOTHER database file (Name2) is a shape + // LibRed has no statement for. + if (action[n2] is string external && external.Length > 0) + return new StoredActionQuery(null, $"A make-table query writing into '{external}' is not executed by LibRed."); + + string selected = columns.Count == 0 + ? "*" + : string.Join(", ", columns.Select(r => + (r[n1] as string) is { } alias ? $"{r[expr] as string} AS {Quote(alias)}" : r[expr] as string ?? "")); + var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); + string grouping = groupBy.Count > 0 ? $" GROUP BY {string.Join(", ", groupBy)}" : ""; + return new StoredActionQuery( + $"{declared}SELECT {selected} INTO {Quote(action[n1] as string ?? "")} FROM {intoSource.From}{Where()}{grouping}", null); + } } // Everything else is stored but not executed. Name the kind: "not supported" that doesn't say what it @@ -415,7 +416,7 @@ static string Render((string Table, string? Alias, string? Sub) t) => short kind = !leftIn && j.Kind is 2 or 3 ? (short)(j.Kind == 2 ? 3 : 2) : j.Kind; // flip on reversed order string kw = kind switch { 2 => "LEFT", 3 => "RIGHT", _ => "INNER" }; - from.Append($" {kw} JOIN {Render(tables[ti])} ON {j.Cond}"); + from.Append(CultureInfo.InvariantCulture, $" {kw} JOIN {Render(tables[ti])} ON {j.Cond}"); used.Add(newKey); pending.RemoveAt(i); progress = true; @@ -424,7 +425,7 @@ static string Render((string Table, string? Alias, string? Sub) t) => } // Any tables the joins didn't reach are comma (cross) joins. foreach (var t in tables.Where(t => !used.Contains(Key(t)))) - from.Append($", {Render(t)}"); + from.Append(CultureInfo.InvariantCulture, $", {Render(t)}"); // Joins whose two tables were both already in scope become extra WHERE conditions (a cyclic graph). return (from.ToString(), pending.Select(j => j.Cond).ToList()); @@ -592,4 +593,4 @@ private static int ColumnIndex(IReadOnlyList columns, string name) return columns[i].Index; throw new InvalidOperationException($"MSysObjects is missing the '{name}' column."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/JetDataType.cs b/src/LibRed/LibRed.Core/Catalog/JetDataType.cs index dbabc0f5..6155ad16 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetDataType.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetDataType.cs @@ -8,6 +8,10 @@ namespace LibRed.Catalog; /// them a single unrecognised column made the entire database unopenable, because the catalog reads every /// table's definition — the same shape of failure a long-value guard once caused (page-02b §3.4a). /// +// CA1720 (identifier contains type name) reads Int16/Int32/Single/Double/Guid as accidental Hungarian. They +// are not: each member is named for the CLR type that code stores, which is how the format spec, the probes +// and every table in docs/format refer to them. Scoped to this enum so the rule keeps working everywhere else. +#pragma warning disable CA1720 public enum JetDataType : byte { Boolean = 0x01, @@ -44,3 +48,4 @@ public enum JetDataType : byte Int64 = 0x13, // ACE 16 (Access 2016, version byte 0x05): BIGINT (Large Number) DateTimeExtended = 0x14, // ACE 17 (Access 2019+, version byte 0x06): DATETIME2 (Date/Time Extended) } +#pragma warning restore CA1720 \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/JetName.cs b/src/LibRed/LibRed.Core/Catalog/JetName.cs index 50709da8..822d0ef0 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetName.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetName.cs @@ -42,4 +42,4 @@ public static void Validate(string name, string kind = "name") $"The {kind} '{name}' contains '{name[i]}', which Jet/ACE does not allow in object names " + "(forbidden characters: . ! ` [ ]).", nameof(name)); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs b/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs index fa97f6b9..d559cb80 100644 --- a/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs +++ b/src/LibRed/LibRed.Core/Catalog/LogicalIndexDef.cs @@ -25,4 +25,4 @@ public sealed record LogicalIndexDef( /// of the child side, the table holding the foreign key. public const byte OutgoingRelationship = 2; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/PropertyBlob.cs b/src/LibRed/LibRed.Core/Catalog/PropertyBlob.cs index 3e83283e..316386f5 100644 --- a/src/LibRed/LibRed.Core/Catalog/PropertyBlob.cs +++ b/src/LibRed/LibRed.Core/Catalog/PropertyBlob.cs @@ -49,12 +49,14 @@ public static class PropertyBlob /// part of the object's definition and some are only recognised correctly by Access when flagged. It /// defaults to because the properties LibRed currently creates are schema /// properties such as DefaultValue, Required, and CheckConstraints. +#pragma warning disable CA1716 // "Property" is the format's own name for this record, and it is nested in PropertyBlob. public readonly record struct Property( string Owner, string Name, string Value, JetDataType Type = JetDataType.Memo, byte[]? RawValue = null) { /// Whether this entry is a DDL/property-definition property. public bool IsDdl { get; init; } = true; } +#pragma warning restore CA1716 /// A boolean property (e.g. Required), stored as a single 0/1 byte. public static Property Bool(string owner, string name, bool value) => @@ -62,6 +64,7 @@ public static Property Bool(string owner, string name, bool value) => /// Builds the blob for a set of properties, grouped by owner in the given order — matching /// what ACE writes (verified byte-for-byte for column DefaultValues). + /// The properties to write, grouped by owner in the order given. /// The 4-byte signature to stamp. Pass the ORIGINAL blob's first four bytes when /// rewriting one, so a Jet-4 KKD\0 blob is not silently reissued as an ACE MR2\0 one — two /// of the four write paths here already preserve it, and the faithful round-trip rule says all four @@ -119,7 +122,7 @@ public static byte[] AddColumnProperties(ReadOnlySpan blob, string owner, } /// Appends one owner's value block: the owner record then a property entry per property. - private static void AppendOwnerBlock(List blob, string owner, IEnumerable props, IReadOnlyDictionary nameIndex) + private static void AppendOwnerBlock(List blob, string owner, IEnumerable props, Dictionary nameIndex) { Property[] propertyArray = props.ToArray(); ValidateOwnerProperties(owner, propertyArray, nameIndex); @@ -392,7 +395,7 @@ private static string ReadOwner(ReadOnlySpan body) return Encoding.Unicode.GetString(body.Slice(6, ownerLength)); } - private static void ReadProperties(ReadOnlySpan body, IReadOnlyList names, List properties) + private static void ReadProperties(ReadOnlySpan body, List names, List properties) { string owner = ReadOwner(body); int pos = BinaryPrimitives.ReadUInt16LittleEndian(body[..2]); @@ -432,7 +435,7 @@ private static void ValidateForWrite(IReadOnlyList properties) ValidateOwnerProperties(group.Owner, group.Properties, nameIndex); } - private static void ValidateNames(IReadOnlyList names) + private static void ValidateNames(List names) { if (names.Count > ushort.MaxValue + 1) throw new ArgumentException($"A property blob cannot name more than {ushort.MaxValue + 1} properties."); @@ -449,7 +452,7 @@ private static void ValidateNames(IReadOnlyList names) } private static void ValidateOwnerProperties( - string owner, IEnumerable properties, IReadOnlyDictionary nameIndex) + string owner, IEnumerable properties, Dictionary nameIndex) { int ownerLength = Encoding.Unicode.GetByteCount(owner); if (ownerLength > ushort.MaxValue - 6) @@ -508,4 +511,4 @@ private static void AppendInt32(List buffer, int value) { buffer.Add((byte)value); buffer.Add((byte)(value >> 8)); buffer.Add((byte)(value >> 16)); buffer.Add((byte)(value >> 24)); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/RecordLayout.cs b/src/LibRed/LibRed.Core/Catalog/RecordLayout.cs index d57345ca..cb3ab4d2 100644 --- a/src/LibRed/LibRed.Core/Catalog/RecordLayout.cs +++ b/src/LibRed/LibRed.Core/Catalog/RecordLayout.cs @@ -79,4 +79,4 @@ public static void ValidateRecordFits( + "containing such a table at all. Make the wide columns variable-length, or move them to " + "Memo/OLE, which live on their own pages."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/RelationshipSpec.cs b/src/LibRed/LibRed.Core/Catalog/RelationshipSpec.cs index 6deebb58..cc2f5382 100644 --- a/src/LibRed/LibRed.Core/Catalog/RelationshipSpec.cs +++ b/src/LibRed/LibRed.Core/Catalog/RelationshipSpec.cs @@ -45,4 +45,4 @@ public sealed record RelationshipSpec( } /// A UNIQUE constraint to create as a unique (non-primary) index over the named columns. -public sealed record UniqueIndexSpec(string Name, IReadOnlyList Columns); +public sealed record UniqueIndexSpec(string Name, IReadOnlyList Columns); \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/TableDef.cs b/src/LibRed/LibRed.Core/Catalog/TableDef.cs index 36ec18a4..0741177b 100644 --- a/src/LibRed/LibRed.Core/Catalog/TableDef.cs +++ b/src/LibRed/LibRed.Core/Catalog/TableDef.cs @@ -66,4 +66,4 @@ public sealed class TableDef public ColumnDef? FindColumn(string name) => Columns.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/TableType.cs b/src/LibRed/LibRed.Core/Catalog/TableType.cs index aa74ca85..fd5ec0b9 100644 --- a/src/LibRed/LibRed.Core/Catalog/TableType.cs +++ b/src/LibRed/LibRed.Core/Catalog/TableType.cs @@ -8,4 +8,4 @@ public enum TableType : byte /// A system (MSys*) table ('S'). System = 0x53, -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs b/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs index 5f39d4e4..45b7da2d 100644 --- a/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs +++ b/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs @@ -1,7 +1,7 @@ -using System.Buffers.Binary; -using System.Text; using LibRed.Formats; using LibRed.Pages; +using System.Buffers.Binary; +using System.Text; namespace LibRed.Catalog; @@ -105,7 +105,6 @@ public static class TdefBuilder // TDEF header offsets + the record marker / continuation-header size live on JetFormatBase (shared, // version-aware); the column-descriptor sub-offsets Access needs but the reader ignores are below. private const int ColumnRecordMarkerOffset = 0x01; // 0x0659 - private const int ColumnNumber2Offset = 0x09; // duplicate column id // Index-data and index-info block layout + flags are shared with the reader via IndexBlockFormat / IndexFlags. private const int MaxIndexesPerTable = 32; // Jet/ACE limit, counting keys- and relationship-backing indexes @@ -367,7 +366,7 @@ private static void ValidateNameLength(string name, string kind) private static int DefinitionSize( JetFormatBase format, - IReadOnlyList columns, + List columns, IReadOnlyList indexes, IReadOnlyList logical, IReadOnlyList longValueColumns) @@ -670,4 +669,4 @@ private static int WriteColumnNames(byte[] page, JetFormatBase format, List? GroupBy = null, IReadOnlyList? Parameters = null, IReadOnlyList? OrderBy = null, - int? Top = null); + int? Top = null); \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/ConstraintViolationException.cs b/src/LibRed/LibRed.Core/ConstraintViolationException.cs index 0db7703d..9bf83bec 100644 --- a/src/LibRed/LibRed.Core/ConstraintViolationException.cs +++ b/src/LibRed/LibRed.Core/ConstraintViolationException.cs @@ -25,4 +25,4 @@ public sealed class ConstraintViolationException(string message, string constrai /// Whether that index is the table's primary key rather than a plain unique index. public bool IsPrimaryKey { get; } = isPrimaryKey; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Crypto/AccessEncryption.cs b/src/LibRed/LibRed.Core/Crypto/AccessEncryption.cs index 75ea265d..da7fa6d4 100644 --- a/src/LibRed/LibRed.Core/Crypto/AccessEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/AccessEncryption.cs @@ -14,7 +14,7 @@ public enum AccessEncryption /// Legacy Jet 3/4 page encoding (RC4 keyed by the 0x3E database key). .mdb only. /// Not a set-scheme here — the legacy Jet database password (obfuscation only) is set via /// , and RC4 page encoding via - /// . Both are implemented; they are simply two different + /// . Both are implemented; they are simply two different /// mechanisms, which is why this one enum member cannot stand for either. LegacyJet, @@ -45,4 +45,4 @@ public enum StandardHash Sha384, /// SHA-512 (0x800e). Requires the Enhanced provider / EncryptionEnhancer add-in. Sha512, -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Crypto/AgileEncryption.cs b/src/LibRed/LibRed.Core/Crypto/AgileEncryption.cs index 5f8e47c3..fe8e5186 100644 --- a/src/LibRed/LibRed.Core/Crypto/AgileEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/AgileEncryption.cs @@ -86,13 +86,13 @@ private AgileEncryption(byte[] secretKey, byte[] keyDataSalt, int blockSize, byt RequireLength(keyDataSalt, 16, "keyData saltValue"); RequireLength(pwdSalt, 16, "encryptedKey saltValue"); - // Cross-check the descriptor's declared sizes against reality: the salt bytes must be saltSize long, the - // hash must match hashSize, and both elements must name the same hash. A disagreement means a malformed or - // misparsed descriptor — fail here with a clear message rather than deep in the KDF. + // Cross-check the descriptor's declared sizes against reality: the salt bytes must be saltSize long, the + // hash must match hashSize, and both elements must name the same hash. A disagreement means a malformed or + // misparsed descriptor — fail here with a clear message rather than deep in the KDF. VerifyDeclaredSizes(keyData, hash, keyDataSalt.Length); VerifyDeclaredSizes(encKey, hash, pwdSalt.Length); - // H_spin = Hash(salt ‖ UTF16LE(password)), then spinCount iterations of Hash(LE32(i) ‖ H). + // H_spin = Hash(salt ‖ UTF16LE(password)), then spinCount iterations of Hash(LE32(i) ‖ H). byte[] hspin = Hash(hash, pwdSalt, Encoding.Unicode.GetBytes(password)); Span iter = stackalloc byte[4]; for (int i = 0; i < spinCount; i++) @@ -103,7 +103,7 @@ private AgileEncryption(byte[] secretKey, byte[] keyDataSalt, int blockSize, byt byte[] DeriveKey(byte[] blockKey) => Fit(Hash(hash, hspin, blockKey), keyBytes); - // Verify the password before trusting anything: SHA(verifierInput) must equal verifierValue. + // Verify the password before trusting anything: SHA(verifierInput) must equal verifierValue. byte[] encryptedVerifierInput = B64(encKey, "encryptedVerifierHashInput"); byte[] encryptedVerifierValue = B64(encKey, "encryptedVerifierHashValue"); byte[] encryptedKeyValue = B64(encKey, "encryptedKeyValue"); @@ -321,6 +321,9 @@ private static void VerifyDeclaredSizes(XElement el, HashKind hash, int actualSa private static byte[] Hash(HashKind kind, params byte[][] parts) { byte[] all = parts.Length == 1 ? parts[0] : Concat(parts); + // CA5350: the agile descriptor names its own hash, and SHA-1 is what Access writes for many files; + // reading them back means using it. Scoped to the dispatch that honours the descriptor. +#pragma warning disable CA5350 return kind switch { HashKind.Sha1 => SHA1.HashData(all), @@ -329,6 +332,7 @@ private static byte[] Hash(HashKind kind, params byte[][] parts) HashKind.Sha512 => SHA512.HashData(all), _ => throw new NotSupportedException() }; +#pragma warning restore CA5350 } private static byte[] Concat(params byte[][] parts) @@ -358,4 +362,4 @@ private static byte[] AesCbcDecrypt(byte[] key, byte[] iv, byte[] data) aes.IV = iv.Length == 16 ? iv : Fit(iv, 16); return aes.DecryptCbc(data, aes.IV, PaddingMode.None); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs b/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs index 45c2ac0d..259a261d 100644 --- a/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs @@ -1,7 +1,7 @@ +using LibRed.Formats; using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; -using LibRed.Formats; namespace LibRed.Crypto; @@ -348,4 +348,4 @@ private static void WriteDatabaseKey(byte[] file, int dbKey) } private static byte[] RandomBytes(int n) { byte[] b = new byte[n]; RandomNumberGenerator.Fill(b); return b; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Crypto/IPageCodec.cs b/src/LibRed/LibRed.Core/Crypto/IPageCodec.cs index 07eb6088..dee8c24c 100644 --- a/src/LibRed/LibRed.Core/Crypto/IPageCodec.cs +++ b/src/LibRed/LibRed.Core/Crypto/IPageCodec.cs @@ -14,4 +14,4 @@ public interface IPageCodec /// Encrypts (a full page) in place — the inverse of /// , used when writing a page back to disk. No-op for page 0. void EncryptPage(int pageNumber, Span page); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Crypto/JetLegacyEncryption.cs b/src/LibRed/LibRed.Core/Crypto/JetLegacyEncryption.cs index dda20daf..bcb0b45f 100644 --- a/src/LibRed/LibRed.Core/Crypto/JetLegacyEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/JetLegacyEncryption.cs @@ -57,4 +57,4 @@ private static void Rc4(ReadOnlySpan key, Span data) data[n] ^= s[(s[a] + s[b]) & 0xFF]; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs b/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs index 672292f7..23349b88 100644 --- a/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs @@ -69,6 +69,10 @@ private OfficeStandardEncryption(bool rc4, HashAlgorithmName hashName, byte[] ba _ => throw new NotSupportedException($"Unsupported Office-Standard hash {name}."), }; + // CA5350/CA5351 (weak hashing): MD5 and SHA-1 are not a choice here. The Office "Standard" encryption + // descriptor NAMES the algorithm, and a file Access wrote with one cannot be opened with anything else, + // so refusing them would mean refusing the documents. Scoped to the dispatch that honours the descriptor. +#pragma warning disable CA5350, CA5351 private static byte[] Hash(HashAlgorithmName name, byte[] data) => name.Name switch { "MD5" => MD5.HashData(data), @@ -78,6 +82,7 @@ private OfficeStandardEncryption(bool rc4, HashAlgorithmName hashName, byte[] ba "SHA512" => SHA512.HashData(data), _ => throw new NotSupportedException($"Unsupported hash algorithm {name}."), }; +#pragma warning restore CA5350, CA5351 // The CryptoAPI CryptDeriveKey 0x36/0x5C expansion uses a fixed 64-byte pad buffer for every hash algorithm // (it is not HMAC, so SHA-384/512 do NOT switch to their 128-byte block size). Verified: AES-256 + SHA-512. @@ -200,6 +205,9 @@ internal static (byte[] Descriptor, OfficeStandardEncryption Codec) Create(strin uint algId = aes ? 0x6610u : AlgIdRc4; int keyBits = aes ? 256 : 40; byte[] salt = RandomBytes(16); + // CA5350: the Office-Standard scheme hashes the password with SHA-1 by definition — a file created + // with anything else is not this scheme, and Access would not open it. +#pragma warning disable CA5350 byte[] baseHash = SHA1.HashData(Concat(salt, Encoding.Unicode.GetBytes(password))); int keyLen = keyBits / 8; int finalLen = !aes && keyBits == 40 ? 16 : keyLen; @@ -209,6 +217,7 @@ internal static (byte[] Descriptor, OfficeStandardEncryption Codec) Create(strin byte[] verKey = codec.ComputeKey(VerifierBlock); byte[] verifier = RandomBytes(16); byte[] verifierHash = SHA1.HashData(verifier); +#pragma warning restore CA5350 byte[] encVerifier, encVerifierHash; if (aes) { @@ -447,4 +456,4 @@ private static byte[] Concat(byte[] a, byte[] b) b.CopyTo(r, a.Length); return r; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/CatalogFormat.cs b/src/LibRed/LibRed.Core/Formats/CatalogFormat.cs index e42c2817..2f14f3b5 100644 --- a/src/LibRed/LibRed.Core/Formats/CatalogFormat.cs +++ b/src/LibRed/LibRed.Core/Formats/CatalogFormat.cs @@ -14,4 +14,4 @@ internal static class CatalogFormat /// MSysObjects.Type value for a relationship object. public const short ObjectTypeRelationship = 8; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/IndexBlockFormat.cs b/src/LibRed/LibRed.Core/Formats/IndexBlockFormat.cs index f12b23e3..c738960c 100644 --- a/src/LibRed/LibRed.Core/Formats/IndexBlockFormat.cs +++ b/src/LibRed/LibRed.Core/Formats/IndexBlockFormat.cs @@ -40,4 +40,4 @@ internal static class IndexBlockFormat public const byte TypeSecondary = 0x00; public const byte TypePrimary = 0x01; public const byte TypeForeign = 0x02; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/IndexFlags.cs b/src/LibRed/LibRed.Core/Formats/IndexFlags.cs index 39d59215..aeffb694 100644 --- a/src/LibRed/LibRed.Core/Formats/IndexFlags.cs +++ b/src/LibRed/LibRed.Core/Formats/IndexFlags.cs @@ -20,4 +20,4 @@ internal static class IndexFlags /// Always set on Access 2000+ index-data blocks. public const ushort AlwaysSet = 0x0080; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/IndexKeyFlags.cs b/src/LibRed/LibRed.Core/Formats/IndexKeyFlags.cs index 45807ee8..8adc7391 100644 --- a/src/LibRed/LibRed.Core/Formats/IndexKeyFlags.cs +++ b/src/LibRed/LibRed.Core/Formats/IndexKeyFlags.cs @@ -19,4 +19,4 @@ internal static class IndexKeyFlags /// Descending column, null value. public const byte DescNull = 0xFF; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/Jet12Format.cs b/src/LibRed/LibRed.Core/Formats/Jet12Format.cs index 12e3a024..0e5e27c6 100644 --- a/src/LibRed/LibRed.Core/Formats/Jet12Format.cs +++ b/src/LibRed/LibRed.Core/Formats/Jet12Format.cs @@ -6,4 +6,4 @@ internal class Jet12Format : Jet4Format public override JetVersion Version => JetVersion.Version12_2007; public override bool IsAccdb => true; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/Jet14Format.cs b/src/LibRed/LibRed.Core/Formats/Jet14Format.cs index f5ff6d5b..18bf8bcc 100644 --- a/src/LibRed/LibRed.Core/Formats/Jet14Format.cs +++ b/src/LibRed/LibRed.Core/Formats/Jet14Format.cs @@ -4,4 +4,4 @@ namespace LibRed.Formats; internal class Jet14Format : Jet12Format { public override JetVersion Version => JetVersion.Version14_2010; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/Jet16Format.cs b/src/LibRed/LibRed.Core/Formats/Jet16Format.cs index f401c50f..0badadfe 100644 --- a/src/LibRed/LibRed.Core/Formats/Jet16Format.cs +++ b/src/LibRed/LibRed.Core/Formats/Jet16Format.cs @@ -4,4 +4,4 @@ namespace LibRed.Formats; internal class Jet16Format : Jet14Format { public override JetVersion Version => JetVersion.Version16_2016; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/Jet17Format.cs b/src/LibRed/LibRed.Core/Formats/Jet17Format.cs index 1f57a20f..f03848ef 100644 --- a/src/LibRed/LibRed.Core/Formats/Jet17Format.cs +++ b/src/LibRed/LibRed.Core/Formats/Jet17Format.cs @@ -4,4 +4,4 @@ namespace LibRed.Formats; internal sealed class Jet17Format : Jet16Format { public override JetVersion Version => JetVersion.Version17_2019; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/Jet3Format.cs b/src/LibRed/LibRed.Core/Formats/Jet3Format.cs index 797c6a62..e8f30b9c 100644 --- a/src/LibRed/LibRed.Core/Formats/Jet3Format.cs +++ b/src/LibRed/LibRed.Core/Formats/Jet3Format.cs @@ -6,4 +6,4 @@ internal sealed class Jet3Format : JetFormatBase public Jet3Format() => PageSize = 2048; public override JetVersion Version => JetVersion.Version3; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/Jet4Format.cs b/src/LibRed/LibRed.Core/Formats/Jet4Format.cs index 1e49b6fe..708f9a65 100644 --- a/src/LibRed/LibRed.Core/Formats/Jet4Format.cs +++ b/src/LibRed/LibRed.Core/Formats/Jet4Format.cs @@ -6,4 +6,4 @@ internal class Jet4Format : JetFormatBase public Jet4Format() => PageSize = 4096; public override JetVersion Version => JetVersion.Version4; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs b/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs index 1701cc6e..57639a32 100644 --- a/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs +++ b/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs @@ -400,4 +400,4 @@ public static string ReadFormatIdentifier(ReadOnlySpan header) => 0x06 => new Jet17Format(), _ => throw new NotSupportedException($"Unknown Jet/ACE format version byte 0x{versionByte:X2}."), }; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/JetVersion.cs b/src/LibRed/LibRed.Core/Formats/JetVersion.cs index eb684237..1aacbf9e 100644 --- a/src/LibRed/LibRed.Core/Formats/JetVersion.cs +++ b/src/LibRed/LibRed.Core/Formats/JetVersion.cs @@ -2,6 +2,10 @@ namespace LibRed.Formats { + // CA1707 (no underscores in identifiers): each member names the format version AND the Access release it + // shipped with — Version16_2016 — which is how the spec, the probes and the ACE documentation all refer + // to them. Scoped to this enum so the rule keeps working everywhere else. +#pragma warning disable CA1707 public enum JetVersion { Version3 = 0x0, @@ -12,6 +16,7 @@ public enum JetVersion Version16_2016 = 0x5, Version17_2019 = 0x6, } +#pragma warning restore CA1707 /// /// Which format version a column type needs in order to exist on disk at all. @@ -49,4 +54,4 @@ public static void EnsureStorable(JetDataType type, JetVersion version, string c + $"this database is {version}."); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/LongValueFormat.cs b/src/LibRed/LibRed.Core/Formats/LongValueFormat.cs index 716bae45..65588a0c 100644 --- a/src/LibRed/LibRed.Core/Formats/LongValueFormat.cs +++ b/src/LibRed/LibRed.Core/Formats/LongValueFormat.cs @@ -59,4 +59,4 @@ internal static void ValidateLength(int length) /// The 4-byte owner marker on a long-value page: ASCII "LVAL" (little-endian 4C 56 41 4C). public const uint LvalMarker = 0x4C41564C; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/RelationshipFlags.cs b/src/LibRed/LibRed.Core/Formats/RelationshipFlags.cs index 184ee6df..713558e0 100644 --- a/src/LibRed/LibRed.Core/Formats/RelationshipFlags.cs +++ b/src/LibRed/LibRed.Core/Formats/RelationshipFlags.cs @@ -17,4 +17,4 @@ internal static class RelationshipFlags /// Deleting the parent sets the child's FK columns to NULL (Jet ON DELETE SET NULL). public const int DeleteSetNull = 0x00002000; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/RowPointer.cs b/src/LibRed/LibRed.Core/Formats/RowPointer.cs index 6494d701..a8bc3bbf 100644 --- a/src/LibRed/LibRed.Core/Formats/RowPointer.cs +++ b/src/LibRed/LibRed.Core/Formats/RowPointer.cs @@ -40,4 +40,4 @@ internal static class RowPointer /// /// public const int MaxRowsPerPage = 255; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs b/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs index 20ef22fb..00dca489 100644 --- a/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs +++ b/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs @@ -81,4 +81,4 @@ public static (int? Size, int? Precision, int? Scale) UnpackParameterFacets(JetD : type == JetDataType.FixedPoint ? (null, packed & 0xFFFF, packed >> 16) : type == JetDataType.Text ? (packed, null, null) : (null, null, null); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/IO/ILockManager.cs b/src/LibRed/LibRed.Core/IO/ILockManager.cs index c3295a75..fd25fcd6 100644 --- a/src/LibRed/LibRed.Core/IO/ILockManager.cs +++ b/src/LibRed/LibRed.Core/IO/ILockManager.cs @@ -38,4 +38,4 @@ public interface ILockManager /// Releases the exclusive lock taken by . void ExitExclusive(int page); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/IO/MonitorLockManager.cs b/src/LibRed/LibRed.Core/IO/MonitorLockManager.cs index bd3b9620..102247c9 100644 --- a/src/LibRed/LibRed.Core/IO/MonitorLockManager.cs +++ b/src/LibRed/LibRed.Core/IO/MonitorLockManager.cs @@ -36,7 +36,7 @@ public void Dispose() // --- refcounted per-path registry: one manager per canonical file path, freed on the last Release --- private static readonly Dictionary Registry = - new(StringComparer.Ordinal); + [with(StringComparer.Ordinal)]; private static string Key(string path) => Path.GetFullPath(path).ToLowerInvariant(); @@ -77,4 +77,4 @@ public static void Release(string path) } } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/IO/PageBuffer.cs b/src/LibRed/LibRed.Core/IO/PageBuffer.cs index 9077306c..38eac8c2 100644 --- a/src/LibRed/LibRed.Core/IO/PageBuffer.cs +++ b/src/LibRed/LibRed.Core/IO/PageBuffer.cs @@ -35,4 +35,4 @@ public readonly struct PageBuffer(ReadOnlyMemory data, int pageNumber) public ReadOnlySpan Slice(int offset, int length) => Span.Slice(offset, length); public string ReadString(int offset, int length, Encoding encoding) => encoding.GetString(Span.Slice(offset, length)); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/IO/PageCache.cs b/src/LibRed/LibRed.Core/IO/PageCache.cs index 118bdb79..58bbecfd 100644 --- a/src/LibRed/LibRed.Core/IO/PageCache.cs +++ b/src/LibRed/LibRed.Core/IO/PageCache.cs @@ -14,7 +14,7 @@ namespace LibRed.IO; /// threads is serialised at the page level (concurrent unsynchronised writes to the same page were never /// defined behaviour anyway). /// -internal sealed class PageCache +internal sealed class PageCache : IDisposable { /// Max resident pages per file. At a 4 KB page this is ~32 MB, comfortably covering the whole of a /// typical Access database (and the benchmark tables); larger databases simply evict LRU. @@ -31,7 +31,6 @@ private sealed class Entry(int page, byte[] bytes) public object? Parsed; } - private readonly int _pageSize; private readonly object _gate = new(); // Shared for readers, exclusive for publication. Recursive so a writing statement can hold the exclusive // scope while each page write re-enters it; an attempted read→write upgrade is rejected explicitly below. @@ -47,17 +46,21 @@ private sealed class Entry(int page, byte[] bytes) private readonly Dictionary> _map = []; private readonly LinkedList _lru = new(); // first = most-recently-used - private PageCache(int pageSize) => _pageSize = pageSize; + private PageCache() { } + + /// Releases the publication lock. Called when the last channel on the file lets the cache go, so + /// an open/close cycle does not leave one behind. + public void Dispose() => _publishGate.Dispose(); // --- shared registry: one cache per canonical file path, refcounted by the channels using it --- - private static readonly Dictionary Registry = new(StringComparer.Ordinal); - private static readonly object RegistryGate = new(); + private static readonly Dictionary Registry = [with(StringComparer.Ordinal)]; + private static readonly Lock RegistryGate = new(); /// Returns the shared cache for , creating it on first use; each call must be /// paired with a . The key is the case-folded full path so relative and absolute opens /// of the same file share one pool (two pools for one file would reintroduce cross-handle staleness). - public static PageCache Acquire(string path, int pageSize) + public static PageCache Acquire(string path) { string key = Path.GetFullPath(path).ToLowerInvariant(); lock (RegistryGate) @@ -67,7 +70,7 @@ public static PageCache Acquire(string path, int pageSize) Registry[key] = (slot.Cache, slot.RefCount + 1); return slot.Cache; } - var cache = new PageCache(pageSize); + var cache = new PageCache(); Registry[key] = (cache, 1); return cache; } @@ -81,7 +84,11 @@ public static void Release(string path) lock (RegistryGate) { if (!Registry.TryGetValue(key, out var slot)) return; - if (slot.RefCount <= 1) Registry.Remove(key); + if (slot.RefCount <= 1) + { + Registry.Remove(key); + slot.Cache.Dispose(); + } else Registry[key] = (slot.Cache, slot.RefCount - 1); } } @@ -241,4 +248,4 @@ public void SetParsed(int page, object parsed) if (_map.TryGetValue(page, out var node)) node.Value.Parsed = parsed; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/IO/PageChannel.cs b/src/LibRed/LibRed.Core/IO/PageChannel.cs index 5c1d1b23..8e37137c 100644 --- a/src/LibRed/LibRed.Core/IO/PageChannel.cs +++ b/src/LibRed/LibRed.Core/IO/PageChannel.cs @@ -1,6 +1,6 @@ -using System.Buffers.Binary; using LibRed.Crypto; using LibRed.Formats; +using System.Buffers.Binary; namespace LibRed.IO; @@ -65,7 +65,7 @@ private PageChannel(FileStream stream, JetFormatBase format, bool readOnly, stri // A test may inject its own manager; otherwise share the per-path one (refcounted, released on Dispose). _ownsLocks = locks is null; _locks = locks ?? MonitorLockManager.Acquire(path); - _cache = PageCache.Acquire(path, format.PageSize); + _cache = PageCache.Acquire(path); _cache.InitFileLength(stream.Length); } @@ -292,8 +292,7 @@ public void WritePage(int pageNumber, ReadOnlySpan source) throw new InvalidOperationException("This channel was opened read-only."); if (source.Length != PageSize) throw new ArgumentException($"A page write must be exactly {PageSize} bytes.", nameof(source)); - if (pageNumber < 0) - throw new ArgumentOutOfRangeException(nameof(pageNumber)); + ArgumentOutOfRangeException.ThrowIfNegative(pageNumber); // ACE-only full-database probe: the file reaches exactly 2 GiB (524288 Jet4/ACE pages) // and rejects the next allocation. Check before staging a page or extending the stream. @@ -647,4 +646,4 @@ public void Dispose() PageCache.Release(_path); // last channel on this file drops the shared pool if (_ownsLocks) MonitorLockManager.Release(_path); // and the shared lock manager } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/IO/Transaction.cs b/src/LibRed/LibRed.Core/IO/Transaction.cs index ce9af4e9..cd68d3ac 100644 --- a/src/LibRed/LibRed.Core/IO/Transaction.cs +++ b/src/LibRed/LibRed.Core/IO/Transaction.cs @@ -113,4 +113,4 @@ private void ValidateFrame(int index) if (index < 0 || index >= _frames.Count) throw new InvalidOperationException("The savepoint is not open in this transaction."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/JetDatabase.cs b/src/LibRed/LibRed.Core/JetDatabase.cs index b79ae6f3..5df2c7c4 100644 --- a/src/LibRed/LibRed.Core/JetDatabase.cs +++ b/src/LibRed/LibRed.Core/JetDatabase.cs @@ -482,4 +482,4 @@ public void Dispose() _channel.Dispose(); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Pages/DataPage.cs b/src/LibRed/LibRed.Core/Pages/DataPage.cs index 4460d297..94143e3c 100644 --- a/src/LibRed/LibRed.Core/Pages/DataPage.cs +++ b/src/LibRed/LibRed.Core/Pages/DataPage.cs @@ -142,4 +142,4 @@ internal static void ValidateSlot( throw new InvalidDataException( $"Data page {pageNumber} row slot {index} has offset {offset}, above the previous row boundary {previousEnd}."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs b/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs index 3bbcd018..d2723ef5 100644 --- a/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs +++ b/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs @@ -1,6 +1,6 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.IO; +using System.Buffers.Binary; namespace LibRed.Pages; @@ -116,4 +116,4 @@ internal static void Demask(ReadOnlySpan page, Span clear) // rather than as an argument error. Its own documented bounds: 0100-01-01 through 9999-12-31. private const double MinOleAutomationDate = -657435.0; private const double MaxOleAutomationDate = 2958466.0; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Pages/Page.cs b/src/LibRed/LibRed.Core/Pages/Page.cs index 7a4bf828..654c36fe 100644 --- a/src/LibRed/LibRed.Core/Pages/Page.cs +++ b/src/LibRed/LibRed.Core/Pages/Page.cs @@ -17,4 +17,4 @@ public abstract class Page /// Decodes this page's fields from the supplied buffer using version-specific offsets. public abstract void Read(PageBuffer buffer, JetFormatBase format); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Pages/PageType.cs b/src/LibRed/LibRed.Core/Pages/PageType.cs index 9cf9d9fe..4cd0a1e7 100644 --- a/src/LibRed/LibRed.Core/Pages/PageType.cs +++ b/src/LibRed/LibRed.Core/Pages/PageType.cs @@ -27,4 +27,4 @@ public enum PageType : byte /// which is why only the packed form produces this. Nothing needs to handle it on read: allocation /// selects on the free map, not on this byte. See docs/format/page-09-released-long-value.md. ReleasedLongValuePage = 0x09, -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs index 452afc1d..ea1792a0 100644 --- a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs +++ b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs @@ -1,7 +1,7 @@ -using System.Text; using LibRed.Catalog; using LibRed.Formats; using LibRed.IO; +using System.Text; namespace LibRed.Pages; @@ -412,4 +412,4 @@ private static void EnsureAvailable(PageBuffer buffer, int pos, int length, stri throw new InvalidDataException( $"TDEF {section} extends past the declared definition ({pos} + {length} > {buffer.Length})."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Pages/TdefChainReader.cs b/src/LibRed/LibRed.Core/Pages/TdefChainReader.cs index b6954bb8..3e089d84 100644 --- a/src/LibRed/LibRed.Core/Pages/TdefChainReader.cs +++ b/src/LibRed/LibRed.Core/Pages/TdefChainReader.cs @@ -87,4 +87,4 @@ private static void ValidatePageNumber(PageChannel channel, int pageNumber, stri throw new InvalidDataException( $"{role} page {pageNumber} is outside the database's {channel.PageCount} pages."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs b/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs index 40b39618..3901584b 100644 --- a/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs +++ b/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs @@ -25,4 +25,4 @@ public sealed class SchemaObjectExistsException(string message, string objectNam { /// The name that was already taken. public string ObjectName { get; } = objectName; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedEvaluator.cs b/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedEvaluator.cs index 9c6475e8..cfc181ba 100644 --- a/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedEvaluator.cs +++ b/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedEvaluator.cs @@ -1,6 +1,6 @@ -using System.Globalization; using EntityFrameworkCore.Jet.Data; using LibRed.Catalog; +using System.Globalization; namespace LibRed.Storage.Calculated; @@ -58,14 +58,14 @@ internal static class CalculatedEvaluator switch (node.Operator) { case "+": - { - // '+' concatenates when BOTH sides are text, and adds otherwise — the overload that makes - // it the null-propagating sibling of '&'. Adding a number to a date yields a DATE, not the - // serial underneath it, so `[D] + 1` stays a date the way ACE stores it. - if (left is string a && right is string b) return a + b; - double sum = ToNumber(left) + ToNumber(right); - return left is DateTime ^ right is DateTime ? DateTime.FromOADate(sum) : sum; - } + { + // '+' concatenates when BOTH sides are text, and adds otherwise — the overload that makes + // it the null-propagating sibling of '&'. Adding a number to a date yields a DATE, not the + // serial underneath it, so `[D] + 1` stays a date the way ACE stores it. + if (left is string a && right is string b) return a + b; + double sum = ToNumber(left) + ToNumber(right); + return left is DateTime ^ right is DateTime ? DateTime.FromOADate(sum) : sum; + } case "-": return left is DateTime && right is DateTime ? ToNumber(left) - ToNumber(right) @@ -76,21 +76,30 @@ internal static class CalculatedEvaluator case "And": return ToBoolean(left) && ToBoolean(right); case "Or": return ToBoolean(left) || ToBoolean(right); case "Like": return Like(ToText(left), ToText(right)); - case "=": case "<>": case "<": case ">": case "<=": case ">=": - { - int c = Compare(left, right); - return node.Operator switch + case "=": + case "<>": + case "<": + case ">": + case "<=": + case ">=": { - "=" => c == 0, "<>" => c != 0, "<" => c < 0, - ">" => c > 0, "<=" => c <= 0, _ => c >= 0, - }; - } + int c = Compare(left, right); + return node.Operator switch + { + "=" => c == 0, + "<>" => c != 0, + "<" => c < 0, + ">" => c > 0, + "<=" => c <= 0, + _ => c >= 0, + }; + } default: throw new CalculatedExpressionException($"Unsupported operator '{node.Operator}'."); } } - private static object? In(CalcIn node, Func value) + private static bool? In(CalcIn node, Func value) { object? operand = Evaluate(node.Operand, value); if (operand is null) return null; @@ -124,24 +133,24 @@ internal static class CalculatedEvaluator switch (name.ToUpperInvariant()) { case "IIF": - { - if (node.Arguments.Count != 3) - throw new CalculatedExpressionException( - $"{name} takes 3 arguments, not {node.Arguments.Count}."); - object? condition = Evaluate(node.Arguments[0], value); - return Evaluate(node.Arguments[condition is not null && ToBoolean(condition) ? 1 : 2], value); - } + { + if (node.Arguments.Count != 3) + throw new CalculatedExpressionException( + $"{name} takes 3 arguments, not {node.Arguments.Count}."); + object? condition = Evaluate(node.Arguments[0], value); + return Evaluate(node.Arguments[condition is not null && ToBoolean(condition) ? 1 : 2], value); + } case "CHOOSE": - { - if (node.Arguments.Count < 2) - throw new CalculatedExpressionException($"{name} needs at least two arguments."); - object? selector = Evaluate(node.Arguments[0], value); - if (selector is null) return null; - int index = (int)Math.Round(ToNumber(selector), MidpointRounding.ToEven); - return index >= 1 && index < node.Arguments.Count - ? Evaluate(node.Arguments[index], value) - : null; - } + { + if (node.Arguments.Count < 2) + throw new CalculatedExpressionException($"{name} needs at least two arguments."); + object? selector = Evaluate(node.Arguments[0], value); + if (selector is null) return null; + int index = (int)Math.Round(ToNumber(selector), MidpointRounding.ToEven); + return index >= 1 && index < node.Arguments.Count + ? Evaluate(node.Arguments[index], value) + : null; + } } object?[] args = [.. node.Arguments.Select(a => Evaluate(a, value))]; @@ -581,4 +590,4 @@ private static bool Like(string text, string pattern) _ => throw new CalculatedExpressionException($"Cannot store a calculated {type} value."), }; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedExpression.cs b/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedExpression.cs index a7d1dbf4..1783273f 100644 --- a/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedExpression.cs +++ b/src/LibRed/LibRed.Core/Storage/Calculated/CalculatedExpression.cs @@ -61,15 +61,16 @@ public static CalcNode Parse(string text) /// Trim is in and LTrim/RTrim are out, Asc is in and Chr is out, and /// CDbl is the only conversion of the ten. Independently confirmed against the list Access's own /// Expression Builder offers. - private static readonly HashSet Accepted = new(StringComparer.OrdinalIgnoreCase) - { + private static readonly HashSet Accepted = + [ + with(StringComparer.OrdinalIgnoreCase), "Abs", "Sgn", "Int", "Fix", "Round", "Sqr", "Exp", "Log", "Sin", "Cos", "Tan", "Atn", "Len", "LCase", "UCase", "Trim", "Left", "Right", "Mid", "InStr", "Space", "String", "Str", "Asc", "DateSerial", "TimeSerial", "Year", "Month", "Day", "Hour", "Minute", "Second", "Weekday", "MonthName", "WeekdayName", "IIf", "Choose", "IsNull", "IsEmpty", "CDbl", "Pmt", "FV", "PV", "NPer", "Rate", "IPmt", "PPmt", "SLN", "SYD", "DDB", - }; + ]; /// Parses and checks it is one ACE would accept for a column named /// over . @@ -374,32 +375,32 @@ private static CalcNode ParsePrimary(List t, ref int p) return new CalcColumn(token.Text); case TokenKind.Punctuation when token.Text == "(": - { - p++; - CalcNode inner = ParseOr(t, ref p); - Expect(t, ref p, ")", TokenKind.Punctuation); - return inner; - } + { + p++; + CalcNode inner = ParseOr(t, ref p); + Expect(t, ref p, ")", TokenKind.Punctuation); + return inner; + } case TokenKind.Keyword or TokenKind.Identifier: - { - if (token.Text.Equals("True", StringComparison.OrdinalIgnoreCase)) { p++; return new CalcLiteral(true); } - if (token.Text.Equals("False", StringComparison.OrdinalIgnoreCase)) { p++; return new CalcLiteral(false); } - if (token.Text.Equals("Null", StringComparison.OrdinalIgnoreCase)) { p++; return new CalcLiteral(null); } + { + if (token.Text.Equals("True", StringComparison.OrdinalIgnoreCase)) { p++; return new CalcLiteral(true); } + if (token.Text.Equals("False", StringComparison.OrdinalIgnoreCase)) { p++; return new CalcLiteral(false); } + if (token.Text.Equals("Null", StringComparison.OrdinalIgnoreCase)) { p++; return new CalcLiteral(null); } - p++; - if (!IsPunctuation(t, p, "(")) return new CalcColumn(token.Text); + p++; + if (!IsPunctuation(t, p, "(")) return new CalcColumn(token.Text); - p++; // the '(' - var args = new List(); - if (!IsPunctuation(t, p, ")")) - { - args.Add(ParseOr(t, ref p)); - while (IsPunctuation(t, p, ",")) { p++; args.Add(ParseOr(t, ref p)); } + p++; // the '(' + var args = new List(); + if (!IsPunctuation(t, p, ")")) + { + args.Add(ParseOr(t, ref p)); + while (IsPunctuation(t, p, ",")) { p++; args.Add(ParseOr(t, ref p)); } + } + Expect(t, ref p, ")", TokenKind.Punctuation); + return new CalcCall(token.Text, args); } - Expect(t, ref p, ")", TokenKind.Punctuation); - return new CalcCall(token.Text, args); - } default: throw new CalculatedExpressionException($"Unexpected '{token.Text}' in expression."); @@ -532,4 +533,4 @@ private static List Tokenise(string text) /// A calculated-column expression LibRed cannot parse, or cannot evaluate the way ACE would. /// Never thrown for a value: a row that fails to evaluate must not be written at all. -public sealed class CalculatedExpressionException(string message) : InvalidOperationException(message); +public sealed class CalculatedExpressionException(string message) : InvalidOperationException(message); \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/CalculatedValue.cs b/src/LibRed/LibRed.Core/Storage/CalculatedValue.cs index 6758f57f..0de1a0c4 100644 --- a/src/LibRed/LibRed.Core/Storage/CalculatedValue.cs +++ b/src/LibRed/LibRed.Core/Storage/CalculatedValue.cs @@ -1,5 +1,5 @@ -using System.Buffers.Binary; using LibRed.Catalog; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -118,12 +118,12 @@ private static byte[] EncodePayload(ColumnDef column, JetDataType type, object v case JetDataType.Boolean: return [Convert.ToBoolean(value, culture) ? (byte)0xFF : (byte)0x00]; case JetDataType.Text: - { - string text = Convert.ToString(value, culture) ?? ""; - byte[] utf16 = System.Text.Encoding.Unicode.GetBytes(text); - if (column.HasLongValueMap) return utf16; // a calculated Memo: never compressed - return Types.JetTypeCodec.TryCompressText(column, text, requireCapableFlag: false) ?? utf16; - } + { + string text = Convert.ToString(value, culture) ?? ""; + byte[] utf16 = System.Text.Encoding.Unicode.GetBytes(text); + if (column.HasLongValueMap) return utf16; // a calculated Memo: never compressed + return Types.JetTypeCodec.TryCompressText(column, text, requireCapableFlag: false) ?? utf16; + } default: return Types.JetTypeCodec.Encode(column, type, value); } @@ -218,4 +218,4 @@ public static ReadOnlySpan Payload(ReadOnlySpan envelope, ColumnDef return envelope.Slice(HeaderSize + LengthSize, (int)length); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs index faa0fba3..1515e39a 100644 --- a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs @@ -1,8 +1,9 @@ -using System.Buffers.Binary; -using System.Text; using LibRed.Catalog; using LibRed.Formats; using LibRed.Pages; +using System.Buffers.Binary; +using System.Globalization; +using System.Text; namespace LibRed.Storage; @@ -23,8 +24,8 @@ public static class DatabaseCreator /// Format version byte (e.g. 0x02 = ACE 12 / Access 2007). /// true for the ACCDB identifier, false for the MDB (Jet) identifier. /// ANSI code page (1252 for en-US). - /// Default collation LCID (1033 = en-US). - /// Sort-order version (0 = General Legacy, 1 = General). + /// The database's default collation — its LCID and sort-order version + /// (1033 / version 0 is General Legacy en-US). /// Creation timestamp as an OLE-automation date (days since 1899-12-30), passed /// as the raw double so the exact millisecond-precise bit pattern is preserved — the page-0 SID mask is bound /// to those exact bits (see ). @@ -236,6 +237,8 @@ private static readonly (string Name, ColumnSpec[] Columns)[] MSysComplexTypeTab /// commit-byte table is seeded here, and Access adds the system tables it wants (MSysAccessStorage, the /// navigation-pane objects) itself — hand-creating those was tried and made things worse. /// + /// Where the new file is written. + /// The format version byte to create it at. /// /// The database's default text collating order, written to page 0 and inherited by every column created /// in it. Defaults to General-Legacy (LCID 1033, version 0), which is what the engine writes; pass @@ -344,11 +347,11 @@ void Ace(int objectId, byte[] sid, int acm, bool inherit = false) Ace(tablesC, SidCreator, 0x0F00FE, inherit: true); Ace(tablesC, SidUsers, 0x060001); Ace(tablesC, SidAdmin, 0x0FFEFF, inherit: true); Ace(databasesC, SidUsers, 0x060000); Ace(relationshipsC, SidCreator, 0x0F00FE, inherit: true); Ace(relationshipsC, SidUsers, 0x060001); Ace(relationshipsC, SidAdmin, 0x0FFFFF, inherit: true); - Ace(0x10000000, SidUsers, 0x06000E); Ace(0x10000000, SidAdmin, 0x00000E); // MSysDb - Ace(objPage, SidUsers, 0x060000); Ace(objPage, SidAdmin, 0x000014); // MSysObjects + Ace(0x10000000, SidUsers, 0x06000E); Ace(0x10000000, SidAdmin, 0x00000E); // MSysDb + Ace(objPage, SidUsers, 0x060000); Ace(objPage, SidAdmin, 0x000014); // MSysObjects Ace(acesPage, SidUsers, 0x060000); // MSysACEs (Users only) - Ace(queriesPage, SidUsers, 0x060000); Ace(queriesPage, SidAdmin, 0x000014); // MSysQueries - Ace(relPage, SidUsers, 0x0E0000); Ace(relPage, SidAdmin, 0x000014); // MSysRelationships + Ace(queriesPage, SidUsers, 0x060000); Ace(queriesPage, SidAdmin, 0x000014); // MSysQueries + Ace(relPage, SidUsers, 0x0E0000); Ace(relPage, SidAdmin, 0x000014); // MSysRelationships // The system tables carry the indexes Access uses to navigate the catalog. ParentIdName is the first // real index, Id (the PK) second — matching real files. @@ -423,7 +426,8 @@ private static void MarkAsSystemTable(JetDatabase db, string name, int flags) foreach ((RowId rowId, object?[] values) in msysObjects.Rows().WithIds()) { - if (values[idIndex] is not { } id || Convert.ToInt32(id) != definition.DefinitionPage) continue; + if (values[idIndex] is not { } id + || Convert.ToInt32(id, CultureInfo.InvariantCulture) != definition.DefinitionPage) continue; values[flagsIndex] = flags; values[ownerIndex] = SidEngine; msysObjects.Update(rowId, values, new HashSet { flagsIndex, ownerIndex }); @@ -524,4 +528,4 @@ private static void WriteInt24(byte[] b, int o, int v) { b[o] = (byte)v; b[o + 1] = (byte)(v >> 8); b[o + 2] = (byte)(v >> 16); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/IndexCursor.cs b/src/LibRed/LibRed.Core/Storage/IndexCursor.cs index 4c0b36c1..127a6a5f 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexCursor.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexCursor.cs @@ -71,4 +71,4 @@ public IEnumerable Entries(IReadOnlyList<(ColumnDef Column, bool Asc } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs index 3eb505f7..b05af257 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.Formats; @@ -146,4 +145,4 @@ private static long DecodeFloatBits(Span raw, bool ascending) foreach (byte b in raw) bits = (bits << 8) | b; return bits; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 765bea22..9baa15ac 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -1,10 +1,10 @@ -using System.Buffers.Binary; -using System.Globalization; -using System.Runtime.InteropServices; using EntityFrameworkCore.Jet.Data; using LibRed.Catalog; using LibRed.Formats; using LibRed.Storage.Types; +using System.Buffers.Binary; +using System.Globalization; +using System.Runtime.InteropServices; namespace LibRed.Storage; @@ -361,4 +361,4 @@ private static byte[] EncodeFloatBits(long bits, int size) return raw; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs b/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs index e051cf92..0a55e747 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs @@ -1,6 +1,6 @@ -using System.Buffers.Binary; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -137,4 +137,4 @@ private static void ValidatePageNumber(PageChannel channel, int pageNumber, stri throw new InvalidDataException( $"Index {kind} pointer {pageNumber} is outside the file's 1..{channel.PageCount - 1} range."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/IndexWriter.cs b/src/LibRed/LibRed.Core/Storage/IndexWriter.cs index c2ee4410..0bad6c5a 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexWriter.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexWriter.cs @@ -1,8 +1,8 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.Formats; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -325,6 +325,13 @@ private void InsertIntoLeaf(IndexDef index, List path, byte[] key, int poin /// Splits the (leaf or node) page at into two, writes both, then promotes a /// separator into the parent — splitting parents in turn, or growing a new root at the top. /// + /// The index whose tree is being split. + /// The pages from the root down to the one being split, one per level. + /// Which entry of is the page to split. + /// That page's entries, in key order, including the one just inserted. + /// Leaf or node — what the two halves are written as. + /// The split page's left sibling, for the leaf chain. + /// Its right sibling. /// How many entries stay on the left page; negative for the default half. Only a /// leaf split sets it, to keep a page full when the new entry is its maximum (see InsertIntoLeaf). private void SplitAndPropagate(IndexDef index, List path, int level, List entries, @@ -434,6 +441,12 @@ private CheckedIndexPage ReadMutationPage(int pageNumber, PageType expectedType) /// both matching what Access writes. (An isolation test showed neither is strictly required — Access reads /// a node with 0x1A=0 and compressed just fine; they are kept purely for byte-faithfulness. The one /// hard requirement is a leaf's 0x1A=0 and the leaf-chain offsets at 0x0C/0x10.) + /// Leaf or node. + /// The page's left sibling, written into the leaf chain. + /// Its right sibling. + /// The page's trailing pointer — a node's rightmost child. + /// A node's height above the leaves; 0 on a leaf. + /// The entries to write, in key order. /// The shared-prefix length to store the entries at. Null computes the largest /// available, which is what a split writes. It must not exceed what the entries actually share. private byte[]? Build(PageType type, int prev, int next, int tail, int level, List entries, @@ -626,6 +639,7 @@ private static byte[] WithTrailer(byte[] key, int trailer) /// Fills an empty index from by writing each page once, instead of /// inserting the entries one at a time and rewriting a whole leaf per entry. /// + /// The empty index to fill. /// (key, row pointer) pairs with a NullKey marker; any order. Sorted here. /// Enforce uniqueness — adjacent equal keys after the sort, null keys exempt /// (Jet's uniqueness is over the non-null keys only). @@ -818,4 +832,4 @@ private static int CompareBytes(ReadOnlySpan a, ReadOnlySpan b) private static void WriteInt32Le(byte[] page, int offset, int value) => BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(offset, 4), value); private static void WriteInt32Be(byte[] page, int offset, int value) => BinaryPrimitives.WriteInt32BigEndian(page.AsSpan(offset, 4), value); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs b/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs index f80691e9..6f6d3802 100644 --- a/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs +++ b/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs @@ -74,4 +74,4 @@ public static ushort Compute(ReadOnlySpan discarded) foreach (byte b in discarded[..^1]) crc = (ushort)((crc >> 8) ^ Table[crc & 0xFF] ^ b); return (ushort)(crc ^ (discarded[^1] << 8)); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs b/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs index 6342f526..f961dfa0 100644 --- a/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs +++ b/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs @@ -60,4 +60,4 @@ private static void AddFlags(List output, List flags, int marked, in output.Add((byte)packed); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs index 69fd1457..ce74dc9c 100644 --- a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs +++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs @@ -1,5 +1,5 @@ -using System.Collections.Frozen; using LibRed.Catalog; +using System.Collections.Frozen; namespace LibRed.Storage; @@ -19,10 +19,12 @@ namespace LibRed.Storage; /// departs in more than 47 of 193 sampled characters (see /// docs/format/page-03-04-index-btree.md §10.4). /// -/// Uppercase keys, except where a locale disagrees with invariant casing. -/// Whether a doubled digraph is written by doubling only its first letter, so +/// +/// Entries has uppercase keys, except where a locale disagrees with invariant casing. +/// DoublesDigraphs says whether a doubled digraph is written by doubling only its first letter, so /// ggy weighs as gy+gy rather than g+gy. Hungarian alone does this; -/// Czech, Croatian, Spanish and Danish all take the plain greedy match (cch = c+ch). +/// Czech, Croatian, Spanish and Danish all take the plain greedy match (cch = c+ch). +/// internal sealed class LocaleTailoring { public LocaleTailoring( @@ -69,6 +71,10 @@ public LocaleTailoring( /// with invariant casing — which Turkish does, where I is the dotless letter and i is not /// its lowercase. /// + /// The text being weighed. + /// Where in it to match. + /// The matched entry's weights. + /// How many characters the match covers. /// True when the match is a doubled digraph and must be emitted twice. public bool TryMatch( ReadOnlySpan text, int start, out TailoredWeight weight, out int consumed, out bool repeat) @@ -764,4 +770,4 @@ private static LocaleTailoring Table( table[text] = new TailoredWeight(primaries, secondary); return new LocaleTailoring(table, doublesDigraphs, reverseDiacritics); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 0c187b18..abbebc07 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -149,32 +149,76 @@ internal static class JetTextCollation private static readonly Dictionary Symbols = new() { [' '] = [0x07], - ['!'] = [0x09], ['"'] = [0x0A], ['#'] = [0x0C], ['$'] = [0x0E], ['%'] = [0x10], - ['&'] = [0x12], ['('] = [0x14], [')'] = [0x16], ['*'] = [0x18], [','] = [0x1A], - ['.'] = [0x1C], ['/'] = [0x1E], [':'] = [0x20], [';'] = [0x22], ['?'] = [0x24], - ['@'] = [0x26], ['['] = [0x27], ['\\'] = [0x29], [']'] = [0x2A], ['+'] = [0x2C], - ['<'] = [0x2E], ['='] = [0x30], ['>'] = [0x32], - ['^'] = [0x2B, 0x02], ['_'] = [0x2B, 0x03], ['`'] = [0x2B, 0x07], - ['{'] = [0x2B, 0x09], ['|'] = [0x2B, 0x0B], ['}'] = [0x2B, 0x0D], ['~'] = [0x2B, 0x0F], + ['!'] = [0x09], + ['"'] = [0x0A], + ['#'] = [0x0C], + ['$'] = [0x0E], + ['%'] = [0x10], + ['&'] = [0x12], + ['('] = [0x14], + [')'] = [0x16], + ['*'] = [0x18], + [','] = [0x1A], + ['.'] = [0x1C], + ['/'] = [0x1E], + [':'] = [0x20], + [';'] = [0x22], + ['?'] = [0x24], + ['@'] = [0x26], + ['['] = [0x27], + ['\\'] = [0x29], + [']'] = [0x2A], + ['+'] = [0x2C], + ['<'] = [0x2E], + ['='] = [0x30], + ['>'] = [0x32], + ['^'] = [0x2B, 0x02], + ['_'] = [0x2B, 0x03], + ['`'] = [0x2B, 0x07], + ['{'] = [0x2B, 0x09], + ['|'] = [0x2B, 0x0B], + ['}'] = [0x2B, 0x0D], + ['~'] = [0x2B, 0x0F], // Latin-1 punctuation and symbols, harvested from ACE's own index keys (see // SortKeyComparisonProbeTest). Each group mirrors the order of the corresponding Win32 NLS primaries // in ACE's compacted one-byte-per-group numbering: // 0x2B continues the ^_`{|}~ group NLS 0x0751..0x0757 - ['¡'] = [0x2B, 0x10], ['¦'] = [0x2B, 0x11], ['¨'] = [0x2B, 0x12], ['¯'] = [0x2B, 0x13], - ['´'] = [0x2B, 0x14], ['¸'] = [0x2B, 0x15], ['¿'] = [0x2B, 0x16], + ['¡'] = [0x2B, 0x10], + ['¦'] = [0x2B, 0x11], + ['¨'] = [0x2B, 0x12], + ['¯'] = [0x2B, 0x13], + ['´'] = [0x2B, 0x14], + ['¸'] = [0x2B, 0x15], + ['¿'] = [0x2B, 0x16], // 0x33 mathematical NLS 0x0817..0x081D (both skip the same slots) - ['±'] = [0x33, 0x04], ['«'] = [0x33, 0x05], ['»'] = [0x33, 0x07], - ['×'] = [0x33, 0x09], ['÷'] = [0x33, 0x0A], + ['±'] = [0x33, 0x04], + ['«'] = [0x33, 0x05], + ['»'] = [0x33, 0x07], + ['×'] = [0x33, 0x09], + ['÷'] = [0x33, 0x0A], // 0x34 currency then symbols — ACE runs two NLS groups (0x0797.. and 0x0A06..) into one - ['¢'] = [0x34, 0xA6], ['£'] = [0x34, 0xA7], ['¤'] = [0x34, 0xA8], ['¥'] = [0x34, 0xA9], - ['§'] = [0x34, 0xAA], ['©'] = [0x34, 0xAB], ['¬'] = [0x34, 0xAC], ['®'] = [0x34, 0xAD], - ['°'] = [0x34, 0xAE], ['µ'] = [0x34, 0xAF], ['¶'] = [0x34, 0xB0], ['·'] = [0x34, 0xB1], + ['¢'] = [0x34, 0xA6], + ['£'] = [0x34, 0xA7], + ['¤'] = [0x34, 0xA8], + ['¥'] = [0x34, 0xA9], + ['§'] = [0x34, 0xAA], + ['©'] = [0x34, 0xAB], + ['¬'] = [0x34, 0xAC], + ['®'] = [0x34, 0xAD], + ['°'] = [0x34, 0xAE], + ['µ'] = [0x34, 0xAF], + ['¶'] = [0x34, 0xB0], + ['·'] = [0x34, 0xB1], // 0x37 fractions NLS 0x0D0D/0x0D11/0x0D15 (step 4 in both) - ['¼'] = [0x37, 0x12], ['½'] = [0x37, 0x16], ['¾'] = [0x37, 0x1A], + ['¼'] = [0x37, 0x12], + ['½'] = [0x37, 0x16], + ['¾'] = [0x37, 0x1A], // Superscript digits take the *same* primary as their base digit and no distinguishing secondary, so // ACE sorts (and compares) '¹' equal to '1'. Verified: both encode to 7F 38 01 00. - ['¹'] = [0x38], ['²'] = [0x3A], ['³'] = [0x3C], + ['¹'] = [0x38], + ['²'] = [0x3A], + ['³'] = [0x3C], }; /// @@ -212,11 +256,16 @@ void Add(int ligature, params int[] components) /// codes, and the terminator). Trailing spaces are dropped. Returns false if any character is /// not yet supported. /// + /// The text to encode. + /// The key body is appended to this. /// Per-character overrides for a locale order other than General; null for /// General itself. See . public static bool TryEncode(string value, List output, LocaleTailoring? tailoring = null) => TryEncode(value, output, tailoring, out _); + /// The text to encode. + /// The key body is appended to this. + /// Per-character overrides for a locale order other than General; null for General. /// /// Whether the key carries an inline word-sort section. The caller needs this to decide whether an /// over-long entry may be truncated: the checksum that replaces the dropped bytes is unverified when @@ -288,7 +337,7 @@ public static bool TryEncode( // lengthen, and it stays the ordinary FF FF primary the table already holds. if (c is (char)0x30FC or (char)0xFF70 && kanaVowel != 0 && kanaWeight == secondaries.Count - 1) { - AddWeight([JetKanaSection.KanaPage,kanaVowel], DefaultSecondary); + AddWeight([JetKanaSection.KanaPage, kanaVowel], DefaultSecondary); kana.Add(kanaSmall); prolonged.Add(true); kanaWeight = secondaries.Count - 1; @@ -298,7 +347,7 @@ public static bool TryEncode( if (JetTextCollationTableV0.TryGetKana(c, out byte sound, out byte voicing, out bool small, out byte vowel)) { - AddWeight([JetKanaSection.KanaPage,sound], voicing); + AddWeight([JetKanaSection.KanaPage, sound], voicing); kana.Add(small); prolonged.Add(false); kanaWeight = secondaries.Count - 1; @@ -552,4 +601,4 @@ private static bool TryAddAccented(char u, Action add) } return false; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs index d4c1e393..ce007662 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs @@ -56,6 +56,10 @@ public static bool TryGetInlineCode(char c, out byte code) /// identically. Voicing is an ordinary secondary — 03 dakuten, 04 handakuten — though a few /// characters carry other values. Small forms are recorded in the kana section instead of the /// primary. + /// The character to look up. + /// Its sound index, the second byte of the primary. + /// Its voicing secondary. + /// Whether it is a small form. /// The sound a following prolonged mark takes: ー lengthens the preceding /// kana's VOWEL, not its sound, so がー is 7F 0A then 7F 02 — "ga" lengthened by /// "a". Zero where it could not be measured, in which case a following ー must be refused. @@ -155,4 +159,4 @@ private static int ReadVarInt(byte[] source, ref int offset) shift += 7; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index f7f04fbd..8a6baa53 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -1,7 +1,6 @@ using System.Buffers.Binary; using System.Diagnostics.CodeAnalysis; using System.IO.Compression; -using System.Reflection; using System.Text; namespace LibRed.Storage; @@ -75,6 +74,8 @@ internal static class JetTextCollationV1 public static bool TryEncode(string value, List output, LocaleTailoring? tailoring = null) => TryEncode(value, output, tailoring, out _); + /// The text to encode. + /// The key body is appended to this. /// Per-character overrides for a version-1 locale order other than General; null /// for General itself. The same mechanism as version 0 uses, and the same six devices — the entries just /// carry a two-byte (Script Member, Alphabetic Weight) primary instead of v0's single byte. @@ -502,4 +503,4 @@ public bool TryGetWeight(char character, out byte scriptMember, out byte alphabe public bool TryExpand(char character, [NotNullWhen(true)] out char[]? sequence) => expansions.TryGetValue(character, out sequence); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs index 701f108c..6e055e46 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs @@ -144,4 +144,4 @@ private static int ReadVarInt(byte[] source, ref int offset) shift += 7; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/JetTextComparer.cs b/src/LibRed/LibRed.Core/Storage/JetTextComparer.cs index e01ef703..37b729f2 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextComparer.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextComparer.cs @@ -19,4 +19,4 @@ public static class JetTextComparer return null; return Math.Sign(CollectionsMarshal.AsSpan(left).SequenceCompareTo(CollectionsMarshal.AsSpan(right))); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/LongValueReader.cs b/src/LibRed/LibRed.Core/Storage/LongValueReader.cs index 621f7c44..d383df7d 100644 --- a/src/LibRed/LibRed.Core/Storage/LongValueReader.cs +++ b/src/LibRed/LibRed.Core/Storage/LongValueReader.cs @@ -150,4 +150,4 @@ private byte[] ReadLvalRow(int page, int row) $"Long-value pointer {page}:{row} targets a deleted or overflow row slot."); return lval.GetRow(row).ToArray(); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/LongValueWriter.cs b/src/LibRed/LibRed.Core/Storage/LongValueWriter.cs index 4ec7d192..8c57857b 100644 --- a/src/LibRed/LibRed.Core/Storage/LongValueWriter.cs +++ b/src/LibRed/LibRed.Core/Storage/LongValueWriter.cs @@ -1,7 +1,7 @@ -using System.Buffers.Binary; using LibRed.Formats; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -193,4 +193,4 @@ private static byte[] Descriptor(int length, byte flag, int firstPage, int row = BinaryPrimitives.WriteUInt32LittleEndian(d.AsSpan(LongValueFormat.ChainStampOffset, 4), stamp); return d; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/PageAllocator.cs b/src/LibRed/LibRed.Core/Storage/PageAllocator.cs index a20f5a39..01102bb6 100644 --- a/src/LibRed/LibRed.Core/Storage/PageAllocator.cs +++ b/src/LibRed/LibRed.Core/Storage/PageAllocator.cs @@ -1,7 +1,7 @@ -using System.Buffers.Binary; -using System.Numerics; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; +using System.Numerics; namespace LibRed.Storage; @@ -151,7 +151,7 @@ public void ReturnReleasedPages() } /// The pages set in the global released-pages map, inline or reference form. - private IEnumerable ReleasedMapPages(MapRecord released) + private List ReleasedMapPages(MapRecord released) { ReadOnlySpan record = released.Record; var pages = new List(); @@ -664,4 +664,4 @@ public bool Contains(int page) return (bitmap[BitmapPageHeaderSize + inRange / 8] & (1 << (inRange % 8))) != 0; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/RowDecoder.cs b/src/LibRed/LibRed.Core/Storage/RowDecoder.cs index fe99d438..0014a39c 100644 --- a/src/LibRed/LibRed.Core/Storage/RowDecoder.cs +++ b/src/LibRed/LibRed.Core/Storage/RowDecoder.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.Formats; using LibRed.Storage.Types; @@ -152,4 +151,4 @@ private RowLayout ParseLayout(ReadOnlySpan row) private static bool IsPresent(ReadOnlySpan nullBitmap, int storedColumnCount, int columnId) => columnId >= 0 && columnId < storedColumnCount && (nullBitmap[columnId >> 3] & (1 << (columnId & 7))) != 0; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/RowEncoder.cs b/src/LibRed/LibRed.Core/Storage/RowEncoder.cs index d3070b3f..ed6dfcc0 100644 --- a/src/LibRed/LibRed.Core/Storage/RowEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/RowEncoder.cs @@ -1,7 +1,7 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.Formats; using LibRed.Storage.Types; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -24,7 +24,13 @@ public sealed class RowEncoder(IReadOnlyList columns, JetFormatBase f Func? spillCalculated = null) { private readonly IReadOnlyList _columns = columns; + + // Nothing in the row layout varies by format across the Jet 4 family, so this is unread today. It stays + // because Jet 3 rows differ (1-byte column counts, no variable-length offset widening), and the encoder + // will have to branch on it when that format is implemented. +#pragma warning disable CA1823, IDE0052 // Deliberately unread; see above. private readonly JetFormatBase _format = format; +#pragma warning restore CA1823, IDE0052 // How an oversized calculated result reaches an LVAL page, returning the in-row descriptor. A calculated // column is the one value the encoder DERIVES rather than receives, so it cannot have been materialised @@ -45,6 +51,7 @@ public sealed class RowEncoder(IReadOnlyList columns, JetFormatBase f public byte[] Encode(object?[] values) => Encode(values, null); /// Encodes a row, recomputing its calculated columns. + /// The row's values, one per column in table order. /// Envelopes to carry over verbatim, keyed by /// . An UPDATE that touches nothing a calculated column reads must leave /// the cached value exactly as it was: ACE recomputes only when a referenced column is written, so @@ -172,10 +179,10 @@ private static void EnsureFitsDeclaredLength(ColumnDef column, byte[] encoded) /// [count][fixed][var data][var-offset table][numVar] (the variable section is omitted entirely when /// there are none) then [null bitmap]. The count and bitmap width are maxColumnId + 1; a /// column's bit is set when present (Boolean = its truthy value), and dead ids (gaps below the max, from a - /// burned/dropped id) are set present too — all verified vs ACE (§5). Shared by and + /// burned/dropped id) are set present too — all verified vs ACE (§5). Shared by Encode and /// the ALTER COLUMN row re-lay so the two can never drift. /// - /// The declared-width check runs HERE rather than in . It used to sit above this call, + /// The declared-width check runs HERE rather than in Encode. It used to sit above this call, /// which meant the ALTER COLUMN re-lay — the other caller — never got it, and a narrowing retype could /// write rows Access refuses. A guard that both paths must pass through belongs on the shared path. /// @@ -263,4 +270,4 @@ private static int ComputeFixedDataLength(IReadOnlyList columns) length = Math.Max(length, c.FixedOffset + c.Length); return length; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/RowId.cs b/src/LibRed/LibRed.Core/Storage/RowId.cs index c48c52c7..36613a7a 100644 --- a/src/LibRed/LibRed.Core/Storage/RowId.cs +++ b/src/LibRed/LibRed.Core/Storage/RowId.cs @@ -1,4 +1,4 @@ namespace LibRed.Storage; /// A pointer to a row: the data page it lives on and its slot index within that page. -public readonly record struct RowId(int Page, int Row); +public readonly record struct RowId(int Page, int Row); \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/RowInserter.cs b/src/LibRed/LibRed.Core/Storage/RowInserter.cs index ad716d23..724fdd2a 100644 --- a/src/LibRed/LibRed.Core/Storage/RowInserter.cs +++ b/src/LibRed/LibRed.Core/Storage/RowInserter.cs @@ -1,10 +1,11 @@ -using System.Buffers; -using System.Buffers.Binary; -using System.Text; using LibRed.Catalog; using LibRed.Formats; using LibRed.IO; using LibRed.Pages; +using System.Buffers; +using System.Buffers.Binary; +using System.Globalization; +using System.Text; namespace LibRed.Storage; @@ -640,8 +641,7 @@ private HashSet IndexesGainingANewKey(object?[] values) private static bool HasNullKey(IndexDef index, object?[] values) => index.Columns.Any(c => values[c.Column.Index] is null or DBNull); - /// Finds a data page with room for a row of bytes, growing the table - /// if none has. + /// Finds a data page with room for a row of the needed size, growing the table if none has. /// /// Consults the free-pages map, not the owned-pages map. The two agree only while a table is /// small: once a page fills, Access clears its free bit (see ), so the free @@ -836,8 +836,8 @@ private void MaterializeLongValues(object?[] values) } // A page is dropped from the free-pages map once it cannot hold the smallest long value (a 65-byte - // payload — anything up to 64 inlines — plus its 2-byte row-directory entry). - private const int MaxLvalRowSize = 4076; // one LVAL page row (Jackcess MAX_LONG_VALUE_ROW_SIZE, Jet4) + // payload — anything up to 64 inlines — plus its 2-byte row-directory entry). The largest such row is + // 4076 bytes on a Jet 4 page (Jackcess MAX_LONG_VALUE_ROW_SIZE), which nothing here needs to name. private const int MinLvalRow = 65 + 2; /// Rejects a caller-supplied value for a calculated column, as ACE does — it refuses both an @@ -921,7 +921,7 @@ private byte[] StoreLongValue(LongValueWriter writer, byte[] payload, (int Row, } /// Reads every page marked in a validated inline or reference usage map. - private IReadOnlyList MapPages(int mapRow, int mapPage) + private List MapPages(int mapRow, int mapPage) { if (mapPage <= 1 || mapPage >= _channel.PageCount) throw new InvalidDataException($"Long-value usage-map page {mapPage} is outside the physical file."); @@ -1057,7 +1057,7 @@ private static int RandomAutoNumber() /// (verified: an index created on an empty table reads total `0` through any number of inserts). /// /// - private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? generatedAutoNumbers, IReadOnlySet newKeys) + private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? generatedAutoNumbers, HashSet newKeys) { byte[] tdef = _channel.ReadPageShared(_table.DefinitionPage).Span.ToArray(); @@ -1071,7 +1071,7 @@ private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? // a UI-authored Random AutoNumber reads last-value 0). Advancing it would be meaningless (random ids // don't form a monotone sequence) and diverge from Access's on-disk state. if (column.IsRandomAutoNumber) continue; - int assigned = Convert.ToInt32(value); + int assigned = Convert.ToInt32(value, CultureInfo.InvariantCulture); int highWater = BinaryPrimitives.ReadInt32LittleEndian(tdef.AsSpan(format.TdefLastAutoNumberOffset, 4)); // An id this insert *generated* always becomes the new high-water: it came from 0x14 + increment, // so it is by construction the next value in the sequence. That includes the wrap past @@ -1113,4 +1113,4 @@ private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? _channel.WritePage(_table.DefinitionPage, tdef); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/RowLayout.cs b/src/LibRed/LibRed.Core/Storage/RowLayout.cs index 9d1b3045..419855ea 100644 --- a/src/LibRed/LibRed.Core/Storage/RowLayout.cs +++ b/src/LibRed/LibRed.Core/Storage/RowLayout.cs @@ -1,5 +1,5 @@ -using System.Buffers.Binary; using LibRed.Catalog; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -11,12 +11,11 @@ namespace LibRed.Storage; /// /// The leading count is maxColumnId + 1 and drives the null-bitmap width. A table with NO variable /// columns omits the whole variable section (offset table + numVar) — such a row can't self-describe that, -/// so the caller passes from the schema. +/// so the caller passes hasVar from the schema. /// internal readonly ref struct RowLayout { private readonly ReadOnlySpan _row; - private readonly int _countSize; /// The leading column count (= max column id + 1). public int ColumnCount { get; } @@ -35,7 +34,6 @@ private RowLayout(ReadOnlySpan row, int countSize, bool hasVar) throw new InvalidDataException("Row is too short to contain its 2-byte column count."); _row = row; - _countSize = countSize; ColumnCount = BinaryPrimitives.ReadUInt16LittleEndian(row[..countSize]); NullBitmapSize = (ColumnCount + 7) / 8; if (row.Length < countSize + NullBitmapSize) @@ -111,4 +109,4 @@ public ReadOnlySpan VarChunk(int variableIndex) private int VarOffset(int entry) => BinaryPrimitives.ReadUInt16LittleEndian(_row.Slice(VarTableStart + entry * 2, 2)); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs b/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs index 928cbc8b..6ba62986 100644 --- a/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs +++ b/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs @@ -1,6 +1,6 @@ -using System.Buffers.Binary; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -63,4 +63,4 @@ public static RelocatedRow Resolve(PageChannel channel, int owningTablePage, return new RelocatedRow(targetBuffer, targetSlot, rowNumber); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/Table.cs b/src/LibRed/LibRed.Core/Storage/Table.cs index d6ca9d2b..307e3807 100644 --- a/src/LibRed/LibRed.Core/Storage/Table.cs +++ b/src/LibRed/LibRed.Core/Storage/Table.cs @@ -118,4 +118,4 @@ public bool HasDuplicateKey(IndexDef index, object?[] values, RowId excludeRow) /// Removes a deleted row's entry from one index. public void RemoveIndexEntry(IndexDef index, object?[] values, RowId id) => new IndexWriter(Channel, Definition).DeleteEntry(index, values, id); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/TableCreator.cs b/src/LibRed/LibRed.Core/Storage/TableCreator.cs index 10118345..3d43ff9d 100644 --- a/src/LibRed/LibRed.Core/Storage/TableCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/TableCreator.cs @@ -1,10 +1,11 @@ -using System.Buffers.Binary; -using System.Text; using EntityFrameworkCore.Jet.Data; using LibRed.Catalog; using LibRed.Formats; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; +using System.Globalization; +using System.Text; using MapRetirement = ((int Row, int Page) Map, System.Collections.Generic.IReadOnlyList<(int Row, int Page)> Clear, System.Collections.Generic.IReadOnlyList Pages); namespace LibRed.Storage; @@ -326,7 +327,8 @@ public void Create( JetDataType.Byte, RawValue: [(byte)resultType]); foreach (string version in CalculatedVersionProperties) yield return new PropertyBlob.Property( - column.Name, version, CalculatedMinimumVersion, JetDataType.Text) { IsDdl = false }; + column.Name, version, CalculatedMinimumVersion, JetDataType.Text) + { IsDdl = false }; } private static readonly string[] CalculatedVersionProperties = @@ -581,7 +583,7 @@ private static void RejectOleIndexColumns(IEnumerable columnNames, Func< } /// Resolves index column names to (columnId, ascending) slots against a table. - private static IReadOnlyList<(int Id, bool Ascending)> ResolveSlots( + private static List<(int Id, bool Ascending)> ResolveSlots( TableDef table, IEnumerable<(string Column, bool Ascending)> columns) { var byName = table.Columns.ToDictionary(c => c.Name, c => c.ColumnId, StringComparer.OrdinalIgnoreCase); @@ -592,7 +594,7 @@ private static void RejectOleIndexColumns(IEnumerable columnNames, Func< /// Surgically inserts one data index and its logical info block into an existing table's TDEF, /// name-sorted. gets (block number, data-block ordinal) and /// returns the 28-byte info block — a plain index or an outgoing-FK block. Returns the new block number. - private int InsertIndex(TableDef table, string indexName, IReadOnlyList<(int Id, bool Ascending)> slots, + private int InsertIndex(TableDef table, string indexName, List<(int Id, bool Ascending)> slots, bool unique, bool required, bool ignoreNulls, Func buildInfo) { JetFormatBase format = _channel.Format; @@ -1216,8 +1218,8 @@ public bool DropQueryObject(string name) foreach (object?[] values in new Table(_channel, mo).Rows()) if (string.Equals(values[nameIdx] as string, name, StringComparison.OrdinalIgnoreCase) - && Convert.ToInt16(values[typeIdx] ?? (short)0) == type) - return Convert.ToInt32(values[idIdx]); + && Convert.ToInt16(values[typeIdx] ?? (short)0, CultureInfo.InvariantCulture) == type) + return Convert.ToInt32(values[idIdx], CultureInfo.InvariantCulture); return null; } @@ -1266,7 +1268,8 @@ private void RenameCatalogObject(int tdefPage, string newName) var table = new Table(_channel, def); foreach ((RowId id, object?[] values) in table.Rows().WithIds() - .Where(r => r.Values[idIndex] is not null && Convert.ToInt32(r.Values[idIndex]) == tdefPage) + .Where(r => r.Values[idIndex] is not null + && Convert.ToInt32(r.Values[idIndex], CultureInfo.InvariantCulture) == tdefPage) .ToList()) { SetCatalogValues(table, def, id, values, (nameIndex, newName)); @@ -1371,7 +1374,7 @@ private void RenameColumnProperties(int tdefPage, string oldName, string newName foreach ((RowId id, object?[] values) in table.Rows().WithIds()) { - if (values[idIndex] is null || Convert.ToInt32(values[idIndex]) != tdefPage) continue; + if (values[idIndex] is null || Convert.ToInt32(values[idIndex], CultureInfo.InvariantCulture) != tdefPage) continue; if (values[lvProp.Index] is not byte[] { Length: > 0 } blob) return; byte[] renamed = RewriteCalculatedReferences( @@ -1477,8 +1480,9 @@ private bool ObjectNameExists(string name, int exceptObjectId) { if (!NameMatches(values[nameIndex], name)) continue; // Skip the object being renamed — it can't collide with itself (same-name and case-only renames). - if (values[idIndex] is not null && Convert.ToInt32(values[idIndex]) == exceptObjectId) continue; - short type = Convert.ToInt16(values[typeIndex] ?? (short)0); + if (values[idIndex] is not null + && Convert.ToInt32(values[idIndex], CultureInfo.InvariantCulture) == exceptObjectId) continue; + short type = Convert.ToInt16(values[typeIndex] ?? (short)0, CultureInfo.InvariantCulture); if (type is ObjectTypeTable or StoredQueryFormat.ObjectTypeQuery) return true; } @@ -1525,7 +1529,8 @@ private void DeleteCatalogRows(string catalogTable, string keyColumn, int keyVal var table = new Table(_channel, t); var rows = table.Rows().WithIds() - .Where(r => r.Values[idx] is not null && Convert.ToInt32(r.Values[idx]) == keyValue) + .Where(r => r.Values[idx] is not null + && Convert.ToInt32(r.Values[idx], CultureInfo.InvariantCulture) == keyValue) .ToList(); foreach ((RowId id, object?[] values) in rows) { @@ -1868,7 +1873,7 @@ private void MutateLvPropForColumn(string tableName, string columnName, Action 0 } blob) return; byte[] cleaned = PropertyBlob.RemoveOwner(blob, columnName); @@ -3215,7 +3220,7 @@ private void WriteDefinition(int firstPage, byte[] def, IReadOnlyList oldCo } } - private static byte[] BuildIndexDataBlock(IReadOnlyList<(int Id, bool Ascending)> columns, int rootPage, int usageRow, int usagePage, bool unique, bool required, bool ignoreNulls) + private static byte[] BuildIndexDataBlock(List<(int Id, bool Ascending)> columns, int rootPage, int usageRow, int usagePage, bool unique, bool required, bool ignoreNulls) { var b = new byte[IndexBlockFormat.DataBlockSize]; System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0, 4), IndexBlockFormat.DataMarker); @@ -3322,7 +3327,7 @@ private void AddIncomingRelationshipBlock(IncomingRelationship inc) WriteDefinition(inc.ParentPage, def, existingContinuations, rewrite: true); } - private byte[] BuildIncomingInfoBlock(IncomingRelationship inc) + private static byte[] BuildIncomingInfoBlock(IncomingRelationship inc) { var b = new byte[IndexBlockFormat.InfoBlockSize]; BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(IndexBlockFormat.InfoMarkerOffset, 4), JetFormatBase.TdefRecordMarker); @@ -3501,4 +3506,4 @@ private static void WriteInt24(byte[] buffer, int offset, int value) buffer[offset + 1] = (byte)(value >> 8); buffer[offset + 2] = (byte)(value >> 16); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/TableCursor.cs b/src/LibRed/LibRed.Core/Storage/TableCursor.cs index f3ed39d0..eb881e78 100644 --- a/src/LibRed/LibRed.Core/Storage/TableCursor.cs +++ b/src/LibRed/LibRed.Core/Storage/TableCursor.cs @@ -57,4 +57,4 @@ public sealed class TableCursor(Table table) : IEnumerable } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs index 52b14d88..02b2bb20 100644 --- a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs +++ b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs @@ -1,8 +1,8 @@ -using System.Buffers.Binary; -using System.Text; using EntityFrameworkCore.Jet.Data; using LibRed.Catalog; using LibRed.Formats; +using System.Buffers.Binary; +using System.Text; namespace LibRed.Storage.Types; @@ -209,7 +209,8 @@ public static string DecodeText(ReadOnlySpan value) } /// - /// Encodes a non-null CLR value to its on-disk bytes — the inverse of . + /// Encodes a non-null CLR value to its on-disk bytes — the inverse of + /// . /// Boolean is not handled here (its value lives in the null bitmap). Text is written as UTF-16LE, or /// compressed (§7) when says ACE would. A memo/OLE value small enough to /// inline is written as an inline long value (see ); a larger @@ -260,16 +261,16 @@ internal static byte[] Encode(ColumnDef column, JetDataType type, object value) return Bytes(8, b => BinaryPrimitives.WriteInt64LittleEndian(b, (long)decimal.Round(JetDecimalConverter.ToDecimal(value, c) * 10000m))); case JetDataType.Guid: // Coerced, not cast: every other type here accepts what the caller has (AsText, AsBinary, ToOaDate, - // Convert.To*), and TableCreator.ConvertValue already parses a string GUID on the ALTER path. A hard - // cast turned a string reaching a GUID column into an InvalidCastException with no column named. - return (value switch - { - Guid g => g, - byte[] b when b.Length == 16 => new Guid(b), - string s when Guid.TryParse(s, out Guid parsed) => parsed, - _ => throw new NotSupportedException( - $"Cannot store {value.GetType().Name} in GUID column '{column.Name}'."), - }).ToByteArray(); + // Convert.To*), and TableCreator.ConvertValue already parses a string GUID on the ALTER path. A hard + // cast turned a string reaching a GUID column into an InvalidCastException with no column named. + return (value switch + { + Guid g => g, + byte[] b when b.Length == 16 => new Guid(b), + string s when Guid.TryParse(s, out Guid parsed) => parsed, + _ => throw new NotSupportedException( + $"Cannot store {value.GetType().Name} in GUID column '{column.Name}'."), + }).ToByteArray(); case JetDataType.Text: return EncodeText(column, AsText(value, c)); case JetDataType.Binary: @@ -281,14 +282,14 @@ string s when Guid.TryParse(s, out Guid parsed) => parsed, // text as UTF-16LE, OLE as raw bytes). LongValueReader reads this back via the inline // flag. Chained LVAL pages for values too large to inline are not written yet. case JetDataType.Memo: - { - // An inline memo compresses whether or not the column was declared WITH COMPRESSION — the - // capable flag gates single-page values, not inline ones (see TryCompressText). Only values - // the caller has already decided to inline reach here, so no storage-form test is needed. - string memo = AsText(value, c); - return EncodeInlineLongValue( - TryCompressText(column, memo, requireCapableFlag: false) ?? Encoding.Unicode.GetBytes(memo)); - } + { + // An inline memo compresses whether or not the column was declared WITH COMPRESSION — the + // capable flag gates single-page values, not inline ones (see TryCompressText). Only values + // the caller has already decided to inline reach here, so no storage-form test is needed. + string memo = AsText(value, c); + return EncodeInlineLongValue( + TryCompressText(column, memo, requireCapableFlag: false) ?? Encoding.Unicode.GetBytes(memo)); + } case JetDataType.Ole: return EncodeInlineLongValue(AsBinary(column, value)); @@ -520,4 +521,4 @@ private static byte[] EncodeNumeric(ColumnDef column, decimal value) BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(13, 4), (uint)bits[0]); return result; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/UsageMap.cs b/src/LibRed/LibRed.Core/Storage/UsageMap.cs index adb0a40b..147cb056 100644 --- a/src/LibRed/LibRed.Core/Storage/UsageMap.cs +++ b/src/LibRed/LibRed.Core/Storage/UsageMap.cs @@ -1,8 +1,8 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.Formats; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -136,7 +136,7 @@ private byte[] ReadMapRecord(int pointerOffset) /// Reads the usage map whose (row, page) pointer sits at in /// the TDEF. Both maps share the same pointer shape and record format. - private IEnumerable PagesAt(int pointerOffset) + private List PagesAt(int pointerOffset) { PageBuffer tdef = _channel.ReadPage(_table.DefinitionPage); return ReadMapAt(tdef.ReadByte(pointerOffset), tdef.ReadInt24(pointerOffset + 1)); @@ -248,4 +248,4 @@ private void AppendSetBits(List pages, ReadOnlySpan bitmap, int baseP } } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/UsageMapWriter.cs b/src/LibRed/LibRed.Core/Storage/UsageMapWriter.cs index 839b828a..8a43d847 100644 --- a/src/LibRed/LibRed.Core/Storage/UsageMapWriter.cs +++ b/src/LibRed/LibRed.Core/Storage/UsageMapWriter.cs @@ -1,7 +1,7 @@ -using System.Buffers.Binary; using LibRed.Formats; using LibRed.IO; using LibRed.Pages; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -339,4 +339,4 @@ private void ConvertInlineToReference(int mapPage, int mapRow) BinaryPrimitives.WriteUInt16LittleEndian(result.AsSpan(format.DataFreeSpaceOffset, 2), (ushort)(offset - directoryEnd)); return result; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs index fc125c4f..3961f7bd 100644 --- a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs @@ -1,7 +1,7 @@ -using System.Buffers.Binary; using LibRed.Catalog; using LibRed.Formats; using LibRed.IO; +using System.Buffers.Binary; namespace LibRed.Storage; @@ -321,4 +321,4 @@ private static void SetByName(TableDef table, object?[] values, string column, o private static int ColumnIndex(TableDef table, string column) => (table.FindColumn(column) ?? throw new InvalidOperationException($"'{table.Name}' is missing the '{column}' column.")).Index; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Design/Internal/LibRedDesignTimeServices.cs b/src/LibRed/LibRed.EFCore/Design/Internal/LibRedDesignTimeServices.cs index 8514237a..ed3b5ecb 100644 --- a/src/LibRed/LibRed.EFCore/Design/Internal/LibRedDesignTimeServices.cs +++ b/src/LibRed/LibRed.EFCore/Design/Internal/LibRedDesignTimeServices.cs @@ -33,4 +33,4 @@ public virtual void ConfigureDesignTimeServices(IServiceCollection serviceCollec .TryAddCoreServices(); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Extensions/LibRedDbContextOptionsBuilderExtensions.cs b/src/LibRed/LibRed.EFCore/Extensions/LibRedDbContextOptionsBuilderExtensions.cs index 3777232a..6ffafb1a 100644 --- a/src/LibRed/LibRed.EFCore/Extensions/LibRedDbContextOptionsBuilderExtensions.cs +++ b/src/LibRed/LibRed.EFCore/Extensions/LibRedDbContextOptionsBuilderExtensions.cs @@ -1,11 +1,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Data.Common; using EntityFrameworkCore.LibRed.Infrastructure; using EntityFrameworkCore.LibRed.Infrastructure.Internal; using LibRed.Data; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Infrastructure; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore; @@ -445,4 +442,4 @@ var coreOptionsExtension ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(coreOptionsExtension); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Extensions/LibRedServiceCollectionExtensions.cs b/src/LibRed/LibRed.EFCore/Extensions/LibRedServiceCollectionExtensions.cs index c84eb3a2..466daf2b 100644 --- a/src/LibRed/LibRed.EFCore/Extensions/LibRedServiceCollectionExtensions.cs +++ b/src/LibRed/LibRed.EFCore/Extensions/LibRedServiceCollectionExtensions.cs @@ -2,17 +2,16 @@ using EntityFrameworkCore.Jet.Internal; using EntityFrameworkCore.Jet.Metadata.Internal; using EntityFrameworkCore.Jet.Migrations.Internal; -using EntityFrameworkCore.LibRed.Migrations.Internal; using EntityFrameworkCore.Jet.Query; using EntityFrameworkCore.Jet.Query.ExpressionTranslators.Internal; using EntityFrameworkCore.Jet.Query.Internal; -using EntityFrameworkCore.Jet.Query.Sql.Internal; using EntityFrameworkCore.Jet.Storage.Internal; using EntityFrameworkCore.Jet.Update.Internal; using EntityFrameworkCore.Jet.Utilities; using EntityFrameworkCore.Jet.ValueGeneration.Internal; using EntityFrameworkCore.LibRed.Infrastructure.Internal; using EntityFrameworkCore.LibRed.Internal; +using EntityFrameworkCore.LibRed.Migrations.Internal; using EntityFrameworkCore.LibRed.Query.Internal; using EntityFrameworkCore.LibRed.Query.Sql.Internal; using EntityFrameworkCore.LibRed.Storage.Internal; @@ -72,4 +71,4 @@ public static IServiceCollection AddEntityFrameworkLibRed(this IServiceCollectio return serviceCollection; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Infrastructure/Internal/ILibRedOptions.cs b/src/LibRed/LibRed.EFCore/Infrastructure/Internal/ILibRedOptions.cs index 8f30ba42..f5d21990 100644 --- a/src/LibRed/LibRed.EFCore/Infrastructure/Internal/ILibRedOptions.cs +++ b/src/LibRed/LibRed.EFCore/Infrastructure/Internal/ILibRedOptions.cs @@ -11,4 +11,4 @@ public interface ILibRedOptions : ISingletonOptions bool UseShortTextForSystemString { get; } LibRedSqlMode SqlMode { get; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Infrastructure/LibRedDbContextOptionsBuilder.cs b/src/LibRed/LibRed.EFCore/Infrastructure/LibRedDbContextOptionsBuilder.cs index e1114e55..f3a7a9e6 100644 --- a/src/LibRed/LibRed.EFCore/Infrastructure/LibRedDbContextOptionsBuilder.cs +++ b/src/LibRed/LibRed.EFCore/Infrastructure/LibRedDbContextOptionsBuilder.cs @@ -1,9 +1,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using EntityFrameworkCore.LibRed.Infrastructure.Internal; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; namespace EntityFrameworkCore.LibRed.Infrastructure { diff --git a/src/LibRed/LibRed.EFCore/Infrastructure/LibRedSqlMode.cs b/src/LibRed/LibRed.EFCore/Infrastructure/LibRedSqlMode.cs index f09a2ebf..c31089cd 100644 --- a/src/LibRed/LibRed.EFCore/Infrastructure/LibRedSqlMode.cs +++ b/src/LibRed/LibRed.EFCore/Infrastructure/LibRedSqlMode.cs @@ -1,4 +1,4 @@ -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. namespace EntityFrameworkCore.LibRed.Infrastructure { @@ -18,4 +18,4 @@ public enum LibRedSqlMode /// Compatible = 1, } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Internal/LibRedOptions.cs b/src/LibRed/LibRed.EFCore/Internal/LibRedOptions.cs index 81b680db..98a663d3 100644 --- a/src/LibRed/LibRed.EFCore/Internal/LibRedOptions.cs +++ b/src/LibRed/LibRed.EFCore/Internal/LibRedOptions.cs @@ -1,6 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Linq; using EntityFrameworkCore.LibRed.Infrastructure; using EntityFrameworkCore.LibRed.Infrastructure.Internal; @@ -74,4 +73,4 @@ public virtual void Validate(IDbContextOptions options) /// public virtual string? ConnectionString { get; private set; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/LibRed.EFCore.csproj b/src/LibRed/LibRed.EFCore/LibRed.EFCore.csproj index 03f80152..73107321 100644 --- a/src/LibRed/LibRed.EFCore/LibRed.EFCore.csproj +++ b/src/LibRed/LibRed.EFCore/LibRed.EFCore.csproj @@ -19,9 +19,6 @@ - - - @@ -44,7 +41,6 @@ - - - + + diff --git a/src/LibRed/LibRed.EFCore/LibRedDesignTimeMarker.cs b/src/LibRed/LibRed.EFCore/LibRedDesignTimeMarker.cs index cf904ecb..41e6aab3 100644 --- a/src/LibRed/LibRed.EFCore/LibRedDesignTimeMarker.cs +++ b/src/LibRed/LibRed.EFCore/LibRedDesignTimeMarker.cs @@ -43,4 +43,4 @@ public static class LibRedProviderInfo { /// Invariant name suitable for ADO.NET provider registration. public const string InvariantName = "LibRed.Data"; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/LibRedRetryingExecutionStrategy.cs b/src/LibRed/LibRed.EFCore/LibRedRetryingExecutionStrategy.cs index 758be97f..72d624e5 100644 --- a/src/LibRed/LibRed.EFCore/LibRedRetryingExecutionStrategy.cs +++ b/src/LibRed/LibRed.EFCore/LibRedRetryingExecutionStrategy.cs @@ -1,7 +1,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using EntityFrameworkCore.LibRed.Storage.Internal; -using Microsoft.EntityFrameworkCore.Storage; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore; @@ -133,4 +132,4 @@ protected override bool ShouldRetryOn(Exception exception) } return baseDelay; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Metadata/Conventions/LibRedConventionSetBuilder.cs b/src/LibRed/LibRed.EFCore/Metadata/Conventions/LibRedConventionSetBuilder.cs index 6c53c1ba..2022fa99 100644 --- a/src/LibRed/LibRed.EFCore/Metadata/Conventions/LibRedConventionSetBuilder.cs +++ b/src/LibRed/LibRed.EFCore/Metadata/Conventions/LibRedConventionSetBuilder.cs @@ -58,4 +58,4 @@ private static IServiceScope CreateServiceScope() return serviceProvider.GetRequiredService().CreateScope(); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Migrations/Internal/LibRedHistoryRepository.cs b/src/LibRed/LibRed.EFCore/Migrations/Internal/LibRedHistoryRepository.cs index 2726b171..04e1f09c 100644 --- a/src/LibRed/LibRed.EFCore/Migrations/Internal/LibRedHistoryRepository.cs +++ b/src/LibRed/LibRed.EFCore/Migrations/Internal/LibRedHistoryRepository.cs @@ -1,7 +1,7 @@ -using System.Text; using EntityFrameworkCore.Jet.Internal; using EntityFrameworkCore.Jet.Migrations.Internal; using EntityFrameworkCore.Jet.Utilities; +using System.Text; namespace EntityFrameworkCore.LibRed.Migrations.Internal { @@ -447,4 +447,4 @@ public override IReadOnlyList GetAppliedMigrations() return rows; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Migrations/LibRedMigrationsSqlGenerator.cs b/src/LibRed/LibRed.EFCore/Migrations/LibRedMigrationsSqlGenerator.cs index 0e73177a..0d5ed70d 100644 --- a/src/LibRed/LibRed.EFCore/Migrations/LibRedMigrationsSqlGenerator.cs +++ b/src/LibRed/LibRed.EFCore/Migrations/LibRedMigrationsSqlGenerator.cs @@ -4,10 +4,8 @@ using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Metadata.Internal; using EntityFrameworkCore.Jet.Migrations.Operations; -using EntityFrameworkCore.Jet.Storage.Internal; using EntityFrameworkCore.Jet.Update.Internal; using EntityFrameworkCore.Jet.Utilities; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using System.Text; // ReSharper disable once CheckNamespace @@ -830,7 +828,7 @@ protected override void ColumnDefinition( var columnType = GetColumnType(schema, table, name, operation, model); //int has no size - ignore - if (columnType != null && columnType.StartsWith("int(")) + if (columnType != null && columnType.StartsWith("int(", StringComparison.Ordinal)) { columnType = "int"; } @@ -846,7 +844,7 @@ protected override void ColumnDefinition( protected override string GetColumnType( string? schema, - string table, + string tableName, string name, ColumnOperation operation, IModel? model) @@ -867,7 +865,7 @@ protected override string GetColumnType( storeType = "integer"; } - storeType ??= base.GetColumnType(schema, table, name, operation, model); + storeType ??= base.GetColumnType(schema, tableName, name, operation, model); if (string.Equals(storeType, "counter", StringComparison.OrdinalIgnoreCase) && operation[JetAnnotationNames.Identity] is string identity && @@ -887,7 +885,7 @@ operation[JetAnnotationNames.Identity] is string identity && return storeType; } - var fullTableName = schema != null ? $"{schema}.{table}" : table; + var fullTableName = schema != null ? $"{schema}.{tableName}" : tableName; throw new InvalidOperationException( RelationalStrings.UnsupportedTypeForColumn(fullTableName, name, operation.ClrType?.Name ?? "unknown")); } diff --git a/src/LibRed/LibRed.EFCore/Properties/AssemblyInfo.cs b/src/LibRed/LibRed.EFCore/Properties/AssemblyInfo.cs index e4c1b95a..09b5b7ef 100644 --- a/src/LibRed/LibRed.EFCore/Properties/AssemblyInfo.cs +++ b/src/LibRed/LibRed.EFCore/Properties/AssemblyInfo.cs @@ -1,3 +1 @@ -using Microsoft.EntityFrameworkCore.Design; - -[assembly: DesignTimeProviderServices("EntityFrameworkCore.LibRed.Design.Internal.LibRedDesignTimeServices")] +[assembly: DesignTimeProviderServices("EntityFrameworkCore.LibRed.Design.Internal.LibRedDesignTimeServices")] \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMathTranslator.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMathTranslator.cs index 6c2e5077..b3765ae5 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMathTranslator.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMathTranslator.cs @@ -194,4 +194,4 @@ public class LibRedMathTranslator(ISqlExpressionFactory sqlExpressionFactory) : return null; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs index cf244d49..7694d22f 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs @@ -42,4 +42,4 @@ public LibRedMethodCallTranslatorProvider( new JetTimeOnlyMethodTranslator(sqlExpressionFactory) ]); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessor.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessor.cs index 89112bd0..1256d2f1 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessor.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessor.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using EntityFrameworkCore.Jet.Query.Internal; -using EntityFrameworkCore.Jet.Utilities; using EntityFrameworkCore.LibRed.Infrastructure; namespace EntityFrameworkCore.LibRed.Query.Internal; @@ -65,9 +64,9 @@ public override Expression Process(Expression queryExpression, ParametersCacheDe } /// - protected override Expression ProcessSqlNullability(Expression selectExpression, ParametersCacheDecorator Decorator) + protected override Expression ProcessSqlNullability(Expression queryExpression, ParametersCacheDecorator decorator) { return new JetSqlNullabilityProcessor(Dependencies, Parameters).Process( - selectExpression, Decorator); + queryExpression, decorator); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessorFactory.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessorFactory.cs index 0a0c7430..50455b1f 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessorFactory.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedParameterBasedSqlProcessorFactory.cs @@ -35,4 +35,4 @@ public class LibRedParameterBasedSqlProcessorFactory( /// public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) => new LibRedParameterBasedSqlProcessor(Dependencies, parameters, options.SqlMode); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessor.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessor.cs index 659d6def..91c4bdc8 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessor.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessor.cs @@ -1,14 +1,10 @@ -using System.Diagnostics.CodeAnalysis; -using EntityFrameworkCore.Jet.Internal; using EntityFrameworkCore.Jet.Query.Internal; using EntityFrameworkCore.LibRed.Infrastructure; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; namespace EntityFrameworkCore.LibRed.Query.Internal { public class LibRedQueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor { - private readonly IRelationalTypeMappingSource _relationalTypeMappingSource; private readonly JetLiftOrderByPostprocessor _liftOrderByPostprocessor; private readonly JetSkipTakePostprocessor _skipTakePostprocessor; private readonly LibRedSqlMode _sqlMode; @@ -21,7 +17,6 @@ public LibRedQueryTranslationPostprocessor( LibRedSqlMode sqlMode) : base(dependencies, relationalDependencies, queryCompilationContext) { - _relationalTypeMappingSource = relationalTypeMappingSource; _sqlMode = sqlMode; _liftOrderByPostprocessor = new JetLiftOrderByPostprocessor(relationalTypeMappingSource, relationalDependencies.SqlExpressionFactory, queryCompilationContext.SqlAliasManager); _skipTakePostprocessor = new JetSkipTakePostprocessor(relationalTypeMappingSource, @@ -45,4 +40,4 @@ public override Expression Process(Expression query) return query; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessorFactory.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessorFactory.cs index 57f81cf6..c5b277dc 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessorFactory.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedQueryTranslationPostprocessorFactory.cs @@ -30,4 +30,4 @@ public virtual QueryTranslationPostprocessor Create(QueryCompilationContext quer relationalTypeMappingSource, options.SqlMode); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitor.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitor.cs index 120ee5c5..1db6c3a2 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitor.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitor.cs @@ -1,9 +1,8 @@ -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -using System.Text; using EntityFrameworkCore.Jet; using EntityFrameworkCore.Jet.Internal; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using System.Globalization; +using System.Text; using ExpressionExtensions = Microsoft.EntityFrameworkCore.Query.ExpressionExtensions; namespace EntityFrameworkCore.LibRed.Query.Internal; @@ -191,12 +190,12 @@ protected override Expression VisitUnary(UnaryExpression unaryExpression) protected override Expression VisitExtension(Expression extensionExpression) { - var result = base.VisitExtension(extensionExpression); + var result = base.VisitExtension(extensionExpression); if (extensionExpression is ShapedQueryExpression shapedQueryExpression) { var shaperExpression = shapedQueryExpression.ShaperExpression; if (shapedQueryExpression.ResultCardinality == ResultCardinality.SingleOrDefault - && !shaperExpression.Type.IsNullableType() && result is SqlFunctionExpression { Name: "COALESCE"} sqlFunctionExpression) + && !shaperExpression.Type.IsNullableType() && result is SqlFunctionExpression { Name: "COALESCE" } sqlFunctionExpression) { if (sqlFunctionExpression.Arguments?[1] is SqlConstantExpression { Value: DateTime { Ticks: 0 } }) { @@ -301,7 +300,7 @@ bool TryTranslateStartsEndsWithContains( // IsLikeWildChar) and send a simple LIKE translation = patternConstant.Value switch { - null => _sqlExpressionFactory.Like(translatedInstance, _sqlExpressionFactory.Constant(null,typeof(string), stringTypeMapping)), + null => _sqlExpressionFactory.Like(translatedInstance, _sqlExpressionFactory.Constant(null, typeof(string), stringTypeMapping)), // In .NET, all strings start with/end with/contain the empty string, but SQL LIKE return false for empty patterns. // Return % which always matches instead. @@ -614,4 +613,4 @@ private IReadOnlyList ApplyMinMaxResultTypeMapping( private static string? GetProviderType(SqlExpression expression) => expression.TypeMapping?.StoreType; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitorFactory.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitorFactory.cs index d76e2bbc..3cbc38bb 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitorFactory.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedSqlTranslatingExpressionVisitorFactory.cs @@ -32,4 +32,4 @@ public virtual RelationalSqlTranslatingExpressionVisitor Create( ? new JetSqlTranslatingExpressionVisitor(Dependencies, queryCompilationContext, queryableMethodTranslatingExpressionVisitor) : new LibRedSqlTranslatingExpressionVisitor(Dependencies, queryCompilationContext, queryableMethodTranslatingExpressionVisitor); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGenerator.cs b/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGenerator.cs index b69adad7..a60daf82 100644 --- a/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGenerator.cs +++ b/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGenerator.cs @@ -1,8 +1,6 @@ -using EntityFrameworkCore.Jet.Infrastructure; using EntityFrameworkCore.Jet.Query.Sql.Internal; using EntityFrameworkCore.Jet.Utilities; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -using Microsoft.EntityFrameworkCore.Storage.Internal; namespace EntityFrameworkCore.LibRed.Query.Sql.Internal { @@ -25,7 +23,7 @@ public class LibRedQuerySqlGenerator : QuerySqlGenerator, IJetExpressionVisitor { nameof(Decimal), "CDEC" }, { nameof(DateTime), "CDATE" }, { nameof(TimeOnly), "TIMEVALUE" }, - }; + }; private readonly ITypeMappingSource _typeMappingSource; private readonly ISqlGenerationHelper _sqlGenerationHelper; @@ -214,7 +212,7 @@ protected override Expression VisitSqlUnary(SqlUnaryExpression sqlUnaryExpressio } - private Expression VisitJetConvertExpression(SqlUnaryExpression convertExpression) + private SqlUnaryExpression VisitJetConvertExpression(SqlUnaryExpression convertExpression) { var typeMapping = convertExpression.TypeMapping ?? throw new InvalidOperationException( RelationalStrings.UnsupportedType(convertExpression.Type.ShortDisplayName())); @@ -274,8 +272,7 @@ protected override string GetOperator(SqlBinaryExpression binaryExpression) _ => base.GetOperator(binaryExpression), }; - /// - /// Generates the TOP part of the SELECT statement, + /// Generates the TOP part of the SELECT statement. /// The select expression. protected override void GenerateTop(SelectExpression selectExpression) { @@ -442,4 +439,4 @@ protected override void CheckComposableSqlTrimmed(ReadOnlySpan sql) } } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGeneratorFactory.cs b/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGeneratorFactory.cs index 26c415ca..e7c96269 100644 --- a/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGeneratorFactory.cs +++ b/src/LibRed/LibRed.EFCore/Query/Sql/Internal/LibRedQuerySqlGeneratorFactory.cs @@ -1,4 +1,4 @@ -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using EntityFrameworkCore.Jet.Query.Sql.Internal; using EntityFrameworkCore.LibRed.Infrastructure; @@ -24,4 +24,4 @@ public virtual QuerySqlGenerator Create() ? new JetQuerySqlGenerator(_dependencies, _typeMappingSource) : new LibRedQuerySqlGenerator(_dependencies, _typeMappingSource); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedCodeGenerator.cs b/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedCodeGenerator.cs index 5055ea02..70e2e371 100644 --- a/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedCodeGenerator.cs +++ b/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedCodeGenerator.cs @@ -35,4 +35,4 @@ public override MethodCallCodeFragment GenerateUseProvider( ? [connectionString] : [connectionString, new NestedClosureCodeFragment("x", providerOptions)]); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedDatabaseModelFactory.cs b/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedDatabaseModelFactory.cs index 51f92a28..e40671b7 100644 --- a/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedDatabaseModelFactory.cs +++ b/src/LibRed/LibRed.EFCore/Scaffolding/Internal/LibRedDatabaseModelFactory.cs @@ -1,17 +1,11 @@ -using System.Data.Common; -using System.Text.RegularExpressions; using EntityFrameworkCore.Jet.Internal; // FK scaffolding-logger extensions (ForeignKeyFound, …) using EntityFrameworkCore.Jet.Metadata.Internal; // JetAnnotationNames (identity seed/increment) using LibRed; using LibRed.Catalog; using LibRed.Data; using LibRed.Engine.Schema; // shared store-type/nullability derivations (JetStoreType) — kept in sync with INFORMATION_SCHEMA -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using System.Text.RegularExpressions; namespace EntityFrameworkCore.LibRed.Scaffolding.Internal; @@ -74,7 +68,7 @@ public override DatabaseModel Create(DbConnection connection, DatabaseModelFacto return databaseModel; } - private IReadOnlyList GetTables(JetDatabase database, DatabaseModel databaseModel, Func? filter) + private List GetTables(JetDatabase database, DatabaseModel databaseModel, Func? filter) { var tables = new List(); @@ -398,4 +392,4 @@ private static object ParseStringDefault(string text) return null; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/ILibRedRelationalConnection.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/ILibRedRelationalConnection.cs index d1354791..f5211c99 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/ILibRedRelationalConnection.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/ILibRedRelationalConnection.cs @@ -1,5 +1,3 @@ -using EntityFrameworkCore.Jet.Storage.Internal; - namespace EntityFrameworkCore.LibRed.Storage.Internal; /// @@ -12,4 +10,4 @@ namespace EntityFrameworkCore.LibRed.Storage.Internal; /// public interface ILibRedRelationalConnection : IRelationalConnection { -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedBoolTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedBoolTypeMapping.cs index 6ed3461f..8fafc659 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedBoolTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedBoolTypeMapping.cs @@ -4,7 +4,7 @@ namespace EntityFrameworkCore.LibRed.Storage.Internal { public class LibRedBoolTypeMapping : BoolTypeMapping { - public static new LibRedBoolTypeMapping Default { get; } = new("smallint"); + public static new LibRedBoolTypeMapping Default { get; } = new("smallint"); public LibRedBoolTypeMapping( string storeType, @@ -28,4 +28,4 @@ protected override string GenerateNonNullSqlLiteral(object value) : "FALSE"; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteArrayTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteArrayTypeMapping.cs index e47b050c..3b5bd35b 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteArrayTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteArrayTypeMapping.cs @@ -1,8 +1,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.EntityFrameworkCore.Storage.Json; using System.Globalization; using System.Text; -using Microsoft.EntityFrameworkCore.Storage.Json; namespace EntityFrameworkCore.LibRed.Storage.Internal { @@ -108,4 +108,4 @@ protected override string ProcessStoreType(RelationalTypeMappingParameters param return base.ProcessStoreType(parameters, storeType.Replace("(max)", ""), storeTypeNameBase); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteTypeMapping.cs index 3f1cb9cc..aa7d3509 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedByteTypeMapping.cs @@ -57,4 +57,4 @@ protected override string GenerateNonNullSqlLiteral(object value) { return $"CBYTE({base.GenerateNonNullSqlLiteral(value)})"; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedCurrencyTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedCurrencyTypeMapping.cs index 554b6389..a4e9724f 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedCurrencyTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedCurrencyTypeMapping.cs @@ -48,4 +48,4 @@ protected override string ProcessStoreType(RelationalTypeMappingParameters param return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs index e6cce3a6..1e24bab6 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs @@ -1,6 +1,4 @@ -using EntityFrameworkCore.Jet.Storage.Internal; using LibRed.Data; -using Microsoft.EntityFrameworkCore.Storage; namespace EntityFrameworkCore.LibRed.Storage.Internal; @@ -53,4 +51,4 @@ public override Task HasTablesAsync(CancellationToken cancellationToken = cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(HasTables()); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateOnlyTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateOnlyTypeMapping.cs index a1dcaaaa..24746914 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateOnlyTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateOnlyTypeMapping.cs @@ -68,7 +68,7 @@ protected virtual DateTime ConvertToDateTimeCompatibleValue(object value) private static DateTime CheckDateTimeValue(DateTime dateTime) { - if (dateTime != default && dateTime < new DateTime(100,1,1)) + if (dateTime != default && dateTime < new DateTime(100, 1, 1)) { throw new InvalidOperationException($"The {nameof(DateTime)} value '{dateTime}' is smaller than the minimum supported value of '{new DateTime(100, 1, 1)}'."); } @@ -81,4 +81,4 @@ protected override string ProcessStoreType(RelationalTypeMappingParameters param return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs index db6abe8b..ba928050 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs @@ -49,4 +49,4 @@ protected override string GenerateNonNullSqlLiteral(object value) return $"CDATE({string.Format(CultureInfo.InvariantCulture, format, dateTime)})"; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs index be02f1e2..7f64270b 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs @@ -107,4 +107,4 @@ protected override string ProcessStoreType(RelationalTypeMappingParameters param return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDecimalTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDecimalTypeMapping.cs index d624359b..37e0e65f 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDecimalTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDecimalTypeMapping.cs @@ -1,5 +1,5 @@ -using System.Data; using Microsoft.EntityFrameworkCore.Storage.Json; +using System.Data; namespace EntityFrameworkCore.LibRed.Storage.Internal { @@ -118,4 +118,4 @@ protected override string GenerateNonNullSqlLiteral(object value) return base.GenerateNonNullSqlLiteral(value); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDoubleTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDoubleTypeMapping.cs index 88332c1a..aa2971f2 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDoubleTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDoubleTypeMapping.cs @@ -38,7 +38,7 @@ protected override string GenerateNonNullSqlLiteral(object value) // double.MinValue renders as -1.7976931348623157E+308, which ACE accepts. // ("R" was unreliable on .NET Framework — hence the old G17 advice — but has been // shortest-round-trippable since .NET Core 3.0.) - var doubleValue = Convert.ToDouble(value); + var doubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); var literal = doubleValue.ToString("R", CultureInfo.InvariantCulture); return !literal.Contains('E') @@ -50,4 +50,4 @@ protected override string GenerateNonNullSqlLiteral(object value) : literal; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategy.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategy.cs index ab8aec53..77481c91 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategy.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategy.cs @@ -1,6 +1,4 @@ using EntityFrameworkCore.Jet.Internal; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; namespace EntityFrameworkCore.LibRed.Storage.Internal; @@ -68,4 +66,4 @@ public virtual async Task ExecuteAsync( throw new InvalidOperationException(JetStrings.TransientExceptionDetected, ex); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategyFactory.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategyFactory.cs index d6c2e0fa..08c83d82 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategyFactory.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedExecutionStrategyFactory.cs @@ -1,7 +1,3 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.Extensions.DependencyInjection; - namespace EntityFrameworkCore.LibRed.Storage.Internal; /// @@ -35,4 +31,4 @@ public class LibRedExecutionStrategyFactory( /// protected override IExecutionStrategy CreateDefaultStrategy(ExecutionStrategyDependencies dependencies) => new LibRedExecutionStrategy(dependencies); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedFloatTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedFloatTypeMapping.cs index 96b72518..57f9cfc0 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedFloatTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedFloatTypeMapping.cs @@ -42,6 +42,6 @@ protected override string GenerateNonNullSqlLiteral(object value) // G15, G7 rounds the extremes *down*, so it never yields an out-of-range literal.) // - "R" is the shortest string that parses back exactly: 0.1f/85.55f stay clean, and // float.MaxValue renders as 3.4028235E+38. - => Convert.ToSingle(value).ToString("R", CultureInfo.InvariantCulture); + => Convert.ToSingle(value, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedGuidTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedGuidTypeMapping.cs index d5823b77..52673f7b 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedGuidTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedGuidTypeMapping.cs @@ -28,4 +28,4 @@ protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters p protected override string SqlLiteralFormatString => "{{{0}}}"; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedIntTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedIntTypeMapping.cs index c4290585..4f3908e8 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedIntTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedIntTypeMapping.cs @@ -19,4 +19,4 @@ protected LibRedIntTypeMapping(RelationalTypeMappingParameters parameters) // We therefore lock the store type to its original value (which should be "integer"). protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new LibRedIntTypeMapping(parameters.WithStoreTypeAndSize(Parameters.StoreType, parameters.Size)); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedJsonTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedJsonTypeMapping.cs index 660f98e1..46306332 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedJsonTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedJsonTypeMapping.cs @@ -115,4 +115,4 @@ protected override string GenerateNonNullSqlLiteral(object value) /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new LibRedJsonTypeMapping(parameters); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedLongTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedLongTypeMapping.cs index 168be139..02af3806 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedLongTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedLongTypeMapping.cs @@ -29,4 +29,4 @@ protected LibRedLongTypeMapping(RelationalTypeMappingParameters parameters) protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new LibRedLongTypeMapping(parameters); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalConnection.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalConnection.cs index d0047397..f6469a52 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalConnection.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalConnection.cs @@ -1,7 +1,4 @@ -using System.Data.Common; -using EntityFrameworkCore.Jet.Storage.Internal; using LibRed.Data; -using Microsoft.EntityFrameworkCore.Storage; namespace EntityFrameworkCore.LibRed.Storage.Internal; @@ -15,4 +12,4 @@ public class LibRedRelationalConnection(RelationalConnectionDependencies depende { protected override DbConnection CreateDbConnection() => new LibRedConnection { ConnectionString = ConnectionString }; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalTransaction.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalTransaction.cs index 3fcdf5c9..027e44c3 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalTransaction.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedRelationalTransaction.cs @@ -1,8 +1,3 @@ -using System.Data.Common; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Storage; - namespace EntityFrameworkCore.LibRed.Storage.Internal; /// @@ -44,4 +39,4 @@ public override Task ReleaseSavepointAsync(string name, CancellationToken cancel => _transaction.ReleaseAsync(name, cancellationToken); public override bool SupportsSavepoints => true; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedShortTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedShortTypeMapping.cs index 71c70af9..f17acb1e 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedShortTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedShortTypeMapping.cs @@ -56,4 +56,4 @@ protected override string GenerateNonNullSqlLiteral(object value) { return $"CINT({base.GenerateNonNullSqlLiteral(value)})"; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedStringTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedStringTypeMapping.cs index 083e3029..e29f5577 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedStringTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedStringTypeMapping.cs @@ -1,7 +1,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Data; using Microsoft.EntityFrameworkCore.Storage.Json; +using System.Data; namespace EntityFrameworkCore.LibRed.Storage.Internal { @@ -24,7 +24,7 @@ public class LibRedStringTypeMapping : StringTypeMapping /// public LibRedStringTypeMapping( string? storeType = null, - bool unicode = true , + bool unicode = true, int? size = null, bool fixedLength = false, StoreTypePostfix? storeTypePostfix = null, @@ -32,7 +32,7 @@ public LibRedStringTypeMapping( bool useKeyComparison = false) : this( new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(typeof(string), comparer: useKeyComparison ? CaseInsensitiveValueComparer : null, keyComparer:useKeyComparison ? CaseInsensitiveValueComparer : null, jsonValueReaderWriter: JsonStringReaderWriter.Instance), + new CoreTypeMappingParameters(typeof(string), comparer: useKeyComparison ? CaseInsensitiveValueComparer : null, keyComparer: useKeyComparison ? CaseInsensitiveValueComparer : null, jsonValueReaderWriter: JsonStringReaderWriter.Instance), storeType ?? GetStoreName(fixedLength), storeTypePostfix ?? StoreTypePostfix.Size, (fixedLength @@ -117,4 +117,4 @@ protected override string GenerateNonNullSqlLiteral(object value) .Replace("\n", "' & CHR(10) & '"); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs index ebe0993e..b90e6f9d 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs @@ -41,4 +41,4 @@ protected override string ProcessStoreType(RelationalTypeMappingParameters param return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs index cea6d50b..018dbd69 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs @@ -32,4 +32,4 @@ protected override string GenerateNonNullSqlLiteral(object value) : FormattableString.Invariant($@"TIMEVALUE('{value:hh\:mm\:ss}')"); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransactionFactory.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransactionFactory.cs index 55d858ec..b7eed936 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransactionFactory.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransactionFactory.cs @@ -1,8 +1,3 @@ -using System.Data.Common; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Storage; - namespace EntityFrameworkCore.LibRed.Storage.Internal; /// @@ -26,4 +21,4 @@ public virtual RelationalTransaction Create( bool transactionOwned) => new LibRedRelationalTransaction( connection, transaction, transactionId, logger, transactionOwned, Dependencies.SqlGenerationHelper); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransientExceptionDetector.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransientExceptionDetector.cs index 429d16d2..247f0264 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransientExceptionDetector.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTransientExceptionDetector.cs @@ -30,4 +30,4 @@ public static bool ShouldRetryOn(Exception ex, ISet? additionalErrorNumbers return false; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs index 33f19497..7117d2ec 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs @@ -1,10 +1,9 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Collections; -using System.Data; -using System.Text.Json; using EntityFrameworkCore.Jet.Internal; using EntityFrameworkCore.LibRed.Infrastructure.Internal; +using System.Collections; +using System.Data; namespace EntityFrameworkCore.LibRed.Storage.Internal { @@ -16,7 +15,6 @@ public class LibRedTypeMappingSource : RelationalTypeMappingSource { private readonly LibRedByteArrayTypeMapping _fixedLengthBinary = new("binary"); private readonly LibRedByteArrayTypeMapping _variableLengthBinary = new("varbinary"); - private readonly LibRedByteArrayTypeMapping _variableLengthMaxBinary = new("varbinary", storeTypePostfix: StoreTypePostfix.None); private readonly LibRedByteArrayTypeMapping _unboundedBinary = new("longbinary", storeTypePostfix: StoreTypePostfix.None); private readonly LibRedBoolTypeMapping _bit = new("bit"); // JET bits are not nullable @@ -36,7 +34,7 @@ public class LibRedTypeMappingSource : RelationalTypeMappingSource private readonly LibRedDecimalTypeMapping _decimal = new("decimal(18,2)", DbType.Decimal, precision: 18, scale: 2, StoreTypePostfix.PrecisionAndScale); private readonly LibRedDecimalTypeMapping _decimal18_0 = new("decimal", DbType.Decimal, precision: 18, scale: 0); - private readonly LibRedDecimalTypeMapping _currency = new("currency", DbType.Currency, storeTypePostfix:StoreTypePostfix.None); + private readonly LibRedDecimalTypeMapping _currency = new("currency", DbType.Currency, storeTypePostfix: StoreTypePostfix.None); private readonly LibRedDateTimeTypeMapping _datetime; private readonly LibRedDateTimeTypeMapping _dateasdatetime; @@ -47,7 +45,6 @@ public class LibRedTypeMappingSource : RelationalTypeMappingSource private readonly LibRedStringTypeMapping _fixedLengthUnicodeString = new("char", unicode: true); private readonly LibRedStringTypeMapping _variableLengthUnicodeString = new("varchar", unicode: true); - private readonly LibRedStringTypeMapping _variableLengthMaxUnicodeString = new("varchar", unicode: true, size: 255, storeTypePostfix: StoreTypePostfix.Size); private readonly LibRedStringTypeMapping _unboundedUnicodeString = new("longchar", unicode: true, storeTypePostfix: StoreTypePostfix.None); private readonly LibRedGuidTypeMapping _guid; private readonly LibRedByteArrayTypeMapping _rowversion = new("varbinary", size: 8, @@ -203,10 +200,10 @@ public LibRedTypeMappingSource( // This is because we don't try to make a new type from this string and any max length value // specified in the model, which means use of these strings is almost certainly an error, and // if it is not an error, then using, for example, varbinary(1) will work instead. - _disallowedMappings - = new HashSet(StringComparer.OrdinalIgnoreCase) - { - }; + // IDE0028's only fix here is a collection expression, which would drop the comparer. +#pragma warning disable IDE0028 + _disallowedMappings = new HashSet(StringComparer.OrdinalIgnoreCase); +#pragma warning restore IDE0028 _options = options; } @@ -438,4 +435,4 @@ protected override void ValidateMapping(CoreTypeMapping? mapping, IProperty? pro return parsedName; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.EFCore/Update/Internal/LibRedUpdateSqlGenerator.cs b/src/LibRed/LibRed.EFCore/Update/Internal/LibRedUpdateSqlGenerator.cs index dff24edd..9b4ebc50 100644 --- a/src/LibRed/LibRed.EFCore/Update/Internal/LibRedUpdateSqlGenerator.cs +++ b/src/LibRed/LibRed.EFCore/Update/Internal/LibRedUpdateSqlGenerator.cs @@ -1,11 +1,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Data; -using System.Globalization; -using System.Text; using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Update.Internal; using EntityFrameworkCore.Jet.Utilities; +using System.Data; +using System.Globalization; +using System.Text; namespace EntityFrameworkCore.LibRed.Update.Internal { @@ -226,7 +226,7 @@ public override ResultSetMapping AppendStoredProcedureCall( } Check.DebugAssert( - storedProcedure.Parameters.Any() || storedProcedure.ResultColumns.Any(), + storedProcedure.Parameters.Count > 0 || storedProcedure.ResultColumns.Any(), "Stored procedure call with neither parameters nor result columns"); commandStringBuilder.Append("EXEC "); @@ -247,7 +247,7 @@ public override ResultSetMapping AppendStoredProcedureCall( SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, storedProcedure.Name, storedProcedure.Schema); - if (storedProcedure.Parameters.Any()) + if (storedProcedure.Parameters.Count > 0) { commandStringBuilder.Append(' '); @@ -297,4 +297,4 @@ public override ResultSetMapping AppendStoredProcedureCall( return resultSetMapping; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs index a4b1dcb9..45dfd46a 100644 --- a/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs +++ b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs @@ -62,7 +62,7 @@ public static ColumnSpec ToColumnSpec(ColumnDefinition column, JetVersion versio /// counts 1, 2, 3. Every other type is left exactly as declared: SHORT, BIGINT, TEXT(10), /// GUID and the rest accept the word and ignore it (all verified). /// - internal static ColumnSpec WithIdentity(ColumnSpec spec, IdentityAttribute? identity) + internal static ColumnSpec WithIdentity(ColumnSpec spec, IdentitySpec? identity) { if (identity is null || spec.Type != JetDataType.Int32) return spec; return spec with { IsAutoNumber = true, Seed = identity.Seed ?? 1, Increment = identity.Increment ?? 1 }; @@ -248,4 +248,4 @@ private static ColumnSpec Binary(ColumnDefinition column, bool isFixed) $"bytes in Jet/ACE (got {bytes}). Use LONGBINARY/OLE for longer data."); return new(column.Name, JetDataType.Binary, bytes, IsFixedLength: isFixed); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs b/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs index 3148078c..dd233c80 100644 --- a/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs +++ b/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs @@ -63,4 +63,4 @@ internal void Charge(long startTimestamp) long elapsed = _timestamp() - startTimestamp; if (elapsed > 0) _spent += elapsed; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/EvalScope.cs b/src/LibRed/LibRed.Engine/Execution/EvalScope.cs index 239ee741..9dd19cfa 100644 --- a/src/LibRed/LibRed.Engine/Execution/EvalScope.cs +++ b/src/LibRed/LibRed.Engine/Execution/EvalScope.cs @@ -95,9 +95,10 @@ public IReadOnlyList AllColumns() } } -/// Executes a subquery, correlating it to . +/// Executes a subquery, correlating it to an enclosing query's scope. internal interface IScalarSubqueryRunner { + /// The subquery's single value, correlated to . object? ExecuteScalar(SqlStatement query, EvalScope outerScope); /// True when the (possibly correlated) subquery returns at least one row. @@ -121,4 +122,4 @@ internal interface IScalarSubqueryRunner /// a different kind from them — and the caller then scans 's values as before. /// (bool Found, bool HasNull)? LookupHoistedIn(SqlStatement query, object value); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ExistsSemiJoin.cs b/src/LibRed/LibRed.Engine/Execution/ExistsSemiJoin.cs index 7c9365fd..e57488be 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExistsSemiJoin.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExistsSemiJoin.cs @@ -1,6 +1,5 @@ using EntityFrameworkCore.Jet.Data; using LibRed.Catalog; -using LibRed.Engine.Plan; using LibRed.Engine.Planning; using LibRed.Sql.Ast; @@ -216,6 +215,8 @@ internal bool Matches(QueryExecutor executor, ExpressionEvaluator outerEval) /// column yielded a null for this row — the caller needs both to reproduce SQL's three-valued IN, /// where "no match" and "no match but a null was seen" differ (FALSE against UNKNOWN). /// + /// The executor the probe's own rows are read through. + /// The evaluator for the outer row, used to build this row's key. /// /// The already-evaluated left side. The caller has evaluated it to check for null before reaching here, so /// re-evaluating it would only repeat that work. @@ -264,4 +265,4 @@ internal bool Matches(QueryExecutor executor, ExpressionEvaluator outerEval) return probe; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.Format.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.Format.cs index 03936389..9dded3d0 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.Format.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.Format.cs @@ -1,6 +1,6 @@ +using LibRed.Sql.Ast; using System.Globalization; using System.Text; -using LibRed.Sql.Ast; namespace LibRed.Engine.Execution; @@ -33,7 +33,7 @@ private enum NumberStyle /// date formats. A Null value is empty text, or the format's Null section. A Null format or setting gives Null /// where ACE raises an error. /// - private object? FormatValue(FunctionCall f) + private string? FormatValue(FunctionCall f) { object? value = Evaluate(f.Arguments[0]); string format = ""; @@ -736,7 +736,7 @@ public string Write(FormatDigits value) /// setting), and anything else is an invalid procedure call. A Null value is empty text; a Null setting gives Null /// where ACE raises a type mismatch. /// - private object? FormatStyled(FunctionCall f, NumberStyle style) + private string? FormatStyled(FunctionCall f, NumberStyle style) { int[] settings = [-1, -2, -2, -2]; for (int i = 1; i < f.Arguments.Count; i++) @@ -829,7 +829,7 @@ private static string StyledNumber(FormatDigits value, NumberStyle style, int di /// date, 3 the long time and 4 the 24-hour hh:mm time. A format outside 0-4 is an invalid procedure call even for /// a Null date, which is empty text. The date is read as CDate reads it. /// - private object? FormatDateTime(FunctionCall f) + private string? FormatDateTime(FunctionCall f) { if (Optional(f, 1, 0, v => Setting(v, 0, 4)) is not { } format) return null; @@ -837,4 +837,4 @@ private static string StyledNumber(FormatDigits value, NumberStyle style, int di return ""; return DateSymbols(RoundToSecond(ToDate(value)), DateTimeFormats[format], DayOfWeek.Sunday, CalendarWeekRule.FirstDay); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index 78ada7d8..24a15e51 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -1,9 +1,9 @@ -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; using EntityFrameworkCore.Jet.Data; using LibRed.Sql.Ast; using LibRed.Storage; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; namespace LibRed.Engine.Execution; @@ -102,7 +102,7 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) /// x [NOT] IN (subquery) with SQL three-valued semantics: NULL if x is null or (no match /// and the subquery yields a null), otherwise the membership result (negated for NOT IN). - private object? EvaluateInSubquery(InSubqueryExpression inq) + private bool? EvaluateInSubquery(InSubqueryExpression inq) { object? val = Evaluate(inq.Value); if (val is null) return null; @@ -699,12 +699,11 @@ private static bool IsPlainText(string text) => /// one is an invalid procedure call); a number is a character code in the ANSI code page, taken modulo 256 (verified /// vs ACE: String(3, 321) is 'AAA', String(3, True) 'ÿÿÿ'). /// - private object? StringOf(FunctionCall f) + private string? StringOf(FunctionCall f) { if (CountArgument(f, 0) is not { } count || Evaluate(f.Arguments[1]) is not { } charValue) return null; - char ch = charValue is string or Guid or byte[] - ? FirstCharacter(charValue)[0] + char ch = charValue is string or Guid or byte[]? FirstCharacter(charValue)[0] : AnsiCharacter(AsLong(charValue) & 0xFF); return new string(ch, count); } @@ -714,7 +713,7 @@ private static bool IsPlainText(string text) => /// one the database sort order, with trailing spaces breaking a tie (verified vs ACE: StrComp('ß', 'ss') is 0, /// StrComp('a-b', 'ab') 1, StrComp('a', 'a ') -1). /// - private object? StrComp(FunctionCall f) + private int? StrComp(FunctionCall f) { object? a = Evaluate(f.Arguments[0]); object? b = Evaluate(f.Arguments[1]); @@ -736,7 +735,7 @@ private static bool IsPlainText(string text) => /// effective start position; start=0 (or <-1) → "Invalid procedure call", and one past the end of /// string1 gives 0; and — unlike InStr — a NULL string raises "Data type mismatch" rather than propagating /// NULL. - private object? InstrRev(FunctionCall f) + private int? InstrRev(FunctionCall f) { object? s1v = Evaluate(f.Arguments[0]); object? s2v = Evaluate(f.Arguments[1]); @@ -908,7 +907,7 @@ private bool IsCurrency(Expression expression) => /// locale, is an invalid procedure call even then. ACE keeps an odd trailing byte from 128 inside an expression /// (LenB(StrConv("abc", 128)) is 3); LibRed text has no odd bytes, so it drops it. /// - private object? StrConv(FunctionCall f) + private string? StrConv(FunctionCall f) { object? sv = Evaluate(f.Arguments[0]); if (Evaluate(f.Arguments[1]) is not { } modeV) @@ -963,7 +962,7 @@ private static string ProperCase(string s) /// Access MonthName(month, [abbreviate]): the English month name, abbreviated when asked. A month /// outside 1-12 is an invalid procedure call (verified vs ACE, True included). - private object? MonthNameOf(FunctionCall f) + private string? MonthNameOf(FunctionCall f) { if (Evaluate(f.Arguments[0]) is not { } month || Abbreviate(f) is not { } abbreviate) return null; @@ -977,7 +976,7 @@ private static string ProperCase(string s) /// 1-based position weekday in a week starting on firstdayofweek, which defaults to the system's /// first day (verified vs ACE: WeekdayName(1) is Monday under en-AU). A weekday outside 1-7 is an invalid /// procedure call. - private object? WeekdayNameOf(FunctionCall f) + private string? WeekdayNameOf(FunctionCall f) { if (Evaluate(f.Arguments[0]) is not { } weekday || Abbreviate(f) is not { } abbreviate || FirstDayOfWeek(f, 2, absent: 0) is not { } first) @@ -994,7 +993,7 @@ private static string ProperCase(string s) /// lower = stop+1, upper blank; otherwise the interval bucket (verified vs ACE). The arguments are read as /// the conversion functions read them (a date as its serial); a negative start, a stop not after the start or an /// interval below 1 is an invalid procedure call. NULL-propagating. - private object? PartitionOf(FunctionCall f) + private string? PartitionOf(FunctionCall f) { object?[] args = f.Arguments.Select(Evaluate).ToArray(); if (args.Any(a => a is null)) return null; @@ -1059,11 +1058,11 @@ private static string ProperCase(string s) /// The result of a byte-slice function: a **byte[]** when the input was binary (so a further byte /// function like ASCB(RIGHTB(x,1)) can read the raw byte — the mechanism EFCore.Jet's ByteArrayLength /// relies on), or the decoded string (dropping a trailing odd byte) when the input was text. - private static object ByteResult(object input, byte[] slice) => input is byte[] ? slice : FromBytes(slice); + private static object ByteResult(object input, byte[] slice) => input is byte[]? slice : FromBytes(slice); /// VBA InStrB([start,] string1, string2): the 1-based **byte** position of string2's bytes in /// string1's bytes (0 if not found). NULL-propagating. - private object? InstrB(FunctionCall f) + private int? InstrB(FunctionCall f) { int argc = f.Arguments.Count; object? s1v = Evaluate(f.Arguments[argc >= 3 ? 1 : 0]); @@ -1166,7 +1165,7 @@ private static string FromBytes(byte[] bytes) /// The algorithms and the order of their arithmetic are the VBA runtime's, as Microsoft.VisualBasic's /// Financial module carries them; the order decides the last digit. /// - private object? Financial(FunctionCall f, Func compute) + private double? Financial(FunctionCall f, Func compute) { var arguments = new double[6]; for (int i = 0; i < f.Arguments.Count; i++) @@ -1425,7 +1424,7 @@ static object RoundWritten(T original, int places, Func written, Fun /// LibRed extension, since ACE takes only the one argument. Either argument Null gives Null, and an empty /// set of characters strips nothing. /// - private object? Trim(FunctionCall f, Func trim) + private string? Trim(FunctionCall f, Func trim) { object? value = Evaluate(f.Arguments[0]); if (value is null) return null; @@ -1498,7 +1497,7 @@ private static DateTime DateValueArgument(object v) => /// Left and Right: the text's first or last characters. A Null length raises "Data type mismatch" as /// ACE does, and a negative one is an invalid procedure call. - private object? StringInt(FunctionCall f, Func op) + private string? StringInt(FunctionCall f, Func op) { object? s = Evaluate(f.Arguments[0]); if (s is null) return null; @@ -1510,7 +1509,7 @@ private static DateTime DateValueArgument(object v) => /// Access MID(string, start[, length]) — a 1-based substring; length omitted means to the end. A start /// below 1 or a negative length is an invalid procedure call. - private object? Mid(FunctionCall f) + private string? Mid(FunctionCall f) { object? sv = Evaluate(f.Arguments[0]); if (sv is null || CountArgument(f, 1, least: 1) is not { } start @@ -1525,7 +1524,7 @@ private static DateTime DateValueArgument(object v) => /// Access INSTR([start,] string1, string2[, compare]) — the 1-based position of string2 in string1, 0 if /// it is not there. An empty string2 is found at start, wherever that is, unless string1 is empty; a start below 1 /// is an invalid procedure call. - private object? Instr(FunctionCall f) + private int? Instr(FunctionCall f) { int argc = f.Arguments.Count; // 2 args: (s1, s2); 3+: (start, s1, s2[, compare]). @@ -1547,7 +1546,7 @@ private static DateTime DateValueArgument(object v) => /// Access REPLACE(string, find, replace[, start[, count[, compare]]]) — the text from start on, with /// find replaced at most count times (all when count is -1). A start below 1 is an invalid procedure call. - private object? Replace(FunctionCall f) + private string? Replace(FunctionCall f) { object? sv = Evaluate(f.Arguments[0]), findv = Evaluate(f.Arguments[1]), replv = Evaluate(f.Arguments[2]); // ACE raises "Data type mismatch" here (unlike InStr, which propagates NULL), but LibRed propagates @@ -1606,7 +1605,7 @@ private static DateTime DateValueArgument(object v) => /// function of a number too large to reduce — is an invalid procedure call, and a result past a Double an /// overflow (verified vs ACE). /// - private object? UnaryDouble(FunctionCall f, Func op) + private double? UnaryDouble(FunctionCall f, Func op) { if (Evaluate(f.Arguments[0]) is not { } value) return null; @@ -1621,7 +1620,7 @@ private static DateTime DateValueArgument(object v) => /// A function of two Doubles, read as reads one; NULL-propagating. A result that is not /// a number is an invalid procedure call, and a result past a Double an overflow. /// - private object? BinaryDouble(FunctionCall f, Func op) + private double? BinaryDouble(FunctionCall f, Func op) { if (Evaluate(f.Arguments[0]) is not { } left || Evaluate(f.Arguments[1]) is not { } right) return null; @@ -1638,7 +1637,7 @@ private static Func Trigonometric(Func op) => /// Access DatePart(interval, date, [firstdayofweek], [firstweekofyear]): a component of a date. /// "ms", "mcs" and "ns" are LibRed extensions. - private object? DatePart(FunctionCall f) + private int? DatePart(FunctionCall f) { if (Evaluate(f.Arguments[0]) is not { } interval || Evaluate(f.Arguments[1]) is not { } date || FirstDayOfWeek(f, 2) is not { } first || FirstWeekOfYear(f, 3) is not { } rule) @@ -1804,7 +1803,7 @@ private static object BitNot(object v) /// DateSerial and TimeSerial: a date or time from three Integer parts, read as CInt reads them (verified vs /// ACE: 32768 is an overflow). Parts out of their range carry into the next. - private object? DateParts(FunctionCall f, Func build) + private DateTime? DateParts(FunctionCall f, Func build) { if (Evaluate(f.Arguments[0]) is not { } a || Evaluate(f.Arguments[1]) is not { } b || Evaluate(f.Arguments[2]) is not { } c) return null; @@ -1850,7 +1849,7 @@ private static DateTime InDateRange(Func compute) /// step keeps the day where the month has it and takes the month's last day otherwise, and a result outside /// 100-9999 is an invalid procedure call. "ms" is a LibRed extension. /// - private object? DateAdd(FunctionCall f) + private DateTime? DateAdd(FunctionCall f) { if (Evaluate(f.Arguments[0]) is not { } interval || Evaluate(f.Arguments[1]) is not { } number || Evaluate(f.Arguments[2]) is not { } date) @@ -2120,16 +2119,16 @@ FunctionCall call when call.Name.Equals("COALESCE", StringComparison.OrdinalIgno ? checked(sum.Operator == BinaryOperator.Add ? a + b : a - b) : null; case BinaryExpression product: - { - bool spanOnLeft = product.Operator == BinaryOperator.Divide || IsSpan(product.Left, parameters!.Duration); - if (SpanOf(spanOnLeft ? product.Left : product.Right) is not { } span - || Evaluate(spanOnLeft ? product.Right : product.Left) is not { } by) - return null; - double factor = Dbl(ConversionNumber(by)); - if (product.Operator == BinaryOperator.Divide) - factor = factor != 0 ? 1 / factor : throw new DivideByZeroException("Division by zero."); - return TimeSpan.FromTicks(checked((long)Math.Round(span.Ticks * factor))); - } + { + bool spanOnLeft = product.Operator == BinaryOperator.Divide || IsSpan(product.Left, parameters!.Duration); + if (SpanOf(spanOnLeft ? product.Left : product.Right) is not { } span + || Evaluate(spanOnLeft ? product.Right : product.Left) is not { } by) + return null; + double factor = Dbl(ConversionNumber(by)); + if (product.Operator == BinaryOperator.Divide) + factor = factor != 0 ? 1 / factor : throw new DivideByZeroException("Division by zero."); + return TimeSpan.FromTicks(checked((long)Math.Round(span.Ticks * factor))); + } case FunctionCall call when call.Name.Equals("IIF", StringComparison.OrdinalIgnoreCase): return Evaluate(call.Arguments[0]) is { } condition && IifCondition(condition) ? SpanOf(call.Arguments[1]) @@ -2329,10 +2328,10 @@ internal static NumberType NumberTypeOf( switch (expression) { case LiteralExpression { Written: decimal written }: - { - int places = (written / 1.0000000000000000000000000000m).Scale; // trailing zeros dropped - return places == 0 ? new(NumberClass.Whole) : new(NumberClass.Decimal, places); - } + { + int places = (written / 1.0000000000000000000000000000m).Scale; // trailing zeros dropped + return places == 0 ? new(NumberClass.Whole) : new(NumberClass.Decimal, places); + } case LiteralExpression literal: return literal.Value switch { @@ -2769,6 +2768,9 @@ private static object Divide(object left, object right) /// each operand into a Long first, so a Double, Decimal or Currency past one overflows even where the answer /// would not — 1E12 MOD 7, whose remainder is below 7. Here that is 1, and a quotient past a Long is still /// an overflow, since the column's type is settled before any value is seen. A LibRed extension. + // CA1859 reads the two branches below as one `long`. They are not: the Int32 branch is boxed as an Int32 + // on purpose, and narrowing the return type would widen it back — see the remark above. +#pragma warning disable CA1859 private static object IntegerOp(object left, object right, char op) { left = Serial(left); @@ -2778,6 +2780,7 @@ private static object IntegerOp(object left, object right, char op) // Each branch boxed on its own: a bare `? result : (int)result` is a long, and would widen the Int32 back. return left is long or ulong || right is long or ulong ? (object)result : checked((int)result); } +#pragma warning restore CA1859 /// VBA CBool (verified vs ACE): "True" and "False" as written, otherwise whether the value /// read as a number () is non-zero, so 0.5, '$5' and a date are True. @@ -2894,9 +2897,13 @@ private static int CompareBytes(byte[] a, byte[] b) /// trailing spaces ignored, an accented letter beside its base letter but not equal to it (verified vs ACE: /// 'é' < 'f', 'café' ≠ 'cafe'), 'ß' = 'ss', and a hyphen weighed after the letters. A /// character that order does not cover compares case-insensitively. + // The linguistic comparison is the point: ordinal (CA1309) would put 'é' after 'z' and make 'ß' ≠ 'ss', + // neither of which is what ACE does. +#pragma warning disable CA1309 private static int CompareText(string a, string b) => JetTextComparer.Compare(a, b) ?? Math.Sign(string.Compare(a.TrimEnd(' '), b.TrimEnd(' '), StringComparison.InvariantCultureIgnoreCase)); +#pragma warning restore CA1309 /// Orders two values for SORT (nulls first), using the same coercion as comparisons. public static int CompareForSort(object? a, object? b) => (a, b) switch @@ -2913,4 +2920,4 @@ private static int CompareText(string a, string b) => // it is stored as. internal static bool IsNumeric(object v) => v is bool or byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/HoistedInSet.cs b/src/LibRed/LibRed.Engine/Execution/HoistedInSet.cs index fb5291c3..30defc6b 100644 --- a/src/LibRed/LibRed.Engine/Execution/HoistedInSet.cs +++ b/src/LibRed/LibRed.Engine/Execution/HoistedInSet.cs @@ -94,4 +94,4 @@ private sealed class EvaluatorEquality : IEqualityComparer public int GetHashCode(object value) => ExpressionEvaluator.KeyHash(value); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/JoinPredicateHoisting.cs b/src/LibRed/LibRed.Engine/Execution/JoinPredicateHoisting.cs index 1320362f..ffa9d17e 100644 --- a/src/LibRed/LibRed.Engine/Execution/JoinPredicateHoisting.cs +++ b/src/LibRed/LibRed.Engine/Execution/JoinPredicateHoisting.cs @@ -94,4 +94,4 @@ internal static Expression Substitute(Expression on, IReadOnlyDictionary Substitute(o, values)); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs b/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs index 84858487..315492da 100644 --- a/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs +++ b/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs @@ -188,4 +188,4 @@ public void Match(string text, int p, bool[] next) next[p + 1] = true; } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ListAgg.cs b/src/LibRed/LibRed.Engine/Execution/ListAgg.cs index ab13c93b..6323ee10 100644 --- a/src/LibRed/LibRed.Engine/Execution/ListAgg.cs +++ b/src/LibRed/LibRed.Engine/Execution/ListAgg.cs @@ -39,4 +39,4 @@ internal static class ListAgg var text = values.Select(ExpressionEvaluator.ConcatText).ToList(); return text.Count == 0 ? null : string.Join(separator, text); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ParameterBag.cs b/src/LibRed/LibRed.Engine/Execution/ParameterBag.cs index 5cd9fb7f..e6304bef 100644 --- a/src/LibRed/LibRed.Engine/Execution/ParameterBag.cs +++ b/src/LibRed/LibRed.Engine/Execution/ParameterBag.cs @@ -16,7 +16,11 @@ internal sealed class ParameterBag /// The OLE epoch: Jet stores a time as the epoch plus the time of day. private static readonly DateTime OleEpoch = new(1899, 12, 30); + // IDE0028's only fix here is `[]`, which would silently drop the comparer and make parameter + // lookup case-sensitive. +#pragma warning disable IDE0028 private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase); +#pragma warning restore IDE0028 public ParameterBag(IReadOnlyDictionary? values) { @@ -58,4 +62,4 @@ public ParameterBag(IReadOnlyDictionary? values) : value.GetType(); private static string Normalize(string name) => name.TrimStart('@'); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/Percentile.cs b/src/LibRed/LibRed.Engine/Execution/Percentile.cs index 9fb047cd..f4215dde 100644 --- a/src/LibRed/LibRed.Engine/Execution/Percentile.cs +++ b/src/LibRed/LibRed.Engine/Execution/Percentile.cs @@ -1,6 +1,6 @@ -using System.Globalization; using EntityFrameworkCore.Jet.Data; using LibRed.Sql.Ast; +using System.Globalization; namespace LibRed.Engine.Execution; @@ -66,7 +66,6 @@ internal static class Percentile return below == above ? a : a + (b - a) * share; } - private static double Number(object value) => value is string or char or Guid or byte[] - ? throw new InvalidCastException("Type mismatch: PERCENTILE_CONT interpolates numbers and dates.") + private static double Number(object value) => value is string or char or Guid or byte[]? throw new InvalidCastException("Type mismatch: PERCENTILE_CONT interpolates numbers and dates.") : Convert.ToDouble(ExpressionEvaluator.ConversionNumber(value), CultureInfo.InvariantCulture); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs index 88dfa79b..19563c7c 100644 --- a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs @@ -4,15 +4,20 @@ namespace LibRed.Engine.Execution; -/// A column produced by a plan node: an optional table-alias qualifier and a name. -/// marks a Currency value, which shares with Decimal but calculates differently, and -/// a Decimal's places. marks a column that is a bare NULL, which -/// has no type of its own, unlike one whose type is merely unknown. /// Where a column's values come from, when no stored column stands behind them: an expression over one /// row, an aggregate over many, or the arms of a set operation. Schema metadata describes each differently. /// internal enum ColumnOrigin { Expression, Aggregate, SetOperation } +/// A column produced by a plan node: an optional table-alias qualifier and a name. +/// The table alias the column is reached through, where it has one. +/// The column's output name. +/// The values' CLR type, where it is known. +/// Marks a Currency value, which shares with Decimal but +/// calculates differently. +/// A Decimal's places. +/// Marks a column that is a bare NULL, which has no type of its own, unlike one +/// whose type is merely unknown. /// The stored column this output passes through unchanged, where it does — carried so a /// caller describing a query (the schema rowsets, for a view's columns) can report the declared type and its /// length rather than only the CLR type. Null for anything computed. @@ -67,6 +72,11 @@ public sealed class QueryExecutor : IScalarSubqueryRunner private readonly ParameterBag _parameters; private readonly SessionState? _session; + // Every cache below is keyed by reference identity, so IDE0028 is suppressed across the block: its only + // fix is a collection expression, which would drop ReferenceEqualityComparer and key these on the AST + // nodes' structural equality instead — two distinct-but-equal subqueries would then share one plan. +#pragma warning disable IDE0028 + // Optimised plans for subqueries, keyed by their AST node (reference identity). A correlated subquery is // executed once per outer row, so planning + index selection must be done ONCE, not on every evaluation. private readonly Dictionary _subqueryPlans = new(ReferenceEqualityComparer.Instance); @@ -109,9 +119,15 @@ public sealed class QueryExecutor : IScalarSubqueryRunner // that would settle it isn't known until the outer scan has finished. private readonly Dictionary _gates = new(ReferenceEqualityComparer.Instance); +#pragma warning restore IDE0028 + private DecorrelationGate Gate(SqlStatement query) => _gates.TryGetValue(query, out DecorrelationGate? gate) ? gate : _gates[query] = new DecorrelationGate(); + /// The open database the plan reads from. + /// The statement's parameter values, by name. + /// State shared across statements on one connection (Rnd's seed, and the + /// like); a fresh one is used when none is passed. /// /// Builds the plan for its column shape alone — ADO's CommandBehavior.SchemaOnly. Every leaf yields /// no rows and no seek key, offset or count expression is evaluated, so nothing is read from the file and a @@ -317,6 +333,8 @@ private bool TryHoist(SqlStatement query, EvalScope outerScope, Func + /// The decorrelated subquery body, projecting its correlation columns. + /// How many of that projection's leading columns make up a key. /// /// For IN, whose last key column is the subquery's own output rather than a correlation. A null /// there is not "no match" but SQL's UNKNOWN, so instead of dropping the row its correlation prefix goes @@ -496,97 +514,97 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) { case SingleRowNode: // FROM-less SELECT: one row, no columns — the projection above evaluates its constants once. - return ([], _describing ? [] : [new object?[0]]); + return ([], _describing ? [] : [Array.Empty()]); case ValuesNode values: - { - // A table value constructor as a query. The row expressions can reference outer columns — EF - // emits VALUES (`p`.`Int`) inside a correlated subquery — so they are evaluated against the - // outer scope here, on every run of the node, rather than folded once at planning time. - var evaluator = new ExpressionEvaluator( - new EvalScope([], [], outer), this, parameters: _parameters, session: _session); - - var valueColumns = values.Rows.Count == 0 - ? [] - : values.Rows[0] - .Select((expr, i) => new OutputColumn(null, $"Expr{i + 1}", DeclaredType(expr, []))) + { + // A table value constructor as a query. The row expressions can reference outer columns — EF + // emits VALUES (`p`.`Int`) inside a correlated subquery — so they are evaluated against the + // outer scope here, on every run of the node, rather than folded once at planning time. + var evaluator = new ExpressionEvaluator( + new EvalScope([], [], outer), this, parameters: _parameters, session: _session); + + var valueColumns = values.Rows.Count == 0 + ? [] + : values.Rows[0] + .Select((expr, i) => new OutputColumn(null, $"Expr{i + 1}", DeclaredType(expr, []))) + .ToList(); + + if (_describing) return (valueColumns, []); + + var valueRows = values.Rows + .Select(row => row.Select(evaluator.Evaluate).ToArray()) .ToList(); - if (_describing) return (valueColumns, []); - - var valueRows = values.Rows - .Select(row => row.Select(evaluator.Evaluate).ToArray()) - .ToList(); - - return (valueColumns, valueRows); - } + return (valueColumns, valueRows); + } case ScanNode scan when Schema.InformationSchema.IsInformationSchema(scan.Table): - { - // Virtual INFORMATION_SCHEMA. table: materialise rows from the catalog. - string alias = scan.Alias ?? scan.Table; - var columns = Schema.InformationSchema.ColumnsOf(scan.Table) - .Zip(Schema.InformationSchema.ColumnTypesOf(scan.Table), - (name, type) => new OutputColumn(alias, name, type)).ToList(); - return (columns, _describing ? [] : Schema.InformationSchema.Rows(scan.Table, _database.Catalog)); - } + { + // Virtual INFORMATION_SCHEMA. table: materialise rows from the catalog. + string alias = scan.Alias ?? scan.Table; + var columns = Schema.InformationSchema.ColumnsOf(scan.Table) + .Zip(Schema.InformationSchema.ColumnTypesOf(scan.Table), + (name, type) => new OutputColumn(alias, name, type)).ToList(); + return (columns, _describing ? [] : Schema.InformationSchema.Rows(scan.Table, _database.Catalog)); + } case ScanNode scan: - { - var table = _database.OpenTable(scan.Table); - string alias = scan.Alias ?? scan.Table; - var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); - return (columns, _describing ? [] : table.Rows()); - } + { + var table = _database.OpenTable(scan.Table); + string alias = scan.Alias ?? scan.Table; + var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); + return (columns, _describing ? [] : table.Rows()); + } case IndexSeekNode seek: - { - var table = _database.OpenTable(seek.Table); - string alias = seek.Alias ?? seek.Table; - var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); - if (_describing) return (columns, []); - - // Evaluate the key(s) in the outer scope (so an index-nested-loop join can key off the outer - // row); a single-table seek's key is a constant/parameter. - var evaluator = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); - var keyValues = new object?[table.Definition.Columns.Count]; - for (int i = 0; i < seek.Keys.Count; i++) - keyValues[seek.Index.Columns[i].Column.Index] = evaluator.Evaluate(seek.Keys[i]); - - return (columns, table.SeekRows(seek.Index, keyValues)); - } + { + var table = _database.OpenTable(seek.Table); + string alias = seek.Alias ?? seek.Table; + var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); + if (_describing) return (columns, []); + + // Evaluate the key(s) in the outer scope (so an index-nested-loop join can key off the outer + // row); a single-table seek's key is a constant/parameter. + var evaluator = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); + var keyValues = new object?[table.Definition.Columns.Count]; + for (int i = 0; i < seek.Keys.Count; i++) + keyValues[seek.Index.Columns[i].Column.Index] = evaluator.Evaluate(seek.Keys[i]); + + return (columns, table.SeekRows(seek.Index, keyValues)); + } case IndexRangeSeekNode range: - { - var table = _database.OpenTable(range.Table); - string alias = range.Alias ?? range.Table; - var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); - if (_describing) return (columns, []); - - var evaluator = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); - int col = range.Index.Columns[0].Column.Index; - object?[]? Bound(Expression? e) { - if (e is null) return null; - var v = new object?[table.Definition.Columns.Count]; - v[col] = evaluator.Evaluate(e); - return v; + var table = _database.OpenTable(range.Table); + string alias = range.Alias ?? range.Table; + var columns = table.Definition.Columns.Select(c => OutputColumn.Of(alias, c)).ToList(); + if (_describing) return (columns, []); + + var evaluator = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); + int col = range.Index.Columns[0].Column.Index; + object?[]? Bound(Expression? e) + { + if (e is null) return null; + var v = new object?[table.Definition.Columns.Count]; + v[col] = evaluator.Evaluate(e); + return v; + } + return (columns, table.SeekRangeRows(range.Index, Bound(range.Low), Bound(range.High))); } - return (columns, table.SeekRangeRows(range.Index, Bound(range.Low), Bound(range.High))); - } case DerivedTableNode derived: - { - var (inner, rows) = Execute(derived.Input, outer); - var columns = inner.Select(c => c with { Qualifier = derived.Alias }).ToList(); - return (columns, rows); - } + { + var (inner, rows) = Execute(derived.Input, outer); + var columns = inner.Select(c => c with { Qualifier = derived.Alias }).ToList(); + return (columns, rows); + } case FilterNode filter: - { - var (columns, rows) = Execute(filter.Input, outer); - return (columns, rows.Where(row => Eval(columns, row, outer).IsTrue(filter.Predicate))); - } + { + var (columns, rows) = Execute(filter.Input, outer); + return (columns, rows.Where(row => Eval(columns, row, outer).IsTrue(filter.Predicate))); + } // A lateral join re-runs its right side per left row, so it cannot go through ExecuteJoin (which // materialises the right side once, against the enclosing scope). @@ -606,123 +624,123 @@ private PlanNode SubqueryPlan(SqlStatement query, EvalScope outerScope) return ExecuteAggregate(aggregate, outer); case SortNode sort: - { - var (columns, rows) = Execute(sort.Input, outer); - // As in LimitNode: the count is literal/parameter/arithmetic, so an empty row scope suffices. - // Describing sorts nothing, so its count is not evaluated either. - int? bound = sort.Limit is { } lim && !_describing - ? Convert.ToInt32(Eval([], [], outer).Evaluate(lim), System.Globalization.CultureInfo.InvariantCulture) - : null; - return (columns, SortRows(sort.Keys, columns, outer, rows, bound)); - } + { + var (columns, rows) = Execute(sort.Input, outer); + // As in LimitNode: the count is literal/parameter/arithmetic, so an empty row scope suffices. + // Describing sorts nothing, so its count is not evaluated either. + int? bound = sort.Limit is { } lim && !_describing + ? Convert.ToInt32(Eval([], [], outer).Evaluate(lim), System.Globalization.CultureInfo.InvariantCulture) + : null; + return (columns, SortRows(sort.Keys, columns, outer, rows, bound)); + } case ProjectNode project: - { - var (columns, rows) = Execute(project.Input, outer); - - // The output schema is invariant across outer rows, so build (or reuse) it once. Rows are still - // produced fresh — only the per-item plan (which ran DeclaredType) is cached. - ProjectionSchema schema = ProjectionSchemaFor(project, columns); - var plan = schema.Plan; - - var projected = rows.Select(row => { - var eval = Eval(columns, row, outer); - return plan.Select(p => p.InputIndex >= 0 - ? row[p.InputIndex] - : ExpressionEvaluator.ToResultPlaces( - ExpressionEvaluator.AsColumnType(eval.Evaluate(p.Expr!), p.ConvertTo, currency: false), p.Type)).ToArray(); - }); + var (columns, rows) = Execute(project.Input, outer); - return (schema.Columns, projected); - } + // The output schema is invariant across outer rows, so build (or reuse) it once. Rows are still + // produced fresh — only the per-item plan (which ran DeclaredType) is cached. + ProjectionSchema schema = ProjectionSchemaFor(project, columns); + var plan = schema.Plan; + + var projected = rows.Select(row => + { + var eval = Eval(columns, row, outer); + return plan.Select(p => p.InputIndex >= 0 + ? row[p.InputIndex] + : ExpressionEvaluator.ToResultPlaces( + ExpressionEvaluator.AsColumnType(eval.Evaluate(p.Expr!), p.ConvertTo, currency: false), p.Type)).ToArray(); + }); + + return (schema.Columns, projected); + } case SetOperationNode setOp: - { - // Column names come from the left (leading) query, per SQL; each column's type is the one both - // queries' values fit, and every value is converted to it before rows are compared. - var (leftColumns, leftRows) = Execute(setOp.Left, outer); - var (rightColumns, rightRows) = Execute(setOp.Right, outer); - if (leftColumns.Count != rightColumns.Count) - return (leftColumns, ExecuteSetOp(setOp.Operator, leftRows, rightRows)); - var columns = leftColumns.Zip(rightColumns, SetOperationColumn).ToList(); - return (columns, ExecuteSetOp(setOp.Operator, - ToColumnTypes(leftRows, leftColumns, columns), ToColumnTypes(rightRows, rightColumns, columns))); - } + { + // Column names come from the left (leading) query, per SQL; each column's type is the one both + // queries' values fit, and every value is converted to it before rows are compared. + var (leftColumns, leftRows) = Execute(setOp.Left, outer); + var (rightColumns, rightRows) = Execute(setOp.Right, outer); + if (leftColumns.Count != rightColumns.Count) + return (leftColumns, ExecuteSetOp(setOp.Operator, leftRows, rightRows)); + var columns = leftColumns.Zip(rightColumns, SetOperationColumn).ToList(); + return (columns, ExecuteSetOp(setOp.Operator, + ToColumnTypes(leftRows, leftColumns, columns), ToColumnTypes(rightRows, rightColumns, columns))); + } case LimitNode limit: - { - var (columns, rows) = Execute(limit.Input, outer); - // A limit cannot change the shape, so describing leaves its counts unevaluated. - if (_describing) return (columns, rows); + { + var (columns, rows) = Execute(limit.Input, outer); + // A limit cannot change the shape, so describing leaves its counts unevaluated. + if (_describing) return (columns, rows); - // Counts are literal/parameter/arithmetic (no column refs), so an empty row scope suffices. - var limitEval = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); + // Counts are literal/parameter/arithmetic (no column refs), so an empty row scope suffices. + var limitEval = new ExpressionEvaluator(new EvalScope([], [], outer), this, parameters: _parameters, session: _session); - // OFFSET n ROWS. Applied before the take, so `OFFSET 10 FETCH NEXT 5` gives rows 11-15. A - // negative or zero skip is a no-op rather than an error, matching how a zero TOP is handled - // below; Skip is lazy, so nothing is buffered to discard. - if (limit.Offset is { } offsetExpr) - { - int skip = Convert.ToInt32( - limitEval.Evaluate(offsetExpr), System.Globalization.CultureInfo.InvariantCulture); - if (skip > 0) - rows = rows.Skip(skip); - } + // OFFSET n ROWS. Applied before the take, so `OFFSET 10 FETCH NEXT 5` gives rows 11-15. A + // negative or zero skip is a no-op rather than an error, matching how a zero TOP is handled + // below; Skip is lazy, so nothing is buffered to discard. + if (limit.Offset is { } offsetExpr) + { + int skip = Convert.ToInt32( + limitEval.Evaluate(offsetExpr), System.Globalization.CultureInfo.InvariantCulture); + if (skip > 0) + rows = rows.Skip(skip); + } - // `OFFSET n ROWS` with no FETCH: skip, then return everything left. - if (limit.Count is null) - return (columns, rows); + // `OFFSET n ROWS` with no FETCH: skip, then return everything left. + if (limit.Count is null) + return (columns, rows); - object? countValue = limitEval.Evaluate(limit.Count); - int n = Convert.ToInt32(countValue, System.Globalization.CultureInfo.InvariantCulture); + object? countValue = limitEval.Evaluate(limit.Count); + int n = Convert.ToInt32(countValue, System.Globalization.CultureInfo.InvariantCulture); - // Nothing can be returned, so don't read the input at all. This matters for the PERCENT branch - // below, which materialises its whole input before it can compute the take — so `TOP 0 PERCENT` - // otherwise buffers every row only to discard all of them, once per outer row when it sits inside - // a correlated subquery. (Plain `TOP 0` was already cheap: Take(0) never pulls from the source.) - if (n <= 0) - return (columns, []); + // Nothing can be returned, so don't read the input at all. This matters for the PERCENT branch + // below, which materialises its whole input before it can compute the take — so `TOP 0 PERCENT` + // otherwise buffers every row only to discard all of them, once per outer row when it sits inside + // a correlated subquery. (Plain `TOP 0` was already cheap: Take(0) never pulls from the source.) + if (n <= 0) + return (columns, []); - if (!limit.Percent) - return (columns, rows.Take(n)); + if (!limit.Percent) + return (columns, rows.Take(n)); - // TOP n PERCENT: ceil(rowCount × n / 100), verified vs ACE (10% of 9 → 1, 25% of 9 → 3, - // 1% of 830 → 9). Materialize to count; integer ceil-division avoids float rounding. - var buffered = rows.ToList(); - int take = (int)(((long)buffered.Count * n + 99) / 100); - return (columns, buffered.Take(take)); - } + // TOP n PERCENT: ceil(rowCount × n / 100), verified vs ACE (10% of 9 → 1, 25% of 9 → 3, + // 1% of 830 → 9). Materialize to count; integer ceil-division avoids float rounding. + var buffered = rows.ToList(); + int take = (int)(((long)buffered.Count * n + 99) / 100); + return (columns, buffered.Take(take)); + } case DistinctNode distinct: - { - var (columns, rows) = Execute(distinct.Input, outer); - // A distinct row stands for however many rows shared it, so its columns are no longer single - // stored values that could be written back — the same standing an aggregate's have. - return (columns.Select(c => c with { Origin = ColumnOrigin.Aggregate }).ToList(), Distinct(rows)); - } + { + var (columns, rows) = Execute(distinct.Input, outer); + // A distinct row stands for however many rows shared it, so its columns are no longer single + // stored values that could be written back — the same standing an aggregate's have. + return (columns.Select(c => c with { Origin = ColumnOrigin.Aggregate }).ToList(), Distinct(rows)); + } case DistinctRowNode distinctRow: - { - var (columns, rows) = Execute(distinctRow.Input, outer); - - // Which source tables (qualifiers) contribute output columns, and which exist at all. - var contributing = ContributingQualifiers(distinctRow.Projection, columns); - var all = columns.Select(c => c.Qualifier) - .Where(q => q is not null).Select(q => q!) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Access ignores DISTINCTROW when there is a single source table, when every table - // contributes output, or (our guard) when nothing does — leaving the rows untouched. - if (all.Count <= 1 || contributing.Count == 0 || all.All(contributing.Contains)) - return (columns, rows); - - // Otherwise dedupe on the full set of columns belonging to the contributing tables. - int[] keyIndexes = Enumerable.Range(0, columns.Count) - .Where(i => columns[i].Qualifier is { } q && contributing.Contains(q)) - .ToArray(); - return (columns, DistinctByIndexes(rows, keyIndexes)); - } + { + var (columns, rows) = Execute(distinctRow.Input, outer); + + // Which source tables (qualifiers) contribute output columns, and which exist at all. + var contributing = ContributingQualifiers(distinctRow.Projection, columns); + var all = columns.Select(c => c.Qualifier) + .Where(q => q is not null).Select(q => q!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Access ignores DISTINCTROW when there is a single source table, when every table + // contributes output, or (our guard) when nothing does — leaving the rows untouched. + if (all.Count <= 1 || contributing.Count == 0 || all.All(contributing.Contains)) + return (columns, rows); + + // Otherwise dedupe on the full set of columns belonging to the contributing tables. + int[] keyIndexes = Enumerable.Range(0, columns.Count) + .Where(i => columns[i].Qualifier is { } q && contributing.Contains(q)) + .ToArray(); + return (columns, DistinctByIndexes(rows, keyIndexes)); + } default: throw new NotSupportedException($"Plan node {node.GetType().Name} is not supported yet."); @@ -744,8 +762,12 @@ private static OutputColumn SetOperationColumn(OutputColumn left, OutputColumn r if (left.Null) return left with { - ClrType = right.ClrType, Currency = right.Currency, Scale = right.Scale, Null = false, - Source = null, Origin = ColumnOrigin.SetOperation, + ClrType = right.ClrType, + Currency = right.Currency, + Scale = right.Scale, + Null = false, + Source = null, + Origin = ColumnOrigin.SetOperation, }; // A Decimal column is Currency unless one side is a Decimal of its own, and keeps a scale both sides share. @@ -773,7 +795,7 @@ private static OutputColumn SetOperationColumn(OutputColumn left, OutputColumn r /// The rows with each value converted to its output column's type, where the query's own column had /// another. private static IEnumerable ToColumnTypes( - IEnumerable rows, IReadOnlyList from, IReadOnlyList to) + IEnumerable rows, IReadOnlyList from, List to) { int[] changed = Enumerable.Range(0, to.Count) .Where(i => to[i].ClrType is { } type && !from[i].Null && from[i].ClrType != type) @@ -798,15 +820,15 @@ private static OutputColumn SetOperationColumn(OutputColumn left, OutputColumn r case SetOperator.Union: return Distinct(left.Concat(right)); case SetOperator.Intersect: - { - var keep = new HashSet(right.Select(r => new GroupKey(r))); - return Distinct(left).Where(r => keep.Contains(new GroupKey(r))); - } + { + var keep = new HashSet(right.Select(r => new GroupKey(r))); + return Distinct(left).Where(r => keep.Contains(new GroupKey(r))); + } case SetOperator.Except: - { - var remove = new HashSet(right.Select(r => new GroupKey(r))); - return Distinct(left).Where(r => !remove.Contains(new GroupKey(r))); - } + { + var remove = new HashSet(right.Select(r => new GroupKey(r))); + return Distinct(left).Where(r => !remove.Contains(new GroupKey(r))); + } default: throw new NotSupportedException($"Set operator {op} is not supported."); } @@ -874,7 +896,8 @@ private ProjectionSchema ProjectionSchemaFor(ProjectNode project, IReadOnlyList< : null; plan.Add(( OutputColumn.Computed(name, DeclaredType(item.Value, columns), type, item.Value, referenced?.Source) - with { Origin = referenced?.Origin ?? ColumnOrigin.Expression }, + with + { Origin = referenced?.Origin ?? ColumnOrigin.Expression }, -1, item.Value, type, ChoiceConversion(item.Value, columns))); } } @@ -1926,14 +1949,14 @@ private static void CheckFrame(WindowFunction fn, WindowFrame frame) // declared type and length; anything holding an aggregate is computed over the group. item.Value is ColumnReference reference ? OutputColumn.Find(columns, reference)?.Source : null) with - { - // The grouping produces both its keys and its aggregates, so both count as drawn from many - // rows. An expression that merely evaluates per group (a concatenation of the key, say) is - // still computed from one value, and describes itself as one. - Origin = Aggregates(item.Value).Any() || item.Value is ColumnReference + { + // The grouping produces both its keys and its aggregates, so both count as drawn from many + // rows. An expression that merely evaluates per group (a concatenation of the key, say) is + // still computed from one value, and describes itself as one. + Origin = Aggregates(item.Value).Any() || item.Value is ColumnReference ? ColumnOrigin.Aggregate : ColumnOrigin.Expression, - }) + }) .ToList(); var conversions = node.Projection.Select(item => ChoiceConversion(item.Value, columns)).ToList(); @@ -2237,10 +2260,14 @@ public override int GetHashCode() return hash.ToHashCode(); } + // Grouping has to agree with ExpressionEvaluator's text comparison, which is linguistic on purpose; + // ordinal (CA1309) would split groups ACE puts together, and would disagree with GetHashCode above. +#pragma warning disable CA1309 private static bool KeyEquals(object? a, object? b) => a is string sa && b is string sb ? string.Equals(sa.TrimEnd(' '), sb.TrimEnd(' '), StringComparison.InvariantCultureIgnoreCase) : Equals(a, b); +#pragma warning restore CA1309 } private sealed class ReferenceComparer : IEqualityComparer @@ -2249,4 +2276,4 @@ private sealed class ReferenceComparer : IEqualityComparer public bool Equals(FunctionCall? x, FunctionCall? y) => ReferenceEquals(x, y); public int GetHashCode(FunctionCall obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ResultSet.cs b/src/LibRed/LibRed.Engine/Execution/ResultSet.cs index c5aa7538..1540a1c0 100644 --- a/src/LibRed/LibRed.Engine/Execution/ResultSet.cs +++ b/src/LibRed/LibRed.Engine/Execution/ResultSet.cs @@ -49,8 +49,20 @@ public ResultSet( /// straight from a stored column rather than being computed — that column's table, name, declared type and /// the constraints on it. The ADO layer turns these into GetSchemaTable rows and DbColumns. /// +/// The column's name in the result, which an alias in the query does change. +/// The CLR type of the column's values. /// The table the value is read from, or null when nothing stored stands behind it. /// Its name in that table, which an alias in the query does not change. +/// Whether the column can hold a null. +/// Whether the value is computed rather than read from a stored column. +/// Whether the stored column behind it is an AutoNumber. +/// Whether it is part of the table's primary key. +/// Whether a unique index covers it on its own. +/// Whether it is a long value (Memo or OLE Object), stored off-row. +/// Whether the result cannot be written back through. +/// The declared length, for a sized text or binary column. +/// The declared precision, for a Decimal column. +/// The declared scale, for a Decimal column. /// The OLE DB type code, as the schema collections report it. /// The provider's name for the type, as the DataTypes collection spells it. public sealed record ResultColumn( @@ -69,4 +81,4 @@ public sealed record ResultColumn( int? Precision = null, int? Scale = null, int ProviderType = 0, - string TypeName = ""); + string TypeName = ""); \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/RunningAggregate.cs b/src/LibRed/LibRed.Engine/Execution/RunningAggregate.cs index faa3e16e..c676c06f 100644 --- a/src/LibRed/LibRed.Engine/Execution/RunningAggregate.cs +++ b/src/LibRed/LibRed.Engine/Execution/RunningAggregate.cs @@ -1,5 +1,5 @@ -using System.Globalization; using EntityFrameworkCore.Jet.Data; +using System.Globalization; namespace LibRed.Engine.Execution; @@ -221,7 +221,7 @@ _ when IsPair(_name) => PairResult(), }; } - private object? Statistic() + private double? Statistic() { bool sample = !_name.EndsWith('P'); if (sample && _count < 2) @@ -234,4 +234,4 @@ _ when IsPair(_name) => PairResult(), double variance = (n * squares - squaredSum) / (sample ? n * (n - 1) : n * n); return _name.Contains("DEV", StringComparison.Ordinal) ? Math.Sqrt(variance) : variance; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/ScalarAggregateSemiJoin.cs b/src/LibRed/LibRed.Engine/Execution/ScalarAggregateSemiJoin.cs index 185045d3..259157e7 100644 --- a/src/LibRed/LibRed.Engine/Execution/ScalarAggregateSemiJoin.cs +++ b/src/LibRed/LibRed.Engine/Execution/ScalarAggregateSemiJoin.cs @@ -104,4 +104,4 @@ private ScalarAggregateSemiJoin( return _values.TryGetValue(probe, out object? value) ? value : _empty; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index b1ce53d6..45fda8bf 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -428,7 +428,7 @@ private int ExecuteCreateProcedure(CreateProcedureStatement statement) /// The declared parameters of a stored query as the rows that hold them: each name with the Jet /// type code its declared Access type maps to. An action query declares them exactly as a SELECT does. - private IReadOnlyList? BuildParameterSpecs(IReadOnlyList? parameters) => + private List? BuildParameterSpecs(IReadOnlyList? parameters) => parameters is null or { Count: 0 } ? null : parameters @@ -848,7 +848,7 @@ private sealed record RowDefaults(List<(int Index, Expression Expression)> Colum /// Writes a new row: every column not in takes its default (an explicit /// NULL is left as NULL), the row is checked as any insert is, and it is written. Returns its AutoNumber value, /// if it has one. - private object? InsertNewRow(string tableName, Table table, RowDefaults defaults, object?[] values, IReadOnlySet provided) + private object? InsertNewRow(string tableName, Table table, RowDefaults defaults, object?[] values, HashSet provided) { var evaluator = new ExpressionEvaluator(new EvalScope([], [], null), _scalarRunner, parameters: _parameters); foreach (var (index, expression) in defaults.Columns) @@ -971,13 +971,17 @@ void InsertRow(ReadOnlySpan supplied) /// combined evaluation scope), and its rows. A physical table exposes (rows have real /// s and can be a SET/DELETE target); a derived table (a subquery in the source) has /// null and its already-materialised (never a target). + /// The name the source is reached by in the statement. + /// The physical table, or null for a derived one. + /// Its columns, alias-qualified. + /// A derived table's already-materialised rows; null for a physical table. /// The plan of a LATERAL source — an APPLY's right side — which is re-executed once /// per outer row instead of being materialised, because it may correlate to the rows joined before it. /// A writable derived table is one source table per table it reads, in a run: the first carries the /// joined rows the derived query chose (, tables wide) and the rest /// are , filled from them. tells a table's rows apart from another /// table's under the same alias. - private sealed record SourceTable(string Alias, Table? Table, IReadOnlyList Columns, + private sealed record SourceTable(string Alias, Table? Table, List Columns, IReadOnlyList? DerivedRows, Plan.PlanNode? Lateral = null) { public IReadOnlyList<(RowId Id, object?[] Values)[]>? Combos { get; init; } @@ -1720,4 +1724,4 @@ private int ExecuteDelete(DeleteStatement statement) if (_session is not null) _session.RowCount = affected; return affected; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/SubqueryCorrelation.cs b/src/LibRed/LibRed.Engine/Execution/SubqueryCorrelation.cs index e0cf1e17..8888ba83 100644 --- a/src/LibRed/LibRed.Engine/Execution/SubqueryCorrelation.cs +++ b/src/LibRed/LibRed.Engine/Execution/SubqueryCorrelation.cs @@ -256,4 +256,4 @@ internal static bool SameKind( || u == typeof(float) || u == typeof(double) || u == typeof(decimal)) return IndexSelection.TypeKind.Numeric; return null; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/SubqueryHoisting.cs b/src/LibRed/LibRed.Engine/Execution/SubqueryHoisting.cs index c6d6b9cc..a87174f7 100644 --- a/src/LibRed/LibRed.Engine/Execution/SubqueryHoisting.cs +++ b/src/LibRed/LibRed.Engine/Execution/SubqueryHoisting.cs @@ -134,4 +134,4 @@ LiteralExpression or ParameterExpression or SystemVariableExpression InSubqueryExpression isq => Expr(isq.Value, outer) || Statement(isq.Query, outer), _ => e.Operands()?.Any(o => Expr(o, outer)) ?? true, // unknown expression shape }; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/VbaDateText.cs b/src/LibRed/LibRed.Engine/Execution/VbaDateText.cs index f2275d5c..e1a21e6b 100644 --- a/src/LibRed/LibRed.Engine/Execution/VbaDateText.cs +++ b/src/LibRed/LibRed.Engine/Execution/VbaDateText.cs @@ -259,23 +259,23 @@ private static bool IsTimeStart(List tokens, int i) switch (numbers.Count) { case 2: - { - (int day, int mon) = dayFirst ? (numbers[0], numbers[1]) : (numbers[1], numbers[0]); - return Make(currentYear, mon, day) - ?? Make(currentYear, day, mon) - ?? Make(Year(numbers[1], culture), numbers[0], 1) - ?? Make(Year(numbers[0], culture), numbers[1], 1); - } + { + (int day, int mon) = dayFirst ? (numbers[0], numbers[1]) : (numbers[1], numbers[0]); + return Make(currentYear, mon, day) + ?? Make(currentYear, day, mon) + ?? Make(Year(numbers[1], culture), numbers[0], 1) + ?? Make(Year(numbers[0], culture), numbers[1], 1); + } case 3: - { - if (numbers[0] > 31) - return Make(Year(numbers[0], culture), numbers[1], numbers[2]); - (int day, int mon, int year) = YearFirst(culture.DateTimeFormat.ShortDatePattern) - ? (numbers[2], numbers[1], numbers[0]) - : dayFirst ? (numbers[0], numbers[1], numbers[2]) : (numbers[1], numbers[0], numbers[2]); - int y = Year(year, culture); - return Make(y, mon, day) ?? Make(y, day, mon); - } + { + if (numbers[0] > 31) + return Make(Year(numbers[0], culture), numbers[1], numbers[2]); + (int day, int mon, int year) = YearFirst(culture.DateTimeFormat.ShortDatePattern) + ? (numbers[2], numbers[1], numbers[0]) + : dayFirst ? (numbers[0], numbers[1], numbers[2]) : (numbers[1], numbers[0], numbers[2]); + int y = Year(year, culture); + return Make(y, mon, day) ?? Make(y, day, mon); + } default: return null; } @@ -319,4 +319,4 @@ private static bool YearFirst(string pattern) private static bool Is(string word, string name) => name.Length > 0 && string.Equals(word, name.TrimEnd('.'), StringComparison.OrdinalIgnoreCase); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs b/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs index 5ef8f82c..0e3712f8 100644 --- a/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs +++ b/src/LibRed/LibRed.Engine/Execution/WindowFunctions.cs @@ -37,8 +37,12 @@ internal enum WindowOptions Filter = 16, } +/// The fewest arguments the call may carry. +/// The most it may carry. /// The declared CLR type of the result — the window counterpart of /// QueryExecutor.DeclaredFunctionType. The values a function returns are converted to it. +/// Produces the function's value for each row of a partition. +/// The clauses the call accepts beyond its arguments (IGNORE NULLS, DISTINCT, …). internal sealed record WindowFunctionDef( int MinArguments, int MaxArguments, Func ResultType, WindowEvaluator Evaluate, WindowOptions Options = WindowOptions.None); @@ -358,4 +362,4 @@ public static WindowFunctionDef Lookup(string name) => Registry.TryGetValue(name, out WindowFunctionDef? def) ? def : RunningAggregate.Supports(name.ToUpperInvariant()) ? Aggregate(name.ToUpperInvariant()) : throw new NotSupportedException($"Window function '{name}' is not supported."); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Execution/WindowPartition.cs b/src/LibRed/LibRed.Engine/Execution/WindowPartition.cs index 925fd66a..51d30736 100644 --- a/src/LibRed/LibRed.Engine/Execution/WindowPartition.cs +++ b/src/LibRed/LibRed.Engine/Execution/WindowPartition.cs @@ -1,5 +1,5 @@ -using System.Globalization; using LibRed.Sql.Ast; +using System.Globalization; namespace LibRed.Engine.Execution; @@ -91,6 +91,9 @@ public IEnumerable Positions() /// RANK constant over an unordered window. /// /// Positions are indexes into the partition's own window order, not into the input. +/// Per row, the position the row's peer group starts at. +/// Per row, the zero-based ordinal of the row's peer group within the partition. +/// Per row, the already-evaluated arguments of the window call. /// How the call is written; null for a plain one. /// The frame clause; null for the default frame. /// Which rows an aggregate's FILTER lets in; null when it has none. @@ -306,8 +309,7 @@ private int CompareDistance(object other, object here, object offset, bool prece /// A RANGE key as a number: a date as its serial, as dates sort; text and other kinds are a type /// mismatch. - private static object RangeNumber(object key) => key is string or char or Guid or byte[] - ? throw new InvalidCastException("Type mismatch: a RANGE offset needs a number or date to order by.") + private static object RangeNumber(object key) => key is string or char or Guid or byte[]? throw new InvalidCastException("Type mismatch: a RANGE offset needs a number or date to order by.") : ExpressionEvaluator.ConversionNumber(key); /// A RANGE offset: a number of the key's units — days, for a date — Null and negative refused. @@ -329,4 +331,4 @@ private static long WholeOffset(object? offset) long n = Convert.ToInt64(ExpressionEvaluator.ConversionNumber(offset), CultureInfo.InvariantCulture); return n >= 0 ? n : throw new ArgumentException($"Invalid procedure call: a frame offset cannot be {n}."); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Plan/PlanNode.cs b/src/LibRed/LibRed.Engine/Plan/PlanNode.cs index 4c68d7bb..6f091386 100644 --- a/src/LibRed/LibRed.Engine/Plan/PlanNode.cs +++ b/src/LibRed/LibRed.Engine/Plan/PlanNode.cs @@ -11,4 +11,4 @@ public abstract record PlanNode { /// Input nodes feeding this operator (empty for leaves such as Scan). public virtual IReadOnlyList Children => []; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Plan/PlanNodes.cs b/src/LibRed/LibRed.Engine/Plan/PlanNodes.cs index aa84f4b0..af9ca58b 100644 --- a/src/LibRed/LibRed.Engine/Plan/PlanNodes.cs +++ b/src/LibRed/LibRed.Engine/Plan/PlanNodes.cs @@ -117,6 +117,8 @@ public sealed record WindowNode(PlanNode Input, IReadOnlyList Outp } /// Orders rows. +/// The rows to order. +/// The ORDER BY keys, outermost first. /// /// When set, only this many rows are needed from the ordering, so the sort keeps the smallest n as it goes instead /// of ordering its whole input (see QueryExecutor.SortRows). The planner sets it from an enclosing TOP n @@ -154,9 +156,11 @@ public sealed record SetOperationNode(PlanNode Left, PlanNode Right, SetOperator /// an expression (usually a literal, but LibRed also accepts a parameter or a +/- expression) evaluated once /// at execution. When is set (TOP n PERCENT) the count is a percentage of /// the input row count, taken as ceil(rows × n / 100) (verified vs ACE). +/// The rows to take from. /// /// Rows to return, or null for OFFSET n ROWS with no FETCH — skip and then return the rest. /// +/// Whether Count is a percentage of the input row count (TOP n PERCENT). /// /// Rows to skip before returning any, from OFFSET n ROWS. Null means start at the first row. The skip /// is applied before the count, so OFFSET 10 ROWS FETCH NEXT 5 yields rows 11-15. Cannot combine with @@ -183,4 +187,4 @@ public sealed record DistinctNode(PlanNode Input) : PlanNode public sealed record DistinctRowNode(PlanNode Input, IReadOnlyList Projection) : PlanNode { public override IReadOnlyList Children => [Input]; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs b/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs index 2aff7239..35bea5e6 100644 --- a/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs +++ b/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs @@ -14,8 +14,13 @@ namespace LibRed.Engine.Planning; /// internal static class IndexSelection { + // IDE0028's only fix here is `[]`, which would silently drop the comparer. +#pragma warning disable IDE0028 private static readonly HashSet NoOuter = new(StringComparer.OrdinalIgnoreCase); +#pragma warning restore IDE0028 + /// The plan subtree to rewrite. + /// The catalog the candidate indexes are read from. /// Aliases of the enclosing query (empty at top level). A value referencing only these /// is a constant from this query's view, so a correlated predicate t.col = outer.col becomes an /// index seek keyed off the outer row — the point of running index selection on correlated subqueries. @@ -51,7 +56,7 @@ when OrderedIndexRead(sort, ordered, catalog) is { } indexScan => indexScan, }; } - private static PlanNode RewriteFilterOverScan(FilterNode filter, ScanNode scan, JetCatalog catalog, HashSet outer) + private static FilterNode RewriteFilterOverScan(FilterNode filter, ScanNode scan, JetCatalog catalog, HashSet outer) { if (catalog.FindTable(scan.Table) is not { } def) return filter; @@ -138,7 +143,7 @@ private static PlanNode RewriteFilterOverScan(FilterNode filter, ScanNode scan, /// Ties then match too: equal-keyed index entries are ordered by row pointer, i.e. physical position, /// which is the order a scan feeds the sort in — and the sort breaks ties by input position. /// - private static PlanNode? OrderedIndexRead(SortNode sort, ScanNode scan, JetCatalog catalog) + private static IndexRangeSeekNode? OrderedIndexRead(SortNode sort, ScanNode scan, JetCatalog catalog) { if (catalog.FindTable(scan.Table) is not { } def) return null; @@ -232,6 +237,8 @@ private static (Expression Low, Expression High)? Between(Expression conjunct, s /// inner scan becomes an keyed off the outer row. Executed per left row, it /// seeks the index instead of scanning the whole inner table. The join keeps its ON as the residual check. /// + // Not a HashJoinNode as CA1859 reads it: a join that cannot be hashed is returned as the join it was. +#pragma warning disable CA1859 private static PlanNode RewriteJoin(JoinNode j, JetCatalog catalog, HashSet outer) { PlanNode left = Apply(j.Left, catalog, outer); // rewrite the outer side first (independent of this join) @@ -287,6 +294,7 @@ private static PlanNode RewriteJoin(JoinNode j, JetCatalog catalog, HashSetSplits the join condition into equi-key column pairs (left-referencing = right-referencing) /// whose columns are of the same type kind, or null if there is no such pair. The full ON is still applied @@ -453,4 +461,4 @@ internal static IEnumerable Conjuncts(Expression? e) return Conjuncts(b.Left).Concat(Conjuncts(b.Right)); return [e]; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs b/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs index e86beeaa..9b2204b7 100644 --- a/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs +++ b/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs @@ -8,9 +8,9 @@ namespace LibRed.Engine.Planning; /// Turns a bound statement into a logical tree. Index /// selection and other optimisations are applied as rewrites over the tree. /// -public sealed class QueryPlanner +public static class QueryPlanner { - public PlanNode Plan(BoundStatement bound) + public static PlanNode Plan(BoundStatement bound) { return PlanStatement(bound.Statement); } @@ -35,7 +35,7 @@ public PlanNode Plan(BoundStatement bound) /// paging for the same reason does — only that many rows can survive it — except /// under OFFSET, where the skipped rows must be produced before they can be discarded. private static PlanNode PageAndSort( - PlanNode node, IReadOnlyList orderBy, Expression? top, Expression? offset) + PlanNode node, List orderBy, Expression? top, Expression? offset) { if (orderBy.Count > 0) { @@ -134,7 +134,7 @@ public static PlanNode PlanSelect(SelectStatement select) /// last column. gives the expression at a position, or null where the rows sorted /// are the output itself and the position is read from them. /// - private static IReadOnlyList OrderByPositions( + private static List OrderByPositions( IReadOnlyList orderBy, Func projected) => orderBy.Select(item => item.Value is LiteralExpression { Value: int or long or short or byte } literal ? item with @@ -192,6 +192,8 @@ private static Expression ProjectedAt(SelectStatement select, int position) /// sound for LEFT and CROSS, which never drop one — a refinement, not done here.) /// /// + // Not a SortNode as CA1859 reads it: pushing through a join returns the join. +#pragma warning disable CA1859 private static PlanNode PushSort(PlanNode node, IReadOnlyList keys) { if (node is JoinNode { Kind: JoinKind.Inner or JoinKind.Left or JoinKind.Cross } j @@ -202,6 +204,7 @@ private static PlanNode PushSort(PlanNode node, IReadOnlyList keys) return new SortNode(node, keys); } +#pragma warning restore CA1859 /// /// Attaches a row bound to the sort at the top of , descending through the @@ -351,6 +354,9 @@ private static (PlanNode Node, List Unplaced) Place( /// The table aliases (or names) exposed by a plan subtree — a scan's alias/name, or the union /// over a join. A derived table exposes only its own alias (its inner columns are already projected). + // Aliases compare case-insensitively, and IDE0028's only fix for these arms is a collection expression, + // which would drop the comparer. +#pragma warning disable IDE0028 internal static HashSet SubtreeAliases(PlanNode node) => node switch { ScanNode s => new(StringComparer.OrdinalIgnoreCase) { s.Alias ?? s.Table }, @@ -379,6 +385,7 @@ private static (PlanNode Node, List Unplaced) Place( IndexScanNode s => new(StringComparer.OrdinalIgnoreCase) { s.Table }, _ => new(StringComparer.OrdinalIgnoreCase), }; +#pragma warning restore IDE0028 /// The set of table qualifiers an expression's column references use, or /// if it can't be safely placed — an unqualified column (ambiguous) or a subquery (references an inner @@ -411,4 +418,4 @@ private static void SplitAnd(Expression e, List into) private static Expression CombineAnd(IReadOnlyList conjuncts) => conjuncts.Aggregate((a, b) => new BinaryExpression(BinaryOperator.And, a, b)); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Planning/ViewExpander.cs b/src/LibRed/LibRed.Engine/Planning/ViewExpander.cs index 328a9b0b..0f049357 100644 --- a/src/LibRed/LibRed.Engine/Planning/ViewExpander.cs +++ b/src/LibRed/LibRed.Engine/Planning/ViewExpander.cs @@ -19,20 +19,24 @@ namespace LibRed.Engine.Planning; /// internal static class ViewExpander { + // IDE0028's only fix here is `[]`, which would silently drop the comparer and let a view expand + // itself recursively when the cycle guard's names differ only by case. +#pragma warning disable IDE0028 public static SqlStatement Expand(SqlStatement statement, IReadOnlyDictionary views, ISqlParser parser) => views.Count == 0 ? statement : Rewrite(statement, views, parser, new HashSet(StringComparer.OrdinalIgnoreCase)); +#pragma warning restore IDE0028 private static SqlStatement Rewrite( SqlStatement statement, IReadOnlyDictionary views, ISqlParser parser, HashSet active) => statement switch - { - SelectStatement s => RewriteSelect(s, views, parser, active), - SetOperationStatement so => so with { - Left = Rewrite(so.Left, views, parser, active), - Right = Rewrite(so.Right, views, parser, active), - }, - _ => statement, - }; + SelectStatement s => RewriteSelect(s, views, parser, active), + SetOperationStatement so => so with + { + Left = Rewrite(so.Left, views, parser, active), + Right = Rewrite(so.Right, views, parser, active), + }, + _ => statement, + }; private static SelectStatement RewriteSelect( SelectStatement select, IReadOnlyDictionary views, ISqlParser parser, HashSet active) @@ -53,35 +57,35 @@ private static SelectStatement RewriteSelect( /// the operator/function tree; leaf expressions are returned unchanged. private static Expression RewriteExpression( Expression expr, IReadOnlyDictionary views, ISqlParser parser, HashSet active) => expr switch - { - // Rewrite, not RewriteSelect: a subquery may be a set operation, whose arms each need view expansion. - ScalarSubquery s => new ScalarSubquery(Rewrite(s.Query, views, parser, active)), - ExistsExpression x => new ExistsExpression(Rewrite(x.Query, views, parser, active)), - InSubqueryExpression i => i with { - Value = RewriteExpression(i.Value, views, parser, active), - Query = Rewrite(i.Query, views, parser, active), - }, - _ => expr.MapOperands(o => RewriteExpression(o, views, parser, active)), - }; + // Rewrite, not RewriteSelect: a subquery may be a set operation, whose arms each need view expansion. + ScalarSubquery s => new ScalarSubquery(Rewrite(s.Query, views, parser, active)), + ExistsExpression x => new ExistsExpression(Rewrite(x.Query, views, parser, active)), + InSubqueryExpression i => i with + { + Value = RewriteExpression(i.Value, views, parser, active), + Query = Rewrite(i.Query, views, parser, active), + }, + _ => expr.MapOperands(o => RewriteExpression(o, views, parser, active)), + }; private static TableReference RewriteSource( TableReference source, IReadOnlyDictionary views, ISqlParser parser, HashSet active) => source switch - { - NamedTable n when views.TryGetValue(n.Name, out string? sql) => ExpandView(n, sql, views, parser, active), - JoinTable j => j with { - Left = RewriteSource(j.Left, views, parser, active), - Right = RewriteSource(j.Right, views, parser, active), - }, - SubqueryTable sq => sq with { Query = Rewrite(sq.Query, views, parser, active) }, - _ => source, - }; + NamedTable n when views.TryGetValue(n.Name, out string? sql) => ExpandView(n, sql, views, parser, active), + JoinTable j => j with + { + Left = RewriteSource(j.Left, views, parser, active), + Right = RewriteSource(j.Right, views, parser, active), + }, + SubqueryTable sq => sq with { Query = Rewrite(sq.Query, views, parser, active) }, + _ => source, + }; /// Expands one view reference into a derived table, refusing a definition that reaches itself. /// The name is removed again on the way out, so two sibling references to the same view are fine — only a /// reference reached from INSIDE that view's own expansion is a cycle. - private static TableReference ExpandView( + private static SubqueryTable ExpandView( NamedTable table, string sql, IReadOnlyDictionary views, ISqlParser parser, HashSet active) { if (!active.Add(table.Name)) @@ -95,4 +99,4 @@ private static TableReference ExpandView( } finally { active.Remove(table.Name); } } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/QueryEngine.cs b/src/LibRed/LibRed.Engine/QueryEngine.cs index a9214c5e..cb7293fe 100644 --- a/src/LibRed/LibRed.Engine/QueryEngine.cs +++ b/src/LibRed/LibRed.Engine/QueryEngine.cs @@ -1,9 +1,9 @@ using LibRed.Catalog; -using LibRed.IO; using LibRed.Engine.Execution; using LibRed.Engine.Plan; using LibRed.Engine.Planning; using LibRed.Engine.Schema; +using LibRed.IO; using LibRed.Sql.Ast; using LibRed.Sql.Binding; using LibRed.Sql.Parsing; @@ -19,7 +19,6 @@ public sealed class QueryEngine private readonly JetDatabase _database; private readonly ISqlParser _parser; private readonly Binder _binder; - private readonly QueryPlanner _planner = new(); private readonly SessionState _session = new(); public QueryEngine(JetDatabase database, ISqlParser? parser = null) @@ -267,7 +266,7 @@ private CommandResult ExecuteIfThen(IfThenStatement ifThen, IReadOnlyDictionary< /// Plans a bound statement, then applies index selection (turning scans into index seeks where a /// predicate allows). private PlanNode PlanWithIndexes(BoundStatement bound) => - IndexSelection.Apply(_planner.Plan(bound), _database.Catalog); + IndexSelection.Apply(QueryPlanner.Plan(bound), _database.Catalog); /// The optimised plan for a query — exposed for tests to assert the chosen access path/strategy /// (e.g. that an unindexed equi-join becomes a hash join). @@ -289,7 +288,7 @@ private CommandResult ExecuteProcedure(ExecuteStatement exec, IReadOnlyDictionar var executor = new QueryExecutor(_database, parameters, _session); var evaluator = new ExpressionEvaluator(new EvalScope([], [], null), executor, bag, _session); - IReadOnlyList paramNames = catalog.QueryParameters.TryGetValue(exec.Procedure, out var declared) + List paramNames = catalog.QueryParameters.TryGetValue(exec.Procedure, out var declared) ? declared.Select(p => p.Name).ToList() : []; // Access EXEC arguments take three shapes (EF emits all of them): @@ -327,7 +326,10 @@ private CommandResult ExecuteProcedure(ExecuteStatement exec, IReadOnlyDictionar if (positional.Count != paramNames.Count) throw new InvalidOperationException( $"Procedure '{exec.Procedure}' declares {paramNames.Count} parameter(s) but was executed with {positional.Count} argument(s)."); + // IDE0028's only fix here is `[]`, which would silently drop the comparer. +#pragma warning disable IDE0028 args = new Dictionary(StringComparer.OrdinalIgnoreCase); +#pragma warning restore IDE0028 for (int i = 0; i < paramNames.Count; i++) args[paramNames[i]] = positional[i]; } @@ -349,4 +351,4 @@ private CommandResult ExecuteProcedure(ExecuteStatement exec, IReadOnlyDictionar /// The outcome of : query rows (with /// = -1) or an empty set with the DML rows-affected count. -public sealed record CommandResult(ResultSet Rows, int RecordsAffected); +public sealed record CommandResult(ResultSet Rows, int RecordsAffected); \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Schema/CatalogSchemaProvider.cs b/src/LibRed/LibRed.Engine/Schema/CatalogSchemaProvider.cs index c3578fc9..82cd8e01 100644 --- a/src/LibRed/LibRed.Engine/Schema/CatalogSchemaProvider.cs +++ b/src/LibRed/LibRed.Engine/Schema/CatalogSchemaProvider.cs @@ -56,4 +56,4 @@ private sealed class ColumnSchema(ColumnDef col) : IColumnSchema public bool IsNullable => col.IsNullable; public Type ClrType => JetClrTypeMap.ToClrType(col.Type); } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Schema/InformationSchema.cs b/src/LibRed/LibRed.Engine/Schema/InformationSchema.cs index 95923aab..94c7106f 100644 --- a/src/LibRed/LibRed.Engine/Schema/InformationSchema.cs +++ b/src/LibRed/LibRed.Engine/Schema/InformationSchema.cs @@ -15,10 +15,14 @@ namespace LibRed.Engine.Schema; public static class InformationSchema { /// The magic view names, without the INFORMATION_SCHEMA. prefix (case-insensitive). + // IDE0028's only fix here is a collection expression, which would silently drop the comparer and make + // the names case-sensitive. +#pragma warning disable IDE0028 private static readonly HashSet Views = new(StringComparer.OrdinalIgnoreCase) { "TABLES", "COLUMNS", "INDEXES", "INDEX_COLUMNS", "RELATIONS", "RELATION_COLUMNS", "CHECK_CONSTRAINTS", }; +#pragma warning restore IDE0028 private const string Prefix = "INFORMATION_SCHEMA."; @@ -166,4 +170,4 @@ private static string RelationType(ForeignKey fk, JetCatalog catalog) && ix.Columns.All(c => fkColumns.Contains(c.Column.Name))); return backing?.IsUnique == true ? "ONE" : "MANY"; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs b/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs index b9930ec4..70ac64ba 100644 --- a/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs +++ b/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs @@ -24,4 +24,4 @@ public static class JetClrTypeMap JetDataType.Complex => typeof(object), _ => typeof(object), }; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs b/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs index 3f7399a5..fb61c163 100644 --- a/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs +++ b/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs @@ -65,4 +65,4 @@ public static string StoreType(ColumnDef column) _ => name, }; } -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs b/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs index ac5c0ae0..b5465732 100644 --- a/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs +++ b/src/LibRed/LibRed.Engine/Schema/SchemaRowsets.cs @@ -792,4 +792,4 @@ private static bool Nullable(ColumnDef c) => private static string Canonical(string collection) => Names.FirstOrDefault(n => n.Equals(collection, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"'{collection}' is not a schema collection."); -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Engine/SessionState.cs b/src/LibRed/LibRed.Engine/SessionState.cs index dbf81c5d..2397a35a 100644 --- a/src/LibRed/LibRed.Engine/SessionState.cs +++ b/src/LibRed/LibRed.Engine/SessionState.cs @@ -25,4 +25,4 @@ public sealed class SessionState /// here so a Rnd() sequence is deterministic across the connection — matching ACE, whose JES has no /// Randomize to reseed it. public uint RandSeed { get; set; } = 0x50000; -} +} \ No newline at end of file diff --git a/src/LibRed/LibRed.Sql/Ast/Expressions.cs b/src/LibRed/LibRed.Sql/Ast/Expressions.cs index 5319d81b..7964b0e7 100644 --- a/src/LibRed/LibRed.Sql/Ast/Expressions.cs +++ b/src/LibRed/LibRed.Sql/Ast/Expressions.cs @@ -127,6 +127,9 @@ public sealed record WindowFrame(FrameUnit Unit, FrameBound Start, FrameBound En /// AggregateNode. As a sibling record it falls through to "not an aggregate", which is correct — a window /// function returns one value per ROW, not per group, whatever its name. /// +/// The function's name, as written. +/// Its arguments, with an ordered-set aggregate's WITHIN GROUP keys last. +/// The window: its PARTITION BY, ORDER BY and frame. /// A windowed aggregate over the distinct values of its argument in each frame. /// IGNORE NULLS (true) or RESPECT NULLS (false); null when neither is written. /// FROM LAST (true) or FROM FIRST (false); null when neither is written. diff --git a/src/LibRed/LibRed.Sql/Ast/Statements.cs b/src/LibRed/LibRed.Sql/Ast/Statements.cs index 73603091..da669fc6 100644 --- a/src/LibRed/LibRed.Sql/Ast/Statements.cs +++ b/src/LibRed/LibRed.Sql/Ast/Statements.cs @@ -100,6 +100,8 @@ public sealed record ValuesStatement(IReadOnlyList> Ro /// /// An INSERT — Access's two append-query forms. /// +/// The table the rows are appended to. +/// The target columns, in the order the values are given; empty for DEFAULT VALUES. /// /// The single-record form's values, INSERT INTO t (…) VALUES (…). Empty when /// is set. @@ -138,12 +140,13 @@ public sealed record ColumnDefinition( string? Calculated = null, /// A trailing IDENTITY [(seed [, increment])] attribute, or null when the column has none. /// The last one written wins when several are. - IdentityAttribute? Identity = null); + IdentitySpec? Identity = null); /// ACE's IDENTITY [(seed [, increment])] column attribute. It makes a Long column an AutoNumber /// counting from its seed by its increment — each 1 when omitted, replacing any the type declared — and is -/// ignored on every other type. -public sealed record IdentityAttribute(int? Seed, int? Increment); +/// ignored on every other type. (Named for the SQL clause, not for , which is +/// why it is not spelled with that suffix.) +public sealed record IdentitySpec(int? Seed, int? Increment); /// Referential action for a foreign key's ON DELETE / ON UPDATE clause. Jet records only /// enforce + cascade-update + cascade-delete, so NoAction/SetNull/SetDefault collapse to "no cascade". @@ -300,7 +303,7 @@ public sealed record AddCheckAction(CheckConstraint Check) : AlterTableAction; /// type, and optionally its nullability (: true = NOT NULL, false = NULL, null = leave /// as-is) and default. public sealed record AlterColumnAction(string Field, string TypeName, int? Size, int? Scale, string? Default = null, bool? NotNull = null, - IdentityAttribute? Identity = null) : AlterTableAction; + IdentitySpec? Identity = null) : AlterTableAction; /// ALTER COLUMN field SET DEFAULT expr — set (replace) a column's default, without retyping it. public sealed record AlterColumnSetDefaultAction(string Field, string Default) : AlterTableAction; diff --git a/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs b/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs index 8df87990..8c267b53 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs @@ -24,7 +24,7 @@ public SqlStatement ParseStatement(string sql) parser.RemoveErrorListeners(); parser.AddErrorListener(errors); - return new AstBuilder().Build(parser.statement()); + return AstBuilder.Build(parser.statement()); } public bool IsStatementless(string sql) diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index 498c3ebe..9659f6c4 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -8,9 +8,9 @@ namespace LibRed.Sql.Parsing; /// Lowers an ANTLR parse tree into the engine's AST, so nothing /// downstream depends on the generated grammar types. /// -internal sealed class AstBuilder +internal static class AstBuilder { - public SqlStatement Build(StatementContext ctx) + public static SqlStatement Build(StatementContext ctx) { SqlStatement statement = BuildBody(ctx); @@ -27,7 +27,7 @@ public SqlStatement Build(StatementContext ctx) return statement; } - private SqlStatement BuildBody(StatementContext ctx) + private static SqlStatement BuildBody(StatementContext ctx) { if (ctx.ifThenStatement() is { } ifThen) return BuildIfThen(ifThen); if (ctx.createTableStatement() is { } create) return BuildCreateTable(create); @@ -53,10 +53,10 @@ private SqlStatement BuildBody(StatementContext ctx) _ => throw new NotSupportedException($"Unsupported transaction statement: {ctx.GetText()}"), }; - private SqlStatement BuildIfThen(IfThenStatementContext ctx) => + private static IfThenStatement BuildIfThen(IfThenStatementContext ctx) => new IfThenStatement(ctx.not is not null, BuildQueryExpression(ctx.queryExpression()), BuildThenBody(ctx.thenBody())); - private SqlStatement BuildThenBody(ThenBodyContext ctx) + private static SqlStatement BuildThenBody(ThenBodyContext ctx) { if (ctx.createTableStatement() is { } create) return BuildCreateTable(create); if (ctx.createIndexStatement() is { } createIndex) return BuildCreateIndex(createIndex); @@ -71,7 +71,7 @@ private SqlStatement BuildThenBody(ThenBodyContext ctx) return BuildQueryExpression(ctx.queryExpression()); } - private ExecuteStatement BuildExecute(ExecuteStatementContext ctx) => + private static ExecuteStatement BuildExecute(ExecuteStatementContext ctx) => new(Identifier(ctx.name), ctx.expression().Select(BuildExpression).ToList()); private static SqlStatement BuildDrop(DropStatementContext ctx) => ctx switch @@ -106,7 +106,7 @@ private static SystemVariableSelectStatement BuildSystemVariableSelect(SystemVar return new SystemVariableSelectStatement(items); } - private static SqlStatement BuildCreateTable(CreateTableStatementContext ctx) + private static CreateTableStatement BuildCreateTable(CreateTableStatementContext ctx) { if (ctx.temp is not null) throw new NotSupportedException("CREATE TEMPORARY TABLE is not supported."); @@ -192,7 +192,7 @@ ctx.Start is null || ctx.Stop is null ? ctx.GetText() : ctx.Start.InputStream.GetText(Antlr4.Runtime.Misc.Interval.Of(ctx.Start.StartIndex, ctx.Stop.StopIndex)); - private static SqlStatement BuildAlterTable(AlterTableStatementContext ctx) + private static AlterTableStatement BuildAlterTable(AlterTableStatementContext ctx) { AlterTableAction action = ctx.alterTableAction() switch { @@ -350,9 +350,9 @@ private static ColumnDefinition BuildColumnDefinition(ColumnDefinitionContext ct /// A column's IDENTITY attribute — the last one written, when there are several — or null. ACE takes /// it only straight after the type, NULL/NOT NULL or another IDENTITY: after DEFAULT, PRIMARY KEY or any /// other constraint it is a syntax error there (verified), so it is one here too. - private static IdentityAttribute? IdentityOf(IEnumerable constraints) + private static IdentitySpec? IdentityOf(IEnumerable constraints) { - IdentityAttribute? identity = null; + IdentitySpec? identity = null; bool afterOtherConstraint = false; foreach (ColumnConstraintContext constraint in constraints) { @@ -363,7 +363,7 @@ private static ColumnDefinition BuildColumnDefinition(ColumnDefinitionContext ct throw new SqlParseException( "Syntax error in field definition: IDENTITY must come before DEFAULT, PRIMARY KEY and the " + "column's other constraints."); - identity = new IdentityAttribute(SignedInteger(id.seed), SignedInteger(id.increment)); + identity = new IdentitySpec(SignedInteger(id.seed), SignedInteger(id.increment)); break; case NotNullConstraintContext or NullableConstraintContext: break; @@ -375,7 +375,7 @@ private static ColumnDefinition BuildColumnDefinition(ColumnDefinitionContext ct return identity; } - private static SqlStatement BuildCreateIndex(CreateIndexStatementContext ctx) + private static CreateIndexStatement BuildCreateIndex(CreateIndexStatementContext ctx) { var columns = ctx.indexColumn() .Select(ic => (Identifier(ic.col), Descending: ic.dir is { } d && d.Type == DESC)) @@ -391,7 +391,7 @@ private static SqlStatement BuildCreateIndex(CreateIndexStatementContext ctx) Identifier(ctx.name), Identifier(ctx.table), ctx.unique is not null, columns, withOption); } - private static SqlStatement BuildCreateView(CreateViewStatementContext ctx) + private static CreateViewStatement BuildCreateView(CreateViewStatementContext ctx) { var columns = ctx._columns.Select(Identifier).ToList(); ViewDefinition definition = BuildViewDefinition(ctx.query); @@ -443,7 +443,7 @@ private static SqlStatement BuildCreateProcedure(CreateProcedureStatementContext ? Identifier(into) : null; - private static SqlStatement BuildAppendProcedure( + private static CreateActionProcedureStatement BuildAppendProcedure( string name, InsertStatementContext insert, IReadOnlyList parameters) { var columns = insert._columns; @@ -497,7 +497,7 @@ private static SqlStatement BuildAppendProcedure( /// An UPDATE body: its sources and WHERE are stored exactly as a view's are, and each SET /// assignment becomes a column row naming its target (qualified, over a join) and holding the new /// value's verbatim text. - private static SqlStatement BuildUpdateProcedure( + private static CreateActionProcedureStatement BuildUpdateProcedure( string name, UpdateStatementContext update, IReadOnlyList parameters) { var assignments = update.assignment() @@ -510,7 +510,7 @@ private static SqlStatement BuildUpdateProcedure( /// A DELETE body: its sources and WHERE, plus the table.* target when the statement names /// one — which Access stores verbatim, and omits entirely for a bare DELETE FROM. - private static SqlStatement BuildDeleteProcedure( + private static CreateActionProcedureStatement BuildDeleteProcedure( string name, DeleteStatementContext delete, IReadOnlyList parameters) { string? target = delete.target is { } t ? $"{Identifier(t)}.*" : null; @@ -769,7 +769,7 @@ private static (ViewSource Source, string Alias) BuildViewSource(TablePrimaryCon _ => ViewJoinKind.Inner, }; - private static SqlStatement BuildInsert(InsertStatementContext ctx) + private static InsertStatement BuildInsert(InsertStatementContext ctx) { string table = Identifier(ctx.table); if (ctx.DEFAULT() is not null) @@ -802,9 +802,9 @@ private static SqlStatement BuildQueryExpression(QueryExpressionContext ctx) // The ordering and paging of the WHOLE expression — the grammar admits them here and nowhere else, so // there is nothing to disentangle: a leading TOP sits on its operand's own querySpecification, a FETCH // sits here, and the two can no longer be mistaken for each other. - var orderBy = ctx.orderByClause() is { } ob + List orderBy = ctx.orderByClause() is { } ob ? ob.orderByItem().Select(BuildOrderByItem).ToList() - : (IReadOnlyList)[]; + : []; Expression? top = null, offset = null; if (ctx.offsetFetchClause() is { } paging) { @@ -879,7 +879,7 @@ private static SqlStatement BuildBalanced(SqlStatement[] operands, SetOperator o /// A table value constructor used as a query. Every row must be the same width, and DEFAULT is /// rejected: it means "the column's default", which only has a meaning when there is a target column — /// so the standard allows it in an INSERT alone. - private static SqlStatement BuildValuesQuery(ValuesTermContext ctx) + private static ValuesStatement BuildValuesQuery(ValuesTermContext ctx) { var rows = new List>(); foreach (RowValuesContext row in ctx.rowValues()) @@ -1074,7 +1074,7 @@ private static OrderByItem BuildOrderByItem(OrderByItemContext ctx) => /// searched form here by turning each arm into operand = value, so evaluation only ever sees one /// shape. The operand is re-emitted per arm, which is what the standard's own definition implies and /// matches how the Jet generator expands a CASE into IIFs. - private static Expression BuildCase(CaseExpressionContext ctx) + private static CaseExpression BuildCase(CaseExpressionContext ctx) { Expression? operand = ctx.operand is null ? null : BuildExpression(ctx.operand); @@ -1118,7 +1118,7 @@ private static Expression BuildFunctionCall(FunctionCallContext ctx) /// one argument, the fraction, no DISTINCT and one key; LISTAGG takes the value and optionally a separator, which /// the standard makes a string literal, and any number of keys. /// - private static IReadOnlyList? BuildWithinGroup(FunctionCallContext ctx, string name, List args) + private static List? BuildWithinGroup(FunctionCallContext ctx, string name, List args) { bool stringAgg = name.Equals("STRING_AGG", StringComparison.OrdinalIgnoreCase); if (ctx.withinGroup() is not { } within) @@ -1217,21 +1217,21 @@ private static FrameBound BuildFrameBound(FrameBoundContext ctx) private static string FunctionName(FunctionNameContext ctx) => ctx.identifier() is { } id ? Identifier(id) : ctx.GetText(); - private static Expression BuildColumn(ColumnRefContext ctx) => + private static ColumnReference BuildColumn(ColumnRefContext ctx) => new ColumnReference(OptionalIdentifier(ctx.qualifier), Identifier(ctx.name)); /// x IN (a, b, …) becomes a flat evaluated iteratively — NOT a /// deep (x = a) OR (x = b) OR … tree, which recurses once per item and overflows the stack when EF Core /// inlines a "huge number of values" Contains (thousands of constants). The evaluator reproduces the same /// OR/=/NOT three-valued semantics in a loop. - private static Expression BuildIn(InExprContext ctx) + private static InListExpression BuildIn(InExprContext ctx) { Expression value = BuildExpression(ctx.val); var items = ctx._items.Select(BuildExpression).ToList(); return new InListExpression(value, items, ctx.not is not null); } - private static Expression BuildBetween(BetweenExprContext ctx) => + private static BetweenExpression BuildBetween(BetweenExprContext ctx) => new BetweenExpression(BuildExpression(ctx.val), BuildExpression(ctx.lo), BuildExpression(ctx.hi), ctx.not is not null); /// Parses an Access #…# date literal (e.g. #1/1/1997#, month/day/year) to a @@ -1295,7 +1295,7 @@ private static LiteralExpression BuildNumber(string text, int tokenType) => toke ? new LiteralExpression(ParseInteger(text)) : new LiteralExpression(double.Parse(text, CultureInfo.InvariantCulture), WrittenDecimal(text)); - private static Expression BuildLiteral(LiteralContext ctx) => ctx switch + private static LiteralExpression BuildLiteral(LiteralContext ctx) => ctx switch { IntLiteralContext i => BuildNumber(i.GetText(), INTEGER_LITERAL), NumberLiteralContext n => BuildNumber(n.GetText(), NUMBER_LITERAL), diff --git a/src/LibRed/LibRed.Sql/SqlKeywords.cs b/src/LibRed/LibRed.Sql/SqlKeywords.cs index dcbc9ca9..bceef298 100644 --- a/src/LibRed/LibRed.Sql/SqlKeywords.cs +++ b/src/LibRed/LibRed.Sql/SqlKeywords.cs @@ -16,7 +16,7 @@ public static class SqlKeywords /// Every keyword, upper-case and sorted. public static IReadOnlyList Reserved => All.Value; - private static IReadOnlyList Collect() + private static List Collect() { var vocabulary = AccessSqlLexer.DefaultVocabulary; var words = new List(); diff --git a/src/Shared/Check.cs b/src/Shared/Check.cs index 3af278c2..e75297d4 100644 --- a/src/Shared/Check.cs +++ b/src/Shared/Check.cs @@ -1,9 +1,5 @@ -#nullable enable - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using JetBrains.Annotations; +using System.Runtime.CompilerServices; using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; namespace EntityFrameworkCore.Jet.Utilities; @@ -137,4 +133,4 @@ private static void ThrowCollectionHasEmptyElements(string parameterName) [DoesNotReturn] private static void ThrowArgumentException(string message, string parameterName) => throw new ArgumentException(message, parameterName); -} +} \ No newline at end of file diff --git a/src/Shared/CodeAnnotations.cs b/src/Shared/CodeAnnotations.cs index 426211ee..d0fedbed 100644 --- a/src/Shared/CodeAnnotations.cs +++ b/src/Shared/CodeAnnotations.cs @@ -1,7 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; - namespace JetBrains.Annotations { [AttributeUsage( @@ -88,6 +86,9 @@ internal enum ImplicitUseKindFlags } [Flags] + // Default is deliberately an alias for Itself, as ReSharper's own annotations declare it; CA1069 reads the + // two as duplicates. +#pragma warning disable CA1069 internal enum ImplicitUseTargetFlags { Default = Itself, @@ -95,4 +96,5 @@ internal enum ImplicitUseTargetFlags Members = 2, WithMembers = Itself | Members } +#pragma warning restore CA1069 } \ No newline at end of file diff --git a/src/Shared/Data/JetDecimalConverter.cs b/src/Shared/Data/JetDecimalConverter.cs index be86c93c..9d978679 100644 --- a/src/Shared/Data/JetDecimalConverter.cs +++ b/src/Shared/Data/JetDecimalConverter.cs @@ -1,4 +1,9 @@ +// IDE0005 cannot be answered once for this file: it is LINKED into both EFCore.Jet.Data and LibRed.Core, and +// only the latter has ImplicitUsings — `using System;` is load-bearing in the former. The rule is reporting +// the project, not the file. +#pragma warning disable IDE0005 using System; +#pragma warning restore IDE0005 using System.Runtime.CompilerServices; namespace EntityFrameworkCore.Jet.Data @@ -57,7 +62,6 @@ public static decimal ToDecimal(object? value, IFormatProvider? provider = null) }; private const int DecScaleMax = 28; - private const int ScaleShift = 16; private static ReadOnlySpan UInt32Powers10 => [ @@ -268,4 +272,4 @@ private static decimal Convert(double input, int significantDigits) return new decimal((int)low, (int)mid, (int)high, isNegative, (byte)power); } } -} +} \ No newline at end of file diff --git a/src/Shared/EnumerableMthods.cs b/src/Shared/EnumerableMthods.cs index 4532c95d..a73992c5 100644 --- a/src/Shared/EnumerableMthods.cs +++ b/src/Shared/EnumerableMthods.cs @@ -1,11 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; namespace EntityFrameworkCore.Jet; @@ -643,4 +639,4 @@ MethodInfo GetMethod(string name, int genericParameterCount, Func e.ParameterType).SequenceEqual( parameterGenerator(mi.IsGenericMethod ? mi.GetGenericArguments() : []))); } -} +} \ No newline at end of file diff --git a/src/Shared/ExpressionVisitorExtensions.cs b/src/Shared/ExpressionVisitorExtensions.cs index 0518017f..ca6eccf1 100644 --- a/src/Shared/ExpressionVisitorExtensions.cs +++ b/src/Shared/ExpressionVisitorExtensions.cs @@ -1,13 +1,8 @@ -using Microsoft.EntityFrameworkCore.Diagnostics; -using System.Collections.Generic; -using System.Diagnostics; // ReSharper disable once CheckNamespace using System.Runtime.CompilerServices; namespace System.Linq.Expressions; -#nullable enable - [DebuggerStepThrough] internal static class ExpressionVisitorExtensions { @@ -127,4 +122,4 @@ public static IReadOnlyList Visit(this ExpressionVisitor visitor, IReadOnl return newNodes ?? nodes; } -} +} \ No newline at end of file diff --git a/src/Shared/MemberInfoExtensions.cs b/src/Shared/MemberInfoExtensions.cs index aea8dfed..7ab111cd 100644 --- a/src/Shared/MemberInfoExtensions.cs +++ b/src/Shared/MemberInfoExtensions.cs @@ -1,7 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Linq; - namespace System.Reflection { internal static class MemberInfoExtensions @@ -47,4 +45,4 @@ public static string GetSimpleMemberName(this MemberInfo member) public static bool IsReallyVirtual(this MethodInfo method) => method.IsVirtual && !method.IsFinal; } -} +} \ No newline at end of file diff --git a/src/Shared/PropertyInfoExtensions.cs b/src/Shared/PropertyInfoExtensions.cs index 3c995999..d02d3243 100644 --- a/src/Shared/PropertyInfoExtensions.cs +++ b/src/Shared/PropertyInfoExtensions.cs @@ -1,8 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Diagnostics; -using System.Linq; - // ReSharper disable once CheckNamespace namespace System.Reflection { @@ -37,4 +34,4 @@ public static bool IsIndexerProperty(this PropertyInfo propertyInfo) .GetPropertiesInHierarchy(propertyInfo.GetSimpleMemberName()) .FirstOrDefault(p => p.SetMethod != null); } -} +} \ No newline at end of file diff --git a/src/Shared/SharedSource.props b/src/Shared/SharedSource.props new file mode 100644 index 00000000..860e9f08 --- /dev/null +++ b/src/Shared/SharedSource.props @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/src/Shared/SharedTypeExtensions.cs b/src/Shared/SharedTypeExtensions.cs index 28ff3c23..3b1fb8e1 100644 --- a/src/Shared/SharedTypeExtensions.cs +++ b/src/Shared/SharedTypeExtensions.cs @@ -1,11 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -613,4 +607,4 @@ public static bool IsTimeRelatedType(this Type type) unwrappedType == typeof(TimeSpan); } } -} +} \ No newline at end of file diff --git a/src/Shared/StringBuilderExtensions.cs b/src/Shared/StringBuilderExtensions.cs index 3f6f5a77..003975ec 100644 --- a/src/Shared/StringBuilderExtensions.cs +++ b/src/Shared/StringBuilderExtensions.cs @@ -1,6 +1,5 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System.Collections.Generic; using System.Globalization; namespace System.Text @@ -109,4 +108,4 @@ public static void AppendBytes(this StringBuilder builder, byte[] bytes) builder.Append('\''); } } -} +} \ No newline at end of file diff --git a/test/LibRed.Benchmarks/Suites/PipelineBenchmarks.cs b/test/LibRed.Benchmarks/Suites/PipelineBenchmarks.cs index 44920b1b..0b567db2 100644 --- a/test/LibRed.Benchmarks/Suites/PipelineBenchmarks.cs +++ b/test/LibRed.Benchmarks/Suites/PipelineBenchmarks.cs @@ -30,7 +30,6 @@ public class PipelineBenchmarks private QueryEngine _engine = null!; private ISqlParser _parser = null!; private Binder _binder = null!; - private readonly QueryPlanner _planner = new(); private SqlStatement _ast = null!; private PlanNode _plan = null!; @@ -56,7 +55,7 @@ public void Setup() _binder = new Binder(new CatalogSchemaProvider(_database.Catalog)); _ast = _parser.ParseStatement(Case.Sql); - _plan = IndexSelection.Apply(_planner.Plan(_binder.Bind(_ast)), _database.Catalog); + _plan = IndexSelection.Apply(QueryPlanner.Plan(_binder.Bind(_ast)), _database.Catalog); Consume.Drain(_engine.ExecuteQuery(Case.Sql)); } @@ -72,7 +71,7 @@ public void Setup() /// AST → bound → plan → access paths chosen. Also data-independent, but it is where index /// selection lives, so this is the number that moves when the optimiser changes. [Benchmark] - public object BindAndPlan() => IndexSelection.Apply(_planner.Plan(_binder.Bind(_ast)), _database.Catalog); + public object BindAndPlan() => IndexSelection.Apply(QueryPlanner.Plan(_binder.Bind(_ast)), _database.Catalog); /// Executing an already-built plan and draining the rows: the part that scales with the data, /// and the only part a prepared-statement cache would leave behind. From efe387d8802bedee47d11a1170eadf6ce86eb01b Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 01:55:03 +0800 Subject: [PATCH 08/14] Count past a Long Integer with DateDiff_Big MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateDiff counts into Access's Long Integer, so DateTimeOffset.ToUnixTimeSeconds overflowed it against any date before about 1902 — the BasicTypes data holds year-0101 ones — and the evaluator threw. ACE does not: measured over OLE DB against that data, the column reports adInteger for every row while those rows come back null. SQL Server's DATEDIFF has the same Int32 ceiling and documents the same limits, and answers them with a separate DATEDIFF_BIG. So DateDiff now gives Null where a count will not fit, and DateDiff_Big counts the same boundaries over the same interval table into an Int64. Hours cannot pass a Long Integer between any two dates Access represents; minutes, seconds and milliseconds can, and only those are checked. "ms" is no longer the one interval DateDiff counts wide: it returns an Int32 like the rest, and DateDiff_Big is what counts further. Extended mode translates both Unix-epoch methods through DateDiff_Big, in a copy of the Jet translator selected by the same mode ternary the Math ones use. Compatible mode keeps the Jet translator, because its statements have to run against ACE, which has no such function — so ToUnixTimeMilliseconds there is short a row rather than wrong, which is ACE's own ceiling and not ours to fix. Co-Authored-By: Claude Opus 5 --- .../Internal/JetDateTimeMethodTranslator.cs | 2 - .../LibRedDateTimeMethodTranslator.cs | 137 ++++++++++++++++++ .../LibRedMethodCallTranslatorProvider.cs | 4 +- .../Execution/ExpressionEvaluator.cs | 71 ++++++--- .../LibRed.Engine/Execution/QueryExecutor.cs | 11 +- src/LibRed/docs/functions.md | 5 +- .../DateTimeOffsetTranslationsLibRedTest.cs | 4 +- 7 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 src/LibRed/LibRed.EFCore/Query/Internal/LibRedDateTimeMethodTranslator.cs diff --git a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs index 546fccc6..8760b62b 100644 --- a/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs +++ b/src/EFCore.Jet.Common/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs @@ -1,5 +1,3 @@ -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - using Microsoft.EntityFrameworkCore.Query.SqlExpressions; namespace EntityFrameworkCore.Jet.Query.ExpressionTranslators.Internal diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedDateTimeMethodTranslator.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedDateTimeMethodTranslator.cs new file mode 100644 index 00000000..61bb90fe --- /dev/null +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedDateTimeMethodTranslator.cs @@ -0,0 +1,137 @@ +using EntityFrameworkCore.Jet.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.LibRed.Query.Internal +{ + /// + /// This API supports the Entity Framework Core infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class LibRedDateTimeMethodTranslator(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator + { + private readonly JetSqlExpressionFactory _sqlExpressionFactory = (JetSqlExpressionFactory)sqlExpressionFactory; + + private readonly Dictionary _methodInfoDatePartMapping = new() + { + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddYears), [typeof(int)])!, "yyyy"}, + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddMonths), [typeof(int)])!, "m"}, + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddDays), [typeof(double)])!, "d"}, + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddHours), [typeof(double)])!, "h"}, + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddMinutes), [typeof(double)])!, "n"}, + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddSeconds), [typeof(double)])!, "s"}, + {typeof(DateTime).GetRuntimeMethod(nameof(DateTime.AddMilliseconds), [typeof(double)])!, "ms"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddYears), [typeof(int)])!, "yyyy"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddMonths), [typeof(int)])!, "m"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddDays), [typeof(double)])!, "d"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddHours), [typeof(double)])!, "h"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddMinutes), [typeof(double)])!, "n"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddSeconds), [typeof(double)])!, "s"}, + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddMilliseconds), [typeof(double)])!, "ms"} + }; + + private static readonly Dictionary _methodInfoDateDiffMapping = new() + { + { typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeSeconds), Type.EmptyTypes)!, "s" }, + { typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeMilliseconds), Type.EmptyTypes)!, "ms" } + }; + + public SqlExpression? Translate(SqlExpression? instance, MethodInfo method, IReadOnlyList arguments, IDiagnosticsLogger logger) + { + if (_methodInfoDatePartMapping.TryGetValue(method, out var datePart) && instance != null) + { + var amountToAdd = arguments[0]; + + if (!datePart.Equals("yyyy", StringComparison.Ordinal) + && !datePart.Equals("m", StringComparison.Ordinal) + && amountToAdd is SqlConstantExpression constantExpression + && constantExpression.Value is double doubleValue + && (doubleValue >= int.MaxValue + || doubleValue <= int.MinValue)) + { + return null; + } + //The DateAdd function can not take a null for the argument with the amount to add + //We rewrite the expression a bit to simplify things + //We first take dig town into the operand of the convert expression to colaesce that to 0 + //Then do the convert on that + //This can simplify the expression as the reverse would have more IIF when there would be the 0 + if (amountToAdd is SqlUnaryExpression { OperatorType: ExpressionType.Convert } convert) + { + var cols = ExtractColumnExpressions(convert); + if (cols.Any(c => c.IsNullable)) + { + amountToAdd = _sqlExpressionFactory.Coalesce(convert.Operand, + _sqlExpressionFactory.Constant(0, amountToAdd.TypeMapping)); + amountToAdd = _sqlExpressionFactory.Convert(amountToAdd, convert.Type, convert.TypeMapping); + } + } + + return _sqlExpressionFactory.Function( + "DATEADD", + [ + new SqlConstantExpression(datePart, null), + amountToAdd, + instance + ], + true, + argumentsPropagateNullability: [false, false, true], + instance.Type, + instance.TypeMapping); + } + + if (_methodInfoDateDiffMapping.TryGetValue(method, out var timePart)) + { + return _sqlExpressionFactory.Function( + "DATEDIFF_BIG", + [ + new SqlConstantExpression(timePart, null), + _sqlExpressionFactory.Constant(DateTimeOffset.UnixEpoch, instance!.TypeMapping), + instance + ], + nullable: true, + argumentsPropagateNullability: [false, true, true], + typeof(long)); + } + + return null; + } + + private static List ExtractColumnExpressions(SqlBinaryExpression binaryexp) + { + List result = []; + if (binaryexp.Left is SqlBinaryExpression left) + { + result.AddRange(ExtractColumnExpressions(left)); + } + else if (binaryexp.Left is ColumnExpression colLeft) + { + result.Add(colLeft); + } + + if (binaryexp.Right is SqlBinaryExpression right) + { + result.AddRange(ExtractColumnExpressions(right)); + } + else if (binaryexp.Right is ColumnExpression colRight) + { + result.Add(colRight); + } + + return result; + } + private static List ExtractColumnExpressions(SqlUnaryExpression unaryexp) + { + List result = []; + if (unaryexp.Operand is SqlBinaryExpression left) + { + result.AddRange(ExtractColumnExpressions(left)); + } + else if (unaryexp.Operand is ColumnExpression colLeft) + { + result.Add(colLeft); + } + + return result; + } + } +} diff --git a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs index 7694d22f..8c6b86f2 100644 --- a/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs +++ b/src/LibRed/LibRed.EFCore/Query/Internal/LibRedMethodCallTranslatorProvider.cs @@ -29,7 +29,9 @@ public LibRedMethodCallTranslatorProvider( new JetConvertTranslator(sqlExpressionFactory), new JetDateDiffFunctionsTranslator(sqlExpressionFactory), new JetDateOnlyMethodTranslator(sqlExpressionFactory), - new JetDateTimeMethodTranslator(sqlExpressionFactory), + options.SqlMode == LibRedSqlMode.Compatible + ? new JetDateTimeMethodTranslator(sqlExpressionFactory) + : new LibRedDateTimeMethodTranslator(sqlExpressionFactory), new JetIsDateFunctionTranslator(sqlExpressionFactory), options.SqlMode == LibRedSqlMode.Compatible ? new JetMathTranslator(sqlExpressionFactory) diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index 24a15e51..4f82b69c 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -265,6 +265,7 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) // rounds to the second, so Second(0.00001) is 0 here and 1 in ACE. "DATEADD" => DateAdd(f), "DATEDIFF" => DateDiff(f), + "DATEDIFF_BIG" => DateDiffBig(f), "DATESERIAL" => DateParts(f, DateSerial), "TIMESERIAL" => DateParts(f, static (h, m, s) => OaDate((h * 3600 + m * 60 + s) / 86400.0)), "NOW" => DateTime.Now, @@ -436,7 +437,7 @@ internal static void ValidateArity(string name, int count) "NOW" or "DATE" or "TIME" or "TIMER" or "GENUNIQUEID" or "GENGUID" => (0, 0), "DATEADD" => (3, 3), - "DATEDIFF" => (3, 5), + "DATEDIFF" or "DATEDIFF_BIG" => (3, 5), "DATEPART" => (2, 4), "DATESERIAL" or "TIMESERIAL" => (3, 3), "WEEKDAY" or "MONTHNAME" => (1, 2), @@ -1875,10 +1876,11 @@ private static DateTime InDateRange(Func compute) /// /// Access DateDiff(interval, date1, date2, [firstdayofweek], [firstweekofyear]) (verified vs ACE): the /// number of interval boundaries from date1 to date2, as a Long Integer. "w" is whole weeks of days, "ww" counts - /// the weeks' first days, and "h", "n" and "s" count hour, minute and second boundaries; a count past a Long - /// Integer is an overflow. The first day of the week matters only to "ww", and the first week of the year to none. + /// the weeks' first days, and "h", "n" and "s" count hour, minute and second boundaries. The first day of the + /// week matters only to "ww", and the first week of the year to none. + /// A count past a Long Integer is Null /// - private object? DateDiff(FunctionCall f) + private int? DateDiff(FunctionCall f) { if (Evaluate(f.Arguments[0]) is not { } intervalV || Evaluate(f.Arguments[1]) is not { } d1V || Evaluate(f.Arguments[2]) is not { } d2V) @@ -1886,19 +1888,6 @@ private static DateTime InDateRange(Func compute) DateTime d1 = ToDate(d1V), d2 = ToDate(d2V); string interval = ConcatText(intervalV).ToLowerInvariant(); - // "ms" is a LibRed extension — ACE's interval list stops at "s". It is available because LibRed stores - // the full OA double rather than truncating to whole seconds as ACE does, and it is exact: .NET's OA - // conversion quantises to whole milliseconds, so nothing below a millisecond survived storage anyway - // (measured: 12:34:56.123 round-trips with zero tick loss, .1234560 comes back as .123). - // - // Handled before the switch, and as Int64 rather than the Long Integer every other interval returns: a - // millisecond difference overflows Int32 after 25 days, and ToUnixTimeMilliseconds spans decades. A - // long arm inside the switch would widen every other interval's result type along with it. - if (interval == "ms") - { - return (long)(d2 - d1).TotalMilliseconds; - } - if (interval == "ww") { if (FirstDayOfWeek(f, 3) is not { } first) @@ -1913,14 +1902,52 @@ private static DateTime InDateRange(Func compute) "m" => (d2.Year - d1.Year) * 12 + d2.Month - d1.Month, "y" or "d" => (d2.Date - d1.Date).Days, "w" => (d2.Date - d1.Date).Days / 7, - "h" => Boundaries(TimeSpan.TicksPerHour), - "n" => Boundaries(TimeSpan.TicksPerMinute), - "s" => Boundaries(TimeSpan.TicksPerSecond), - // "ms" is handled above, as Int64. + // Hours cannot pass a Long Integer between any two dates Access represents. Minutes and seconds + // can, and a count that does is Null — what ACE's result column gives, measured over OLE DB. + "h" => (int)(d2.Ticks / TimeSpan.TicksPerHour - d1.Ticks / TimeSpan.TicksPerHour), + "n" => d2.Ticks / TimeSpan.TicksPerMinute - d1.Ticks / TimeSpan.TicksPerMinute + is >= int.MinValue and <= int.MaxValue and var minutes ? (int)minutes : null, + "s" => d2.Ticks / TimeSpan.TicksPerSecond - d1.Ticks / TimeSpan.TicksPerSecond + is >= int.MinValue and <= int.MaxValue and var seconds ? (int)seconds : null, + "ms" => (long)(d2 - d1).TotalMilliseconds + is >= int.MinValue and <= int.MaxValue and var milliseconds ? (int)milliseconds : null, _ => throw UnknownInterval(intervalV), }; + } + + /// + /// DateDiff_Big(interval, date1, date2, [firstdayofweek], [firstweekofyear]) — the same + /// boundaries counts, over the same interval table, counted into an Int64. A + /// LibRed extension: ACE has no such function, and the Jet-dialect generator never emits it. + /// + private long? DateDiffBig(FunctionCall f) + { + if (Evaluate(f.Arguments[0]) is not { } intervalV || Evaluate(f.Arguments[1]) is not { } d1V + || Evaluate(f.Arguments[2]) is not { } d2V) + return null; + DateTime d1 = ToDate(d1V), d2 = ToDate(d2V); + string interval = ConcatText(intervalV).ToLowerInvariant(); + + if (interval == "ww") + { + if (FirstDayOfWeek(f, 3) is not { } first) + return null; + return (long)((d2.Date.AddDays(-DaysIntoWeek(d2, first)) - d1.Date.AddDays(-DaysIntoWeek(d1, first))).Days / 7); + } - int Boundaries(long unit) => checked((int)(d2.Ticks / unit - d1.Ticks / unit)); + return interval switch + { + "yyyy" => (long)(d2.Year - d1.Year), + "q" => (long)((d2.Year - d1.Year) * 4 + (d2.Month - 1) / 3 - (d1.Month - 1) / 3), + "m" => (long)((d2.Year - d1.Year) * 12 + d2.Month - d1.Month), + "y" or "d" => (long)(d2.Date - d1.Date).Days, + "w" => (long)((d2.Date - d1.Date).Days / 7), + "h" => d2.Ticks / TimeSpan.TicksPerHour - d1.Ticks / TimeSpan.TicksPerHour, + "n" => d2.Ticks / TimeSpan.TicksPerMinute - d1.Ticks / TimeSpan.TicksPerMinute, + "s" => d2.Ticks / TimeSpan.TicksPerSecond - d1.Ticks / TimeSpan.TicksPerSecond, + "ms" => (long)(d2 - d1).TotalMilliseconds, + _ => throw UnknownInterval(intervalV), + }; } // Access truthiness: a filter/logical context treats any non-zero number as true (so a boolean stored diff --git a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs index 19563c7c..a9a0d973 100644 --- a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs @@ -1132,14 +1132,9 @@ var pair when RunningAggregate.IsPair(pair) => typeof(double), "CSTR" or "FORMAT" or "LCASE" or "UCASE" or "TRIM" or "LTRIM" or "RTRIM" or "LEFT" or "RIGHT" or "MID" or "REPLACE" or "STRING" or "SPACE" or "HEX" or "OCT" or "WEEKDAYNAME" or "MONTHNAME" or "PARTITION" => typeof(string), - // DateDiff's "ms", LibRed's own interval, counts in Int64 — a millisecond difference passes Int32 after - // 25 days — where every other interval is a Long Integer. Only a written interval says which: one read - // from a parameter or a column is declared as the Long Integer the rest give. - "DATEDIFF" => function.Arguments is [LiteralExpression { Value: string interval }, ..] - && interval.Equals("ms", StringComparison.OrdinalIgnoreCase) - ? typeof(long) - : typeof(int), - "LEN" or "DATALENGTH" or "INSTR" or "INSTRREV" or "ASC" or "ASCW" or "DATEPART" + // DateDiff counts into Access's Long Integer whatever the interval, and DateDiff_Big into an Int64 + "DATEDIFF_BIG" => typeof(long), + "LEN" or "DATALENGTH" or "INSTR" or "INSTRREV" or "ASC" or "ASCW" or "DATEPART" or "DATEDIFF" or "YEAR" or "MONTH" or "DAY" or "HOUR" or "MINUTE" or "SECOND" or "WEEKDAY" => typeof(int), "CDATE" or "NOW" or "DATE" or "TIME" or "DATEADD" or "DATESERIAL" or "TIMESERIAL" or "DATEVALUE" or "TIMEVALUE" => typeof(DateTime), diff --git a/src/LibRed/docs/functions.md b/src/LibRed/docs/functions.md index f2e03867..de2382f1 100644 --- a/src/LibRed/docs/functions.md +++ b/src/LibRed/docs/functions.md @@ -96,7 +96,7 @@ procedure call, and a result past a Double an overflow. ### Date and time - **Current** — `Now` `Date` `Time` `Timer` -- **Arithmetic** — `DateAdd` `DateDiff` `DatePart` `DateSerial` `TimeSerial` +- **Arithmetic** — `DateAdd` `DateDiff` `DateDiff_Big` `DatePart` `DateSerial` `TimeSerial` - **Parsing** — `DateValue` `TimeValue` - **Parts** — `Year` `Month` `Day` `Hour` `Minute` `Second` `Weekday` (Sunday = 1) - **Names** — `MonthName` `WeekdayName` @@ -235,7 +235,8 @@ syntax, not a function). - **`DatePart`** also takes `"ms"`, `"mcs"` and `"ns"`: the millisecond, microsecond and nanosecond of the time. - **`DateAdd`** also takes `"ms"`. -- **`DateDiff`** also takes `"ms"`, counting in **Int64** — a millisecond difference passes Int32 after 25 days. +- **`DateDiff`** also takes `"ms"` — Returns an Int32 +- **`DateDiff_Big(interval, date1, date2, …)`**, Returns an Int64 ACE's interval list stops at `"s"`. diff --git a/test/EFCore.LibRed.Extended.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs b/test/EFCore.LibRed.Extended.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs index c850c517..6ca902a5 100644 --- a/test/EFCore.LibRed.Extended.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.Extended.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs @@ -307,7 +307,7 @@ public override async Task ToUnixTimeMilliseconds() SELECT `b`.`Id`, `b`.`Bool`, `b`.`Byte`, `b`.`ByteArray`, `b`.`DateOnly`, `b`.`DateTime`, `b`.`DateTimeOffset`, `b`.`Decimal`, `b`.`Double`, `b`.`Enum`, `b`.`FlagsEnum`, `b`.`Float`, `b`.`Guid`, `b`.`Int`, `b`.`Long`, `b`.`Short`, `b`.`String`, `b`.`TimeOnly`, `b`.`TimeSpan` FROM `BasicTypesEntities` AS `b` -WHERE DATEDIFF('ms', CDATE('1970-01-01 00:00:00'), `b`.`DateTimeOffset`) = @unixEpochMilliseconds +WHERE DATEDIFF_BIG('ms', CDATE('1970-01-01 00:00:00'), `b`.`DateTimeOffset`) = @unixEpochMilliseconds """); } @@ -321,7 +321,7 @@ public override async Task ToUnixTimeSecond() SELECT `b`.`Id`, `b`.`Bool`, `b`.`Byte`, `b`.`ByteArray`, `b`.`DateOnly`, `b`.`DateTime`, `b`.`DateTimeOffset`, `b`.`Decimal`, `b`.`Double`, `b`.`Enum`, `b`.`FlagsEnum`, `b`.`Float`, `b`.`Guid`, `b`.`Int`, `b`.`Long`, `b`.`Short`, `b`.`String`, `b`.`TimeOnly`, `b`.`TimeSpan` FROM `BasicTypesEntities` AS `b` -WHERE DATEDIFF('s', CDATE('1970-01-01 00:00:00'), `b`.`DateTimeOffset`) = @unixEpochSeconds +WHERE DATEDIFF_BIG('s', CDATE('1970-01-01 00:00:00'), `b`.`DateTimeOffset`) = @unixEpochSeconds """); } From fd6b3605dc125e2b7b2e74918c7f7c92b5020dda Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 19:08:58 +0800 Subject: [PATCH 09/14] Hold the DateDiff tests to the contract efe387d8 set That commit gave DateDiff a different contract - a count that will not fit is Null rather than an OverflowException, and "ms" counts into a Long Integer like every other interval instead of being the one that counts wide - but left the tests that pinned the old one. Six have failed since. Values_past_their_type_overflow keeps DateSerial and TimeSerial, which still overflow. The two DateDiff cases move to a test that asserts Null, joined by "ms", which now has the same ceiling; DateDiff_Big answers the same three spans in an Int64. The Int64 expectations in DateDiffMillisecondTests move to DateDiff_Big rather than going away, so the wide count keeps its coverage: Spans_beyond_int32 asserts Null from DateDiff and the whole 50-year count from DateDiff_Big, and the declared-column-type theory names the function beside the interval. Co-Authored-By: Claude Opus 5 --- .../DateDiffMillisecondTests.cs | 38 ++++++++++++------- test/LibRed.Engine.Tests/DateFunctionTests.cs | 19 +++++++++- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs b/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs index ae145fa7..1177e7e1 100644 --- a/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs +++ b/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs @@ -7,8 +7,9 @@ namespace LibRed.Engine.Tests; // DATEDIFF("ms", a, b) — a LibRed extension. ACE's interval list stops at "s", but LibRed stores the full OA // double instead of truncating to whole seconds, so a millisecond difference is both meaningful and exact. // -// It returns Int64, unlike every other interval: a millisecond count overflows Int32 after about 25 days, and -// DateTimeOffset.ToUnixTimeMilliseconds — which is what emits this — spans decades. +// It counts into a Long Integer like every other interval, and gives Null for a span that will not fit — a +// millisecond count passes Int32 after about 25 days. DATEDIFF_BIG is the one that counts the same span in an +// Int64, and is what DateTimeOffset.ToUnixTimeMilliseconds emits. public class DateDiffMillisecondTests : TempDatabaseTest { private static QueryEngine Fresh() @@ -30,20 +31,29 @@ private static QueryEngine Fresh() public void Counts_whole_milliseconds(string from, string to, long expected) => Assert.Equal(expected, Convert.ToInt64(Eval(Fresh(), $"DATEDIFF('ms', {from}, {to})"))); - // The reason it is Int64: a millisecond span overflows Int32 after ~25 days, and the caller that emits - // this interval measures from 1970. + // A millisecond span passes Int32 after ~25 days, and the caller that emits this interval measures from + // 1970: past the Long Integer, DATEDIFF is Null and DATEDIFF_BIG carries the count. [Fact] public void Spans_beyond_int32() { - long value = Convert.ToInt64(Eval(Fresh(), "DATEDIFF('ms', #1970-01-01 00:00:00#, #2020-01-01 00:00:00#)")); + const string span = "#1970-01-01 00:00:00#, #2020-01-01 00:00:00#"; + QueryEngine engine = Fresh(); + Assert.Null(Eval(engine, $"DATEDIFF('ms', {span})")); + + long value = Convert.ToInt64(Eval(engine, $"DATEDIFF_BIG('ms', {span})")); Assert.Equal((long)(new DateTime(2020, 1, 1) - new DateTime(1970, 1, 1)).TotalMilliseconds, value); Assert.True(value > int.MaxValue, "a 50-year millisecond span must not be truncated to Int32"); } [Fact] - public void Returns_a_long_not_an_int() - => Assert.IsType(Eval(Fresh(), "DATEDIFF('ms', #2020-01-01 00:00:00#, #2020-01-01 00:00:01#)")); + public void Returns_an_int_and_DateDiff_Big_a_long() + { + const string span = "#2020-01-01 00:00:00#, #2020-01-01 00:00:01#"; + QueryEngine engine = Fresh(); + Assert.IsType(Eval(engine, $"DATEDIFF('ms', {span})")); + Assert.IsType(Eval(engine, $"DATEDIFF_BIG('ms', {span})")); + } // Adding the arm must not have widened the other intervals, which stay Access's Long Integer. [Theory] @@ -57,14 +67,16 @@ public void Other_intervals_still_return_int(string interval) // The column is declared as the values are — what a reader's GetFieldType reports, and what EF reads with. [Theory] - [InlineData("ms", typeof(long))] - [InlineData("MS", typeof(long))] - [InlineData("s", typeof(int))] - [InlineData("d", typeof(int))] - public void The_column_is_declared_as_its_values(string interval, Type expected) + [InlineData("DATEDIFF", "ms", typeof(int))] + [InlineData("DATEDIFF", "MS", typeof(int))] + [InlineData("DATEDIFF", "s", typeof(int))] + [InlineData("DATEDIFF", "d", typeof(int))] + [InlineData("DATEDIFF_BIG", "ms", typeof(long))] + [InlineData("DATEDIFF_BIG", "s", typeof(long))] + public void The_column_is_declared_as_its_values(string function, string interval, Type expected) { var result = Fresh().ExecuteQuery( - $"SELECT DATEDIFF('{interval}', #2020-01-01 00:00:00#, #2020-01-02 00:00:00#) FROM `Shippers` WHERE `ShipperID` = 1"); + $"SELECT {function}('{interval}', #2020-01-01 00:00:00#, #2020-01-02 00:00:00#) FROM `Shippers` WHERE `ShipperID` = 1"); Assert.Equal(expected, result.ColumnTypes[0]); Assert.IsType(expected, result.Rows.Single()[0]); } diff --git a/test/LibRed.Engine.Tests/DateFunctionTests.cs b/test/LibRed.Engine.Tests/DateFunctionTests.cs index 01ddd912..f92051c7 100644 --- a/test/LibRed.Engine.Tests/DateFunctionTests.cs +++ b/test/LibRed.Engine.Tests/DateFunctionTests.cs @@ -252,11 +252,26 @@ public void Out_of_range_arguments_are_an_invalid_procedure_call(string expressi [Theory] [InlineData("DATESERIAL(2020, 1, 32768)")] [InlineData("TIMESERIAL(0, 0, 32768)")] - [InlineData("DATEDIFF('s', #0100-01-01#, #9999-12-31#)")] - [InlineData("DATEDIFF('n', #0100-01-01#, #9999-12-31#)")] public void Values_past_their_type_overflow(string expression) => Assert.Throws(() => Scalar(expression)); + // DATEDIFF counts into a Long Integer and gives Null for a span that will not fit — what ACE's result + // column gives, measured over OLE DB, rather than an error. Minutes and seconds are the intervals that + // can pass it; DATEDIFF_BIG is the one that counts the same span in an Int64. + [Theory] + [InlineData("DATEDIFF('s', #0100-01-01#, #9999-12-31#)")] + [InlineData("DATEDIFF('n', #0100-01-01#, #9999-12-31#)")] + [InlineData("DATEDIFF('ms', #0100-01-01#, #9999-12-31#)")] + public void A_count_past_a_long_integer_is_null(string expression) => + Assert.Null(Scalar(expression)); + + [Theory] + [InlineData("DATEDIFF_BIG('s', #0100-01-01#, #9999-12-31#)")] + [InlineData("DATEDIFF_BIG('n', #0100-01-01#, #9999-12-31#)")] + [InlineData("DATEDIFF_BIG('ms', #0100-01-01#, #9999-12-31#)")] + public void DateDiff_Big_counts_the_same_span(string expression) => + Assert.IsType(Scalar(expression)); + [Theory] [InlineData("DATEVALUE(43832.5)")] [InlineData("TIMEVALUE(43832.5)")] From 096cffb2d9c190a2d91b3f90f00205ec132a0cef Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 19:09:21 +0800 Subject: [PATCH 10/14] Read the queries Access saved A file of saved queries showed two ways a query Access wrote comes back wrong, both silent. A totals query with a HAVING was dropped from the catalog entirely. The reconstruction bails on any attribute outside a known list and 0x0A was not in it, so the query surfaced neither as a view nor as an action query and selecting from it said the table did not exist - a missing-object error for an object plainly in MSysObjects. GROUP BY and ORDER BY were already rendered either side of it. The engine could always run the statement; only the rendering could not write it. HAVING now reads and writes like the rest: ViewSpec and ViewDefinition carry it, ViewCreator writes the row flagless as the WHERE row is, and both the view and the make-table paths render it between GROUP BY and ORDER BY. The parser's refusal goes with it. A test pinned that gap from the other side, asserting an ACE-authored HAVING view appears as neither an action query nor a view because such queries are omitted until their attributes reconstruct losslessly. That is the limitation being lifted here, so it now asserts the view is present and that the HAVING reaches the rebuilt SQL - reading the GROUP BY while dropping the HAVING would answer with every country rather than fail. ViewSpec's summary said aggregates, GROUP BY, HAVING and ORDER BY were "not permitted" while three of the four were already members; the same stale claim sat on ViewDefinition and BuildViewDefinition. Corrected. The second is the designer's own way of writing a qualified column: one delimited name, [Order Details.UnitPrice], not [Order Details].[UnitPrice]. Nothing resolved it, so Hex([MSysObjectsEXT.Flags]) failed to bind. A period cannot appear in an Access object name, which makes the split unambiguous, and it happens in the parser rather than at lookup because the planner reads ColumnReference.Table to decide which source a predicate belongs to - resolved later, the reference would find its value but stop being pushed down or seekable. An undelimited a.b never reaches that path; the grammar has already split it. Also recorded: MSysDb's ANSI Query Mode, the per-database half of Access's "SQL Server Compatible Syntax (ANSI 92)" option. It is a real field - 33 of 38 files carry 0, five carry 1 - and the engine does not consult it. Measured across nine combinations: absent/0/1 against OLE DB, ODBC, and ODBC with ExtendedAnsiSQL=1, for ad-hoc SQL and for a saved query, authored by ACE itself or by LibRed. Every result identical. The wildcard set belongs to the connection instead: OLE DB is ANSI-92, plain ODBC reads a saved query's pattern as ANSI-89, and ODBC with ExtendedAnsiSQL=1 - which JetConnection sets on every ODBC connection - is ANSI-92 again. Worth the words because the corpus argues the opposite: all 17 extractable LIKE patterns in saved queries are ANSI-89, which makes it look as though the file's mode must be driving them. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Catalog/JetCatalog.cs | 15 ++- src/LibRed/LibRed.Core/Catalog/ViewSpec.cs | 7 +- src/LibRed/LibRed.Core/Storage/ViewCreator.cs | 3 + .../LibRed.Engine/Execution/LikeMatcher.cs | 8 ++ .../Execution/StatementExecutor.cs | 1 + src/LibRed/LibRed.Sql/Ast/Statements.cs | 8 +- src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 32 +++++-- src/LibRed/docs/format/system-catalog.md | 28 +++++- .../ActionQueryProcedureAccessTests.cs | 11 ++- .../HavingViewAccessTests.cs | 94 +++++++++++++++++++ .../BracketedQualifiedColumnTests.cs | 66 +++++++++++++ test/LibRed.Engine.Tests/CreateViewTests.cs | 50 +++++++++- 12 files changed, 300 insertions(+), 23 deletions(-) create mode 100644 test/LibRed.Core.AccessTests/HavingViewAccessTests.cs create mode 100644 test/LibRed.Engine.Tests/BracketedQualifiedColumnTests.cs diff --git a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs index 213d8099..28127ce8 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs @@ -341,8 +341,11 @@ private static StoredActionQuery ReconstructAction( (r[n1] as string) is { } alias ? $"{r[expr] as string} AS {Quote(alias)}" : r[expr] as string ?? "")); var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); string grouping = groupBy.Count > 0 ? $" GROUP BY {string.Join(", ", groupBy)}" : ""; + string having = OfAttr(StoredQueryFormat.AttrHaving) + .Select(r => r[expr] as string).FirstOrDefault(s => !string.IsNullOrEmpty(s)) is { } h + ? $" HAVING {h}" : ""; return new StoredActionQuery( - $"{declared}SELECT {selected} INTO {Quote(action[n1] as string ?? "")} FROM {intoSource.From}{Where()}{grouping}", null); + $"{declared}SELECT {selected} INTO {Quote(action[n1] as string ?? "")} FROM {intoSource.From}{Where()}{grouping}{having}", null); } } @@ -486,11 +489,11 @@ private static string Facets(byte code, int? lvExtra) static int Ord(object? v) => v is byte[] b && b.Length >= 4 ? System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(b) : 0; IEnumerable OfAttr(byte a) => rows.Where(r => r[attr] is byte b && b == a).OrderBy(r => Ord(r[order])); - // Bail out if the query uses attributes beyond a simple SELECT: a HAVING clause (0x0A), a pass-through - // connection string (0x04) or complex-type data (0x0C) all mean this is not a shape we can render. + // Bail out if the query uses attributes beyond a simple SELECT: a pass-through connection string + // (0x04) or complex-type data (0x0C) mean this is not a shape we can render. // AttrOperation is in the list because a SELECT may carry one — the caller has already checked that // its kind IS SELECT, so reaching here with any other kind is impossible. - var known = new byte[] { StoredQueryFormat.AttrType, StoredQueryFormat.AttrOperation, StoredQueryFormat.AttrParameter, StoredQueryFormat.AttrOption, StoredQueryFormat.AttrTable, StoredQueryFormat.AttrColumn, StoredQueryFormat.AttrJoin, StoredQueryFormat.AttrWhere, StoredQueryFormat.AttrGroupBy, StoredQueryFormat.AttrOrderBy, 0xFF }; + var known = new byte[] { StoredQueryFormat.AttrType, StoredQueryFormat.AttrOperation, StoredQueryFormat.AttrParameter, StoredQueryFormat.AttrOption, StoredQueryFormat.AttrTable, StoredQueryFormat.AttrColumn, StoredQueryFormat.AttrJoin, StoredQueryFormat.AttrWhere, StoredQueryFormat.AttrGroupBy, StoredQueryFormat.AttrHaving, StoredQueryFormat.AttrOrderBy, 0xFF }; if (rows.Any(r => r[attr] is byte b && !known.Contains(b))) return null; // A column row's Name1 (when present) is its output alias. @@ -524,6 +527,9 @@ private static string Facets(byte code, int? lvExtra) .Select(r => (r[expr] as string ?? "") + (string.Equals(r[n1] as string, "d", StringComparison.OrdinalIgnoreCase) ? " DESC" : "")) .Where(s => s.Length > 0).ToList(); var groupBy = OfAttr(StoredQueryFormat.AttrGroupBy).Select(r => r[expr] as string ?? "").ToList(); + // HAVING: a single AttrHaving row holding the predicate verbatim, carrying no flag of its own. + string? having = OfAttr(StoredQueryFormat.AttrHaving) + .Select(r => r[expr] as string).FirstOrDefault(s => !string.IsNullOrEmpty(s)); string? whereClause = WhereClause(rows, attr, expr, order, source?.Extra ?? []); var sql = new System.Text.StringBuilder(ParametersClause(rows, attr, flag, n1, order, lvExtra)); @@ -535,6 +541,7 @@ private static string Facets(byte code, int? lvExtra) if (source is { } from) sql.Append(" FROM ").Append(from.From); if (whereClause is not null) sql.Append(" WHERE ").Append(whereClause); if (groupBy.Count > 0) sql.Append(" GROUP BY ").Append(string.Join(", ", groupBy)); + if (having is not null) sql.Append(" HAVING ").Append(having); if (orderBy.Count > 0) sql.Append(" ORDER BY ").Append(string.Join(", ", orderBy)); return sql.ToString(); } diff --git a/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs b/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs index 4f1b1043..7ee68375 100644 --- a/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs +++ b/src/LibRed/LibRed.Core/Catalog/ViewSpec.cs @@ -85,8 +85,10 @@ public sealed record StoredQueryParameter( string Name, JetDataType? Type, int? Size = null, int? Precision = null, int? Scale = null); /// -/// A view's decomposed "simple SELECT" — the columns, source tables, joins and WHERE (all verbatim text) — -/// that Access stores as MSysQueries rows. Aggregates / GROUP BY / HAVING / ORDER BY are not permitted. +/// A view's decomposed "simple SELECT" — the columns, source tables, joins, WHERE, GROUP BY, HAVING and +/// ORDER BY (all verbatim text) — that Access stores as MSysQueries rows. and +/// together are what Access calls a "totals" query: the aggregate output columns +/// are ordinary column rows, and only the grouping keys and the group filter get rows of their own. /// public sealed record ViewSpec( bool Distinct, @@ -95,6 +97,7 @@ public sealed record ViewSpec( IReadOnlyList Joins, string? Where, IReadOnlyList? GroupBy = null, + string? Having = null, IReadOnlyList? Parameters = null, IReadOnlyList? OrderBy = null, int? Top = null); \ No newline at end of file diff --git a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs index 3961f7bd..f5895e35 100644 --- a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs @@ -288,6 +288,9 @@ private void AddQueryRows(int objectId, ViewSpec spec) AddJoinAndWhereRows(mq, objectId, spec); for (int i = 0; i < (spec.GroupBy?.Count ?? 0); i++) Row(mq, objectId, StoredQueryFormat.AttrGroupBy, order: i + 1, flag: 0, expression: spec.GroupBy![i]); + // The group filter carries no flag of its own, exactly as the WHERE row doesn't. + if (spec.Having is { } having) + Row(mq, objectId, StoredQueryFormat.AttrHaving, order: 1, expression: having); for (int i = 0; i < (spec.OrderBy?.Count ?? 0); i++) Row(mq, objectId, StoredQueryFormat.AttrOrderBy, order: i + 1, expression: spec.OrderBy![i].Expression, name1: spec.OrderBy[i].Descending ? "d" : null); diff --git a/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs b/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs index 315492da..3c6518ab 100644 --- a/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs +++ b/src/LibRed/LibRed.Engine/Execution/LikeMatcher.cs @@ -4,6 +4,14 @@ namespace LibRed.Engine.Execution; /// The LIKE match with ANSI-92 wildcards, the set EF Core emits over both OLE DB and ODBC (verified vs ACE). /// /// +/// The wildcard set belongs to the connection, not to the database file — measured over ACE +/// across nine combinations. OLE DB is ANSI-92 for ad-hoc SQL and for saved queries alike; ODBC is too once +/// ExtendedAnsiSQL=1 is set, which JetConnection sets on every ODBC connection it makes. Only a +/// bare ODBC connection — one this repo never opens — reads a saved query's pattern as ANSI-89. The +/// MSysDb property ANSI Query Mode changes none of it: it is an Access application setting the +/// engine does not consult, so there is one wildcard set here, not two. Access's own saved queries are full +/// of * patterns, which makes the opposite look true; it isn't, and this is the expensive way to find +/// out. See system-catalog.md. /// /// % matches any run and _ any one character. *, ? and # are plain /// characters. diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index 45fda8bf..cf336313 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -712,6 +712,7 @@ private int ExecuteCreateActionProcedure(CreateActionProcedureStatement statemen j.Condition, j.LeftAlias, j.RightAlias)).ToList(), d.Where, d.GroupBy, + d.Having, Parameters: null, OrderBy: d.OrderBy.Select(o => new ViewOrderBySpec(o.Expression, o.Descending)).ToList(), Top: d.Top); diff --git a/src/LibRed/LibRed.Sql/Ast/Statements.cs b/src/LibRed/LibRed.Sql/Ast/Statements.cs index da669fc6..eae35c2e 100644 --- a/src/LibRed/LibRed.Sql/Ast/Statements.cs +++ b/src/LibRed/LibRed.Sql/Ast/Statements.cs @@ -215,9 +215,10 @@ public sealed record ViewColumn(string Expression, string? Alias); /// MSysQueries Attribute=0x0B row — Expression = the sort column, Name1 = "d" for descending). public sealed record ViewOrderBy(string Expression, bool Descending); -/// A view's decomposed SELECT (columns/tables/joins/where/group-by, all as verbatim text), which -/// Access stores as MSysQueries rows. A GROUP BY makes it a "totals" query (aggregate columns are ordinary -/// column rows; the group-by columns are separate rows). HAVING and ORDER BY are not stored yet. +/// A view's decomposed SELECT (columns/tables/joins/where/group-by/having, all as verbatim text), +/// which Access stores as MSysQueries rows. A GROUP BY makes it a "totals" query (aggregate columns are +/// ordinary column rows; the group-by columns are separate rows), and is that +/// query's own filter over the groups, stored as its own Attribute=0x0A row. public sealed record ViewDefinition( bool Distinct, IReadOnlyList Columns, @@ -225,6 +226,7 @@ public sealed record ViewDefinition( IReadOnlyList Joins, string? Where, IReadOnlyList GroupBy, + string? Having, IReadOnlyList OrderBy, int? Top); diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index 9659f6c4..3b690de7 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -530,7 +530,7 @@ private static ViewDefinition ActionBody(TableSourceContext[] sources, WhereClau return new ViewDefinition( Distinct: false, Columns: [], tables, joins, where is null ? null : OriginalText(where.expression()), - GroupBy: [], OrderBy: [], Top: null); + GroupBy: [], Having: null, OrderBy: [], Top: null); } /// A declared parameter's name, with any leading @ stripped — Access stores the bare @@ -632,9 +632,9 @@ private static SelectStatement LowerSelect(SelectStatement sel, HashSet private static FrameBound LowerBound(FrameBound bound, HashSet names) => bound.Offset is null ? bound : bound with { Offset = LowerExpr(bound.Offset, names) }; - /// Decomposes a view's "simple SELECT" into the columns/tables/joins/where Access stores as - /// MSysQueries rows. Rejects anything Access itself rejects in a view (UNION, GROUP BY/aggregates, - /// HAVING, ORDER BY) or that we can't decompose (a derived-table/subquery source). + /// Decomposes a view's "simple SELECT" into the columns/tables/joins/where/group-by/having + /// Access stores as MSysQueries rows. Rejects what we can't decompose into them: a UNION (which Access + /// stores as segment rows instead) and a parenthesised query. private static ViewDefinition BuildViewDefinition(QueryExpressionContext ctx) { if (ctx.setOperator().Length > 0) @@ -643,10 +643,10 @@ private static ViewDefinition BuildViewDefinition(QueryExpressionContext ctx) throw new NotSupportedException("A parenthesised query is not a valid (simple) view."); QuerySpecificationContext select = term.querySpecification(); - if (select.havingClause() is not null) - throw new NotSupportedException("A view with HAVING is not stored yet."); var groupBy = select.groupByClause() is { } g ? g.expression().Select(OriginalText).ToList() : (IReadOnlyList)[]; + // The group filter of a "totals" query, stored verbatim in its own row alongside the GROUP BY ones. + string? having = select.havingClause() is { } h ? OriginalText(h.expression()) : null; // The view's ORDER BY comes off the query expression, not the SELECT: it orders the view's result. var orderBy = ctx.orderByClause() is { } ob ? ob.orderByItem().Select(i => new ViewOrderBy(OriginalText(i.expression()), i.dir?.Type == DESC)).ToList() @@ -673,7 +673,7 @@ private static ViewDefinition BuildViewDefinition(QueryExpressionContext ctx) CollectSources(ts, tables, joins); string? where = select.whereClause() is { } w ? OriginalText(w.expression()) : null; - return new ViewDefinition(select.predicate?.DISTINCT() is not null, columns, tables, joins, where, groupBy, orderBy, top); + return new ViewDefinition(select.predicate?.DISTINCT() is not null, columns, tables, joins, where, groupBy, having, orderBy, top); } private static void CollectSources(TableSourceContext ts, List tables, List joins) @@ -1217,8 +1217,22 @@ private static FrameBound BuildFrameBound(FrameBoundContext ctx) private static string FunctionName(FunctionNameContext ctx) => ctx.identifier() is { } id ? Identifier(id) : ctx.GetText(); - private static ColumnReference BuildColumn(ColumnRefContext ctx) => - new ColumnReference(OptionalIdentifier(ctx.qualifier), Identifier(ctx.name)); + /// + /// A column reference. Access's own query designer writes a qualified column as ONE delimited name — + /// [Order Details.UnitPrice] rather than [Order Details].[UnitPrice] — and resolves it as + /// table.column, so a saved query read back out of MSysQueries arrives in that form. A period cannot + /// appear in an Access object name, which is what makes the split unambiguous: a dot inside the + /// delimiters is always the qualifier. An undelimited a.b never reaches here as one name — the + /// grammar has already split it — so this only ever rewrites what was bracketed or backticked. + /// + private static ColumnReference BuildColumn(ColumnRefContext ctx) + { + string? qualifier = OptionalIdentifier(ctx.qualifier); + string name = Identifier(ctx.name); + if (qualifier is null && name.IndexOf('.', StringComparison.Ordinal) is var dot && dot > 0 && dot < name.Length - 1) + return new ColumnReference(name[..dot], name[(dot + 1)..]); + return new ColumnReference(qualifier, name); + } /// x IN (a, b, …) becomes a flat evaluated iteratively — NOT a /// deep (x = a) OR (x = b) OR … tree, which recurses once per item and overflows the stack when EF Core diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md index 2810c944..5f05d52a 100644 --- a/src/LibRed/docs/format/system-catalog.md +++ b/src/LibRed/docs/format/system-catalog.md @@ -87,6 +87,31 @@ > one place the value matters is [data-types.md](data-types.md), where it says which files carry the > unmodelled `0x11` column. > + > **`ANSI Query Mode`** — an `MSysDb` property, empty owner, `dataType` `0x04` (Int32), holding a 4-byte + > little-endian `0` or `1`. It is the per-database half of Access's *Object Designers → SQL Server + > Compatible Syntax (ANSI 92)* option: `0` = ANSI-89, `1` = ANSI-92. (The *"Default for new databases"* + > checkbox beside it is an application setting and appears nowhere in any file; it only decides what Access + > stamps into the next database it creates.) The two modes name the `LIKE` wildcard sets — ANSI-89 `*` `?` + > `#`, ANSI-92 `%` `_`. **Verified** across 38 files: 33 carry `0`, 5 carry `1`. + > + > **The engine does not consult it.** Measured over ACE in nine combinations: the property set to absent, + > `0` or `1` × OLE DB / ODBC / ODBC with `ExtendedAnsiSQL=1` gives identical results, for ad-hoc SQL and for + > a saved query alike, whether the saved query was written by ACE itself or by LibRed. The wildcard set + > comes from the **connection**: OLE DB is ANSI-92, plain ODBC reads a saved query's pattern as ANSI-89, and + > ODBC with `ExtendedAnsiSQL=1` — which `JetConnection` sets on every ODBC connection — is ANSI-92 again. + > So this property records what Access's own UI will do with the file, not what any engine does with it. + > + > **Absent is not the same as `0`.** A clear checkbox is recorded as an explicit `0`; absent means nothing + > ever wrote the property. Neither DAO nor Access-on-first-open writes it: a pristine DAO 2000 file carries + > an `MSysDb` row with **zero** properties, and after Access opens it the row has nine — still not this one + > (verified). Ten of the corpus files carry a populated blob without it, Northwind among them, so absent is + > an ordinary state rather than an old-format quirk. + > + > LibRed neither reads nor writes it, because acting on it would diverge from the engine: its SQL surface is + > ANSI-92 throughout, which is what every connection into an ACE database uses. Note the trap — Access's + > saved queries are full of ANSI-89 `*` patterns (17 of the 17 extractable ones in the corpus), which makes + > it look as though the file's mode must be driving them. It isn't; the connection is. + > > **Property-reader/writer guardrails.** LibRed validates the signature and consumes the blob exactly to > its end. Every block, pooled UTF-16 name, owner record, property entry, name-pool index, and value length > must remain within its declared parent and use the exact nested lengths above. `Read`, @@ -254,7 +279,8 @@ `Customers.CustomerID = Orders.CustomerID` → `Name1=Customers`, `Name2=Orders`), `0x08` = WHERE (`Expression`), `0x09` = a **GROUP BY** column (`Expression`; one row per group column, in order — their presence makes it a "totals" query, and the aggregate output columns are ordinary `0x06` rows, - e.g. `Expression=Sum(...)`), `0x0A` = a **HAVING** predicate (`Expression`), `0x0B` = an **ORDER BY** key (`Expression`=the sort column, `Name1`=`"d"` + e.g. `Expression=Sum(...)`), `0x0A` = a **HAVING** predicate over those groups (`Expression`; one row, and — like the `0x08` WHERE + row — it carries no `Flag` at all, verified), `0x0B` = an **ORDER BY** key (`Expression`=the sort column, `Name1`=`"d"` for **descending**, absent for ascending; one row per key, `Order` 1-based — verified), `0x0C` = **complex-type data** (`Flag 1` = long-text version history, `Flag 2` = MVF / attachment), `0xFF` = end. A **FROM source** (`0x05`) is either a **named table** (`Name1`=table, `Name2`=alias), a **derived table / subquery** (`Expression`=the verbatim inner diff --git a/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs b/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs index 8dc91827..be3a1f3b 100644 --- a/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs +++ b/test/LibRed.Core.AccessTests/ActionQueryProcedureAccessTests.cs @@ -192,9 +192,14 @@ public void Ace_having_view_is_not_misclassified_as_an_action_query() using var db = JetDatabase.Open(path); Assert.False(db.Catalog.ActionQueries.ContainsKey("CountriesWithManyCustomers")); - // Complex ACE-authored SELECT queries are deliberately omitted until their MSysQueries attributes - // can be reconstructed losslessly; they must not appear as a different executable query shape. - Assert.False(db.Catalog.Views.ContainsKey("CountriesWithManyCustomers")); + + // A grouped SELECT is a view, and its HAVING rides along: the Attribute=0x0A row beside the + // Attribute=9 GROUP BY ones. Reading the GROUP BY while silently dropping the HAVING would + // reconstruct a query that returns every country — a wrong answer, not a missing feature — so the + // clause has to survive into the rebuilt SQL. + Assert.True(db.Catalog.Views.TryGetValue("CountriesWithManyCustomers", out string? sql)); + Assert.Contains("GROUP BY", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("HAVING", sql, StringComparison.OrdinalIgnoreCase); } finally { TemporaryDatabase.Delete(path); } } diff --git a/test/LibRed.Core.AccessTests/HavingViewAccessTests.cs b/test/LibRed.Core.AccessTests/HavingViewAccessTests.cs new file mode 100644 index 00000000..aadc2d74 --- /dev/null +++ b/test/LibRed.Core.AccessTests/HavingViewAccessTests.cs @@ -0,0 +1,94 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +/// +/// A "totals" view whose groups are filtered — the Attribute=0x0A HAVING row that rides alongside the +/// Attribute=9 GROUP BY ones. Access runs it, and reads it as the same query it would have written. +/// +[Collection(AceCollection.Name)] +public class HavingViewAccessTests +{ + private const string Subtotal = "Sum(CCur([Order Details].UnitPrice*Quantity*(1-Discount)/100)*100)"; + + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); + + private static List<(int OrderId, decimal Total)> Read(OleDbConnection conn, string sql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + using var reader = cmd.ExecuteReader(); + var rows = new List<(int, decimal)>(); + while (reader.Read()) + rows.Add((Convert.ToInt32(reader.GetValue(0)), Convert.ToDecimal(reader.GetValue(1)))); + return rows; + } + + [Fact] + public void Access_runs_a_totals_view_with_having() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "having-view-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + db.CreateView("BigSubtotals", new ViewSpec( + Distinct: false, + Columns: + [ + new ViewColumnSpec("[Order Details].OrderID", null), + new ViewColumnSpec(Subtotal, "Subtotal"), + ], + Tables: [new ViewTableSpec("Order Details", null)], + Joins: [], + Where: null, + GroupBy: ["[Order Details].OrderID"], + Having: $"{Subtotal} > 5000")); + + using var conn = OpenOleDb(path); + + // The stored view has to give exactly what Access gives for the query written out in full: the + // HAVING row is only correct if it lands in the same place the inline clause does. + var stored = Read(conn, "SELECT OrderID, Subtotal FROM BigSubtotals ORDER BY OrderID"); + var inline = Read(conn, + $"SELECT [Order Details].OrderID, {Subtotal} AS Subtotal FROM [Order Details] " + + $"GROUP BY [Order Details].OrderID HAVING {Subtotal} > 5000 ORDER BY [Order Details].OrderID"); + Assert.Equal(inline, stored); + + // …and it has to have filtered something, or the comparison above proves nothing. Northwind has + // 830 orders; the ones over 5000 are a strict, non-empty subset. + Assert.NotEmpty(stored); + Assert.True(stored.Count < 830, $"HAVING filtered nothing: {stored.Count} groups."); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Having_on_a_grouping_column_keeps_that_group() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "having-key-view-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + db.CreateView("OneSubtotal", new ViewSpec( + Distinct: false, + Columns: + [ + new ViewColumnSpec("[Order Details].OrderID", null), + new ViewColumnSpec(Subtotal, "Subtotal"), + ], + Tables: [new ViewTableSpec("Order Details", null)], + Joins: [], + Where: null, + GroupBy: ["[Order Details].OrderID"], + Having: "[Order Details].OrderID = 10248")); + + using var conn = OpenOleDb(path); + var rows = Read(conn, "SELECT OrderID, Subtotal FROM OneSubtotal"); + Assert.Equal([(10248, 440m)], rows); + } + finally { TemporaryDatabase.Delete(path); } + } +} diff --git a/test/LibRed.Engine.Tests/BracketedQualifiedColumnTests.cs b/test/LibRed.Engine.Tests/BracketedQualifiedColumnTests.cs new file mode 100644 index 00000000..6e8034df --- /dev/null +++ b/test/LibRed.Engine.Tests/BracketedQualifiedColumnTests.cs @@ -0,0 +1,66 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// Access's query designer writes a qualified column as one delimited name — [Order Details.UnitPrice] +/// rather than [Order Details].[UnitPrice] — and resolves it as table.column, so every saved query read +/// back out of MSysQueries arrives in that form. The split happens in the parser rather than at lookup time, +/// because the planner reads ColumnReference.Table to decide which source a predicate belongs to: a +/// reference left unqualified would resolve but stop being pushed down or seekable. +/// +public class BracketedQualifiedColumnTests : TempDatabaseTest +{ + private static QueryEngine Northwind() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "bracket-qualified-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: true)); + } + + private static List Column(QueryEngine e, string sql) => + e.ExecuteQuery(sql).Rows.Select(r => r[0]).ToList(); + + [Theory] + // The bracketed form and the plain one are the same reference, so they must give the same answer. + [InlineData("SELECT [Customers.CustomerID] FROM Customers", "SELECT Customers.CustomerID FROM Customers")] + // A table whose name has a space is exactly where the designer's one-name form shows up. + [InlineData("SELECT [Order Details.UnitPrice] FROM [Order Details]", "SELECT [Order Details].UnitPrice FROM [Order Details]")] + // In a WHERE, where the reference also has to survive planning. + [InlineData("SELECT CustomerID FROM Customers WHERE [Customers.City] = 'London'", + "SELECT CustomerID FROM Customers WHERE Customers.City = 'London'")] + // Against an alias rather than the table name. + [InlineData("SELECT [c.CustomerID] FROM Customers AS c WHERE [c.City] = 'London'", + "SELECT c.CustomerID FROM Customers AS c WHERE c.City = 'London'")] + // Across a join, which is where a mis-attributed qualifier would show up as a wrong row count. + [InlineData("SELECT [o.OrderID] FROM Customers AS c INNER JOIN Orders AS o ON [c.CustomerID] = [o.CustomerID]", + "SELECT o.OrderID FROM Customers AS c INNER JOIN Orders AS o ON c.CustomerID = o.CustomerID")] + public void Bracketed_qualified_column_matches_the_plain_form(string bracketed, string plain) + { + QueryEngine e = Northwind(); + var viaBrackets = Column(e, bracketed); + Assert.NotEmpty(viaBrackets); + Assert.Equal(Column(e, plain), viaBrackets); + } + + [Fact] + public void A_delimited_name_without_a_dot_is_still_one_name() + { + QueryEngine e = Northwind(); + // Ordinary delimited names, and a delimited TABLE name that has a space in it — nothing to split. + Assert.NotEmpty(Column(e, "SELECT [CustomerID] FROM Customers")); + Assert.NotEmpty(Column(e, "SELECT [ShipName] FROM Orders")); + Assert.NotEmpty(Column(e, "SELECT [UnitPrice] FROM [Order Details]")); + } + + [Fact] + public void A_bracketed_qualifier_naming_no_source_is_still_an_error() + { + QueryEngine e = Northwind(); + var ex = Assert.Throws( + () => Column(e, "SELECT [Nope.CustomerID] FROM Customers")); + Assert.Contains("Nope.CustomerID", ex.Message, StringComparison.Ordinal); + } +} diff --git a/test/LibRed.Engine.Tests/CreateViewTests.cs b/test/LibRed.Engine.Tests/CreateViewTests.cs index 5726adb5..cc0d4f1b 100644 --- a/test/LibRed.Engine.Tests/CreateViewTests.cs +++ b/test/LibRed.Engine.Tests/CreateViewTests.cs @@ -86,8 +86,11 @@ public void View_is_queryable_through_libred() finally { TemporaryDatabase.Delete(path); } } + // What is left that a view cannot be decomposed into: Access stores a UNION as segment rows rather than + // the one query shape these build, and a parenthesised body has no query specification to take apart. [Theory] - [InlineData("CREATE VIEW `V` AS SELECT `Country`, COUNT(*) FROM `Customers` GROUP BY `Country` HAVING COUNT(*) > 1", "HAVING")] + [InlineData("CREATE VIEW `V` AS SELECT `CustomerID` FROM `Customers` UNION SELECT `CustomerID` FROM `Customers`", "UNION")] + [InlineData("CREATE VIEW `V` AS (SELECT `CustomerID` FROM `Customers`)", "parenthesised")] public void Non_simple_view_throws(string sql, string expected) { string path = Fresh(); @@ -123,6 +126,51 @@ public void A_group_by_totals_view_round_trips() finally { TemporaryDatabase.Delete(path); } } + // A totals view whose groups are filtered: the HAVING is stored as its own row beside the GROUP BY ones, + // and has to come back in the same place. Filtering on the grouping key pins exact values; the aggregate + // form is checked against the same query written inline, which is the only thing it must agree with. + [Fact] + public void A_having_totals_view_round_trips() + { + const string subtotal = "Sum(CCur(`Order Details`.UnitPrice*Quantity*(1-Discount)/100)*100)"; + string path = Fresh(); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery( + $"CREATE VIEW `OneSubtotal` AS SELECT `Order Details`.OrderID, {subtotal} AS Subtotal " + + "FROM `Order Details` GROUP BY `Order Details`.OrderID HAVING `Order Details`.OrderID = 10248"); + e.ExecuteNonQuery( + $"CREATE VIEW `BigSubtotals` AS SELECT `Order Details`.OrderID, {subtotal} AS Subtotal " + + $"FROM `Order Details` GROUP BY `Order Details`.OrderID HAVING {subtotal} > 5000"); + } + + using (var db = JetDatabase.Open(path)) + { + var e = new QueryEngine(db); + + object?[] only = e.ExecuteQuery("SELECT OrderID, Subtotal FROM `OneSubtotal`").Rows.Single(); + Assert.Equal(10248, Convert.ToInt32(only[0])); + Assert.Equal(440.00m, Convert.ToDecimal(only[1])); + + var viaView = e.ExecuteQuery("SELECT OrderID FROM `BigSubtotals`") + .Rows.Select(r => Convert.ToInt32(r[0])).OrderBy(x => x).ToList(); + var inline = e.ExecuteQuery( + $"SELECT `Order Details`.OrderID FROM `Order Details` " + + $"GROUP BY `Order Details`.OrderID HAVING {subtotal} > 5000") + .Rows.Select(r => Convert.ToInt32(r[0])).OrderBy(x => x).ToList(); + Assert.Equal(inline, viaView); + + // The comparison above proves nothing if the HAVING dropped everything or nothing. + Assert.NotEmpty(viaView); + Assert.True(viaView.Count < 830, $"HAVING filtered nothing: {viaView.Count} groups."); + } + } + finally { TemporaryDatabase.Delete(path); } + } + [Fact] public void View_name_colliding_with_an_object_throws() { From df381c28b2208b0823cdd8afe0fde512f3bb1a03 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 22:24:03 +0800 Subject: [PATCH 11/14] Store complex values, and write index pages ACE's way A complex column - the type behind multi-value and attachment fields - decoded as a long-value descriptor and gave back nothing usable. Its in-row value is not a descriptor at all but a 4-byte record id, and the values themselves are rows of a per-column flat table keyed by it. Core now resolves the pair and reads them: JetCatalog walks MSysComplexColumns to the flat table and finds the owner link and the per-value id by index shape rather than by building names, because neither name follows a rename - a column now called BK_category can be backed by f_..._TempField*7. JetTypeCodec decodes the in-row value as the Int32 it is, and IndexKeyEncoder keys it as one, verified across 43 entries in 9 such indexes. The id is allocated per row, not per value, and every complex column of a table shares it, because the counter is a single TDEF word at 0x1C rather than one per column. So a row carries the same id in all four of complex1's attachment columns, an empty read is the ordinary answer rather than an error, and RowInserter advances 0x1C separately from the table's own counter at 0x14. Attachments write too: ComplexAttachment packs the payload Access expects - an outer header carrying a compression flag and the uncompressed length, then an inner header with the extension as null-terminated UTF-16 - and ACE extracts a file LibRed attached, byte for byte. Whether a file is deflated is Access's convention keyed on extension, not a rule of the format; both forms read back, and the 256 MB ceiling is Access's policy, well under the format's own. That a complex column carries the descriptor's 0x04 AutoNumber flag had a consequence nothing accounted for. Five columns of complex1's Table1 read as AutoNumber, and MSysResources in every Northwind has two - so "a table has at most one AutoNumber column" is false as stated, and three guards enforcing it rejected tables Access created normally: any ALTER COLUMN on such a table threw. The rule is about the header's seed/increment pair, which one column may draw on; a complex column is flagged the same but allocates from 0x1C. Worse, the read applied that pair to every flagged column, so all five reported the ID counter's seed as their own. Fixed at the read and in all three guards. Then the writes themselves. A delete now leaves the bytes ACE's delete leaves, measured by running the same delete through DAO and through LibRed on two copies and diffing whole files - an ordinary table and one whose delete cascades into four flat tables, 5 and 20 pages, every page identical and no page touched that ACE left alone. Three things differed, all of them dead-byte handling. A page is zero-filled when allocated and never cleared again, so an in-place rewrite must carry the destination's bytes past the new live end instead of building in a clean buffer. A delete keeps the prefix length the page was stored at, because ACE re-compresses a leaf only when it fills and a delete never fills one. And the per-index entry counters skip a block whose total already reads 0 - not a clamp, since unique stays where it is rather than dropping - which is what kept LibRed's totals from going negative. That last one also reconciles an apparent contradiction in the spec: an index built empty and filled by inserts has total 0, so the delete skips it, which is why unique looked as though it never fell. A leaf splits where ACE splits it. The cut is taken over the entries the page held before the insert, and the new entry joins whichever side its key falls on, so the original page keeps one more entry for a key landing below the midpoint than for one landing on or above it; halving the post-insert list instead always hands the odd entry rightward. Measured on a 602-entry leaf split by a single further key at positions 1, 50, 301, 302 and 400 - the isolated case, because the cascade that first showed the difference ran three levels deep over three indexes and could attribute nothing. Left alone deliberately: page allocation. Both engines pick the same first free page, then ACE extends the file by an eight-page chunk while LibRed reuses another free page - verified genuinely free, with every table's rows intact and ACE reading the result back. Matching would mean growing files that need not grow. One split case is still open rather than solved: a key sorting below everything on the page splits at the same point, but ACE then stores that page uncompressed, and the two explanations that fit are not yet separable. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Catalog/ComplexColumn.cs | 224 +++++++++ src/LibRed/LibRed.Core/Catalog/JetCatalog.cs | 59 +++ src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs | 7 +- src/LibRed/LibRed.Core/JetDatabase.cs | 115 +++++ .../LibRed.Core/Pages/TableDefinitionPage.cs | 10 +- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 5 + src/LibRed/LibRed.Core/Storage/IndexWriter.cs | 65 ++- src/LibRed/LibRed.Core/Storage/RowInserter.cs | 107 ++++- .../LibRed.Core/Storage/TableCreator.cs | 19 +- .../LibRed.Core/Storage/Types/JetTypeCodec.cs | 11 +- .../Execution/StatementExecutor.cs | 35 ++ .../LibRed.Engine/Schema/JetClrTypeMap.cs | 4 +- src/LibRed/docs/format/page-02a-tdef.md | 6 +- src/LibRed/docs/format/page-02b-columns.md | 36 +- .../docs/format/page-02d-constraints.md | 33 +- .../docs/format/page-03-04-index-btree.md | 65 ++- src/LibRed/docs/format/system-catalog.md | 135 +++++- .../ComplexDeleteCascadeProbeTest.cs | 118 +++++ .../ComplexDuplicateValueProbeTest.cs | 130 ++++++ test/LibRed.Core.Tests/ComplexColumnTests.cs | 171 +++++++ .../JetTypeCodecBoundaryTests.cs | 14 +- .../ComplexAttachmentWriteAccessTests.cs | 144 ++++++ .../ComplexDeleteByteParityProbeTest.cs | 437 ++++++++++++++++++ .../ComplexWriteAceReadbackTests.cs | 94 ++++ .../DeleteByteParityTests.cs | 110 +++++ .../IndexSplitByteParityTests.cs | 110 +++++ .../IndexSplitPointProbeTest.cs | 127 +++++ .../JetTypeMappingTests.cs | 11 +- 28 files changed, 2332 insertions(+), 70 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Catalog/ComplexColumn.cs create mode 100644 test/LibRed.Core.AccessTests/ComplexDeleteCascadeProbeTest.cs create mode 100644 test/LibRed.Core.AccessTests/ComplexDuplicateValueProbeTest.cs create mode 100644 test/LibRed.Core.Tests/ComplexColumnTests.cs create mode 100644 test/LibRed.Engine.AccessTests/ComplexAttachmentWriteAccessTests.cs create mode 100644 test/LibRed.Engine.AccessTests/ComplexDeleteByteParityProbeTest.cs create mode 100644 test/LibRed.Engine.AccessTests/ComplexWriteAceReadbackTests.cs create mode 100644 test/LibRed.Engine.AccessTests/DeleteByteParityTests.cs create mode 100644 test/LibRed.Engine.AccessTests/IndexSplitByteParityTests.cs create mode 100644 test/LibRed.Engine.AccessTests/IndexSplitPointProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Catalog/ComplexColumn.cs b/src/LibRed/LibRed.Core/Catalog/ComplexColumn.cs new file mode 100644 index 00000000..55bb1070 --- /dev/null +++ b/src/LibRed/LibRed.Core/Catalog/ComplexColumn.cs @@ -0,0 +1,224 @@ +using System.Buffers.Binary; +using System.Text; + +namespace LibRed.Catalog; + +/// +/// A complex (multi-value / attachment) column and the per-column table its values live in. The column's +/// in-row value is a 4-byte complex id naming one record; the values for that record are the rows of +/// whose holds the id. +/// +/// +/// Everything here is resolved structurally, never by building names. The flat table and its two +/// bookkeeping columns keep whatever the table and column were called when the complex column was created, and +/// a later rename does not follow: a column now called BK_category can be backed by +/// f_…_TempField*7, and a table now called Borrow can have a value-id column still named +/// Table1_BRW_book. The flat table comes from MSysComplexColumns.FlatTableID and the two columns +/// from the index shape. See docs/format/system-catalog.md. +/// A record's id is allocated when the row is created, so a non-null id is no evidence that any +/// value exists — an empty is the ordinary answer, not an error. +/// +public sealed class ComplexColumn +{ + internal ComplexColumn( + string columnName, int complexId, TableDef ownerTable, TableDef flatTable, + ColumnDef ownerLink, ColumnDef valueId, string? elementTypeName) + { + ColumnName = columnName; + ComplexId = complexId; + OwnerTable = ownerTable; + FlatTable = flatTable; + OwnerLink = ownerLink; + ValueId = valueId; + ElementTypeName = elementTypeName; + ValueColumns = [.. flatTable.Columns.Where(c => c != ownerLink && c != valueId)]; + } + + /// The complex column's name on . + public string ColumnName { get; } + + /// Its MSysComplexColumns.ComplexID — the same value the column descriptor carries at + /// 0x0B. + public int ComplexId { get; } + + /// The table holding the complex column. + public TableDef OwnerTable { get; } + + /// The per-column table holding the values, one row each. + public TableDef FlatTable { get; } + + /// The flat table's link back to the owning record: it holds that record's complex id and + /// repeats once per value, which is what makes the column multi-valued. Indexed, not unique. + public ColumnDef OwnerLink { get; } + + /// The flat table's per-value id: unique, primary, and an ordinary AutoNumber whose high-water + /// is the flat table's own TDEF 0x14. + public ColumnDef ValueId { get; } + + /// The value columns proper — everything but the two bookkeeping ones. A scalar multi-value + /// column has a single Value; an attachment has the six File* columns. + public IReadOnlyList ValueColumns { get; } + + /// The MSysComplexType_* template this column was made from (e.g. + /// MSysComplexType_Attachment), or null when the catalog row does not resolve to one. + public string? ElementTypeName { get; } + + /// Whether this is an attachment column rather than a multi-value scalar — it carries the + /// FileData/FileName shape, so applies. + public bool IsAttachment => + FlatTable.FindColumn("FileData") is not null && FlatTable.FindColumn("FileName") is not null; +} + +/// An attachment's stored file: the extension Access recorded, and the bytes themselves. +/// The extension from the payload's inner header (pdf, png, …), without +/// a dot. This is the payload's own copy, which need not equal the FileType column. +/// The file's bytes, decompressed where Access compressed them. +public readonly record struct ComplexAttachment(string Extension, byte[] Content) +{ + /// + /// Unwraps the FileData column of an attachment row. The stored blob is an 8-byte header — a + /// compression flag (1 = a zlib stream follows, 0 = raw bytes) and the body's decompressed + /// length — then the body, which itself opens with a 20-byte header carrying the extension as + /// null-terminated UTF-16. Verified against a pdf, an mp3 and a png; see + /// docs/format/system-catalog.md. + /// + /// The blob is too short, or its headers do not describe it. + public static ComplexAttachment Unwrap(ReadOnlySpan fileData) + { + const int OuterHeader = 8, InnerHeaderLength = 20, ExtensionOffset = 12; + if (fileData.Length < OuterHeader) + throw new InvalidDataException($"Attachment data is {fileData.Length} bytes; the header alone is {OuterHeader}."); + + uint compression = BinaryPrimitives.ReadUInt32LittleEndian(fileData); + uint bodyLength = BinaryPrimitives.ReadUInt32LittleEndian(fileData[4..]); + + byte[] body = compression switch + { + 0 => fileData[OuterHeader..].ToArray(), + 1 => Inflate(fileData[OuterHeader..]), + _ => throw new InvalidDataException($"Attachment data has compression flag {compression}; expected 0 or 1."), + }; + if (body.Length != bodyLength) + throw new InvalidDataException($"Attachment body is {body.Length} bytes; its header declares {bodyLength}."); + if (body.Length < InnerHeaderLength) + throw new InvalidDataException($"Attachment body is {body.Length} bytes; its inner header alone is {InnerHeaderLength}."); + + int innerLength = BinaryPrimitives.ReadInt32LittleEndian(body); + if (innerLength < ExtensionOffset || innerLength > body.Length) + throw new InvalidDataException($"Attachment inner header declares {innerLength} bytes, which its {body.Length}-byte body cannot hold."); + + string extension = Encoding.Unicode + .GetString(body.AsSpan(ExtensionOffset, innerLength - ExtensionOffset)).TrimEnd('\0'); + return new ComplexAttachment(extension, body[innerLength..]); + } + + /// + /// The extensions Access leaves uncompressed because the format already is. From the documented list + /// (Attachment object, "Types of files that Access compresses"), which is explicitly partial — anything + /// absent is compressed, so this set is the exception rather than the rule. + /// + // The comparer is explicit because an extension matches case-insensitively (.PNG is still a png), which + // IDE0028's collection expression would leave to the default. +#pragma warning disable IDE0028 + private static readonly HashSet NativelyCompressed = new(StringComparer.OrdinalIgnoreCase) + { + "jpg", "jpeg", "gif", "png", "zip", "cab", "docx", "xlsx", "xlsb", "pptx", + }; +#pragma warning restore IDE0028 + + /// + /// The largest attachment Access accepts — "Individual files cannot exceed 256 megabytes". This is + /// Access's policy, not the format's capability: a long value's stored length runs to 0x3FFFFFFF, + /// which ACE itself accepts and reads back, so the file format holds four times this. + /// + public const int MaxAccessAttachmentBytes = 256 * 1024 * 1024; + + /// + /// Whether Access would deflate a file of this extension rather than store it as it is — its + /// convention, not a rule of the format. Both forms are valid on disk and ACE reads either, so this only + /// decides whether a file LibRed writes looks like one Access wrote. + /// + public static bool Compresses(string extension) => + !NativelyCompressed.Contains((extension ?? "").TrimStart('.')); + + /// + /// Throws when or breaks one of Access's + /// documented attachment limits — the 256 MB per file, the 255-character name, and the characters a name + /// may not contain. None of these is a format limit; they are what the Access UI enforces, and a file + /// breaking them is one Access would not have created and may not open. + /// + public static void ValidateAccessLimits(string fileName, ReadOnlySpan content) + { + ArgumentException.ThrowIfNullOrEmpty(fileName); + if (content.Length > MaxAccessAttachmentBytes) + throw new ArgumentOutOfRangeException(nameof(content), + $"An attachment is {content.Length} bytes; Access accepts at most {MaxAccessAttachmentBytes} per file."); + if (fileName.Length > 255) + throw new ArgumentException( + $"An attachment name is at most 255 characters including the extension; '{fileName}' is {fileName.Length}.", + nameof(fileName)); + if (fileName.IndexOfAny(['?', '"', '/', '\\', '<', '>', '*', '|', ':']) >= 0) + throw new ArgumentException( + $"An attachment name cannot contain any of ? \" / \\ < > * | : — '{fileName}' does.", + nameof(fileName)); + } + + /// + /// Builds the FileData blob for an attachment — the inverse of . + /// + /// + /// Both storage forms are valid and ACE reads either, so compression is a choice, not a + /// correctness question. Left to it follows Access's own convention — + /// "Access will compress your attached files unless those files are compressed natively" — which matches + /// what ACE wrote in the sample file: a png raw, a pdf and an mp3 deflated. + /// + /// The file's extension without a dot, as the inner header records it. + /// The file's bytes. + /// Force the stored form, or to follow Access's convention + /// for the extension. + public static byte[] Pack(string extension, ReadOnlySpan content, bool? compress = null) + { + ArgumentNullException.ThrowIfNull(extension); + bool deflate = compress ?? Compresses(extension); + const int OuterHeader = 8, ExtensionOffset = 12; + + // The extension is null-terminated UTF-16, and the count at [8] is its length in UTF-16 units + // INCLUDING that terminator — "pdf" gives 4 and a 20-byte inner header, "thmx" 5 and 22. + int units = extension.Length + 1; + int innerLength = ExtensionOffset + units * 2; + + // The body is the inner header followed by the file; the outer header's length is the body's + // UNCOMPRESSED size, whether or not the stream that follows is deflated. + byte[] body = new byte[innerLength + content.Length]; + BinaryPrimitives.WriteInt32LittleEndian(body, innerLength); + BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(4), 1); + BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(8), units); + Encoding.Unicode.GetBytes(extension, body.AsSpan(ExtensionOffset)); // terminator stays 0 + content.CopyTo(body.AsSpan(innerLength)); + + byte[] stored = deflate ? Deflate(body) : body; + byte[] blob = new byte[OuterHeader + stored.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(blob, deflate ? 1u : 0u); + BinaryPrimitives.WriteUInt32LittleEndian(blob.AsSpan(4), (uint)body.Length); + stored.CopyTo(blob.AsSpan(OuterHeader)); + return blob; + } + + private static byte[] Deflate(byte[] body) + { + using var output = new MemoryStream(); + using (var zlib = new System.IO.Compression.ZLibStream( + output, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) + zlib.Write(body); + return output.ToArray(); + } + + private static byte[] Inflate(ReadOnlySpan stream) + { + using var input = new MemoryStream(stream.ToArray()); + using var zlib = new System.IO.Compression.ZLibStream(input, System.IO.Compression.CompressionMode.Decompress); + using var output = new MemoryStream(); + zlib.CopyTo(output); + return output.ToArray(); + } +} diff --git a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs index 28127ce8..c3ebe292 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs @@ -38,6 +38,7 @@ public sealed class JetCatalog(PageChannel channel, int catalogPage = 2) private Dictionary? _views; private Dictionary? _actionQueries; private Dictionary>? _queryParameters; + private List? _complexColumns; private long _seenSchemaGeneration = channel.SchemaGeneration; /// All tables in the database (user and system). @@ -65,10 +66,68 @@ public void Invalidate(bool markChanged = true) _views = null; _actionQueries = null; _queryParameters = null; + _complexColumns = null; _seenSchemaGeneration = _channel.SchemaGeneration; if (markChanged) _channel.MarkSchemaChanged(); } + /// Every complex (multi-value / attachment) column in the database, wired to the table its + /// values live in. Empty when the file has no MSysComplexColumns — a Jet 4 database has none. + public IReadOnlyList ComplexColumns + { + get { EnsureFresh(); return _complexColumns ??= LoadComplexColumns(); } + } + + /// The complex column of , or null when that + /// column is not a complex one. + public ComplexColumn? FindComplexColumn(string table, string column) => + ComplexColumns.FirstOrDefault(c => + string.Equals(c.OwnerTable.Name, table, StringComparison.OrdinalIgnoreCase) + && string.Equals(c.ColumnName, column, StringComparison.OrdinalIgnoreCase)); + + /// + /// Reads MSysComplexColumns and resolves each row to its owner table, its flat table and the flat + /// table's two bookkeeping columns. A row whose tables are missing is skipped rather than throwing: the + /// catalog must stay readable even where the complex wiring is incomplete. + /// + private List LoadComplexColumns() + { + var resolved = new List(); + if (FindTable("MSysComplexColumns") is not { } definition) return resolved; + + int name = ColumnIndex(definition, "ColumnName"), id = ColumnIndex(definition, "ComplexID"), + elementType = ColumnIndex(definition, "ComplexTypeObjectID"), + owner = ColumnIndex(definition, "ConceptualTableID"), flat = ColumnIndex(definition, "FlatTableID"); + if (name < 0 || id < 0 || owner < 0 || flat < 0) return resolved; + + foreach (object?[] row in new Storage.Table(_channel, definition).Rows()) + { + if (row[name] is not string columnName) continue; + if (TableWithId(row[owner]) is not { } ownerTable || TableWithId(row[flat]) is not { } flatTable) continue; + if (ownerTable.FindColumn(columnName) is null) continue; + + // Structural, never by name: the primary index names the per-value id, and the one non-unique + // single-column index names the link back to the owning record. + ColumnDef? valueId = flatTable.Indexes.FirstOrDefault(i => i.IsPrimaryKey)?.Columns is [{ Column: { } pk }] ? pk : null; + ColumnDef? ownerLink = flatTable.Indexes + .FirstOrDefault(i => !i.IsUnique && !i.IsPrimaryKey && i.Columns.Count == 1)?.Columns[0].Column; + if (valueId is null || ownerLink is null || valueId == ownerLink) continue; + + resolved.Add(new ComplexColumn( + columnName, row[id] is null ? 0 : Convert.ToInt32(row[id], CultureInfo.InvariantCulture), + ownerTable, flatTable, ownerLink, valueId, + elementType < 0 ? null : TableWithId(row[elementType])?.Name)); + } + return resolved; + + static int ColumnIndex(TableDef t, string column) => t.FindColumn(column)?.Index ?? -1; + } + + /// The table whose MSysObjects id (its TDEF page) is . + private TableDef? TableWithId(object? id) => + id is null ? null + : Tables.FirstOrDefault(t => t.DefinitionPage == Convert.ToInt32(id, CultureInfo.InvariantCulture)); + private void EnsureFresh() { long generation = _channel.SchemaGeneration; diff --git a/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs b/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs index 45b7da2d..8f5f679d 100644 --- a/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs +++ b/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs @@ -171,7 +171,12 @@ public static Result Build( // AutoNumber column would be written with the 0x04 flag set and no counter configuration of its own — // two columns then claiming one header counter at insert. ALTER's promote path already refuses this; // CREATE silently took the first and ignored the rest. - var counters = specs.Where(s => s.IsAutoNumber).ToList(); + // + // Complex columns are exempt, and are not a second claimant: they carry the same 0x04 flag but are + // allocated from 0x1C, not from this pair. Counting them would refuse to rebuild any table that has an + // ordinary counter beside a complex column — complex1.accdb's Table1 has one of each kind — and could + // hand `counter` a complex spec whose seed/increment describe nothing. + var counters = specs.Where(s => s.IsAutoNumber && s.Type != JetDataType.Complex).ToList(); if (counters.Count > 1) throw new NotSupportedException( $"A table may have only one AutoNumber column; {string.Join(", ", counters.Select(c => $"'{c.Name}'"))} are all declared as one."); diff --git a/src/LibRed/LibRed.Core/JetDatabase.cs b/src/LibRed/LibRed.Core/JetDatabase.cs index 5df2c7c4..f4c151c4 100644 --- a/src/LibRed/LibRed.Core/JetDatabase.cs +++ b/src/LibRed/LibRed.Core/JetDatabase.cs @@ -241,6 +241,121 @@ public void RollbackNested() /// The system catalog, used to enumerate and resolve tables. public JetCatalog Catalog { get; } + /// + /// The values a complex (multi-value / attachment) column holds for one record — the rows of the column's + /// flat table whose owner link is , which is the Int32 that record's row + /// carries in the column itself. + /// + /// An empty result is ordinary, not an error: the id is allocated when the row is created, + /// so a record that never had a value still has one. Each returned array is positional over + /// . For an attachment column, pass the FileData value to + /// to get the file itself. + /// One array per value, in the flat table's row order. + public IReadOnlyList ReadComplexValues(ComplexColumn column, int complexId) + { + ArgumentNullException.ThrowIfNull(column); + var values = new List(); + foreach (object?[] row in OpenTable(column.FlatTable.Name).Rows()) + { + if (row[column.OwnerLink.Index] is not { } link + || Convert.ToInt32(link, System.Globalization.CultureInfo.InvariantCulture) != complexId) + continue; + values.Add([.. column.ValueColumns.Select(c => row[c.Index])]); + } + return values; + } + + /// + /// Adds one value to a complex column for the record names — + /// a row in the column's flat table, linked back by that id. + /// + /// The complex column to add to. + /// The owning record's complex id, as its row carries it in the column. + /// The value's columns, positional over — + /// the same shape returns. + /// + /// The per-value id fills itself: it is an ordinary AutoNumber on the flat table, so the insert draws it + /// from that table's own counter. The record's 0x1C high-water is not touched — that counts + /// records, not values (verified: adding three attachments to an existing row left it unchanged). + /// A record may not hold the same value twice — ACE enforces the flat table's + /// IdxFKPrimaryScalar over [ownerLink + Value/FileName] and refuses the duplicate. LibRed + /// enforces it through the same unique index, so the insert is rejected here too. + /// + public void AddComplexValue(ComplexColumn column, int complexId, params object?[] value) + { + ArgumentNullException.ThrowIfNull(column); + ArgumentNullException.ThrowIfNull(value); + if (value.Length != column.ValueColumns.Count) + throw new ArgumentException( + $"'{column.OwnerTable.Name}.{column.ColumnName}' takes {column.ValueColumns.Count} value column(s), " + + $"not {value.Length}.", nameof(value)); + + Storage.Table flat = OpenTable(column.FlatTable.Name); + var row = new object?[column.FlatTable.Columns.Count]; + row[column.OwnerLink.Index] = complexId; // the value id is left null for the AutoNumber to fill + for (int i = 0; i < value.Length; i++) row[column.ValueColumns[i].Index] = value[i]; + flat.Insert(row); + } + + /// + /// Attaches a file to an attachment column: stores under + /// for the record names. + /// + /// + /// Fills the columns as Access does — FileName whole, FileType the extension without + /// its dot, FileData the packed payload, and FileFlags/FileURL/FileTimeStamp + /// null, which is what ACE leaves on every attachment measured. A file whose name a record already holds + /// is rejected — that one is the engine's rule, enforced by the flat table's unique index, and ACE + /// refuses it too. + /// The rest of what Access imposes on an attachment is policy, not capability: which + /// extensions it deflates, its 256 MB per file (the format holds four times that), its name length and + /// character rules, and its blocked-extension list. The defaults here follow Access, because a file + /// Access cannot open is rarely what a caller wants — but and + /// exist so the choice is the caller's. The blocked-extension list + /// is never applied: it belongs to the Access UI, and whether the engine refuses those through DAO is + /// untested. + /// + /// The attachment column to add to. + /// The owning record's complex id, as its row carries it in the column. + /// The file's name, stored whole in FileName. + /// The file's bytes. + /// Force the stored form, or to follow Access's convention + /// for the extension. Both forms are valid on disk and ACE reads either. + /// Apply Access's size and naming limits + /// (). Off writes what the format permits. + public void AddAttachment( + ComplexColumn column, int complexId, string fileName, byte[] content, + bool? compress = null, bool enforceAccessLimits = true) + { + ArgumentNullException.ThrowIfNull(column); + ArgumentException.ThrowIfNullOrEmpty(fileName); + ArgumentNullException.ThrowIfNull(content); + if (!column.IsAttachment) + throw new InvalidOperationException( + $"'{column.OwnerTable.Name}.{column.ColumnName}' is not an attachment column."); + if (enforceAccessLimits) ComplexAttachment.ValidateAccessLimits(fileName, content); + + string extension = Path.GetExtension(fileName).TrimStart('.'); + var value = new object?[column.ValueColumns.Count]; + for (int i = 0; i < value.Length; i++) + value[i] = column.ValueColumns[i].Name switch + { + "FileName" => fileName, + "FileType" => extension, + "FileData" => ComplexAttachment.Pack(extension, content, compress), + _ => null, + }; + AddComplexValue(column, complexId, value); + } + + /// The values a complex column holds for one record, by table and column name. Throws when + /// of is not a complex column. + public IReadOnlyList ReadComplexValues(string table, string column, int complexId) => + ReadComplexValues( + Catalog.FindComplexColumn(table, column) + ?? throw new InvalidOperationException($"'{table}.{column}' is not a complex column."), + complexId); + /// /// Creates a new table and registers it in the catalog. An optional primary key creates a /// unique index over the named columns. The database must have been opened writable; the diff --git a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs index ea1792a0..5743bfd1 100644 --- a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs +++ b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs @@ -372,12 +372,18 @@ private int ReadColumns(PageBuffer buffer, JetFormatBase format, int columnBlock // AutoNumber seed/increment from the TDEF header: 0x18 = increment, 0x14 = last-assigned value. On a // freshly created table the last value is Seed-Increment, so Seed = last + increment (matching what a - // no-insert scaffold reports). A table has at most one AutoNumber column; apply to it. + // no-insert scaffold reports). + // + // At most one column draws on THAT pair — but it is not the only counter a table has. A complex column + // carries the very same 0x04 flag and is allocated from 0x1C (ComplexAutoNumber), so a table can hold an + // ordinary counter and any number of complex columns all reading IsAutoNumber (complex1.accdb's Table1 + // has five). Applying the header pair to those too reports a seed and increment that describe a + // different counter, so skip them: their high-water is the table's ComplexAutoNumber. int increment = buffer.ReadInt32(format.TdefAutoNumberIncrementOffset); if (increment == 0) increment = 1; int lastAuto = buffer.ReadInt32(format.TdefLastAutoNumberOffset); foreach (ColumnDef column in _columns) - if (column.IsAutoNumber) + if (column.IsAutoNumber && column.Type != JetDataType.Complex) { column.Increment = increment; column.Seed = lastAuto + increment; diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 9baa15ac..94d2f919 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -260,6 +260,10 @@ private static void EncodeBinaryChunked(List buffer, byte[] data, bool asc JetDataType.Byte => 1, JetDataType.Int16 => 2, JetDataType.Int32 => 4, + // A complex (multi-value / attachment) column's key is its Int32 complex id, encoded exactly as an + // Int32 — verified against ACE over 43 entries across 9 such indexes in two files, covering + // attachment, Text and Long element types, with no difference in any byte. + JetDataType.Complex => 4, JetDataType.Single => 4, JetDataType.Double or JetDataType.DateTime => 8, // Int64/BIGINT keys like Currency — both are an int64, sign bit flipped, big-endian. Its VARIABLE @@ -279,6 +283,7 @@ private static byte[] EncodeFixed(ColumnDef column, object value) case JetDataType.Int16: return EncodeInteger(Convert.ToInt16(value, c), 2); case JetDataType.Int32: + case JetDataType.Complex: // the complex id, keyed as the Int32 it is return EncodeInteger(Convert.ToInt32(value, c), 4); case JetDataType.Currency: return EncodeInteger((long)decimal.Round(JetDecimalConverter.ToDecimal(value, c) * 10000m), 8); diff --git a/src/LibRed/LibRed.Core/Storage/IndexWriter.cs b/src/LibRed/LibRed.Core/Storage/IndexWriter.cs index 0bad6c5a..ca26d679 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexWriter.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexWriter.cs @@ -181,9 +181,14 @@ public void RemoveEntry(IndexDef index, object?[] values, RowId rowId) $"Index '{index.Name}': entry for row {rowId.Page}:{rowId.Row} was not found on leaf {leafPage}."); entries.RemoveAt(idx); - // Removing only shrinks the page, so Build never overflows. - _channel.WritePage(leafPage, - Build(PageType.LeafIndexPage, page.Previous, page.Next, tail: 0, level: 0, entries)!); + // Removing only shrinks the page, so Build never overflows. The page keeps the prefix length it was + // already stored at: ACE re-compresses a leaf only when it must (see InsertIntoLeaf), and a delete + // never must. Letting Build pick the largest prefix now available instead repacks entries ACE left + // alone — measured as a 4-byte-shorter live region on every leaf a cascading delete touched. + // Dropping an entry can only keep or widen what the rest share, so the stored length stays valid. + WriteOrThrow(leafPage, + Build(PageType.LeafIndexPage, page.Previous, page.Next, tail: 0, level: 0, entries, + page.CompressedByteCount)); } private static bool HasNullKey(IndexDef index, object?[] values) => @@ -295,7 +300,7 @@ private void InsertIntoLeaf(IndexDef index, List path, byte[] key, int poin if (Build(PageType.LeafIndexPage, page.Previous, page.Next, tail: 0, level: 0, entries, keep) is { } asIs) { - _channel.WritePage(leafPage, asIs); + _channel.WritePage(leafPage, KeepTail(leafPage, asIs)); return; } @@ -303,7 +308,7 @@ private void InsertIntoLeaf(IndexDef index, List path, byte[] key, int poin && Build(PageType.LeafIndexPage, page.Previous, page.Next, tail: 0, level: 0, entries, share) is { } compressed) { - _channel.WritePage(leafPage, compressed); + _channel.WritePage(leafPage, KeepTail(leafPage, compressed)); return; } @@ -316,7 +321,17 @@ private void InsertIntoLeaf(IndexDef index, List path, byte[] key, int poin // // AutoNumber and identity keys are ascending by construction, so this is the ordinary case. The // condition cannot fire on a random insert, which is why the general behaviour is unchanged. - int splitAt = pos == entries.Count - 1 ? entries.Count - 1 : entries.Count / 2; + // + // Everywhere else ACE halves the entries the page held BEFORE this insert, and the new entry then + // joins whichever side its key falls in — so a key landing below the midpoint leaves one MORE entry + // behind than a key landing on or above it. Halving the post-insert list instead always hands the odd + // entry to the right page, which agrees with ACE only for the upper half. Measured on a 602-entry + // leaf split by one further key (§10.5): ACE keeps 302 entries for a key at position 1 or 50, and 301 + // for one at 301, 302 or 400 — so the midpoint itself goes right. + int mid = (entries.Count - 1) / 2; // midpoint of the pre-insert entries + int splitAt = pos == entries.Count - 1 + ? entries.Count - 1 + : Math.Max(1, pos < mid ? mid + 1 : mid); // never leave the left page empty SplitAndPropagate(index, path, path.Count - 1, entries, PageType.LeafIndexPage, page.Previous, page.Next, splitAt); } @@ -406,7 +421,7 @@ private void InsertSeparator(IndexDef index, List path, int level, int oldC int parentLevel = path.Count - 1 - level; if (Build(PageType.IntermediateIndexPage, 0, 0, tail, parentLevel, entries) is { } built) { - _channel.WritePage(parentPage, built); + _channel.WritePage(parentPage, KeepTail(parentPage, built)); return; } @@ -621,8 +636,40 @@ private void SetPrev(int pageNumber, int prev) } private void WriteOrThrow(int pageNumber, byte[]? page) => - _channel.WritePage(pageNumber, page ?? throw new NotSupportedException( - "An index page still overflows after a split (a key wider than half a page).")); + _channel.WritePage(pageNumber, KeepTail(pageNumber, page ?? throw new NotSupportedException( + "An index page still overflows after a split (a key wider than half a page)."))); + + /// + /// Carries the destination's bytes past the new live end into a freshly built page, because that is what + /// Access leaves behind. + /// + /// + /// works in a zeroed buffer and fills it only as far as the last entry, so + /// everything beyond is written back as zeros. ACE instead edits a page in place: it moves the free-space + /// pointer and lets the bytes the entries used to occupy stand. Measured on an insert that splits three + /// levels of [Order Details]: on every index page ACE rewrote (both leaves and nodes) the region + /// past the new live end still held the original bytes, and on the one page it took fresh from the + /// allocator that region was zero. So the rule is zero-fill on allocation, never clear again. + /// Only a rewrite of this index's own page qualifies — a page just recycled from somewhere else + /// still carries the previous owner's type and owner id, and ACE zero-fills that one, which is what + /// building in a clean buffer already does. + /// The bytes kept are dead: they sit past the free-space boundary, so no reader reaches them. + /// Keeping them is for byte-faithfulness with ACE, not for meaning. + /// + private byte[] KeepTail(int pageNumber, byte[] built) + { + if (pageNumber >= _channel.PageCount) return built; + + ReadOnlySpan existing = _channel.ReadPage(pageNumber).Span; + if (existing[0] != built[0] + || BinaryPrimitives.ReadInt32LittleEndian(existing[OwnerOffset..]) != _table.DefinitionPage) + return built; + + int liveEnd = _channel.PageSize + - BinaryPrimitives.ReadUInt16LittleEndian(built.AsSpan(FreeSpaceOffset, 2)); + existing[liveEnd..].CopyTo(built.AsSpan(liveEnd)); + return built; + } /// Width of the row/child pointer an entry carries after its key. private const int TrailerSize = 4; diff --git a/src/LibRed/LibRed.Core/Storage/RowInserter.cs b/src/LibRed/LibRed.Core/Storage/RowInserter.cs index 724fdd2a..55bb79bd 100644 --- a/src/LibRed/LibRed.Core/Storage/RowInserter.cs +++ b/src/LibRed/LibRed.Core/Storage/RowInserter.cs @@ -266,7 +266,13 @@ public void Delete(RowId id) JetFormatBase format = _channel.Format; // Free the deleted row's chained long-value pages — held until close, as ACE holds them. - var oldDescriptors = new RowDecoder(_table.Columns, format).LongValueRaw(ReadRowBytes(id)); + byte[] rowBytes = ReadRowBytes(id); + var oldDescriptors = new RowDecoder(_table.Columns, format).LongValueRaw(rowBytes); + + // Which real indexes this row was the LAST holder of a key for. Asked before the row goes, and with + // the row itself excluded, so the answer is "does another row still carry this key" either way — + // the caller may or may not have removed this row's entries yet. + HashSet lastKeyLost = IndexesLosingTheirLastKey(format, rowBytes, id); foreach (ColumnDef column in _table.Columns) if ((column.Type is JetDataType.Memo or JetDataType.Ole || column.HasLongValueMap) && oldDescriptors.TryGetValue(column.Index, out byte[]? d)) @@ -302,11 +308,57 @@ public void Delete(RowId id) _channel.ReadPage(_table.DefinitionPage, tdef); int rowCount = BinaryPrimitives.ReadInt32LittleEndian(tdef.AsSpan(format.TdefRowCountOffset, 4)); BinaryPrimitives.WriteInt32LittleEndian(tdef.AsSpan(format.TdefRowCountOffset, 4), rowCount - 1); + + // ACE's index entry counts on delete, measured against a DAO delete of the same row + // (ComplexDeleteByteParityProbeTest): the TOTAL count (+0) drops by one on every real index, and + // the UNIQUE count (+4) only where the row was the last holder of its key. Deliberately NOT the + // mirror of insert, which leaves the total alone and only raises the unique count — so the total + // drifts downward across insert/delete cycles in ACE too. Faithful, not tidy. + // + // A total already reading 0 is the exception: ACE leaves that index's pair ENTIRELY alone, unique + // included. Measured on the same probe — a flat table's index with total 0 and unique 3 came back + // from a DAO delete still 0 and 3, while its siblings with a non-zero total each dropped by one. + // So it is not a clamp (a clamp would still have taken unique to 2); a zero total marks a counter + // ACE is not maintaining, and it stops touching the pair. Without this the totals go negative. + foreach (IndexDef index in _table.Indexes.GroupBy(i => i.RealIndexOrdinal).Select(g => g.First())) + { + int at = format.TdefRealIndexBlockOffset + index.RealIndexOrdinal * format.RealIndexEntrySize; + int total = BinaryPrimitives.ReadInt32LittleEndian(tdef.AsSpan(at, 4)); + if (total == 0) continue; + BinaryPrimitives.WriteInt32LittleEndian(tdef.AsSpan(at, 4), total - 1); + + if (!lastKeyLost.Contains(index.RealIndexOrdinal)) continue; + int unique = BinaryPrimitives.ReadInt32LittleEndian(tdef.AsSpan(at + 4, 4)); + BinaryPrimitives.WriteInt32LittleEndian(tdef.AsSpan(at + 4, 4), unique - 1); + } + _channel.WritePage(_table.DefinitionPage, tdef.AsSpan(0, format.PageSize)); } finally { ArrayPool.Shared.Return(tdef); } } + /// + /// The real-index ordinals for which the row at holds the only copy of its key, so + /// removing the row also removes a distinct key. An index the row is excluded from (IgnoreNulls with a + /// null key) never counted it, so it is never counted down either — the mirror of the insert side. + /// + private HashSet IndexesLosingTheirLastKey(JetFormatBase format, byte[] rowBytes, RowId id) + { + var lost = new HashSet(); + if (_table.Indexes.Count == 0) return lost; + + object?[] values = new RowDecoder(_table.Columns, format).Decode(rowBytes); + var writer = new IndexWriter(_channel, _table); + foreach (IndexDef index in _table.Indexes.GroupBy(i => i.RealIndexOrdinal).Select(g => g.First())) + { + if (index.RootPage <= 0) continue; + if (index.IgnoreNulls && HasNullKey(index, values)) continue; + if (!writer.KeyExists(index, values, (id.Page << 8) | id.Row)) + lost.Add(index.RealIndexOrdinal); + } + return lost; + } + /// Reclaims the row a relocation pointer forwards to, on whatever page it lives. The pointer /// is the first four bytes of the source row: page = pointer >> 8, row = pointer & 0xFF. /// The target is flagged deleted so scans skip it, which is why it is read straight off the directory @@ -1009,9 +1061,16 @@ private TableDefinitionPage ReadDefinition() ReadOnlySpan tdef = _channel.ReadPageShared(_table.DefinitionPage).Span; int highWater = BinaryPrimitives.ReadInt32LittleEndian(tdef.Slice(format.TdefLastAutoNumberOffset, 4)); + // A complex (multi-value / attachment) column is an AutoNumber too, but it draws on its own counter + // at 0x1C and there is ONE id per row shared by every complex column of the table — the counter is a + // single TDEF word, not one per column (verified: complex1.accdb's Table1 has four attachment columns + // and each record carries the same id in all four). So it is assigned once here, outside the 0x14 + // loop below, which must skip these columns or they would consume the table's ordinary counter. + AssignComplexId(format, values, tdef); + bool[] generated = new bool[values.Length]; foreach (ColumnDef column in _table.Columns) - if (column.IsAutoNumber && values[column.Index] is null or DBNull) + if (column.IsAutoNumber && column.Type != JetDataType.Complex && values[column.Index] is null or DBNull) { if (column.IsRandomAutoNumber) // "Random" AutoNumber (DefaultValue = GenUniqueID()): a random Int32, independent of the @@ -1030,6 +1089,26 @@ private TableDefinitionPage ReadDefinition() return generated; } + /// + /// Gives the row its complex id — one value from the table's 0x1C high-water, written into every + /// complex column the caller left unset. The id identifies the record, so all of the table's + /// complex columns share it; each column's values are then the flat-table rows carrying it. + /// + /// An id is allocated whether or not any value follows — that is what Access does, and it is why + /// a non-null complex id is no evidence that the record holds anything. A caller-supplied id is left + /// alone, as for any AutoNumber. + private void AssignComplexId(JetFormatBase format, object?[] values, ReadOnlySpan tdef) + { + var complex = _table.Columns + .Where(c => c.Type == JetDataType.Complex && values[c.Index] is null or DBNull) + .ToList(); + if (complex.Count == 0) return; + + int next = unchecked(BinaryPrimitives.ReadInt32LittleEndian( + tdef.Slice(format.TdefComplexAutoNumberOffset, 4)) + 1); + foreach (ColumnDef column in complex) values[column.Index] = next; + } + /// A random non-zero signed Int32 for a "Random" AutoNumber, mirroring Access's GenUniqueID(). private static int RandomAutoNumber() { @@ -1052,9 +1131,12 @@ private static int RandomAutoNumber() /// each **unique** index (a unique index gets a distinct key per row) and each non-unique index in /// (the row brings a key it does not hold yet). Access advances it on insert only, /// once per real index, and never decrements it. The sibling **total-entry count** - /// (`+0`) is deliberately left untouched: Access does **not** maintain it live — it is written only + /// (`+0`) is deliberately left untouched **on insert**: Access does not raise it there — it is written /// when the index is built (to its entries, in TableCreator's back-fill) or the file compacted - /// (verified: an index created on an empty table reads total `0` through any number of inserts). + /// (verified: an index created on an empty table reads total `0` through any number of inserts). + /// Delete is not the mirror of this — ACE drops the total by one on every real index when a row + /// goes, so the figure drifts downward across insert/delete cycles in ACE as much as here. Measured, and + /// matched, in . /// /// private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? generatedAutoNumbers, HashSet newKeys) @@ -1064,9 +1146,26 @@ private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? int count = BinaryPrimitives.ReadInt32LittleEndian(tdef.AsSpan(format.TdefRowCountOffset, 4)); BinaryPrimitives.WriteInt32LittleEndian(tdef.AsSpan(format.TdefRowCountOffset, 4), count + 1); + // The record's complex id rides its own counter at 0x1C, and every complex column of the table holds + // the one id, so the high-water moves once. Deleting a row never rolls it back (verified vs ACE — + // ComplexDeleteCascadeProbeTest), which is the same high-water rule 0x14 follows. + if (_table.Columns.FirstOrDefault(c => c.Type == JetDataType.Complex) is { } complexColumn + && values[complexColumn.Index] is { } complexValue) + { + int assignedId = Convert.ToInt32(complexValue, CultureInfo.InvariantCulture); + int complexHighWater = BinaryPrimitives.ReadInt32LittleEndian( + tdef.AsSpan(format.TdefComplexAutoNumberOffset, 4)); + if (assignedId > complexHighWater) + BinaryPrimitives.WriteInt32LittleEndian( + tdef.AsSpan(format.TdefComplexAutoNumberOffset, 4), assignedId); + } + foreach (ColumnDef column in _table.Columns) { if (!column.IsAutoNumber || values[column.Index] is not { } value) continue; + // Complex columns are AutoNumbers on their own 0x1C counter, handled above; they must not touch + // the table's ordinary one or a row would burn an id from both. + if (column.Type == JetDataType.Complex) continue; // A "Random" AutoNumber leaves the high-water at its default (Access ignores 0x14 for it — verified: // a UI-authored Random AutoNumber reads last-value 0). Advancing it would be meaningless (random ids // don't form a monotone sequence) and diverge from Access's on-disk state. diff --git a/src/LibRed/LibRed.Core/Storage/TableCreator.cs b/src/LibRed/LibRed.Core/Storage/TableCreator.cs index 3d43ff9d..278f8827 100644 --- a/src/LibRed/LibRed.Core/Storage/TableCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/TableCreator.cs @@ -1649,11 +1649,14 @@ public bool AddColumn(string tableName, ColumnSpec spec, string? defaultValue = maxCols + 1, format); - // Same single-counter rule the CREATE path and the promote path enforce; ADD COLUMN had neither. - if (spec.IsAutoNumber && table.Columns.Any(c => c.IsAutoNumber)) + // Same single-counter rule the CREATE path and the promote path enforce; ADD COLUMN had neither. The + // rule is about the header's seed/increment pair, so complex columns — flagged 0x04 but allocated from + // 0x1C — are neither the existing counter nor a conflicting one. + if (spec.IsAutoNumber && spec.Type != JetDataType.Complex + && table.Columns.Any(c => c.IsAutoNumber && c.Type != JetDataType.Complex)) throw new NotSupportedException( $"Cannot add AutoNumber column '{spec.Name}': table '{table.Name}' already has one " - + "(Jet allows a single AutoNumber column per table)."); + + "(Jet allows a single column to draw on the table's seed/increment counter)."); var newColumn = new ColumnDef { @@ -2190,13 +2193,15 @@ private void ReseedCounter(TableDef table, ColumnDef col, int seed, int incremen /// Promotes a plain Int32 column to an AutoNumber in place — ALTER COLUMN c COUNTER(seed, increment) /// where c is a plain integer. A counter is stored identically to a Long Integer, so this only sets the /// column descriptor's 0x04 AutoNumber flag and the header's seed/increment (0x14/0x18); - /// existing values are untouched. Jet allows only one AutoNumber per table, so it rejects a second; and (like - /// the reseed path) a column in a relationship is rejected, matching ACE. + /// existing values are untouched. Only one column may draw on that pair, so a second is rejected — complex + /// columns are flagged 0x04 too but allocate from 0x1C, so they do not count as the existing + /// one; and (like the reseed path) a column in a relationship is rejected, matching ACE. private void PromoteColumnToCounter(TableDef table, ColumnDef col, int seed, int increment) { - if (table.Columns.Any(c => c.IsAutoNumber && c.ColumnId != col.ColumnId)) + if (table.Columns.Any(c => c.IsAutoNumber && c.Type != JetDataType.Complex && c.ColumnId != col.ColumnId)) throw new InvalidOperationException( - $"Cannot make '{col.Name}' an AutoNumber: table '{table.Name}' already has one (Jet allows a single AutoNumber column per table)."); + $"Cannot make '{col.Name}' an AutoNumber: table '{table.Name}' already has one " + + "(Jet allows a single column to draw on the table's seed/increment counter)."); EnsureColumnIsNotInRelationship(table, col); if (increment == 0) increment = 1; diff --git a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs index 02b2bb20..97249860 100644 --- a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs +++ b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs @@ -28,7 +28,7 @@ public static class JetTypeCodec { JetDataType.Byte => 1, JetDataType.Int16 => 2, - JetDataType.Int32 or JetDataType.Single => 4, + JetDataType.Int32 or JetDataType.Single or JetDataType.Complex => 4, JetDataType.Int64 or JetDataType.Double or JetDataType.DateTime or JetDataType.Currency => 8, JetDataType.Guid => 16, JetDataType.FixedPoint => 17, @@ -49,6 +49,11 @@ public static class JetTypeCodec return BinaryPrimitives.ReadInt16LittleEndian(value); case JetDataType.Int32: return BinaryPrimitives.ReadInt32LittleEndian(value); + // A Complex column's four bytes are an Int32 **complex id**, not a long-value descriptor: the + // column carries the auto-number flag and its ids come from the table's 0x1C counter, one per + // row. The values themselves live in a per-column flat table — see . + case JetDataType.Complex: + return BinaryPrimitives.ReadInt32LittleEndian(value); case JetDataType.Int64: // ACE 16 BIGINT return BinaryPrimitives.ReadInt64LittleEndian(value); case JetDataType.Single: @@ -75,7 +80,6 @@ public static class JetTypeCodec // substitutes the real value, and hands the raw bytes here only when it has none. case JetDataType.Memo: case JetDataType.Ole: - case JetDataType.Complex: default: return value.ToArray(); } @@ -246,6 +250,9 @@ internal static byte[] Encode(ColumnDef column, JetDataType type, object value) case JetDataType.Int16: return Bytes(2, b => BinaryPrimitives.WriteInt16LittleEndian(b, Convert.ToInt16(value, c))); case JetDataType.Int32: + // The complex id, written as the Int32 it is — the values it names live in the column's flat + // table and are not part of the row (see ComplexColumn). + case JetDataType.Complex: return Bytes(4, b => BinaryPrimitives.WriteInt32LittleEndian(b, Convert.ToInt32(value, c))); case JetDataType.Int64: return Bytes(8, b => BinaryPrimitives.WriteInt64LittleEndian(b, Convert.ToInt64(value, c))); diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index cf336313..88cece22 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -286,6 +286,7 @@ private void CascadeDelete(Table rootTable, IEnumerable<(RowId Id, object?[] Val foreach (var (table, id, values) in order) { + DeleteComplexValues(table, values); foreach (IndexDef index in table.Definition.Indexes.Where(i => i.RootPage > 0) .GroupBy(i => i.RootPage).Select(g => g.First())) table.RemoveIndexEntry(index, values, id); @@ -293,6 +294,40 @@ private void CascadeDelete(Table rootTable, IEnumerable<(RowId Id, object?[] Val } } + /// + /// Removes the values every complex (multi-value / attachment) column of holds + /// for the row being deleted — the flat-table rows carrying that record's complex id. + /// + /// + /// Measured against ACE (ComplexDeleteCascadeProbeTest): deleting a record removes its values from + /// every complex column of the table — a record holding three attachments in one column and two in + /// another loses all five — and rolls no counter back. The owner's 0x1C and each flat + /// table's 0x14 keep their values, so the next row still takes the following id and the freed + /// value ids are never reissued. Leaving the rows behind would orphan values no record points at. + /// + private void DeleteComplexValues(Table table, object?[] values) + { + foreach (ComplexColumn complex in _database.Catalog.ComplexColumns) + { + if (!string.Equals(complex.OwnerTable.Name, table.Name, StringComparison.OrdinalIgnoreCase)) continue; + if (values[complex.OwnerTable.FindColumn(complex.ColumnName)!.Index] is not { } raw) continue; + int recordId = Convert.ToInt32(raw, System.Globalization.CultureInfo.InvariantCulture); + + Table flat = _database.OpenTable(complex.FlatTable.Name); + foreach ((RowId flatId, object?[] flatValues) in flat.Rows().WithIds().ToList()) + { + if (flatValues[complex.OwnerLink.Index] is not { } link + || Convert.ToInt32(link, System.Globalization.CultureInfo.InvariantCulture) != recordId) + continue; + + foreach (IndexDef index in flat.Definition.Indexes.Where(i => i.RootPage > 0) + .GroupBy(i => i.RootPage).Select(g => g.First())) + flat.RemoveIndexEntry(index, flatValues, flatId); + flat.Delete(flatId); + } + } + } + /// Applies each enforced relationship's ON DELETE action to a parent row about to be deleted: /// CASCADE schedules its children for deletion (unless already scheduled), SET NULL nulls their FK columns /// now, and NO ACTION rejects the delete if any child exists (matching Access's "record cannot be deleted… diff --git a/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs b/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs index 70ac64ba..498180cf 100644 --- a/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs +++ b/src/LibRed/LibRed.Engine/Schema/JetClrTypeMap.cs @@ -21,7 +21,9 @@ public static class JetClrTypeMap JetDataType.Text or JetDataType.Memo => typeof(string), JetDataType.Guid => typeof(Guid), JetDataType.FixedPoint => typeof(decimal), - JetDataType.Complex => typeof(object), + // The in-row value of a complex (multi-value / attachment) column is its Int32 complex id; the + // values it stands for are read through ComplexColumn. + JetDataType.Complex => typeof(int), _ => typeof(object), }; } \ No newline at end of file diff --git a/src/LibRed/docs/format/page-02a-tdef.md b/src/LibRed/docs/format/page-02a-tdef.md index 0c027fd3..2a72f280 100644 --- a/src/LibRed/docs/format/page-02a-tdef.md +++ b/src/LibRed/docs/format/page-02a-tdef.md @@ -13,10 +13,10 @@ | `0x08` | 4 | TDEF length (total logical bytes) | | `0x0C` | 4 | Unknown — a constant `0x00000659` (1625) observed in every file | | `0x10` | 4 | Row count | -| `0x14` | 4 | **Highest AutoNumber value assigned** = the id of the last row inserted (the *next* id is this **`+ increment`**, see `0x18`); `0` when the table has no AutoNumber column. On a freshly created custom counter it is **`Seed - Increment`** so the first insert yields the `Seed` (verified: `COUNTER(1000, 7)` → `0x14` = `993`, first id `1000`). Verified **directly against `@@IDENTITY`**. It is not the row count, and deleted ids are not reused: insert 3 rows, delete id `3`, insert again (assigned id `4`) → `0x14` = `4` while the row **count** is `3`. mdbtools labels this *"Next autonumber value"* — that's **off by one**; the stored value is the last assigned, and the next id is `+ increment`. It is a **plain signed int32 that wraps** — there is no "counter exhausted" state (see the wrap note below). **Write requirement:** a writer inserting into an AutoNumber table must advance this to the last id it writes (LibRed does so in `RowInserter`); leaving it stale makes Access reissue an existing id and reject the insert as a duplicate primary key — verified end-to-end. | +| `0x14` | 4 | **Highest AutoNumber value assigned** = the id of the last row inserted (the *next* id is this **`+ increment`**, see `0x18`); `0` when the table has no AutoNumber column. On a freshly created custom counter it is **`Seed - Increment`** so the first insert yields the `Seed` (verified: `COUNTER(1000, 7)` → `0x14` = `993`, first id `1000`). Verified **directly against `@@IDENTITY`**. It is not the row count, and deleted ids are not reused: insert 3 rows, delete id `3`, insert again (assigned id `4`) → `0x14` = `4` while the row **count** is `3`. mdbtools labels this *"Next autonumber value"* — that's **off by one**; the stored value is the last assigned, and the next id is `+ increment`. It is a **plain signed int32 that wraps** — there is no "counter exhausted" state (see the wrap note below). **Write requirement:** a writer inserting into an AutoNumber table must advance this to the last id it writes (LibRed does so in `RowInserter`); leaving it stale makes Access reissue an existing id and reject the insert as a duplicate primary key — verified end-to-end. **This pair (`0x14`/`0x18`) describes exactly one column**, and a reader must not apply it to every column bearing the descriptor's `0x04` AutoNumber flag: complex columns carry that flag too and are allocated from `0x1C` instead, so a table can have several flagged columns while only the non-complex one has a seed and increment here (`MSysResources` has two — `Id` and the complex `Data`; `complex1.accdb`'s `Table1` has five). | | `0x18` | 4 | **AutoNumber increment** — a **signed 32-bit int** (same width as `0x14`); the step added to `0x14` for each new id. Default `1` (a plain `COUNTER`); a custom `COUNTER(seed, increment)` / `AUTOINCREMENT(seed, increment)` / `INTEGER IDENTITY(seed, increment)` sets it. **Confirmed a full int32, not a byte + 3 unknown** (verified vs ACE): `COUNTER(1, 300)` → `2C 01 00 00` (spans 2 bytes, ids `1, 301, 601`); `COUNTER(5, 100000)` → `A0 86 01 00` (3 bytes); and decisively `COUNTER(100, -5)` → `FB FF FF FF` = `-5` in two's-complement (all 4 bytes) with a **descending** sequence `100, 95, 90`. It reads `1` on every table (autonumber or not) because that is the default increment — mdbtools labels it a 1-byte constant / "autonumber enable" flag, which only *looks* right because the default increment is 1. The seed itself is not stored separately — it is recovered as `0x14 + increment` (correct on a freshly-created, un-inserted table). A writer/reader must treat it as a signed int32; the insert bump of `0x14` moves in the increment's direction (max for +, min for −) so a descending counter doesn't reissue an id — except at the int32 wrap, where the generated id is the correct continuation despite comparing as backwards (see the wrap note below). | -| `0x1C` | 4 | Complex-type AutoNumber (mdbtools `ct_autonum`) — the high-water value for a *complex* column (multi-value / attachment). `0` in every table observed; a non-zero value is unverified (OLE DB DDL can't create such a column). **Read into `TableDef.ComplexAutoNumber` and written through `TdefBuilder` (0 for a table with no complex column) so it round-trips via the model, not only the raw surgery path** | -| `0x20` | 8 | Unknown / reserved (zero observed) | +| `0x1C` | 4 | Complex-type AutoNumber (mdbtools `ct_autonum`) — the high-water **record** id for a table that has a complex (multi-value / attachment) column. Each row's inline 4-byte value in such a column is one of these ids, allocated when the **row** is created, whether or not any value is ever stored against it. There is **one id per row, shared by every complex column of the table** — the counter is a single TDEF word, not one per column: `complex1.accdb`'s `Table1` has four attachment columns and each record carries the same id in all four (verified, as is the two-column case in `LIBRARY.accdb`'s `Borrow`). Deleting a row does **not** roll the counter back. **Verified** across 108 tables carrying a non-zero value: it equals the largest live inline id, or exceeds it where rows have been deleted (`PasesDeSalida` `14` = max `14`; `Book` `10` against max `5`; `XSDFiles` `242` against max `1`). **It counts records, not values** — measured by changing one file: adding three attachments to an existing row of `complex1.accdb` left `0x1C` at `3` while the flat table's own `0x14` went `0` → `3`. Still `0` for a table with no complex column, which is what `TdefBuilder` writes. **Read into `TableDef.ComplexAutoNumber`** so it round-trips via the model, not only the raw surgery path. The *per-value* ids inside the flat table are **not** this counter — those come from an ordinary AutoNumber column with its own `0x14`; see [system-catalog.md](system-catalog.md), "Complex columns" | +| `0x20` | 8 | **Uninitialised — not a field.** ACE leaves these 8 bytes as whatever its write buffer held: **non-zero in 754 of 1,676 tables** measured across the corpus, carrying Windows user-mode pointers (`00007FFDF1459118`), UTF-16 and ASCII text fragments (`00460020006D0061` = `a m F`, `65706F7250206D6F` = `om Prope`), float bit patterns (`41B0000044D82000`) and `FFFFFFFFFFFFFFFF`. One value recurs across tables written in the same operation — eight consecutive `MSysComplexType_*` tables share one, three unrelated tables in another file share another — which is buffer reuse rather than meaning. Preserve verbatim: it can be neither validated nor regenerated. (Whether ACE *reads* anything here is untested; it writes pointers, so almost certainly not.) | | `0x28` | 1 | Table type: `0x4E` 'N' user, `0x53` 'S' system | | `0x29` | 2 | Maximum column count | | `0x2B` | 2 | Variable-length column count | diff --git a/src/LibRed/docs/format/page-02b-columns.md b/src/LibRed/docs/format/page-02b-columns.md index f4828520..326e95c9 100644 --- a/src/LibRed/docs/format/page-02b-columns.md +++ b/src/LibRed/docs/format/page-02b-columns.md @@ -12,8 +12,8 @@ | `0x05` | 2 | Column id | | `0x07` | 2 | Variable-table index. For a **fixed** column it is the running count of variable columns with a smaller id (**not** `0`) — ACE's own `ADD COLUMN` writes `2` for a LONG added to `(K LONG, A TEXT, B TEXT)`. For a **variable** column it is that column's own slot index, which is the `0x2B` **high-water** and *not* the count of live variable columns: after a variable column is dropped the next one goes above the abandoned slot, so the two part company. Verified byte-for-byte against DAO-written system tables and against ACE performing the same DDL. | | `0x09` | 2 | Column number — a second copy of the column id `0x05` on a **user** table, but **zero** on the tables the engine writes for itself (see the note below). It **diverges after an `ALTER COLUMN` type change**, which burns a new id into `0x05` yet leaves `0x09` at the *old* id; see §3.8 | -| `0x0B` | 1 | Numeric **precision** (Decimal/Numeric columns); otherwise the low byte of the collation's LANGID (the database default, e.g. `0x09` for en-US) | -| `0x0C` | 1 | Numeric **scale** (Decimal/Numeric columns); otherwise the high byte of the LANGID (`0x04` for en-US) | +| `0x0B` | 1 | Numeric **precision** (Decimal/Numeric columns); on a **Complex** column the `MSysComplexColumns.ComplexID` (see below); otherwise the low byte of the collation's LANGID (the database default, e.g. `0x09` for en-US) | +| `0x0C` | 1 | Numeric **scale** (Decimal/Numeric columns); `0` on a **Complex** column; otherwise the high byte of the LANGID (`0x04` for en-US) | | `0x0D` | 1 | Collation **sort id** — the LCID's high word; `0` except for an alternate sort order (see the note below) | | `0x0E` | 1 | Collation **sort-order version**: `0` = General Legacy (Access 2000–2007), `1` = the "General" order Access 2010+ made default (a different key encoding, §10.4) | | `0x0F` | 1 | Flags (see below) | @@ -25,6 +25,38 @@ **Flags (`0x0F`):** `0x01` fixed-length, `0x02` updatable, `0x04` auto-number, `0x40` auto-number GUID, `0x80` hyperlink (on a Memo column). +> **A `Complex` column (type `0x12`) is an AutoNumber, and its `0x0B` names its `MSysComplexColumns` row.** +> Verified on all six complex columns across two files: `0x0F` = `0x07` on every one — fixed `0x01`, +> updatable `0x02` and **auto-number `0x04`** — so the 4-byte in-row complex id is engine-generated per row, +> exactly like an ordinary counter. Its high-water is **not** `0x14`, which belongs to the table's own +> AutoNumber; it is [`0x1C`](page-02a-tdef.md), which is why mdbtools calls that field `ct_autonum`. **A +> table can therefore carry two independent AutoNumber counters** — `LIBRARY.accdb`'s `Book` has `BK_ID` at +> `0x14` = 8 and `BK_category` at `0x1C` = 10. +> +> Two counters, but **any number of flagged columns**: every complex column on the table sets `0x04` while +> sharing the single `0x1C`, so counting flagged columns does not count counters. `MSysResources` has two +> flagged (`Id`, `Data`) and `complex1.accdb`'s `Table1` five (`ID` plus four attachment columns). Two +> consequences for a reader/writer: the `0x14`/`0x18` seed and increment describe only the non-complex one and +> must not be reported for the others, and a "one AutoNumber per table" rule must be applied over the +> non-complex columns alone or it rejects a table Access created quite normally. +> +> `0x0B` carries the column's `ComplexID`, the key of its `MSysComplexColumns` row, where an ordinary column +> would hold a precision or the LANGID low byte — and `0x0C` is `0`, where an ordinary column holds the +> LANGID high byte (`04` for en-US on every plain column beside these). So the descriptor points at the +> catalog entry directly; a reader need not match on column name. +> +> | column | `0x0B` | `MSysComplexColumns.ComplexID` | +> | --- | --- | --- | +> | `complex1` `MSysResources.Data` | 1 | 1 | +> | `complex1` `Table1.Attachment` | 2 | 2 | +> | `LIBRARY` `MSysResources.Data` | 1 | 1 | +> | `LIBRARY` `Book.BK_category` | 2 | 2 | +> | `LIBRARY` `Borrow.BRW_book` | 3 | 3 | +> | `LIBRARY` `Borrow.BRW_mmberINFO` | 4 | 4 | +> +> *Unverified:* whether the id is one byte at `0x0B` or a 16-bit value spanning `0x0B`–`0x0C`. Every observed +> id is 1–4, so a zero at `0x0C` fits both readings. + > **`0x09` is written by everything that creates a user table, and only by those.** Every genuine user > table carries the id on every column, and every zero belongs to a table the engine made for itself — > `MSysObjects`, `MSysACEs`, `MSysQueries`, `MSysRelationships`, `MSysComplexColumns`, diff --git a/src/LibRed/docs/format/page-02d-constraints.md b/src/LibRed/docs/format/page-02d-constraints.md index a6bd7344..97b327d2 100644 --- a/src/LibRed/docs/format/page-02d-constraints.md +++ b/src/LibRed/docs/format/page-02d-constraints.md @@ -16,16 +16,17 @@ statistics: **These two fields are maintained very differently (verified vs ACE):** - **Total entry count (`+0`) is *not* maintained on insert.** Access leaves it unchanged through live - inserts, deletes and updates. It is written when the index is **built over the rows present** — + inserts and updates — but **a delete decrements it** (see "On delete" below). It is written when the index + is **built over the rows present** — `CREATE INDEX` (unique or not), a foreign key's backing index, and the rebuild of an index whose column an `ALTER COLUMN` changes — to the **number of entries the index then holds** (the rows, less those an IGNORE NULL index leaves out); and on **compact/repair**, to the row count. So an index created on an empty table and then filled by inserts reads `0`, while one created over six rows reads `6`. A writer must **not** set it on insert: that would falsely mark the file as compacted. -- **Unique entry count (`+4`) *is* maintained live and is cumulative** — Access advances it on - **INSERT only**, by one when the row brings a key the index **does not hold at that moment**, and **never - decrements** it. Verified: after 3 inserts into a unique index it is `3`; after deleting a row it stays - `3`; after one more insert it is `4`. It equals the current distinct-key count only with no deletions. +- **Unique entry count (`+4`) *is* maintained live** — Access advances it on **INSERT** by one when the row + brings a key the index **does not hold at that moment**, and lowers it only on a delete that clears the + last row holding a key, and then only for an index whose total is non-zero (see "On delete"). It equals the + current distinct-key count only where no delete has left a gap the index never refilled. - A **unique** index gains a new key with every row, so it advances on every insert. - A **non-unique** index advances only for a key not already in it. Keys are compared as index keys, so collation-equal text (`'a'`, `'A'`) is one key; a **Null is a key** like any other (a second Null adds @@ -42,9 +43,25 @@ statistics: for a retype to or from Memo/OLE: only an index over the changed column is rebuilt — a primary key included — and a table referencing this one keeps its foreign-key index's counts. - LibRed maintains both this way — inserts in `RowInserter`, builds in `TableCreator`'s index back-fill, and - the Memo/OLE retype (which LibRed does by rebuilding the whole table) restores every other index's counts - afterwards — and exposes `+4` as `IndexDef.UniqueEntryCount`. +**On delete — and the zero rule that governs both fields.** A delete is the one operation that lowers either +count, and it is gated on the total: + +- **A block whose total (`+0`) reads `0` is left entirely alone** — total and unique both. A zero total marks + an index Access is not maintaining a total for (one built empty and filled by inserts, per the bullet + above), and Access stops touching the pair rather than taking it negative. This is **not** a clamp at zero: + an index whose total was `0` and unique `3` comes back from a delete still `0` and `3`, where a clamp would + have taken unique to `2`. +- **Otherwise the total drops by one**, on every real index, for each row removed. +- **And the unique count drops by one** on those indexes where the deleted row was the **last** holder of its + key — compared as index keys, exactly as on the insert side. + +> This is why an index built empty and filled by three inserts reads unique `3` before a delete and unique +> `3` after it: its total is `0`, so the delete skips it. The rule is visible only on an index that *was* +> built over rows, where both fields move together. + + LibRed maintains both this way — inserts and deletes in `RowInserter`, builds in `TableCreator`'s index + back-fill, and the Memo/OLE retype (which LibRed does by rebuilding the whole table) restores every other + index's counts afterwards — and exposes `+4` as `IndexDef.UniqueEntryCount`. ### 3.5 Index-data block (52 bytes) diff --git a/src/LibRed/docs/format/page-03-04-index-btree.md b/src/LibRed/docs/format/page-03-04-index-btree.md index 8aef118f..a97ce7cf 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -826,28 +826,38 @@ Then the value, transformed: byte-for-byte against ACE-written keys (single- and multi-chunk); descending is **unverified** against ACE, extrapolated from the verified GUID descending. `IndexKeyEncoder.EncodeBinaryChunked`. -### 10.4a Entry removal — LibRed compacts a leaf harder than ACE does +### 10.4a Entry removal — a leaf is rewritten, not repacked -Removing entries leaves the two engines with **identical index content and byte-different pages**. LibRed -rewrites the leaf without the removed entries; ACE returns less of their space. +Removing an entry rewrites the leaf **without changing how the survivors are stored**. Two rules together +make a delete byte-identical to ACE's: -> After the same `DROP TABLE`, the catalog index roots it touches hold the *same entries in the same order* -> under both engines, while their free-space fields diverge: -> -> | index root | before | ACE | LibRed | -> |---|---|---|---| -> | `MSysObjects.ParentIdName` | 3,046 | 3,064 | 3,136 | -> | `MSysObjects.Id` | 3,436 | 3,445 | 3,463 | -> | `MSysACEs.ObjectId` | 3,256 | 3,274 | 3,311 | -> -> Since the surviving entries then sit at different offsets, the pages differ widely despite agreeing on -> every entry. This is index maintenance, not a drop artefact — any `DELETE` removing entries does it. +- **The page keeps its stored `compressedByteCount`.** A delete never recomputes the prefix. Dropping an + entry can only widen what the rest share, so compressing to the largest prefix now available would be + legal — and is not what ACE writes. This is the removal-side half of §10.3's "compressed only when it + fills": a delete never fills a page, so a delete never re-compresses. Measured: recomputing shortens a + two-entry leaf's live region by 4 bytes against ACE's on every leaf a cascading delete touches. +- **Bytes past the new free-space boundary keep their previous contents** (§10.4c). + +Both engines then agree byte for byte. Verified by deleting the same row through DAO and through LibRed on +two separate copies and diffing whole files: an ordinary table (`[Order Details]`, 5 pages touched) and a +table with four attachment columns whose delete cascades into the flat tables (`complex1.accdb`'s `Table1`, +20 pages touched) each came back identical on every page, with LibRed touching no page ACE did not. -**Not currently treated as a defect.** The logical content matches, and ACE goes on writing into these very -indexes in a LibRed-compacted file — after a LibRed drop it inserts rows, allocates pages and adds catalog -entries normally. Packing tighter is also the conservative direction. What is *not* established is why ACE -leaves the space; if a scenario ever turns up that requires matching it byte for byte, this is the knob, and -matching would mean deliberately compacting **less**. +### 10.4c Dead bytes past the free-space boundary + +**A page is zero-filled when it is allocated and never cleared again.** Every later rewrite moves the +free-space field (`0x02`) and leaves whatever the vacated bytes held — so a page normally carries the tail of +a longer earlier generation of itself, which no reader reaches. + +> Measured on an insert that splits three levels of `[Order Details]`: on all seven index pages ACE rewrote +> in place — leaves and nodes alike — the region past the new live end still held the pre-insert bytes; on +> the one page ACE took fresh from the allocator (recycled from a data page of another table) that region was +> zero. + +A writer that instead builds each page in a clean buffer produces correct, readable files that differ from +ACE's across the whole vacated region. The rule is cheap to honour: build as normal, then copy the +destination's bytes from the new live end to the end of the page — but only when the destination is already +this index's own page of the same type, since a recycled page is the zero-fill case. ### 10.4b The 510-byte index entry limit @@ -932,7 +942,13 @@ scan Access uses. The split mechanics: - **Leaf split:** partition the sorted entries in half; the lower half stays on the original page, - the upper half goes to a newly allocated page. The doubly-linked leaf chain is maintained — the + the upper half goes to a newly allocated page. **The half is taken over the entries the page held + *before* the insert, and the new entry then joins whichever side its key falls in** — so the cut sits at + `mid = preInsertCount / 2`, and the original page keeps `mid + 1` entries for a key landing below `mid` + but `mid` for one landing on or above it. (Halving the post-insert list instead always hands the odd entry + to the right page, which is the same cut only for a key in the upper half.) Measured against ACE on a + 602-entry leaf split by one further key: ACE keeps **302** entries for a new key at position 1 or 50 and + **301** at position 301, 302 or 400 — so the midpoint itself goes right. The doubly-linked leaf chain is maintained — the new right page's *prev* (`0x0C`) points at the left, its *next* (`0x10`) inherits the left's old next, the left's *next* becomes the right, and the old next leaf's *prev* is repointed to the right. **Getting these offsets right is essential** — Access's scan walks the `0x10` next-chain @@ -963,6 +979,15 @@ The split mechanics: does not show up in a gapped load backfilled ascending (ACE: 5 leaves, `1, 1, 1, 7, 55`), because an ascending backfill keeps meeting the right edge of a subtree. A *random* backfill into pre-packed pages is unmeasured. + +> **Open — the compressed length of a page whose FIRST entry is new.** A key that sorts below everything on +> the page splits it at the same point as any other low key (verified: both engines keep 302 of 603), but ACE +> then writes the left page with `compressedByteCount = 0` where the page had been stored at `3` and the +> entries still share 3 bytes. Every other measured position keeps the `3`. Whether the trigger is "the new +> entry became the page's first" or "a split writes its left page uncompressed unless the first entry is +> unchanged" is **not** established — the two fit the measurements equally. Until it is, LibRed recomputes +> the prefix and keeps compressing, which costs a byte difference only in this one case. The right page is +> unaffected (its live bytes match exactly). - **Propagation:** the promoted separator `[key → left page]` is inserted into the parent, whose pointer to the just-split page is repointed to the new right page; if the parent overflows it splits in turn, up to the root. diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md index 5f05d52a..956261fe 100644 --- a/src/LibRed/docs/format/system-catalog.md +++ b/src/LibRed/docs/format/system-catalog.md @@ -179,8 +179,14 @@ > existing values are untouched, no rebuild. ACE rejects the conversion outright (*"Invalid field data > type"*, as does SQL Server); PostgreSQL (`ADD GENERATED AS IDENTITY`) / MySQL (`MODIFY … AUTO_INCREMENT`) / > LibRed allow it. Verified: ACE reads the promoted counter and assigns next id = seed. Guards: - > Jet permits only one AutoNumber per table (a second is rejected), and a column in a relationship is - > rejected (matching ACE). + > only one column may draw on the table's seed/increment pair (a second is rejected), and a column in a + > relationship is rejected (matching ACE). + > + > That guard is about the **header pair**, not about the `0x04` flag, and the two are not the same set. A + > complex column carries `0x04` as well but is allocated from `0x1C`, so a table can hold several columns + > that all read as AutoNumber: `MSysResources` has two (`Id` and the complex `Data`), and a table with four + > attachment columns beside an `ID` counter has five. Only the non-complex one is described by `0x14`/`0x18` + > — applying that pair to the others reports a different counter's seed and increment as theirs. > > **Demoting a counter to a plain int** (`ALTER COLUMN LONG`) is the reverse in-place edit — clear > the `0x04` flag and reset the header to a non-AutoNumber table's state (`0x14` = 0, `0x18` = 1); values are @@ -544,10 +550,123 @@ Four layers — the user table, then three kinds of ordinary hidden/system table codes: `UnsignedByte, Short, Long, IEEESingle, IEEEDouble, GUID, Decimal, Text, Attachment`. `MSysComplexType_Attachment` = `FileData:Ole, FileFlags:Int32, FileName:Text, FileTimeStamp:DateTime, FileType:Text, FileURL:Memo`. -4. **`f__`** — the actual data, one row per value/attachment: `_:Int32` (PK), - `_:Int32` (FK back to the owning row), then the subtype's value columns. For an - Attachments column the rows are the real files (`FileData` = OLE `byte[]`, `FileName`, `FileType`); - a multi-value scalar column is the same shape with just the value column + FK. +4. **`f__`** — the actual data, one row per value/attachment. Two bookkeeping `Int32` columns + plus the subtype's value columns: + - **`_`** is the **link back to the owning row** — it holds that row's complex id, and it + **repeats once per value**, which is what makes the column multi-valued. Indexed, **not** unique. + - **`
_`** is the **per-value id**: unique, carries the `MSysComplexPKIndex` **primary** + index, and is an ordinary **AutoNumber** column whose high-water is the flat table's own + [`0x14`](page-02a-tdef.md) — there is no complex-specific counter on this side. + - **`IdxFKPrimaryScalar`** is unique over `[_ + ]` (`Value` for a scalar, + `FileName` for an attachment): one record cannot hold the same value twice, and **ACE enforces it** + (verified — `ComplexDuplicateValueProbeTest`). Adding a value a record already holds is refused with + *"You cannot enter that value because it duplicates an existing value in the multi-valued lookup or + attachment field"*, while a fresh value on the same record is accepted. The same value on a **different** + record is fine, which is what the composite key says and what the corpus shows. A writer must therefore + reject the duplicate itself rather than relying on the value id's own uniqueness. + + For an Attachments column the rows are the real files (`FileData` = OLE `byte[]`, `FileName`, `FileType`); + a multi-value scalar column is the same shape with a single `Value` column. + +> **Verified, and the reverse reading is excluded.** `PasesDeSalida.Acompaña`: six rows with inline ids +> `1,2,3,4,7,14`, six flat rows, and `_TempField*0` takes the values `2,3,3,4,4,7` — two records holding two +> values each — while `PasesDeSalida_TempField*0` runs `1..6` unique. `XSDFiles.XMLSchemaFiles`: one record, +> `_XMLSchemaFiles` = `1` on all **37** rows. A unique key on the owner link could not produce either. + +> **The names are creation-time and do not follow renames.** `
_` is whatever the table and +> column were called when the complex column was made, and so is the `f__` table itself: +> `Borrow.BRW_book`'s value-id column is still `Table1_BRW_book`, `Book.BK_category` is backed by +> `f_…_TempField*7` with columns `_TempField*7` / `Book_TempField*7`, and `COVER.Attachments` by `f_…_Field1`. +> **Never build these names from the current schema** — resolve the table through `MSysComplexColumns.FlatTableID`, +> then the two columns through the index shape (primary index → value id; the lone non-unique single-column +> index → owner link). + +> **One record id serves every complex column of the table.** The counter is the table's single `0x1C`, so a +> row gets one id when it is created and all of its complex columns carry that same id — `complex1.accdb`'s +> `Table1` has four attachment columns, and records 1, 2 and 3 hold ids 1, 2 and 3 in every one of them. A +> writer allocates once per row, not once per column. + +> **Deleting the owning record cascades to every flat table, and rolls no counter back** (verified through +> DAO — `ComplexDeleteCascadeProbeTest`). Deleting `Table1`'s record 3, which held three attachments in +> `Attachment` and two more in `att4`, removed all five flat rows across both tables. The owner's `0x1C` +> stayed at 3 and each flat table's `0x14` kept its value, so the next row still gets id 4 and the freed +> value ids are never reissued. A delete path must therefore remove the flat rows of **every** complex column +> on the table for that id, and must leave both high-waters alone. + +> **Both id spaces are sparse high-water counters**, so neither is dense or ordered: record ids run +> `1,2,3,4,7,14` over six rows, and `XSDFiles`' value ids reach `116` over 37 values. And an id is allocated +> when the **row** is created, with or without values — `complex1.accdb`'s `Table1` has three rows with ids +> `1,2,3` and an empty flat table, which Access itself renders as `(0)` in every row's attachment cell. A +> non-null inline id is therefore **not** evidence that any value exists. + +> **The in-row complex id is an AutoNumber.** The `0x12` column's descriptor carries the auto-number flag +> (`0x0F` bit `0x04`) and its high-water is the owner table's [`0x1C`](page-02a-tdef.md), separate from the +> table's ordinary counter at `0x14`. Its descriptor also names its catalog row: `0x0B` holds the +> `ComplexID`. See [page-02b-columns.md](page-02b-columns.md). + +### Catalog rows for the hidden tables + +All three kinds sit under `ParentId` `0x0F000001` (the Tables container) with `Type=1` — they are ordinary +tables as far as the catalog is concerned, distinguished only by `Flags` and `Owner`: + +| object | `Flags` | `Owner` | +| --- | --- | --- | +| an ordinary **user** table | `0x00040000` | user SID | +| **`f__`** flat table | `0x800A0000` | **the same user SID** | +| **`MSysComplexType_*`** template | `0x80030000` | `NULL` | +| **`MSysComplexColumns`** | `0x80000000` | `NULL` | +| an ordinary **system** table | `0x80000000` | engine SID | + +A flat table is thus system-flagged (`0x80000000`) yet owned by the *user* SID, unlike a real system table — +consistent with it holding user data. `0x00020000` is common to flat and template tables; flat adds +`0x00080000` and templates `0x00010000`, while the plain user-table bit `0x00040000` is on neither. A flat +table can carry its own `LvProp`. Verified in `complex1.accdb` and `LIBRARY.accdb`. + +### Attachment payload — `FileData` + +An attachment's `FileData` is an OLE long value wrapping the file, with an 8-byte outer header, optional +deflate, and a 20-byte inner header naming the extension. Verified on a `pdf`, an `mp3` and a `png`, across +both storage modes, with the file's own magic number checked after the inner header: + +``` +outer header, 8 bytes + [0..3] compression flag: 1 = a zlib stream follows, 0 = raw bytes follow + [4..7] length of the body once decompressed + +body (inflate when the flag is 1 — the inflated length matched [4..7] exactly) + inner header, 20 bytes + [0..3] = 20, the inner header's own length + [4..7] = 1 (constant on every sample) + [8..11] = 4 (extension length in UTF-16 units; 12 + 8 = 20) + [12..19] extension, UTF-16, null-terminated — "pdf", "mp3", "png" + then the file's bytes verbatim +``` + +The stream is standard zlib (RFC 1950, opening `78 5E`), so `ZLibStream` reads it without skipping a header. +`FileFlags` and `FileTimeStamp` were `NULL` on every attachment Access wrote. + +> **Capability and policy are different here.** The *format* stores a payload either way — the outer flag +> says which, ACE reads both, and a long value's stored length runs to `0x3FFFFFFF` (ACE accepts that and +> rejects `0x40000000`). Everything below is what **Access** chooses on top of that: which extensions it +> deflates, its 256 MB per file — a quarter of what the format holds — its naming rules, and its +> blocked-extension list. A reader needs none of it; a writer wanting files that look like Access's does. +> +> **When Access compresses is an extension list, not a size rule.** Microsoft documents it on the +> [Attachment object](https://learn.microsoft.com/en-us/office/vba/api/access.attachment): *"Access will +> compress your attached files unless those files are compressed natively."* The listed exceptions are +> `.jpg .jpeg .gif .png .zip .cab .docx .xlsx .xlsb .pptx`; `.tif .exif .bmp .emf .wmf .ico` are compressed, +> and the table is explicitly partial, so anything absent from the exception list is compressed. The three +> measured samples agree exactly: `png` is on the list and was stored raw, while `pdf` (3,397,525 → +> 3,303,763) and `mp3` (5,480,476 → 5,442,917) are not on it and were deflated despite saving almost +> nothing — which a size rule would not explain. ACE honours the flag either way, so getting it wrong still +> reads; matching it is about writing what Access writes. +> +> The same page gives a writer three more limits: an individual file may not exceed **256 MB**, a name may +> not exceed **255 characters** including the extension, and a name may not contain `? " / \ < > * | :` or a +> paragraph mark. Access also blocks a long list of executable extensions (`.exe`, `.bat`, `.vbs`, `.mdb`, +> …) — that is an Access **application** policy, and whether the engine refuses them through DAO is +> untested here. -**To materialize** (if ever needed): read the row's `0x12` id → look up its column in `MSysComplexColumns` → -open `FlatTableID`'s `f_` table → select rows whose FK equals that id. All readable today by hand. +**To materialize**: read the row's `0x12` id → find the column's row in `MSysComplexColumns` (by `ComplexID` +from the descriptor's `0x0B`, or by name) → open `FlatTableID`'s `f_` table → select the rows whose +**owner-link** column equals that id. All readable today by hand; LibRed does not yet do it for you. diff --git a/test/LibRed.Core.AccessTests/ComplexDeleteCascadeProbeTest.cs b/test/LibRed.Core.AccessTests/ComplexDeleteCascadeProbeTest.cs new file mode 100644 index 00000000..09f41f0c --- /dev/null +++ b/test/LibRed.Core.AccessTests/ComplexDeleteCascadeProbeTest.cs @@ -0,0 +1,118 @@ +using System.Buffers.Binary; +using System.Reflection; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: what does ACE do to a complex column's stored values when the record that owns them is deleted? +// +// LibRed refuses to delete a row from a table with a complex column today (the index key encoding throws), so +// nothing depends on the answer yet — but it decides what the delete path must do before that refusal can be +// lifted. Three outcomes are possible and only one is guessable: the flat rows cascade away, they are left +// orphaned, or the id is recycled somehow. A writer that guesses wrong leaves a file ACE disagrees with. +// +// Only DAO can delete such a record: ACE's OLE DB and ODBC surfaces do not expose the complex column. +[Collection(AceCollection.Name)] +public class ComplexDeleteCascadeProbeTest(ITestOutputHelper output) +{ + private const string Source = @"D:\exampleaccdb\complex1.accdb"; + private const string Table = "Table1"; + + [Fact] + public void Probe_what_deleting_the_owning_record_does_to_its_values() + { + if (!File.Exists(Source)) { output.WriteLine($"PROBE skipped: {Source} not present."); return; } + + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("PROBE skipped: DAO unavailable in this process."); return; } + output.WriteLine($"PROBE DAO engine: {progId}"); + + string path = TemporaryDatabase.CopyPath(Source, "complex-delete-probe-"); + try + { + int? deleted = Dump(path, "BEFORE"); + if (deleted is not { } target) { output.WriteLine("PROBE: no record owns any value; nothing to delete."); return; } + + object db = Invoke(engine, "OpenDatabase", path)!; + try + { + object rs = Invoke(db, "OpenRecordset", $"SELECT * FROM [{Table}] WHERE ID = {target}")!; + try + { + if (Convert.ToBoolean(Get(rs, "EOF"))) { output.WriteLine($"PROBE: ID={target} not found."); return; } + output.WriteLine($"PROBE deleting {Table} record ID={target} …"); + Invoke(rs, "Delete"); + output.WriteLine("PROBE deleted"); + } + finally { Invoke(rs, "Close"); } + } + finally { Invoke(db, "Close"); } + + Dump(path, "AFTER"); + } + finally { TemporaryDatabase.Delete(path); } + } + + /// Prints every complex column's ids, counters and stored values. Returns the ID of a record + /// that actually owns at least one value, so the delete lands somewhere interesting. + private int? Dump(string path, string label) + { + using var db = JetDatabase.Open(path); + output.WriteLine($"PROBE --- {label} ---"); + + TableDef owner = db.Catalog.FindTable(Table)!; + ColumnDef idColumn = owner.Columns[0]; + int? withValues = null; + + foreach (ComplexColumn c in db.Catalog.ComplexColumns.Where(c => c.OwnerTable.Name == Table)) + { + ReadOnlySpan ownerPage = db.OpenTable(Table).Channel.ReadPageShared(owner.DefinitionPage).Span; + ReadOnlySpan flatPage = db.OpenTable(c.FlatTable.Name).Channel.ReadPageShared(c.FlatTable.DefinitionPage).Span; + output.WriteLine($"PROBE {Table}.{c.ColumnName}: owner 0x1C={BinaryPrimitives.ReadInt32LittleEndian(ownerPage[0x1C..])}" + + $" flat 0x14={BinaryPrimitives.ReadInt32LittleEndian(flatPage[0x14..])}"); + + ColumnDef inRow = owner.FindColumn(c.ColumnName)!; + var ids = new List(); + foreach (object?[] r in db.OpenTable(Table).Rows()) + ids.Add($"ID={r[idColumn.Index]}:cid={r[inRow.Index] ?? "NULL"}"); + output.WriteLine($"PROBE records: {string.Join(" ", ids)}"); + + int fileName = c.ValueColumns.ToList().FindIndex(v => v.Name == "FileName"); + foreach (object?[] flat in db.OpenTable(c.FlatTable.Name).Rows()) + { + int link = Convert.ToInt32(flat[c.OwnerLink.Index]); + string what = fileName >= 0 ? $"{c.ValueColumns[fileName].Name}={flat[c.ValueColumns[fileName].Index]}" : ""; + output.WriteLine($"PROBE value: ownerLink={link} valueId={flat[c.ValueId.Index]} {what}"); + + // Prefer a record that still exists and owns something. + foreach (object?[] r in db.OpenTable(Table).Rows()) + if (r[inRow.Index] is int cid && cid == link) + withValues ??= Convert.ToInt32(r[idColumn.Index]); + } + } + return withValues; + } + + private static object? Get(object target, string member) => + target.GetType().InvokeMember(member, BindingFlags.GetProperty, null, target, null); + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); + + private static object? CreateDbEngine(out string progId) + { + AceTestDatabase.ReleaseAbandonedComObjects(); + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + progId = $"DAO.DBEngine.{n}"; + Type? type = Type.GetTypeFromProgID(progId); + if (type is null) continue; + try { return Activator.CreateInstance(type); } + catch (Exception) { /* registered but not instantiable in this bitness */ } + } + progId = "(none)"; + return null; + } +} diff --git a/test/LibRed.Core.AccessTests/ComplexDuplicateValueProbeTest.cs b/test/LibRed.Core.AccessTests/ComplexDuplicateValueProbeTest.cs new file mode 100644 index 00000000..8c5de758 --- /dev/null +++ b/test/LibRed.Core.AccessTests/ComplexDuplicateValueProbeTest.cs @@ -0,0 +1,130 @@ +using System.Reflection; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: is IdxFKPrimaryScalar enforced? A multi-value column's flat table carries a UNIQUE index over +// [ownerLink + Value], which would mean one record cannot hold the same value twice. The corpus cannot +// answer it — the duplicate values it contains sit on *different* records, which the composite key allows. +// +// Only DAO can reach a complex column: ACE's OLE DB and ODBC surfaces do not expose the child recordset a +// multi-value field resolves to, so there is no way to add a value over them. +[Collection(AceCollection.Name)] +public class ComplexDuplicateValueProbeTest(ITestOutputHelper output) +{ + private const string Source = @"D:\exampleaccdb\PasesDeSalidas.accdb"; + private const string Table = "PasesDeSalida"; + private const string Column = "Acompaña"; + + [Fact] + public void Probe_whether_a_record_may_hold_the_same_value_twice() + { + if (!File.Exists(Source)) { output.WriteLine($"PROBE skipped: {Source} not present."); return; } + + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("PROBE skipped: DAO unavailable in this process."); return; } + output.WriteLine($"PROBE DAO engine: {progId}"); + + // ACE writes to anything it opens, so never the source. + string path = TemporaryDatabase.CopyPath(Source, "complex-dup-probe-"); + try + { + object db = Invoke(engine, "OpenDatabase", path)!; + try + { + object rs = Invoke(db, "OpenRecordset", Table)!; + try + { + // Walk to the first record whose multi-value set already holds something. + int scanned = 0; + while (!Convert.ToBoolean(Get(rs, "EOF")) && scanned < 200) + { + scanned++; + object child = Get(Fields(rs, Column), "Value")!; + var existing = new List(); + while (!Convert.ToBoolean(Get(child, "EOF"))) + { + existing.Add(Get(Fields(child, "Value"), "Value")); + Invoke(child, "MoveNext"); + } + + if (existing.Count == 0) { Invoke(rs, "MoveNext"); continue; } + + output.WriteLine($"PROBE record #{scanned} holds [{string.Join(", ", existing)}]"); + object? duplicate = existing[0]; + + output.WriteLine($"PROBE adding a DUPLICATE of {duplicate} …"); + output.WriteLine("PROBE " + TryAdd(rs, child, duplicate)); + + object fresh = Convert.ToInt32(duplicate) + 9999; + output.WriteLine($"PROBE adding a FRESH value {fresh} (control) …"); + output.WriteLine("PROBE " + TryAdd(rs, child, fresh)); + break; + } + } + finally { Invoke(rs, "Close"); } + } + finally { Invoke(db, "Close"); } + + // What actually landed, read back through LibRed. + using var lib = JetDatabase.Open(path); + foreach (TableDef t in lib.Catalog.Tables.Where(t => t.Name.StartsWith("f_", StringComparison.Ordinal))) + { + ColumnDef? link = t.Indexes.FirstOrDefault(i => !i.IsUnique && !i.IsPrimaryKey && i.Columns.Count == 1)?.Columns[0].Column; + ColumnDef? value = t.FindColumn("Value"); + if (link is null || value is null) continue; + foreach (object?[] r in lib.OpenTable(t.Name).Rows()) + output.WriteLine($"PROBE flat {t.Name}: ownerLink={r[link.Index]} Value={r[value.Index]}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + /// Adds to the record's child recordset, reporting what DAO said. + private static string TryAdd(object rs, object child, object? value) + { + try + { + Invoke(rs, "Edit"); + Invoke(child, "AddNew"); + Set(Fields(child, "Value"), "Value", value); + Invoke(child, "Update"); + Invoke(rs, "Update"); + return "ACCEPTED"; + } + catch (TargetInvocationException ex) when (ex.InnerException is { } inner) + { + return $"REJECTED {inner.GetType().Name}: {inner.Message.ReplaceLineEndings(" ")}"; + } + catch (Exception ex) { return $"REJECTED {ex.GetType().Name}: {ex.Message.ReplaceLineEndings(" ")}"; } + } + + private static object Fields(object target, string name) => + target.GetType().InvokeMember("Fields", BindingFlags.GetProperty, null, target, [name])!; + + private static object? Get(object target, string member) => + target.GetType().InvokeMember(member, BindingFlags.GetProperty, null, target, null); + + private static void Set(object target, string member, object? value) => + target.GetType().InvokeMember(member, BindingFlags.SetProperty, null, target, [value]); + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); + + private static object? CreateDbEngine(out string progId) + { + AceTestDatabase.ReleaseAbandonedComObjects(); + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + progId = $"DAO.DBEngine.{n}"; + Type? type = Type.GetTypeFromProgID(progId); + if (type is null) continue; + try { return Activator.CreateInstance(type); } + catch (Exception) { /* registered but not instantiable in this bitness */ } + } + progId = "(none)"; + return null; + } +} diff --git a/test/LibRed.Core.Tests/ComplexColumnTests.cs b/test/LibRed.Core.Tests/ComplexColumnTests.cs new file mode 100644 index 00000000..a7fc79e2 --- /dev/null +++ b/test/LibRed.Core.Tests/ComplexColumnTests.cs @@ -0,0 +1,171 @@ +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +/// +/// Resolving a complex (multi-value / attachment) column to the values it stands for. Northwind carries one: +/// MSysResources.Data, the attachment column holding Access's Office theme — so this needs no ACE and +/// runs everywhere. +/// +public class ComplexColumnTests +{ + private static JetDatabase Northwind() => TemporaryDatabase.OpenTracked( + TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "complex-"), readOnly: true); + + private static ComplexColumn TheAttachmentColumn(JetDatabase db) + { + ComplexColumn? column = db.Catalog.FindComplexColumn("MSysResources", "Data"); + Assert.NotNull(column); + return column; + } + + [Fact] + public void The_catalog_resolves_the_complex_column_to_its_flat_table() + { + using JetDatabase db = Northwind(); + ComplexColumn column = TheAttachmentColumn(db); + + Assert.Equal("Data", column.ColumnName); + Assert.Equal("MSysResources", column.OwnerTable.Name); + Assert.StartsWith("f_", column.FlatTable.Name, StringComparison.Ordinal); + Assert.Equal("MSysComplexType_Attachment", column.ElementTypeName); + Assert.True(column.IsAttachment); + + // The two bookkeeping columns are found by index shape, and are not each other. + Assert.NotEqual(column.OwnerLink.Name, column.ValueId.Name); + Assert.DoesNotContain(column.OwnerLink, column.ValueColumns); + Assert.DoesNotContain(column.ValueId, column.ValueColumns); + // What is left is the attachment template's own six columns. + Assert.Equal( + ["FileData", "FileFlags", "FileName", "FileTimeStamp", "FileType", "FileURL"], + column.ValueColumns.Select(c => c.Name).Order(StringComparer.Ordinal)); + } + + // The value-id column carries the primary index and is an ordinary AutoNumber; the owner link is the + // non-unique one. Getting these the wrong way round returns one value per record and silently loses + // every multi-valued case, so it is worth asserting directly. + [Fact] + public void The_value_id_is_the_primary_autonumber_and_the_owner_link_is_not() + { + using JetDatabase db = Northwind(); + ComplexColumn column = TheAttachmentColumn(db); + + Assert.True(column.ValueId.IsAutoNumber); + Assert.False(column.OwnerLink.IsAutoNumber); + Assert.Contains(column.FlatTable.Indexes, + i => i.IsPrimaryKey && i.Columns.Count == 1 && i.Columns[0].Column.Name == column.ValueId.Name); + } + + // The in-row value is the record's Int32 complex id — not a long-value descriptor, which is what it used + // to decode as. + [Fact] + public void The_in_row_value_is_an_int32_id() + { + using JetDatabase db = Northwind(); + ComplexColumn column = TheAttachmentColumn(db); + ColumnDef inRow = column.OwnerTable.FindColumn("Data")!; + + object?[] row = db.OpenTable("MSysResources").Rows().First(); + Assert.IsType(row[inRow.Index]); + } + + [Fact] + public void Reading_a_records_values_gives_the_attachment() + { + using JetDatabase db = Northwind(); + ComplexColumn column = TheAttachmentColumn(db); + ColumnDef inRow = column.OwnerTable.FindColumn("Data")!; + + object?[] record = db.OpenTable("MSysResources").Rows().First(); + int complexId = (int)record[inRow.Index]!; + + IReadOnlyList values = db.ReadComplexValues(column, complexId); + object?[] only = Assert.Single(values); + + int fileName = column.ValueColumns.ToList().FindIndex(c => c.Name == "FileName"); + int fileData = column.ValueColumns.ToList().FindIndex(c => c.Name == "FileData"); + Assert.EndsWith(".thmx", (string)only[fileName]!, StringComparison.OrdinalIgnoreCase); + + // The payload unwraps to the real file: a .thmx is a zip, so it opens "PK". + ComplexAttachment attachment = ComplexAttachment.Unwrap((byte[])only[fileData]!); + Assert.Equal("thmx", attachment.Extension); + Assert.Equal([(byte)'P', (byte)'K'], attachment.Content.AsSpan(0, 2).ToArray()); + } + + // An id that names no values is the ordinary answer, not an error: the id is allocated when the row is + // created, whether or not a value ever follows. + [Fact] + public void A_record_with_no_values_reads_as_empty() + { + using JetDatabase db = Northwind(); + Assert.Empty(db.ReadComplexValues("MSysResources", "Data", complexId: 999_999)); + } + + // A complex column carries the descriptor's 0x04 AutoNumber flag, so a table can have several columns + // reading IsAutoNumber at once — MSysResources has two, and complex1.accdb's Table1 has five. Only the + // non-complex one draws on the TDEF header's seed/increment pair (0x14/0x18); a complex column is + // allocated from 0x1C, so it must not report the other counter's configuration as its own. + [Fact] + public void A_complex_column_is_flagged_autonumber_but_does_not_claim_the_header_counter() + { + using JetDatabase db = Northwind(); + TableDef table = db.Catalog.FindTable("MSysResources")!; + ColumnDef data = table.FindColumn("Data")!; + ColumnDef id = table.FindColumn("Id")!; + + Assert.Equal(JetDataType.Complex, data.Type); + Assert.True(data.IsAutoNumber); + Assert.True(id.IsAutoNumber); + + // The ordinary counter reports the header pair; the complex column reports neither. + Assert.Equal(2, id.Seed); + Assert.Equal(1, data.Seed); + Assert.Equal(1, data.Increment); + + // Its high-water lives on the table instead. + Assert.Equal(1, table.ComplexAutoNumber); + } + + // The rebuild reconstructs every ColumnSpec from the live ColumnDefs, so it sees both flagged columns. + // Counting a complex column as a second claimant of the one header counter made this throw outright. + [Fact] + public void A_table_with_both_counters_can_still_be_rebuilt() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "complex-alter-"); + try + { + // Text -> Memo is a storage-type change, which takes the full logical rebuild. + using (var db = JetDatabase.Open(path, readOnly: false)) + db.AlterColumn("MSysResources", "Name", + new ColumnSpec("Name", JetDataType.Memo, 0, IsFixedLength: false)); + + using var reopened = JetDatabase.Open(path); + TableDef table = reopened.Catalog.FindTable("MSysResources")!; + + Assert.Equal(JetDataType.Memo, table.FindColumn("Name")!.Type); + // Both counters survive the rewrite: the header pair still describes Id, and 0x1C is carried through. + Assert.Equal(2, table.FindColumn("Id")!.Seed); + Assert.True(table.FindColumn("Data")!.IsAutoNumber); + Assert.Equal(1, table.ComplexAutoNumber); + Assert.Single(reopened.OpenTable("MSysResources").Rows()); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void A_column_that_is_not_complex_is_rejected() + { + using JetDatabase db = Northwind(); + Assert.Null(db.Catalog.FindComplexColumn("Customers", "CustomerID")); + Assert.Throws(() => db.ReadComplexValues("Customers", "CustomerID", 1)); + } + + [Theory] + [InlineData(new byte[] { 1, 2, 3 })] // shorter than the outer header + [InlineData(new byte[] { 9, 0, 0, 0, 4, 0, 0, 0, 1, 2, 3, 4 })] // unknown compression flag + [InlineData(new byte[] { 0, 0, 0, 0, 99, 0, 0, 0, 1, 2, 3, 4 })] // body shorter than declared + public void Malformed_attachment_data_is_rejected(byte[] blob) => + Assert.Throws(() => ComplexAttachment.Unwrap(blob)); +} diff --git a/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs b/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs index 0f756f74..6515e61f 100644 --- a/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs +++ b/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs @@ -137,11 +137,21 @@ public void An_over_long_value_is_refused_on_a_fixed_column_too() Assert.Throws(() => JetTypeCodec.Encode(binary, new byte[] { 1, 2, 3, 4 })).Message); } + // Complex used to stand in for "unsupported" here. It no longer is — its four bytes are an Int32 complex + // id and LibRed both reads and writes them — so the example is now one of the genuinely unmodelled codes. [Fact] public void Unsupported_encoding_reports_the_column_type() { var error = Assert.Throws(() => - JetTypeCodec.Encode(Column(JetDataType.Complex), new object())); - Assert.Contains(nameof(JetDataType.Complex), error.Message); + JetTypeCodec.Encode(Column(JetDataType.Unknown11), new object())); + Assert.Contains(nameof(JetDataType.Unknown11), error.Message); + } + + [Fact] + public void A_complex_id_round_trips_as_an_int32() + { + ColumnDef column = Column(JetDataType.Complex, length: 4, fixedLength: true); + Assert.Equal(new byte[] { 0x2A, 0, 0, 0 }, JetTypeCodec.Encode(column, 42)); + Assert.Equal(42, JetTypeCodec.Decode(column, JetDataType.Complex, new byte[] { 0x2A, 0, 0, 0 })); } } diff --git a/test/LibRed.Engine.AccessTests/ComplexAttachmentWriteAccessTests.cs b/test/LibRed.Engine.AccessTests/ComplexAttachmentWriteAccessTests.cs new file mode 100644 index 00000000..c1baff6b --- /dev/null +++ b/test/LibRed.Engine.AccessTests/ComplexAttachmentWriteAccessTests.cs @@ -0,0 +1,144 @@ +using System.Reflection; +using System.Text; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// LibRed attaching a file to an attachment column, with ACE asked to read it back through DAO — the only +/// surface that exposes a complex column's values at all. +/// +[Collection(AceCollection.Name)] +public class ComplexAttachmentWriteAccessTests(ITestOutputHelper output) +{ + private const string Source = @"D:\exampleaccdb\complex1.accdb"; + + // Both sides of the documented compression rule: a .txt is absent from the natively-compressed list so + // Access deflates it, while a .png is on the list and is stored as it is. ACE has to extract either. + [Theory] + [InlineData("libred-note.txt", true)] + [InlineData("libred-note.png", false)] + public void Ace_reads_an_attachment_libred_wrote(string fileName, bool expectCompressed) + { + if (!File.Exists(Source)) { output.WriteLine($"Skipped: {Source} not present."); return; } + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("Skipped: DAO unavailable."); return; } + + byte[] content = Encoding.UTF8.GetBytes("LibRed wrote this attachment.\n" + new string('x', 5000)); + string FileName = fileName; + Assert.Equal(expectCompressed, ComplexAttachment.Compresses(Path.GetExtension(fileName).TrimStart('.'))); + + string path = TemporaryDatabase.CopyPath(Source, "attach-write-"); + try + { + int recordKey, complexId; + string columnName; + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + ComplexColumn column = db.Catalog.ComplexColumns + .First(c => c.OwnerTable.Name == "Table1" && c.IsAttachment); + columnName = column.ColumnName; + TableDef owner = column.OwnerTable; + ColumnDef inRow = owner.FindColumn(column.ColumnName)!; + + object?[] record = db.OpenTable("Table1").Rows().First(); + complexId = (int)record[inRow.Index]!; + recordKey = Convert.ToInt32(record[owner.Columns[0].Index]); + + int before = db.ReadComplexValues(column, complexId).Count; + db.AddAttachment(column, complexId, FileName, content); + Assert.Equal(before + 1, db.ReadComplexValues(column, complexId).Count); + + // A record may not hold the same file twice — ACE refuses it, so LibRed must too. + Assert.ThrowsAny(() => db.AddAttachment(column, complexId, FileName, content)); + output.WriteLine($"wrote {FileName} ({content.Length} bytes) to Table1.{columnName} record {recordKey}"); + } + + // LibRed reads its own value back through the payload decoder. + using (var db = JetDatabase.Open(path)) + { + ComplexColumn column = db.Catalog.FindComplexColumn("Table1", columnName)!; + int name = column.ValueColumns.ToList().FindIndex(c => c.Name == "FileName"); + int data = column.ValueColumns.ToList().FindIndex(c => c.Name == "FileData"); + object?[] written = db.ReadComplexValues(column, complexId).Single(v => (string?)v[name] == FileName); + byte[] blob = (byte[])written[data]!; + + // The outer flag says which form was stored, and it must follow the documented rule. + Assert.Equal(expectCompressed ? 1u : 0u, BitConverter.ToUInt32(blob, 0)); + output.WriteLine($"stored {(expectCompressed ? "deflated" : "raw")}: blob {blob.Length} bytes for {content.Length} of content"); + + ComplexAttachment unwrapped = ComplexAttachment.Unwrap(blob); + Assert.Equal(Path.GetExtension(FileName).TrimStart('.'), unwrapped.Extension); + Assert.Equal(content, unwrapped.Content); + } + + // And the real question: does ACE agree the attachment is there, with the right bytes? + object dao = Invoke(engine, "OpenDatabase", path)!; + try + { + object rs = Invoke(dao, "OpenRecordset", + $"SELECT * FROM [Table1] WHERE [{ /* pk */ "ID"}] = {recordKey}")!; + try + { + object child = Get(Fields(rs, columnName), "Value")!; + var seen = new List(); + long size = -1; + string extracted = Path.Combine(Path.GetTempPath(), $"libred-extract-{Guid.NewGuid():N}.txt"); + while (!Convert.ToBoolean(Get(child, "EOF"))) + { + string name = (string)Get(Fields(child, "FileName"), "Value")!; + seen.Add(name); + if (name == FileName) + { + size = Convert.ToInt64(Get(Fields(child, "FileData"), "FieldSize")); + // The real proof: ACE extracting the file it was handed. + Invoke(Fields(child, "FileData"), "SaveToFile", extracted); + } + Invoke(child, "MoveNext"); + } + output.WriteLine($"ACE sees attachments: [{string.Join(", ", seen)}] FileData size {size}"); + Assert.Contains(FileName, seen); + + Assert.True(File.Exists(extracted), "ACE did not extract the attachment."); + try + { + byte[] saved = File.ReadAllBytes(extracted); + output.WriteLine($"ACE extracted {saved.Length} bytes (LibRed wrote {content.Length})"); + Assert.Equal(content, saved); + } + finally { File.Delete(extracted); } + } + finally { Invoke(rs, "Close"); } + } + finally { Invoke(dao, "Close"); } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static object Fields(object target, string name) => + target.GetType().InvokeMember("Fields", BindingFlags.GetProperty, null, target, [name])!; + + private static object? Get(object target, string member) => + target.GetType().InvokeMember(member, BindingFlags.GetProperty, null, target, null); + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); + + private static object? CreateDbEngine(out string progId) + { + AceTestDatabase.ReleaseAbandonedComObjects(); + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + progId = $"DAO.DBEngine.{n}"; + Type? type = Type.GetTypeFromProgID(progId); + if (type is null) continue; + try { return Activator.CreateInstance(type); } + catch (Exception) { /* registered but not instantiable in this bitness */ } + } + progId = "(none)"; + return null; + } +} diff --git a/test/LibRed.Engine.AccessTests/ComplexDeleteByteParityProbeTest.cs b/test/LibRed.Engine.AccessTests/ComplexDeleteByteParityProbeTest.cs new file mode 100644 index 00000000..32349a90 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/ComplexDeleteByteParityProbeTest.cs @@ -0,0 +1,437 @@ +using System.Buffers.Binary; +using System.Reflection; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Engine.Tests; + +// PROBE: does LibRed's delete of a record owning complex values leave the same bytes ACE's does? +// +// ACE writes to any file it merely opens, so a straight A-vs-B diff cannot tell its delete from its +// housekeeping. Four copies separate them: +// +// orig untouched reference +// ACE DAO opens it, deletes the record, closes = housekeeping + delete +// noise DAO opens it and closes, deleting nothing = housekeeping alone +// LibRed LibRed deletes the same record = delete alone +// +// The pages `noise` changes are ACE's own; the interesting comparison is what ACE and LibRed each did to the +// pages outside that set. +[Collection(AceCollection.Name)] +public class ComplexDeleteByteParityProbeTest(ITestOutputHelper output) +{ + private const string Source = @"D:\exampleaccdb\complex1.accdb"; + + [Fact] + public void Probe_byte_parity_of_a_complex_delete() + { + if (!File.Exists(Source)) { output.WriteLine($"PROBE skipped: {Source} not present."); return; } + + string where; + using (var db = JetDatabase.Open(TemporaryDatabase.CopyPath(Source, "cx-parity-pick-"))) + { + TableDef table = db.Catalog.FindTable("Table1")!; + ComplexColumn any = db.Catalog.ComplexColumns.First(c => c.OwnerTable.Name == "Table1"); + int recordId = db.OpenTable(any.FlatTable.Name).Rows() + .Select(r => Convert.ToInt32(r[any.OwnerLink.Index])).Distinct().First(); + int key = db.OpenTable("Table1").Rows() + .Where(r => Convert.ToInt32(r[table.FindColumn(any.ColumnName)!.Index]) == recordId) + .Select(r => Convert.ToInt32(r[table.Columns[0].Index])).First(); + where = $"[{table.Columns[0].Name}] = {key}"; + output.WriteLine($"PROBE complex case: Table1 WHERE {where} (complex id {recordId})"); + } + Compare(Source, "Table1", where, "complex"); + } + + // The control: the same comparison on a table with NO complex column. If the differences look the same, + // they belong to LibRed's ordinary delete rather than to anything complex-specific. + [Fact] + public void Probe_byte_parity_of_an_ordinary_delete() + { + output.WriteLine("PROBE control case: [Order Details] — no complex column"); + Compare(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), + "Order Details", "OrderID = 10248 AND ProductID = 11", "control"); + } + + // Does ACE *increment* the per-index total-entry count on insert? It decrements on delete (above), and + // RowInserter's comment says Access does not maintain the total live. If it only ever decremented, the + // count would drift down — so the insert side decides whether LibRed should touch it at all. + [Fact] + public void Probe_entry_counts_on_insert() + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("PROBE skipped: DAO unavailable."); return; } + output.WriteLine($"PROBE DAO engine: {progId}"); + + string northwind = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); + string orig = TemporaryDatabase.CopyPath(northwind, "ins-parity-orig-"); + string aceCopy = TemporaryDatabase.CopyPath(northwind, "ins-parity-ace-"); + string libredCopy = TemporaryDatabase.CopyPath(northwind, "ins-parity-libred-"); + try + { + const string Insert = "INSERT INTO [Order Details] (OrderID, ProductID, UnitPrice, Quantity, Discount) " + + "VALUES (10248, 1, 1, 1, 0)"; + object db = Invoke(engine, "OpenDatabase", aceCopy)!; + try { Invoke(db, "Execute", Insert); } + finally { Invoke(db, "Close"); } + + using (var d = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(d).ExecuteNonQuery(Insert); + + int page; + using (var d = JetDatabase.Open(orig)) page = d.Catalog.FindTable("Order Details")!.DefinitionPage; + + byte[] o = File.ReadAllBytes(orig), a = File.ReadAllBytes(aceCopy), l = File.ReadAllBytes(libredCopy); + const int PageSize = 4096; + var so = o.AsSpan(page * PageSize, PageSize); + var sa = a.AsSpan(page * PageSize, PageSize); + var sl = l.AsSpan(page * PageSize, PageSize); + + output.WriteLine($"PROBE rowCount(0x10) orig={I(so, 0x10)} ace={I(sa, 0x10)} libred={I(sl, 0x10)}"); + int realIndexes = I(so, 0x33); + for (int i = 0; i < realIndexes; i++) + { + int at = 0x3F + i * 12; + output.WriteLine($"PROBE index {i} @0x{at:X2}: total orig={I(so, at)} ace={I(sa, at)} libred={I(sl, at)}" + + $" | unique orig={I(so, at + 4)} ace={I(sa, at + 4)} libred={I(sl, at + 4)}"); + } + } + finally + { + foreach (string f in new[] { orig, aceCopy, libredCopy }) TemporaryDatabase.Delete(f); + } + } + + // Before any parity question: LibRed reuses page 329 (a MSysNavPaneObjectIDs data page) as a new index + // leaf where ACE extends the file instead. If that page is NOT actually free, this is data loss, not a + // difference of allocation policy. Compare every table's rows before and after, on both engines. + [Fact] + public void Probe_that_a_reused_page_was_really_free() + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("PROBE skipped: DAO unavailable."); return; } + output.WriteLine($"PROBE [reuse] DAO engine: {progId}"); + + string northwind = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); + string orig = TemporaryDatabase.CopyPath(northwind, "reuse-orig-"); + string aceCopy = TemporaryDatabase.CopyPath(northwind, "reuse-ace-"); + string libredCopy = TemporaryDatabase.CopyPath(northwind, "reuse-libred-"); + try + { + const string Insert = "INSERT INTO [Order Details] (OrderID, ProductID, UnitPrice, Quantity, Discount) " + + "VALUES (10248, 1, 1, 1, 0)"; + object db = Invoke(engine, "OpenDatabase", aceCopy)!; + try { Invoke(db, "Execute", Insert); } + finally { Invoke(db, "Close"); } + + using (var d = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(d).ExecuteNonQuery(Insert); + + Dictionary before = RowCounts(orig), afterAce = RowCounts(aceCopy), + afterLibred = RowCounts(libredCopy); + + int mismatches = 0; + foreach ((string name, int n) in before.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + afterAce.TryGetValue(name, out int ace); + afterLibred.TryGetValue(name, out int lib); + if (n == ace && n == lib) continue; + mismatches++; + output.WriteLine($"PROBE [{name}] rows: orig={n} ace={ace} libred={lib}"); + } + output.WriteLine(mismatches == 0 + ? $"PROBE all {before.Count} tables unchanged in row count on both engines" + : $"PROBE {mismatches} table(s) differ"); + + // And does ACE itself accept the file LibRed wrote? + object check = Invoke(engine, "OpenDatabase", libredCopy)!; + try + { + object rs = Invoke(check, "OpenRecordset", "SELECT COUNT(*) FROM [MSysNavPaneObjectIDs]")!; + try { output.WriteLine($"PROBE ACE reads libred's MSysNavPaneObjectIDs: {Get(Fields(rs, 0), "Value")}"); } + finally { Invoke(rs, "Close"); } + } + finally { Invoke(check, "Close"); } + } + finally + { + foreach (string f in new[] { orig, aceCopy, libredCopy }) TemporaryDatabase.Delete(f); + } + } + + private static Dictionary RowCounts(string path) + { + using var db = JetDatabase.Open(path); + var counts = new Dictionary(StringComparer.Ordinal); + foreach (TableDef t in db.Catalog.Tables) + { + try { counts[t.Name] = db.OpenTable(t.Name).Rows().Count(); } + catch (Exception ex) { counts[t.Name] = -1; Console.WriteLine($"{t.Name}: {ex.Message}"); } + } + return counts; + } + + private static object Fields(object target, object name) => + target.GetType().InvokeMember("Fields", BindingFlags.GetProperty, null, target, [name])!; + + private static object? Get(object target, string member) => + target.GetType().InvokeMember(member, BindingFlags.GetProperty, null, target, null); + + private void Compare(string source, string table, string where, string label) + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("PROBE skipped: DAO unavailable."); return; } + output.WriteLine($"PROBE [{label}] DAO engine: {progId}"); + + string orig = TemporaryDatabase.CopyPath(source, $"{label}-parity-orig-"); + string aceCopy = TemporaryDatabase.CopyPath(source, $"{label}-parity-ace-"); + string noiseCopy = TemporaryDatabase.CopyPath(source, $"{label}-parity-noise-"); + string libredCopy = TemporaryDatabase.CopyPath(source, $"{label}-parity-libred-"); + try + { + DaoDelete(engine, aceCopy, table, where); + DaoOpenClose(engine, noiseCopy, table); + using (var db = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery($"DELETE FROM [{table}] WHERE {where}"); + + Report(label, File.ReadAllBytes(orig), File.ReadAllBytes(aceCopy), + File.ReadAllBytes(noiseCopy), File.ReadAllBytes(libredCopy), Owners(orig)); + } + finally + { + foreach (string f in new[] { orig, aceCopy, noiseCopy, libredCopy }) TemporaryDatabase.Delete(f); + } + } + + // The same four-copy comparison for an INSERT. Only the TDEF entry counters had been compared before, + // which says nothing about the data or index pages the insert writes. + [Fact] + public void Probe_byte_parity_of_an_ordinary_insert() + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("PROBE skipped: DAO unavailable."); return; } + output.WriteLine($"PROBE [insert] DAO engine: {progId}"); + + string northwind = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); + string orig = TemporaryDatabase.CopyPath(northwind, "insp-orig-"); + string aceCopy = TemporaryDatabase.CopyPath(northwind, "insp-ace-"); + string noiseCopy = TemporaryDatabase.CopyPath(northwind, "insp-noise-"); + string libredCopy = TemporaryDatabase.CopyPath(northwind, "insp-libred-"); + try + { + const string Insert = "INSERT INTO [Order Details] (OrderID, ProductID, UnitPrice, Quantity, Discount) " + + "VALUES (10248, 1, 1, 1, 0)"; + object db = Invoke(engine, "OpenDatabase", aceCopy)!; + try { Invoke(db, "Execute", Insert); } + finally { Invoke(db, "Close"); } + + DaoOpenClose(engine, noiseCopy, "Order Details"); + + using (var d = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(d).ExecuteNonQuery(Insert); + + Report("insert", File.ReadAllBytes(orig), File.ReadAllBytes(aceCopy), + File.ReadAllBytes(noiseCopy), File.ReadAllBytes(libredCopy), Owners(orig)); + } + finally + { + foreach (string f in new[] { orig, aceCopy, noiseCopy, libredCopy }) TemporaryDatabase.Delete(f); + } + } + + /// Maps each table's TDEF page to its name, so a data/index page can be named by its owner + /// (bytes 0x04 of a `0x01`/`0x03`/`0x04` page point at the owning TDEF). + private static Dictionary Owners(string path) + { + using var db = JetDatabase.Open(path); + var map = new Dictionary(); + foreach (TableDef t in db.Catalog.Tables) map[t.DefinitionPage] = t.Name; + return map; + } + + private static string Describe(byte[] file, int page, Dictionary owners) + { + const int PageSize = 4096; + if ((page + 1) * PageSize > file.Length) return "(absent)"; + var span = file.AsSpan(page * PageSize, PageSize); + byte type = span[0]; + string kind = type switch + { + 0x00 => "db-header", 0x01 => "data", 0x02 => "TDEF", 0x03 => "index-node", + 0x04 => "index-leaf", 0x05 => "usage-map", 0x08 => "long-value", _ => $"?0x{type:X2}", + }; + if (type is 0x01 or 0x03 or 0x04) + { + int tdef = BinaryPrimitives.ReadInt32LittleEndian(span[0x04..]); + string name = owners.TryGetValue(tdef, out string? t) ? t : $"tdef@{tdef}"; + return $"{kind} of [{name}]"; + } + if (type == 0x02 && owners.TryGetValue(page, out string? own)) return $"{kind} [{own}]"; + if (span.TrimStart((byte)0).IsEmpty) return "all-zero"; + return kind; + } + + private void Report(string label, byte[] o, byte[] a, byte[] n, byte[] l, Dictionary owners) + { + { + output.WriteLine($"PROBE [{label}] sizes: orig={o.Length} ace={a.Length} noise={n.Length} libred={l.Length}" + + $" (pages: orig={o.Length / 4096} ace={a.Length / 4096} libred={l.Length / 4096})"); + + const int PageSize = 4096; + HashSet acePages = ChangedPages(o, a, PageSize), noisePages = ChangedPages(o, n, PageSize), + libredPages = ChangedPages(o, l, PageSize); + + output.WriteLine($"PROBE pages changed: ace={acePages.Count} noise={noisePages.Count} libred={libredPages.Count}"); + output.WriteLine($"PROBE ace-only (minus housekeeping): [{Join(acePages.Except(noisePages))}]"); + output.WriteLine($"PROBE libred: [{Join(libredPages)}]"); + output.WriteLine($"PROBE housekeeping (noise): [{Join(noisePages)}]"); + + var aceReal = acePages.Except(noisePages).ToHashSet(); + output.WriteLine($"PROBE libred touched but ACE did not: [{Join(libredPages.Except(aceReal))}]"); + output.WriteLine($"PROBE ACE touched but libred did not: [{Join(aceReal.Except(libredPages))}]"); + + // What every page in play actually is, on each side — a page number alone says nothing. + output.WriteLine("PROBE page inventory (orig -> ace / libred):"); + foreach (int page in aceReal.Union(libredPages).Order()) + output.WriteLine($"PROBE {page,5}: was {Describe(o, page, owners),-28}" + + $" ace={Describe(a, page, owners),-28} libred={Describe(l, page, owners)}"); + + // For the pages both changed, are the resulting bytes identical — and where not, what exactly? + foreach (int page in aceReal.Intersect(libredPages).Order()) + { + var origSpan = o.AsSpan(page * PageSize, PageSize); + var aceSpan = a.AsSpan(page * PageSize, PageSize); + var libSpan = l.AsSpan(page * PageSize, PageSize); + if (aceSpan.SequenceEqual(libSpan)) { output.WriteLine($"PROBE page {page}: IDENTICAL"); continue; } + + // Branch on what the page BECAME, not what it was: a recycled page changes type, and the + // interesting structure is the new one. + byte type = aceSpan[0]; + output.WriteLine($"PROBE page {page}: was=0x{origSpan[0]:X2} now=0x{type:X2}" + + $" ace-vs-libred differs in {Ranges(aceSpan, libSpan).Split(' ').Length} run(s)"); + + if (type == 0x02) // TDEF — the per-index entry counters + { + int realIndexes = BinaryPrimitives.ReadInt32LittleEndian(origSpan[0x33..]); + output.WriteLine($"PROBE rowCount(0x10) orig={I(origSpan, 0x10)} ace={I(aceSpan, 0x10)} libred={I(libSpan, 0x10)}"); + for (int i = 0; i < realIndexes && 0x3F + i * 12 + 8 <= PageSize; i++) + { + int at = 0x3F + i * 12; + if (I(origSpan, at) == I(aceSpan, at) && I(origSpan, at + 4) == I(aceSpan, at + 4) + && I(origSpan, at) == I(libSpan, at) && I(origSpan, at + 4) == I(libSpan, at + 4)) continue; + output.WriteLine($"PROBE index {i} @0x{at:X2}: total orig={I(origSpan, at)} ace={I(aceSpan, at)} libred={I(libSpan, at)}" + + $" | unique orig={I(origSpan, at + 4)} ace={I(aceSpan, at + 4)} libred={I(libSpan, at + 4)}"); + } + } + else if (type is 0x03 or 0x04) // index page — free space, and where each side starts to diverge + { + output.WriteLine($"PROBE freeSpace(0x02) orig={U(origSpan, 0x02)} ace={U(aceSpan, 0x02)} libred={U(libSpan, 0x02)}"); + output.WriteLine($"PROBE first diff vs orig: ace @0x{FirstDiff(origSpan, aceSpan):X3}" + + $" libred @0x{FirstDiff(origSpan, libSpan):X3} ace-vs-libred @0x{FirstDiff(aceSpan, libSpan):X3}"); + output.WriteLine($"PROBE last diff vs orig: ace @0x{LastDiff(origSpan, aceSpan):X3}" + + $" libred @0x{LastDiff(origSpan, libSpan):X3} ace-vs-libred @0x{LastDiff(aceSpan, libSpan):X3}"); + int at = Math.Max(0, FirstDiff(aceSpan, libSpan) - 8); + output.WriteLine($"PROBE @0x{at:X3} orig {Hex(origSpan, at, 40)}"); + output.WriteLine($"PROBE @0x{at:X3} ace {Hex(aceSpan, at, 40)}"); + output.WriteLine($"PROBE @0x{at:X3} libred {Hex(libSpan, at, 40)}"); + + // The decisive question for tail handling: past each side's own last live byte, does the + // page still hold what was there before, or has it been cleared? + int aceEnd = PageSize - U(aceSpan, 0x02), libEnd = PageSize - U(libSpan, 0x02); + output.WriteLine($"PROBE live ends: ace @0x{aceEnd:X3} libred @0x{libEnd:X3}" + + $" tail preserved from orig? ace={origSpan[aceEnd..].SequenceEqual(aceSpan[aceEnd..])}" + + $" libred={origSpan[libEnd..].SequenceEqual(libSpan[libEnd..])}" + + $" tail all-zero? ace={aceSpan[aceEnd..].TrimStart((byte)0).IsEmpty}" + + $" libred={libSpan[libEnd..].TrimStart((byte)0).IsEmpty}"); + } + } + } + } + + private static string Join(IEnumerable pages) => string.Join(", ", pages.Order()); + + private static int FirstDiff(ReadOnlySpan left, ReadOnlySpan right) + { + for (int i = 0; i < left.Length; i++) if (left[i] != right[i]) return i; + return -1; + } + + private static int LastDiff(ReadOnlySpan left, ReadOnlySpan right) + { + for (int i = left.Length - 1; i >= 0; i--) if (left[i] != right[i]) return i; + return -1; + } + + private static string Hex(ReadOnlySpan page, int at, int count) => + Convert.ToHexString(page.Slice(Math.Max(0, at), Math.Min(count, page.Length - Math.Max(0, at)))); + + private static int I(ReadOnlySpan page, int at) => BinaryPrimitives.ReadInt32LittleEndian(page[at..]); + private static ushort U(ReadOnlySpan page, int at) => BinaryPrimitives.ReadUInt16LittleEndian(page[at..]); + + /// The byte ranges where two pages differ, as 0xSTART-0xEND(length). + private static string Ranges(ReadOnlySpan left, ReadOnlySpan right) + { + var spans = new List(); + int start = -1; + for (int i = 0; i <= left.Length; i++) + { + bool differs = i < left.Length && left[i] != right[i]; + if (differs && start < 0) start = i; + else if (!differs && start >= 0) { spans.Add($"0x{start:X3}-0x{i - 1:X3}({i - start})"); start = -1; } + } + return spans.Count == 0 ? "(none)" : string.Join(" ", spans); + } + + private static HashSet ChangedPages(byte[] left, byte[] right, int pageSize) + { + var changed = new HashSet(); + int pages = Math.Min(left.Length, right.Length) / pageSize; + for (int p = 0; p < pages; p++) + if (!left.AsSpan(p * pageSize, pageSize).SequenceEqual(right.AsSpan(p * pageSize, pageSize))) + changed.Add(p); + for (int p = pages; p < Math.Max(left.Length, right.Length) / pageSize; p++) changed.Add(p); + return changed; + } + + private static void DaoDelete(object engine, string path, string table, string where) + { + object db = Invoke(engine, "OpenDatabase", path)!; + try + { + object rs = Invoke(db, "OpenRecordset", $"SELECT * FROM [{table}] WHERE {where}")!; + try { Invoke(rs, "Delete"); } + finally { Invoke(rs, "Close"); } + } + finally { Invoke(db, "Close"); } + } + + private static void DaoOpenClose(object engine, string path, string table) + { + object db = Invoke(engine, "OpenDatabase", path)!; + try + { + object rs = Invoke(db, "OpenRecordset", $"SELECT * FROM [{table}]")!; + Invoke(rs, "Close"); + } + finally { Invoke(db, "Close"); } + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); + + private static object? CreateDbEngine(out string progId) + { + AceTestDatabase.ReleaseAbandonedComObjects(); + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + progId = $"DAO.DBEngine.{n}"; + Type? type = Type.GetTypeFromProgID(progId); + if (type is null) continue; + try { return Activator.CreateInstance(type); } + catch (Exception) { /* registered but not instantiable in this bitness */ } + } + progId = "(none)"; + return null; + } +} diff --git a/test/LibRed.Engine.AccessTests/ComplexWriteAceReadbackTests.cs b/test/LibRed.Engine.AccessTests/ComplexWriteAceReadbackTests.cs new file mode 100644 index 00000000..879d69c5 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/ComplexWriteAceReadbackTests.cs @@ -0,0 +1,94 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// LibRed inserting into and deleting from a table that has complex (multi-value / attachment) columns, with +/// ACE asked whether it still accepts the result. Until this worked both statements were refused outright — +/// the index key encoding for a Complex column threw — so the point of the test is that lifting that refusal +/// did not start producing files ACE disagrees with. +/// +[Collection(AceCollection.Name)] +public class ComplexWriteAceReadbackTests(ITestOutputHelper output) +{ + private const string Source = @"D:\exampleaccdb\complex1.accdb"; + + [Fact] + public void Ace_reads_back_a_table_libred_inserted_into_and_deleted_from() + { + if (!File.Exists(Source)) { output.WriteLine($"Skipped: {Source} not present."); return; } + string path = TemporaryDatabase.CopyPath(Source, "complex-write-"); + try + { + int inserted, deletedRecordId, complexColumnCount; + var idsBefore = new List(); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var engine = new QueryEngine(db); + TableDef table = db.Catalog.FindTable("Table1")!; + ColumnDef idColumn = table.Columns[0]; + complexColumnCount = db.Catalog.ComplexColumns.Count(c => c.OwnerTable.Name == "Table1"); + + // A record that owns values — the one whose deletion has to cascade. + deletedRecordId = db.Catalog.ComplexColumns + .Where(c => c.OwnerTable.Name == "Table1") + .SelectMany(c => db.OpenTable(c.FlatTable.Name).Rows() + .Select(r => Convert.ToInt32(r[c.OwnerLink.Index]))) + .Distinct().First(); + + foreach (object?[] r in db.OpenTable("Table1").Rows()) + idsBefore.Add(Convert.ToInt32(r[idColumn.Index])); + + inserted = engine.ExecuteNonQuery("INSERT INTO Table1 (Col1) VALUES ('libred')"); + engine.ExecuteNonQuery( + $"DELETE FROM Table1 WHERE [{idColumn.Name}] = " + + db.OpenTable("Table1").Rows() + .Where(r => Convert.ToInt32(r[table.FindColumn("Attachment")!.Index]) == deletedRecordId) + .Select(r => Convert.ToInt32(r[idColumn.Index])) + .First()); + } + Assert.Equal(1, inserted); + + using (var db = JetDatabase.Open(path)) + { + // Every complex column of the table lost that record's values. + foreach (ComplexColumn c in db.Catalog.ComplexColumns.Where(c => c.OwnerTable.Name == "Table1")) + { + Assert.Empty(db.ReadComplexValues(c, deletedRecordId)); + output.WriteLine($"{c.ColumnName}: no values remain for record {deletedRecordId}"); + } + + // The new row took one complex id, shared by every complex column. + TableDef table = db.Catalog.FindTable("Table1")!; + object?[] newest = db.OpenTable("Table1").Rows() + .Last(r => !idsBefore.Contains(Convert.ToInt32(r[table.Columns[0].Index]))); + var ids = db.Catalog.ComplexColumns.Where(c => c.OwnerTable.Name == "Table1") + .Select(c => (int)newest[table.FindColumn(c.ColumnName)!.Index]!) + .Distinct().ToList(); + int shared = Assert.Single(ids); + output.WriteLine($"new row carries complex id {shared} in all {complexColumnCount} complex columns"); + } + + // The real question: does ACE still accept the file? + using OleDbConnection ace = AceTestDatabase.Open(path); + using OleDbCommand count = ace.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM Table1"; + int rows = Convert.ToInt32(count.ExecuteScalar()); + output.WriteLine($"ACE reads {rows} rows from Table1 (was {idsBefore.Count}, +1 insert, -1 delete)"); + Assert.Equal(idsBefore.Count, rows); + + using OleDbCommand readAll = ace.CreateCommand(); + readAll.CommandText = "SELECT Col1 FROM Table1"; + using OleDbDataReader reader = readAll.ExecuteReader(); + var values = new List(); + while (reader.Read()) values.Add(reader.IsDBNull(0) ? null : reader.GetString(0)); + Assert.Contains("libred", values); + output.WriteLine($"ACE sees Col1 = [{string.Join(", ", values)}]"); + } + finally { TemporaryDatabase.Delete(path); } + } +} diff --git a/test/LibRed.Engine.AccessTests/DeleteByteParityTests.cs b/test/LibRed.Engine.AccessTests/DeleteByteParityTests.cs new file mode 100644 index 00000000..22016cfa --- /dev/null +++ b/test/LibRed.Engine.AccessTests/DeleteByteParityTests.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using LibRed; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// A delete writes the bytes ACE's delete writes. Two engines delete the same row from two copies of the +/// same file and the copies are compared page by page. +/// +/// +/// ACE writes to any file it opens, so its own housekeeping has to be separated from its delete: a third +/// copy is opened and closed through DAO without deleting anything, and the pages that alone changes are +/// ACE's bookkeeping, not part of the comparison. What is asserted is the two things that were measured to +/// hold — every page both engines wrote came out identical, and LibRed wrote no page ACE left alone. +/// +[Collection(AceCollection.Name)] +public class DeleteByteParityTests(ITestOutputHelper output) +{ + private const int PageSize = 4096; + + [Fact] + public void A_delete_writes_the_same_bytes_ace_writes() + { + object? engine = CreateDbEngine(); + if (engine is null) { output.WriteLine("Skipped: DAO unavailable."); return; } + + string northwind = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); + string orig = TemporaryDatabase.CopyPath(northwind, "delparity-orig-"); + string aceCopy = TemporaryDatabase.CopyPath(northwind, "delparity-ace-"); + string noiseCopy = TemporaryDatabase.CopyPath(northwind, "delparity-noise-"); + string libredCopy = TemporaryDatabase.CopyPath(northwind, "delparity-libred-"); + try + { + const string Table = "Order Details"; + const string Where = "OrderID = 10248 AND ProductID = 11"; + + object ace = Invoke(engine, "OpenDatabase", aceCopy)!; + try + { + object rs = Invoke(ace, "OpenRecordset", $"SELECT * FROM [{Table}] WHERE {Where}")!; + try { Invoke(rs, "Delete"); } + finally { Invoke(rs, "Close"); } + } + finally { Invoke(ace, "Close"); } + + object quiet = Invoke(engine, "OpenDatabase", noiseCopy)!; + try + { + object rs = Invoke(quiet, "OpenRecordset", $"SELECT * FROM [{Table}]")!; + Invoke(rs, "Close"); + } + finally { Invoke(quiet, "Close"); } + + using (var db = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery($"DELETE FROM [{Table}] WHERE {Where}"); + + byte[] o = File.ReadAllBytes(orig), a = File.ReadAllBytes(aceCopy), + n = File.ReadAllBytes(noiseCopy), l = File.ReadAllBytes(libredCopy); + + HashSet aceWrote = Changed(o, a), housekeeping = Changed(o, n), libredWrote = Changed(o, l); + var aceDelete = aceWrote.Except(housekeeping).ToHashSet(); + + // Nothing LibRed touched is a page ACE left alone. + int[] extra = [.. libredWrote.Except(aceDelete).Order()]; + Assert.True(extra.Length == 0, $"LibRed wrote pages ACE did not: {string.Join(", ", extra)}"); + + // And every page it did touch came out byte for byte the same. + int[] both = [.. aceDelete.Intersect(libredWrote).Order()]; + Assert.NotEmpty(both); + foreach (int page in both) + Assert.True( + a.AsSpan(page * PageSize, PageSize).SequenceEqual(l.AsSpan(page * PageSize, PageSize)), + $"page {page} (type 0x{o[page * PageSize]:X2}) differs from ACE's"); + + output.WriteLine($"{both.Length} pages written by both engines, all identical"); + } + finally + { + foreach (string f in new[] { orig, aceCopy, noiseCopy, libredCopy }) TemporaryDatabase.Delete(f); + } + } + + private static HashSet Changed(byte[] left, byte[] right) + { + var changed = new HashSet(); + int pages = Math.Min(left.Length, right.Length) / PageSize; + for (int p = 0; p < pages; p++) + if (!left.AsSpan(p * PageSize, PageSize).SequenceEqual(right.AsSpan(p * PageSize, PageSize))) + changed.Add(p); + for (int p = pages; p < Math.Max(left.Length, right.Length) / PageSize; p++) changed.Add(p); + return changed; + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); + + private static object? CreateDbEngine() + { + AceTestDatabase.ReleaseAbandonedComObjects(); + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { return Activator.CreateInstance(type); } + catch (Exception) { /* registered but not instantiable in this bitness */ } + } + return null; + } +} diff --git a/test/LibRed.Engine.AccessTests/IndexSplitByteParityTests.cs b/test/LibRed.Engine.AccessTests/IndexSplitByteParityTests.cs new file mode 100644 index 00000000..dc351f89 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/IndexSplitByteParityTests.cs @@ -0,0 +1,110 @@ +using System.Buffers.Binary; +using System.Data.OleDb; +using LibRed; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// A leaf that has to split is cut where ACE cuts it. The cut is taken over the entries the page held +/// before the insert, so the side the new key falls on decides which page keeps the odd entry — the +/// two positions below exercise both branches of that rule. +/// +/// +/// The table is filled with even keys through ACE, so both engines start from byte-identical, packed leaves; +/// one odd key is then inserted into a copy by each engine and the files are compared. Filling is the slow +/// part, so it is done once and the copies taken from it. +/// +[Collection(AceCollection.Name)] +public class IndexSplitByteParityTests(ITestOutputHelper output) +{ + private const int PageSize = 4096; + private const int EvenKeys = 900; + + [Fact] + public void A_split_leaf_is_cut_where_ace_cuts_it() + { + string seeded = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "splitguard-seed-"); + try + { + using (OleDbConnection conn = AceTestDatabase.Open(seeded)) + { + using (OleDbCommand ddl = conn.CreateCommand()) + { + ddl.CommandText = "CREATE TABLE SplitGuard (k LONG NOT NULL, CONSTRAINT pk PRIMARY KEY (k))"; + ddl.ExecuteNonQuery(); + } + for (int i = 1; i <= EvenKeys; i++) + { + using OleDbCommand ins = conn.CreateCommand(); + ins.CommandText = $"INSERT INTO SplitGuard (k) VALUES ({i * 2})"; + ins.ExecuteNonQuery(); + } + } + + // 101 lands below the splitting leaf's midpoint, 801 above it — the two branches of the rule. + foreach (int key in new[] { 101, 801 }) + AssertSplitMatchesAce(seeded, key); + } + finally { TemporaryDatabase.Delete(seeded); } + } + + private void AssertSplitMatchesAce(string seeded, int key) + { + string aceCopy = TemporaryDatabase.CopyPath(seeded, $"splitguard-ace-{key}-"); + string libredCopy = TemporaryDatabase.CopyPath(seeded, $"splitguard-libred-{key}-"); + try + { + using (OleDbConnection conn = AceTestDatabase.Open(aceCopy)) + { + using OleDbCommand cmd = conn.CreateCommand(); + cmd.CommandText = $"INSERT INTO SplitGuard (k) VALUES ({key})"; + cmd.ExecuteNonQuery(); + } + + using (var db = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery($"INSERT INTO SplitGuard (k) VALUES ({key})"); + + int tdef; + using (var db = JetDatabase.Open(seeded)) tdef = db.Catalog.FindTable("SplitGuard")!.DefinitionPage; + + byte[] a = File.ReadAllBytes(aceCopy), l = File.ReadAllBytes(libredCopy); + int pages = Math.Min(a.Length, l.Length) / PageSize; + int checkedPages = 0; + + for (int p = 0; p < pages; p++) + { + // Only this index's own pages: the rest of the file is ACE's bookkeeping. + if (!IsOurIndexPage(a, p, tdef) && !IsOurIndexPage(l, p, tdef)) continue; + + int aceFree = BinaryPrimitives.ReadUInt16LittleEndian(a.AsSpan(p * PageSize + 2, 2)); + int libFree = BinaryPrimitives.ReadUInt16LittleEndian(l.AsSpan(p * PageSize + 2, 2)); + Assert.True(aceFree == libFree, + $"key {key}, page {p}: free space {libFree} where ACE wrote {aceFree} — the leaf was cut elsewhere."); + + // Past the live end a page keeps whatever it held, and a page ACE appended past the old + // end-of-file keeps ACE's uninitialised buffer, which is not reproducible. Compare the live + // region, which is the split itself. + int live = PageSize - aceFree; + Assert.True( + a.AsSpan(p * PageSize, live).SequenceEqual(l.AsSpan(p * PageSize, live)), + $"key {key}, page {p}: live bytes differ from ACE's."); + checkedPages++; + } + + Assert.True(checkedPages >= 2, $"key {key}: expected the split leaf and its new sibling, saw {checkedPages}."); + output.WriteLine($"key {key}: {checkedPages} index pages match ACE"); + } + finally + { + TemporaryDatabase.Delete(aceCopy); + TemporaryDatabase.Delete(libredCopy); + } + } + + private static bool IsOurIndexPage(byte[] file, int page, int tdef) => + (page + 1) * PageSize <= file.Length + && file[page * PageSize] is 0x03 or 0x04 + && BinaryPrimitives.ReadInt32LittleEndian(file.AsSpan(page * PageSize + 4, 4)) == tdef; +} diff --git a/test/LibRed.Engine.AccessTests/IndexSplitPointProbeTest.cs b/test/LibRed.Engine.AccessTests/IndexSplitPointProbeTest.cs new file mode 100644 index 00000000..a3555c83 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/IndexSplitPointProbeTest.cs @@ -0,0 +1,127 @@ +using System.Buffers.Binary; +using System.Data.OleDb; +using System.Globalization; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Engine.Tests; + +// PROBE: where does ACE cut a full index leaf that has to split? +// +// The insert parity probe showed the two engines splitting to different fill levels, but it does it inside a +// three-level cascade on a table with three indexes, where nothing can be attributed. This builds the +// isolated case instead: +// +// 1. ACE creates a one-column-key table and fills it with EVEN keys, ascending. Ascending inserts take the +// right-edge split both engines already agree on, so the leaves end up packed and identical. +// 2. That file is copied. One copy gets a single ODD key through ACE, the other the same key through +// LibRed. The key lands inside a full leaf, so exactly one leaf must split, with no cascade to confuse +// the measurement. +// 3. The leaf's free space on each side is the split point. +// +// The odd key is varied across the range so the rule can be read as a function of WHERE the new entry falls. +[Collection(AceCollection.Name)] +public class IndexSplitPointProbeTest(ITestOutputHelper output) +{ + private const int PageSize = 4096; + private const int EvenKeys = 900; // enough for several full leaves at ~9 bytes an entry + + [Theory] + [InlineData(1)] // BELOW every existing key — the index minimum, pos 0 + [InlineData(3)] // near the low end of the first leaf + [InlineData(101)] // inside the first leaf + [InlineData(801)] // past the midpoint of the splitting leaf + [InlineData(603)] // exactly ON the midpoint of the 602 entries the leaf held + [InlineData(605)] // one past it + [InlineData(1799)] // just below the maximum — still not the maximum + public void Probe_where_ace_cuts_a_full_leaf(int oddKey) + { + string seeded = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), $"split-seed-{oddKey}-"); + string aceCopy = "", libredCopy = ""; + try + { + using (OleDbConnection conn = AceTestDatabase.Open(seeded)) + { + Exec(conn, "CREATE TABLE SplitProbe (k LONG NOT NULL, CONSTRAINT pk PRIMARY KEY (k))"); + for (int i = 1; i <= EvenKeys; i++) + Exec(conn, $"INSERT INTO SplitProbe (k) VALUES ({i * 2})"); + } + + aceCopy = TemporaryDatabase.CopyPath(seeded, $"split-ace-{oddKey}-"); + libredCopy = TemporaryDatabase.CopyPath(seeded, $"split-libred-{oddKey}-"); + + using (OleDbConnection conn = AceTestDatabase.Open(aceCopy)) + Exec(conn, $"INSERT INTO SplitProbe (k) VALUES ({oddKey})"); + + using (var db = JetDatabase.Open(libredCopy, readOnly: false)) + new QueryEngine(db).ExecuteNonQuery( + $"INSERT INTO SplitProbe (k) VALUES ({oddKey.ToString(CultureInfo.InvariantCulture)})"); + + byte[] o = File.ReadAllBytes(seeded), a = File.ReadAllBytes(aceCopy), l = File.ReadAllBytes(libredCopy); + + int tdef; + using (var db = JetDatabase.Open(seeded)) tdef = db.Catalog.FindTable("SplitProbe")!.DefinitionPage; + + output.WriteLine($"PROBE key {oddKey}: sizes orig={o.Length} ace={a.Length} libred={l.Length}"); + foreach (int page in Union(Changed(o, a), Changed(o, l)).Order()) + { + if (!Owned(o, page, tdef) && !Owned(a, page, tdef) && !Owned(l, page, tdef)) continue; + output.WriteLine($"PROBE page {page} (was 0x{Type(o, page):X2}):" + + $" free orig={Free(o, page)} ace={Free(a, page)} libred={Free(l, page)}" + + $" | prefix orig={Prefix(o, page)} ace={Prefix(a, page)} libred={Prefix(l, page)}" + + $" | {(Same(a, l, page) ? "IDENTICAL" : $"differs first @0x{FirstDiff(a, l, page):X3}")}"); + } + } + finally + { + foreach (string f in new[] { seeded, aceCopy, libredCopy }) + if (f.Length > 0) TemporaryDatabase.Delete(f); + } + } + + private static void Exec(OleDbConnection conn, string sql) + { + using OleDbCommand cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private static byte Type(byte[] f, int p) => (p + 1) * PageSize <= f.Length ? f[p * PageSize] : (byte)0xFF; + + private static bool Owned(byte[] f, int p, int tdef) => + (p + 1) * PageSize <= f.Length && f[p * PageSize] is 0x03 or 0x04 + && BinaryPrimitives.ReadInt32LittleEndian(f.AsSpan(p * PageSize + 4, 4)) == tdef; + + private static int Free(byte[] f, int p) => (p + 1) * PageSize <= f.Length + ? BinaryPrimitives.ReadUInt16LittleEndian(f.AsSpan(p * PageSize + 2, 2)) : -1; + + private static int Prefix(byte[] f, int p) => (p + 1) * PageSize <= f.Length + ? BinaryPrimitives.ReadUInt16LittleEndian(f.AsSpan(p * PageSize + 0x18, 2)) : -1; + + private static bool Same(byte[] x, byte[] y, int p) => + (p + 1) * PageSize <= Math.Min(x.Length, y.Length) + && x.AsSpan(p * PageSize, PageSize).SequenceEqual(y.AsSpan(p * PageSize, PageSize)); + + private static IEnumerable Union(HashSet x, HashSet y) => x.Union(y); + + private static int FirstDiff(byte[] x, byte[] y, int p) + { + int limit = Math.Min(Math.Min(x.Length, y.Length) - p * PageSize, PageSize); + for (int i = 0; i < limit; i++) + if (x[p * PageSize + i] != y[p * PageSize + i]) return i; + return -1; + } + + private static HashSet Changed(byte[] left, byte[] right) + { + var changed = new HashSet(); + int pages = Math.Min(left.Length, right.Length) / PageSize; + for (int p = 0; p < pages; p++) + if (!left.AsSpan(p * PageSize, PageSize).SequenceEqual(right.AsSpan(p * PageSize, PageSize))) + changed.Add(p); + for (int p = pages; p < Math.Max(left.Length, right.Length) / PageSize; p++) changed.Add(p); + return changed; + } +} diff --git a/test/LibRed.Engine.Tests/JetTypeMappingTests.cs b/test/LibRed.Engine.Tests/JetTypeMappingTests.cs index 35e7f259..679056b7 100644 --- a/test/LibRed.Engine.Tests/JetTypeMappingTests.cs +++ b/test/LibRed.Engine.Tests/JetTypeMappingTests.cs @@ -95,6 +95,15 @@ public void Nullability_and_unknown_types_have_defined_fallbacks() Assert.Equal("varchar", JetStoreType.TypeName(Column(unknown))); Assert.Equal("varchar", JetStoreType.StoreType(Column(unknown))); Assert.Equal(typeof(object), JetClrTypeMap.ToClrType(unknown)); - Assert.Equal(typeof(object), JetClrTypeMap.ToClrType(JetDataType.Complex)); + } + + // Complex used to sit with the unknown types as `object`, because its four in-row bytes were passed + // through undecoded. They are an Int32 complex id — the column carries the auto-number flag and its ids + // come from the table's 0x1C counter — so the id is what a reader sees, and the values it stands for are + // reached through ComplexColumn. + [Fact] + public void A_complex_column_reads_as_its_int32_id() + { + Assert.Equal(typeof(int), JetClrTypeMap.ToClrType(JetDataType.Complex)); } } From b7d167fb7fec76cdab30670fdd3b315ea579ef54 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 22:48:00 +0800 Subject: [PATCH 12/14] Present a complex column the way ACE presents it A complex column reported itself to INFORMATION_SCHEMA and the scaffolder as a non-nullable "varchar", which is wrong twice and malformed once. ACE has no way to name such a column over OLE DB, so it flattens it to long text: an attachment column gives the identical DBSCHEMA_COLUMNS row to a Memo one - type 130, flags 234, maximum length 0 - and a reader types it System.String at the Memo ceiling of 536,870,910. Only DAO names it at all, as type 101, dbAttachment, size 4, that size being the Int32 complex id. JetStoreType had no Complex case, so the name fell through the default to "varchar" - which is also the one store type in the map carrying neither a facet nor a reason to lack one, where every other text type is varchar(n) or char(n). It now reads longchar, the same as Memo, so a caller sees what it would see through ACE. The nullability was the auto-number flag deciding on its own again. A complex column carries 0x04 without drawing on the table's counter, so the rule that an AutoNumber column is never nullable does not reach it - and both ACE surfaces disagreed with us, OLE DB reporting IS_NULLABLE true and DAO Required false. Only the flag is ignored; nullability still comes from the column's own IsNullable, which the catalog sets from the LvProp Required property, and those columns carry no LvProp entry at all. Both derivations live in JetStoreType precisely so INFORMATION_SCHEMA and database-first scaffolding cannot drift apart, so both surfaces move together. The CLR type is left as Int32 rather than ACE's String: a SELECT of a complex column still yields the record id, and that changes when the values projection does, not here. Co-Authored-By: Claude Opus 5 --- .../LibRed.Engine/Schema/JetStoreType.cs | 16 +++++++++++-- .../JetTypeMappingTests.cs | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs b/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs index fb61c163..6429ec69 100644 --- a/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs +++ b/src/LibRed/LibRed.Engine/Schema/JetStoreType.cs @@ -13,8 +13,13 @@ namespace LibRed.Engine.Schema; public static class JetStoreType { /// An AutoNumber (counter) column is never nullable — its non-null behaviour comes from the - /// AutoNumber flag, not the LvProp Required property (verified; matches Access/DAO). - public static bool IsNullable(ColumnDef column) => !column.IsAutoNumber && column.IsNullable; + /// AutoNumber flag, not the LvProp Required property (verified; matches Access/DAO). + /// A complex column is the exception, because it carries the same 0x04 flag without being a + /// counter in this sense. ACE reports an attachment column as nullable over OLE DB's schema + /// rowset, and DAO reports Required = False for it; treating the flag alone as decisive + /// contradicted both. + public static bool IsNullable(ColumnDef column) => + (!column.IsAutoNumber || column.Type == JetDataType.Complex) && column.IsNullable; /// The bare type name (no facets) — INFORMATION_SCHEMA.COLUMNS.DATA_TYPE, with length / /// precision / scale reported in their own columns. Mirrors AdoxSchema.GetDataTypeString. @@ -39,6 +44,13 @@ public static class JetStoreType JetDataType.Binary => "varbinary", JetDataType.Memo => "longchar", JetDataType.Ole => "longbinary", + // A complex (multi-value / attachment) column has no type of its own on this surface, because OLE DB + // cannot express one: ACE flattens it to long text, giving the identical schema row to a Memo column + // — type 130, flags 234, max length 0, and System.String at the Memo ceiling in a reader's schema + // table. Only DAO names it (type 101, dbAttachment). Reported as Memo is, so a caller reading this + // sees what it would see through ACE. Falling through to the default gave a bare "varchar", which is + // also a malformed store type — every other name either carries its facet or takes none at all. + JetDataType.Complex => "longchar", _ => "varchar", }; diff --git a/test/LibRed.Engine.Tests/JetTypeMappingTests.cs b/test/LibRed.Engine.Tests/JetTypeMappingTests.cs index 679056b7..d989d809 100644 --- a/test/LibRed.Engine.Tests/JetTypeMappingTests.cs +++ b/test/LibRed.Engine.Tests/JetTypeMappingTests.cs @@ -106,4 +106,28 @@ public void A_complex_column_reads_as_its_int32_id() { Assert.Equal(typeof(int), JetClrTypeMap.ToClrType(JetDataType.Complex)); } + + // On a schema surface a complex column is presented exactly as a Memo one, because that is what ACE + // presents: over OLE DB an attachment column gives the same schema row as a Memo column — type 130, + // flags 234, max length 0 — and a reader types it System.String at the Memo ceiling. Only DAO names it + // (type 101, dbAttachment). Two things used to go wrong here, both measured against ACE on complex1: + // with no Complex case the name fell through to a bare "varchar", a store type carrying no facet where + // every other text one does; and the counter rule below reported it NOT nullable, where ACE says + // nullable and DAO says Required=False. + [Fact] + public void A_complex_column_is_presented_as_memo_is() + { + ColumnDef complex = Column(JetDataType.Complex, autoNumber: true); + + Assert.Equal("longchar", JetStoreType.TypeName(complex)); + Assert.Equal("longchar", JetStoreType.StoreType(complex)); + Assert.Equal(JetStoreType.TypeName(Column(JetDataType.Memo)), JetStoreType.TypeName(complex)); + Assert.Null(JetStoreType.MaxLength(complex)); + + // The auto-number flag it carries must not make it non-nullable the way a real counter is. Only the + // flag is ignored: nullability still comes from the column's own IsNullable, which the catalog sets + // from the LvProp Required property. + Assert.True(JetStoreType.IsNullable(complex)); + Assert.False(JetStoreType.IsNullable(Column(JetDataType.Int32, autoNumber: true))); + } } From 366196edd2305d0ae4a52b6ce7579a2e58a5aed6 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 23:04:30 +0800 Subject: [PATCH 13/14] Check C# sources out CRLF on every platform Every Linux and macOS leg fails the build with IDE0055 on lines nobody has touched, while Windows builds clean, because two settings that have to agree do not. .gitattributes normalises with `* text=auto`, so the index holds LF and the working tree takes whatever the platform prefers - CRLF on Windows, LF everywhere else. .editorconfig pins `end_of_line = crlf`, for [*.cs] and again for [*.{cs,vb}]. On Windows the checkout matches what the rule asks for; on Linux every line ending violates it. That was a suggestion until EnforceCodeStyleInBuild arrived alongside TreatWarningsAsErrors, which turned it into a build error, so the platform the analysers never ran on is the one that stopped compiling. Pinning the checkout is the smaller of the two fixes: the index is untouched - it stores LF either way - no file is renormalised, and the style contract the repo already states twice is left as it is. Setting end_of_line to lf instead would have moved the rule to match the accident of how each platform checks out, and broken Windows in the same way. Co-Authored-By: Claude Opus 5 --- .gitattributes | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitattributes b/.gitattributes index 1b136160..12ddc67b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,20 @@ ############################################################################### * text=auto +############################################################################### +# C# sources check out CRLF on every platform. +# +# .editorconfig pins end_of_line = crlf for [*.cs] and [*.{cs,vb}], and +# EnforceCodeStyleInBuild makes IDE0055 a build error in src. Under `text=auto` +# alone the working tree takes the platform's ending, so a Linux or macOS +# checkout gets LF and every line then violates that rule while Windows builds +# clean. Pinning the checkout here keeps the one setting that decides this in +# agreement with the one that enforces it. The index is unaffected — it stores +# LF either way. +############################################################################### +*.cs text eol=crlf +*.vb text eol=crlf + ############################################################################### # Set default behavior for command prompt diff. # From 7dd3ddbbde83893c9bcbe94b8da803ee47af5a78 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 22 Sep 2026 23:11:10 +0800 Subject: [PATCH 14/14] Run the Core file-format tests on the right runners pull_request.yml had the two halves of the file-format suite the wrong way round, and lost one of them entirely. LibRed.Core.Tests ran on LibRedAccess, the Windows-with-ACE job. It needs no driver at all, and running it there asserts nothing the five-platform LibRed job could not assert better - reading and writing the on-disk format on Linux, macOS and ARM64 is precisely the part of the cross-platform claim that was going unchecked, because that job ran only LibRed.Engine.Tests. It now runs both, as push.yml already does. LibRed.Core.AccessTests was not run by this workflow at all. It is the half that cross-checks LibRed's output against the real engine, so it belongs on the Windows job, in the slot Core.Tests was occupying. Its path was also missing from the libred filter, so a change confined to that suite marked the whole LibRed leg as unaffected and skipped it. push.yml already had all three of these right; this brings the pull-request flow level with it, comments included. pull_request_without_ci.yml runs no suites and needs nothing. Co-Authored-By: Claude Opus 5 --- .github/workflows/pull_request.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0c47e892..5f70f03b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -103,6 +103,7 @@ jobs: - 'src/LibRed/**' - 'test/LibRed.Ado.Tests/**' - 'test/LibRed.Core.Tests/**' + - 'test/LibRed.Core.AccessTests/**' - 'test/LibRed.EFCore.Tests/**' - 'test/LibRed.Engine.Tests/**' - 'test/LibRed.Engine.AccessTests/**' @@ -676,6 +677,13 @@ jobs: - name: 'Run Tests: LibRed.Engine.Tests' if: env.skipTests != 'true' run: dotnet test ./test/LibRed.Engine.Tests --configuration ${{ env.buildConfiguration }} -p:FixedTestOrder=${{ env.deterministicTests }} --blame-hang-timeout 3m + # The file-format half of LibRed.Core.Tests. It reads and writes .accdb files with LibRed alone — the + # cross-checks against the real Access engine live in LibRed.Core.AccessTests — so it belongs on this + # matrix rather than the Windows one, and reading the on-disk format on Linux/macOS/ARM64 is the part + # of the cross-platform claim that was previously only asserted for the engine. + - name: 'Run Tests: LibRed.Core.Tests' + if: always() && env.skipTests != 'true' + run: dotnet test ./test/LibRed.Core.Tests --configuration ${{ env.buildConfiguration }} -p:FixedTestOrder=${{ env.deterministicTests }} --blame-hang-timeout 3m # The rest of the LibRed suites cross-check LibRed's output against the real engine over OLE DB, # so unlike the job above they do need Windows with ACE installed. @@ -718,10 +726,12 @@ jobs: } $v } - - name: 'Run Tests: LibRed.Core.Tests' + # The file-format tests that cross-check against ACE. Their ACE-free half runs on the five-platform + # LibRed job above. + - name: 'Run Tests: LibRed.Core.AccessTests' if: env.skipTests != 'true' shell: pwsh - run: dotnet test .\test\LibRed.Core.Tests --configuration '${{ env.buildConfiguration }}' -p:FixedTestOrder=${{ env.deterministicTests }} --blame-hang-timeout 5m + run: dotnet test .\test\LibRed.Core.AccessTests --configuration '${{ env.buildConfiguration }}' -p:FixedTestOrder=${{ env.deterministicTests }} --blame-hang-timeout 5m # The engine tests that cross-check against ACE. They belong here rather than in the cross-platform # LibRed job above, which runs on five platforms precisely to prove LibRed needs no ACE at all. - name: 'Run Tests: LibRed.Engine.AccessTests'